Compare commits

...

10 commits

Author SHA1 Message Date
Server
f7811cd999 Use supplied accessibility icon 2026-08-19 10:26:04 +00:00
Server
845515d400 Add runtime-generated accessibility controls 2026-08-19 10:21:10 +00:00
Server
dc482cde94 Support legacy compiled HTML shells 2026-08-19 06:37:42 +00:00
Server
7163d1e4fe Dispatch page change events 2026-08-15 13:17:33 +00:00
Server
485e7e4fba Refine mobile menu navigation 2026-08-14 11:53:04 +00:00
Server
9376bcbd19 Keep sidebar layout stable while collapsing 2026-08-14 11:48:38 +00:00
Server
825f261354 Animate page navigation with content 2026-08-14 11:45:35 +00:00
Server
624f62f25e Animate navigation and sidebar controls 2026-08-14 11:35:50 +00:00
Server
c72f5fed82 Wait for all images before printing 2026-08-12 09:30:15 +00:00
Server
c489209610 Print complete documentation with light output 2026-08-12 09:18:19 +00:00
6 changed files with 562 additions and 49 deletions

View file

@ -15,9 +15,13 @@ SharedDocsLib — небольшой Python-фреймворк для докум
UI не содержит языко-зависимых служебных подписей. Базовый шрифт — `system-ui 16px`, а служебные UTF-8-пиктограммы используют Arial. Навигационные стрелки отрисовываются CSS-границами без текстовых символов. AMOLED-тема включена по умолчанию и сохраняется локально после переключения.
На десктопе компактный header содержит полное название документации, поиск и переключатель темы, а слева постоянно видны header-кнопки и дерево страниц. Поисковый placeholder медленно печатает первые слова случайного названия, добавляет троеточие только в конце и сохраняет готовый текст 5 секунд; при вводе поле раскрывает ранжированные результаты вниз. Проверка manifest выполняется автоматически в фоне без отдельной кнопки обновления.
Accessibility-кнопка создаётся системным JavaScript, поэтому появляется и в ранее скомпилированных HTML после обновления manifest. На десктопе она закреплена в левом нижнем углу ниже loading-overlay, на мобильном располагается рядом с переключателем темы внутри меню. Псевдостраница настроек позволяет менять размер текста от 12 до 28 px, сбрасывать параметры и выбирать только локальные браузерные семейства `system-ui`, `sans-serif`, `serif` и `monospace`; сетевые шрифты не используются. Размер применяется только к sidebar, документации и кнопкам Previous/Next, а семейство — ко всему интерфейсу, кроме UTF-8 glyph-кнопок. Параметры сохраняются в `localStorage`.
На мобильных устройствах используется viewport `100dvh` и нижний dock. Кнопка `☰` заменяет содержимое страницы копией desktop-sidebar. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. Тема и обновление на мобильном скрыты.
На десктопе компактный header содержит toggle боковой панели, полное название документации, поиск и переключатель темы. Sidebar по умолчанию открыт, его состояние не сохраняется между загрузками, а при сворачивании внутренняя раскладка остаётся неподвижной и обрезается границей панели без переноса текста. Поисковый placeholder медленно печатает первые слова случайного названия, добавляет троеточие только в конце и сохраняет готовый текст 5 секунд; при вводе поле раскрывает ранжированные результаты вниз. Проверка manifest выполняется автоматически в фоне без отдельной кнопки обновления.
Обновлённый runtime сохраняет совместимость со старыми скомпилированными HTML-оболочками: отсутствие desktop-toggle, мобильного переключателя темы или общего контейнера page-transition не останавливает запуск клиента.
На мобильных устройствах используется viewport `100dvh` и нижний dock. Кнопка `☰` заменяет содержимое страницы копией desktop-sidebar, которая выезжает с левой границы экрана; между header-кнопками и деревом доступен круглый переключатель темы. Выбор страницы в открытом мобильном меню закрывает его и показывает новый контент моментально, без page-transition. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. При остальных переходах контент вместе с кнопками предыдущей и следующей страницы плавно смещается на 30 px по направлению навигации и меняет прозрачность.
## Установка из Git
@ -244,6 +248,18 @@ document.addEventListener("on_update_available", (event) => {
});
```
Непосредственно перед заменой содержимого при переходе между страницами отправляется всплывающее событие `on_page_change`. В `page` находится полная запись целевой страницы из manifest, а в `previousPage` — предыдущая страница или `null` при первом рендере:
```javascript
document.addEventListener("on_page_change", (event) => {
const { page, previousPage, number, title, index } = event.detail;
console.log(`${previousPage?.number ?? "—"} → ${number}: ${title}`, {
page,
index,
});
});
```
Код из `custom_js` считается доверенным и выполняется в контексте страницы. Изменение системного или пользовательского CSS/JS меняет `revision`, поэтому обновлённый runtime сохраняется в IndexedDB и применяется после перезагрузки клиента.
Версия библиотеки, которой был скомпилирован физический `index.html`, доступна пользовательскому JavaScript в глобальной строковой переменной:
@ -254,6 +270,14 @@ console.log(globalThis.COMPILED_FROM_VERSION); // например, "0.3.7"
Присваивание добавляется в bootstrap-скрипт самой HTML-ки. Автообновление manifest не меняет `COMPILED_FROM_VERSION`: значение обновится только после новой компиляции и замены `index.html`.
Для печати всей документации из `custom_js` или консоли доступна глобальная функция:
```javascript
PrintFullDocumentation();
```
Она сразу закрывает интерфейс тематическим loading-overlay, формирует единый документ из всех страниц в порядке manifest, подставляет и декодирует все изображения, ожидает готовности шрифтов и только затем открывает системный диалог печати. Каждая страница документации начинается с нового печатного листа. Печатная тема всегда использует белый фон, чёрный текст и компактные заголовки независимо от выбранной экранной темы.
## Live-preview
В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами, в которых объявлены страницы, а также за системными и пользовательскими CSS/JS. При изменении он пересобирает manifest; клиент сохраняет его и перезагружается с новым runtime. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.

View file

@ -1,4 +1,3 @@
"""Single source of truth for the SharedDocsLib package version."""
__version__ = "0.3.7"
__version__ = "0.5.1"

View file

@ -13,6 +13,8 @@
--border: #c9c9c9;
--tree-line: #c5c5c5;
--shadow: 0 24px 70px rgba(0, 0, 0, .4);
--accessibility-font-family: system-ui;
--accessibility-font-size: 16px;
color-scheme: light;
}
@ -38,7 +40,7 @@
[hidden] { display: none !important; }
html, body { width: 100%; height: 100dvh; min-height: 100dvh; max-height: 100dvh; overflow: hidden; }
html { background: var(--shell); }
body { margin: 0; color: var(--text); background: var(--shell); font-family: system-ui; font-size: 16px; line-height: 1.55; }
body { margin: 0; color: var(--text); background: var(--shell); font-family: var(--accessibility-font-family); font-size: 16px; line-height: 1.55; }
button, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
@ -46,13 +48,19 @@ button { color: inherit; }
.app-shell { --desktop-sidebar-width: clamp(270px, 25vw, 400px); display: flex; width: 100%; height: 100%; flex-direction: column; overflow: hidden; background: var(--shell); }
.desktop-header { position: relative; z-index: 20; display: grid; min-height: 76px; flex: 0 0 76px; grid-template-columns: minmax(260px, 1fr) auto; align-items: center; gap: 18px; padding: 12px 20px; background: var(--shell); }
.desktop-title-group { display: flex; min-width: 0; align-items: center; gap: 12px; }
.desktop-title { overflow: hidden; margin: 0; font-size: 1.55rem; font-weight: 450; line-height: 1.1; letter-spacing: -.025em; text-overflow: ellipsis; white-space: nowrap; }
.desktop-sidebar-toggle { display: grid; width: 44px; height: 44px; flex: 0 0 44px; place-items: center; border-radius: 50%; color: var(--text); background: var(--control); font-size: 1.6rem; }
.desktop-sidebar-toggle:hover { background: var(--active); }
.header-link { display: block; width: 100%; padding: 6px 12px; border: 0; border-radius: 20px; color: var(--text); background: transparent; text-decoration: none; line-height: 1.2; transition: background .16s ease; }
.header-link:hover { background: var(--control); }
.header-link.active { color: var(--text); background: var(--active); }
.desktop-main { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
.desktop-sidebar { display: flex; width: var(--desktop-sidebar-width); min-width: 0; height: 100%; flex: 0 0 var(--desktop-sidebar-width); flex-direction: column; overflow: hidden; padding: 0 18px; background: var(--shell); }
.desktop-sidebar { display: flex; width: var(--desktop-sidebar-width); min-width: 0; height: 100%; flex: 0 0 var(--desktop-sidebar-width); flex-direction: column; overflow: hidden; padding: 0 18px; background: var(--shell); opacity: 1; transition: width .24s ease, flex-basis .24s ease, padding .24s ease, opacity .16s ease; }
.desktop-sidebar, .mobile-menu-view, .content, .page-navigation { font-size: var(--accessibility-font-size); }
.desktop-sidebar > * { width: calc(var(--desktop-sidebar-width) - 36px); min-width: calc(var(--desktop-sidebar-width) - 36px); }
body.desktop-sidebar-collapsed .desktop-sidebar { width: 0; flex-basis: 0; padding-right: 0; padding-left: 0; opacity: 0; pointer-events: none; }
.sidebar-header-buttons { display: flex; flex: 0 0 auto; flex-direction: column; gap: 4px; margin-bottom: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }
.sidebar-header-buttons .header-link { color: var(--text); }
.sidebar-header-buttons .header-link:hover { background: var(--control); }
@ -99,18 +107,19 @@ button { color: inherit; }
@keyframes refresh-spin { to { transform: rotate(360deg); } }
.page-surface { min-height: 0; flex: 1; overflow-x: hidden; overflow-y: auto; overscroll-behavior-y: contain; border-radius: 36px 0 0; background: var(--surface); scroll-behavior: smooth; -webkit-overflow-scrolling: touch; }
.page-transition { min-width: 0; }
.content { width: min(1120px, calc(100% - 72px)); margin: 0 auto; padding: 42px 0 44px; outline: none; }
.content > :first-child { margin-top: 0; }
.content h1 { margin: 0 0 1.5rem; font-size: clamp(2.15rem, 2.7vw, 3.35rem); font-weight: 400; line-height: 1.08; letter-spacing: -.035em; }
.content h2 { margin: 2.5rem 0 .9rem; padding-bottom: .35rem; border-bottom: 1px solid var(--border); font-size: 1.65rem; font-weight: 500; }
.content h3 { margin: 1.9rem 0 .55rem; font-size: 1.25rem; font-weight: 550; }
.content p { margin: .8rem 0 1.2rem; font-size: 1.08rem; }
.content h1 { margin: 0 0 1.5rem; font-size: clamp(2.15em, 2.7vw, 3.35em); font-weight: 400; line-height: 1.08; letter-spacing: -.035em; }
.content h2 { margin: 2.5rem 0 .9rem; padding-bottom: .35rem; border-bottom: 1px solid var(--border); font-size: 1.65em; font-weight: 500; }
.content h3 { margin: 1.9rem 0 .55rem; font-size: 1.25em; font-weight: 550; }
.content p { margin: .8rem 0 1.2rem; font-size: 1.08em; }
.content a { color: var(--text); text-underline-offset: 4px; }
.content img { display: block; max-width: 100%; height: auto; margin: 1.5rem auto; border-radius: 20px; }
.content figure { margin: 1.6rem 0; }
.content figcaption { margin-top: .55rem; color: var(--muted); text-align: center; }
.content pre { overflow: auto; margin: 1.3rem 0; padding: 18px 20px; border: 1px solid var(--border); border-radius: 18px; background: var(--shell); }
.content code { padding: .12em .35em; border-radius: 5px; background: var(--shell); font: .88em/1.5 ui-monospace, monospace; }
.content code { padding: .12em .35em; border-radius: 5px; background: var(--shell); font: .88em/1.5 var(--accessibility-font-family); }
.content pre code { padding: 0; background: transparent; }
.content blockquote { margin: 1.4rem 0; padding: .35rem 1.25rem; border-left: 2px solid var(--text); color: var(--muted); }
.content hr { margin: 2.6rem 0; border: 0; border-top: 1px solid var(--border); }
@ -133,7 +142,42 @@ button { color: inherit; }
#next-page { color: var(--shell); background: var(--text); }
:root[data-theme="dark"] #next-page { color: #000; background: #eee; }
.accessibility-icon-button { display: grid; place-items: center; padding: 0; border: 1px solid var(--border); border-radius: 50%; color: var(--text); background: var(--surface); cursor: pointer; }
.accessibility-icon-button:hover { background: var(--control); }
.accessibility-icon-button:focus-visible, .accessibility-back:focus-visible, .accessibility-step:focus-visible, .accessibility-reset:focus-visible, .accessibility-font-options button:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
.accessibility-glyph { width: 25px; height: 25px; overflow: visible; fill: currentColor; }
.accessibility-button { position: fixed; z-index: 40; bottom: max(16px, env(safe-area-inset-bottom)); left: 20px; width: 50px; height: 50px; }
body.accessibility-open .accessibility-button { display: none; }
body.accessibility-open .content, body.accessibility-open .page-navigation { display: none; }
.accessibility-view { position: relative; width: min(1120px, calc(100% - 72px)); margin: 0 auto; padding: 42px 0 64px; }
.accessibility-title { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.accessibility-back, .accessibility-reset, .accessibility-step { display: grid; place-items: center; border: 0; color: var(--text); background: var(--control); cursor: pointer; }
.accessibility-back { width: 48px; height: 48px; margin-bottom: 28px; border-radius: 50%; }
.accessibility-back:hover, .accessibility-reset:hover, .accessibility-step:hover { background: var(--active); }
.accessibility-back-arrow { width: 12px; height: 12px; border-bottom: 2px solid currentColor; border-left: 2px solid currentColor; transform: rotate(45deg); }
.accessibility-size-row { display: flex; align-items: center; gap: 18px; }
.accessibility-size-control { display: flex; height: 52px; align-items: center; overflow: hidden; border-radius: 28px; background: var(--control); }
.accessibility-step { width: 52px; height: 52px; flex: 0 0 52px; background: transparent; font-size: 1.45rem; }
.accessibility-step:disabled { color: var(--faint); cursor: default; opacity: .55; }
.accessibility-size-control output { min-width: 42px; color: var(--text); font-size: 1rem; text-align: center; }
.accessibility-reset { width: 52px; height: 52px; flex: 0 0 52px; border-radius: 50%; font-size: 1.35rem; }
.accessibility-font-options { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 36px; }
.accessibility-font-options button { min-height: 48px; padding: 9px 18px; border: 1px solid transparent; border-radius: 26px; color: var(--text); background: var(--control); cursor: pointer; }
.accessibility-font-options button:hover { background: var(--active); }
.accessibility-font-options button[aria-pressed="true"] { color: var(--selection-text); background: var(--selection-bg); }
.accessibility-font-options [data-accessibility-font="system-ui"] { font-family: system-ui; }
.accessibility-font-options [data-accessibility-font="sans-serif"] { font-family: sans-serif; }
.accessibility-font-options [data-accessibility-font="serif"] { font-family: serif; }
.accessibility-font-options [data-accessibility-font="monospace"] { font-family: monospace; }
.accessibility-preview { max-width: 920px; margin-top: 56px; font-size: var(--accessibility-font-size); }
.accessibility-preview h1 { margin: 0 0 12px; font-size: 2em; font-weight: 600; line-height: 1.15; }
.accessibility-preview h2 { margin: 42px 0 10px; font-size: 1.55em; font-weight: 600; line-height: 1.2; }
.accessibility-preview p { margin: 0; font-size: 1.05em; line-height: 1.45; }
.mobile-view, .mobile-dock { display: none; }
.full-print-document { display: none; }
.print-loading-overlay { position: fixed; inset: 0; z-index: 2147483647; display: grid; width: 100vw; height: 100dvh; place-items: center; color: var(--text); background: var(--shell); }
.print-loading-spinner { width: 48px; height: 48px; box-sizing: border-box; border: solid 4px currentColor; border-top-color: transparent; border-radius: 9999px; animation: refresh-spin .8s linear infinite; }
@media (max-width: 820px) {
.app-shell { display: block; }
@ -142,9 +186,9 @@ button { color: inherit; }
.workspace { width: 100%; height: 100%; }
.page-surface { display: block; width: 100%; border-radius: 0; background: var(--shell); }
.content { width: 100%; padding: 52px 28px 28px; }
.content h1 { margin-bottom: 1.55rem; font-size: clamp(2.45rem, 12vw, 4rem); }
.content h2 { font-size: 1.55rem; }
.content p { font-size: 1.05rem; }
.content h1 { margin-bottom: 1.55rem; font-size: 2.45em; }
.content h2 { font-size: 1.55em; }
.content p { font-size: 1.05em; }
.page-navigation { width: 100%; flex-direction: column; align-items: flex-start; gap: 10px; padding: 0 28px calc(150px + env(safe-area-inset-bottom)); }
#next-page { order: 1; }
#previous-page { order: 2; }
@ -154,11 +198,15 @@ button { color: inherit; }
body.mobile-menu-open .mobile-menu-view { display: block; }
body.mobile-search-open .mobile-search-view { display: flex; }
.mobile-menu-view { padding: 52px 28px calc(150px + env(safe-area-inset-bottom)); }
body.mobile-menu-open .mobile-menu-view { animation: mobile-menu-enter .26s cubic-bezier(.2, .8, .2, 1) both; }
.mobile-site-title { margin: 0 0 34px; font-size: clamp(2.4rem, 11vw, 4rem); font-weight: 400; line-height: 1.1; letter-spacing: -.035em; overflow-wrap: anywhere; }
.mobile-header-buttons { display: flex; flex-direction: column; gap: 6px; margin-bottom: 38px; border: 1px solid var(--border); border-radius: 24px; background: var(--surface); }
.mobile-header-buttons .header-link { padding: 12px 18px; border-radius: 28px; font-size: 1.05rem; }
.mobile-header-buttons { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; border: 1px solid var(--border); border-radius: 24px; background: var(--surface); }
.mobile-header-buttons .header-link { padding: 12px 18px; border-radius: 28px; font-size: 1.05em; }
.mobile-preference-actions { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
.mobile-theme-button, .mobile-accessibility-button { display: grid; width: 54px; height: 54px; margin: 0; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--text); background: var(--surface); font-size: 1.35rem; }
.mobile-theme-button:hover { background: var(--control); }
.mobile-page-tree { overflow: visible; padding-bottom: 24px; }
.mobile-page-tree .tree-link { min-height: 46px; padding: 9px 14px; border-radius: 24px; font-size: 1.05rem; }
.mobile-page-tree .tree-link { min-height: 46px; padding: 9px 14px; border-radius: 24px; font-size: 1.05em; }
.mobile-page-tree .tree-children { margin-left: 20px; padding-left: 12px; }
.mobile-search-view { flex-direction: column; justify-content: flex-end; padding: 40px 28px calc(142px + env(safe-area-inset-bottom)); overflow: hidden; }
.mobile-search-results { display: flex; max-height: 100%; flex: 1; flex-direction: column-reverse; justify-content: flex-start; gap: 20px; overflow-y: auto; }
@ -172,6 +220,16 @@ button { color: inherit; }
.mobile-search-control { display: flex; min-width: 0; height: 72px; flex: 1; align-items: center; border-radius: 38px; background: var(--control); }
.mobile-search-control input { padding-left: 30px; font-size: 1.3rem; }
.mobile-search-control .search-glyph { width: 64px; height: 64px; flex-basis: 64px; background: transparent; font-size: 2.3rem; }
.accessibility-button { display: none; }
.accessibility-view { width: 100%; padding: 36px 28px calc(150px + env(safe-area-inset-bottom)); }
.accessibility-back { margin-bottom: 24px; }
.accessibility-font-options { margin-top: 30px; }
.accessibility-preview { margin-top: 46px; }
}
@keyframes mobile-menu-enter {
from { opacity: 0; transform: translateX(-100%); }
to { opacity: 1; transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
@ -179,9 +237,23 @@ button { color: inherit; }
}
@media print {
html, body { height: auto; max-height: none; overflow: visible; }
:root, :root[data-theme="dark"] { color-scheme: light; }
html, body { height: auto; max-height: none; overflow: visible; color: #000 !important; background: #fff !important; }
.app-shell, .workspace, .page-surface, .content, .full-print-document, .print-page { color: #000 !important; background: #fff !important; }
.content a, .content figcaption, .content blockquote { color: #000 !important; }
.content pre, .content code, .content details, .content th, .callout { color: #000 !important; background: #fff !important; border-color: #777 !important; }
.app-shell { display: block; }
.desktop-header, .desktop-sidebar, .mobile-dock, .page-navigation { display: none; }
.desktop-header, .desktop-sidebar, .mobile-dock, .page-navigation, .print-loading-overlay, .accessibility-button, .accessibility-view { display: none !important; }
.workspace, .page-surface { display: block; height: auto; overflow: visible; }
.content { width: 100%; padding: 0; }
body.docslib-print-all > .app-shell { display: none; }
body.docslib-print-all > .full-print-document { display: block; }
.print-page { break-after: page; page-break-after: always; }
.print-page:last-child { break-after: auto; page-break-after: auto; }
.print-page-heading { margin: 0 0 2rem; padding-bottom: 1rem; border-bottom: 1px solid #777; }
.print-page-heading h1 { margin: 0; color: #000 !important; font-size: 1.35rem !important; line-height: 1.25; }
.print-page-number { display: block; margin-bottom: .3rem; color: #555 !important; }
.print-page-content h1 { font-size: 1.8rem !important; line-height: 1.25; }
.print-page-content h2 { font-size: 1.4rem !important; line-height: 1.3; }
.print-page-content h3 { font-size: 1.15rem !important; line-height: 1.35; }
}

View file

@ -7,6 +7,7 @@
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const elements = {
desktopTitle: document.getElementById("desktop-site-title"),
desktopSidebarToggle: document.getElementById("desktop-sidebar-toggle"),
mobileTitle: document.getElementById("mobile-site-title"),
desktopHeaderButtons: document.getElementById("desktop-header-buttons"),
mobileHeaderButtons: document.getElementById("mobile-header-buttons"),
@ -21,13 +22,25 @@
mobileSearchButton: document.getElementById("mobile-search-button"),
mobileSearchResults: document.getElementById("mobile-search-results"),
content: document.getElementById("content"),
pageTransition: document.getElementById("page-transition"),
pageSurface: document.getElementById("page-surface"),
previous: document.getElementById("previous-page"),
next: document.getElementById("next-page"),
themeIcon: document.getElementById("theme-icon"),
mobileThemeButton: document.getElementById("mobile-theme-button"),
mobileThemeIcon: document.getElementById("mobile-theme-icon"),
refresh: document.getElementById("refresh-button"),
identity: document.getElementById("manifest-identity"),
mobileMenuButton: document.getElementById("mobile-menu-button"),
accessibilityButton: null,
mobileAccessibilityButton: null,
accessibilityView: null,
accessibilityBack: null,
accessibilitySizeValue: null,
accessibilitySizeDecrease: null,
accessibilitySizeIncrease: null,
accessibilityReset: null,
accessibilityFontOptions: null,
};
let manifest = window.__DOCSLIB_MANIFEST__ || embedded;
@ -44,6 +57,13 @@
let lastPlaceholderPage = -1;
let customScriptLoaded = false;
let lastSyncedRevision = String(manifest.revision || "");
let pageTransitionId = 0;
let pageAnimation = null;
let skipNextPageAnimation = false;
let accessibilitySize = 16;
let accessibilityFont = "system-ui";
const accessibilityFonts = new Set(["system-ui", "sans-serif", "serif", "monospace"]);
const dbPromise = new Promise((resolve, reject) => {
if (!window.indexedDB) return reject(new Error("IndexedDB"));
@ -135,6 +155,134 @@
document.body.classList.toggle("mobile-search-open", mode === "search");
}
function setDesktopSidebar(open) {
document.body.classList.toggle("desktop-sidebar-collapsed", !open);
elements.desktopSidebarToggle.setAttribute("aria-expanded", String(open));
}
function createAccessibilityButton(id, className) {
const button = document.createElement("button");
button.id = id;
button.className = `${className} accessibility-icon-button`;
button.type = "button";
button.setAttribute("aria-label", "Accessibility settings");
button.setAttribute("aria-controls", "accessibility-view");
button.setAttribute("aria-expanded", "false");
button.innerHTML = '<svg class="accessibility-glyph" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true"><path d="M423.5-743.5Q400-767 400-800t23.5-56.5Q447-880 480-880t56.5 23.5Q560-833 560-800t-23.5 56.5Q513-720 480-720t-56.5-23.5ZM360-80v-520H120v-80h720v80H600v520h-80v-240h-80v240h-80Z"></path></svg>';
return button;
}
function ensureAccessibilityUi() {
let launcher = document.getElementById("accessibility-button");
if (!launcher) {
launcher = createAccessibilityButton("accessibility-button", "accessibility-button");
document.body.append(launcher);
}
let mobileLauncher = document.getElementById("mobile-accessibility-button");
let mobileActions = document.querySelector(".mobile-preference-actions");
if (!mobileActions && elements.mobileTree) {
mobileActions = document.createElement("div");
mobileActions.className = "mobile-preference-actions";
elements.mobileTree.before(mobileActions);
}
if (mobileActions && elements.mobileThemeButton && elements.mobileThemeButton.parentElement !== mobileActions) {
mobileActions.append(elements.mobileThemeButton);
}
if (!mobileLauncher && mobileActions) {
mobileLauncher = createAccessibilityButton("mobile-accessibility-button", "mobile-accessibility-button");
mobileActions.append(mobileLauncher);
}
let view = document.getElementById("accessibility-view");
if (!view) {
view = document.createElement("section");
view.id = "accessibility-view";
view.className = "accessibility-view";
view.setAttribute("aria-labelledby", "accessibility-title");
view.hidden = true;
view.innerHTML = `
<button id="accessibility-back" class="accessibility-back" type="button" aria-label="Back"><span class="accessibility-back-arrow" aria-hidden="true"></span></button>
<h1 id="accessibility-title" class="accessibility-title">Accessibility</h1>
<div class="accessibility-size-row">
<div class="accessibility-size-control" role="group" aria-label="Text size">
<button id="accessibility-size-decrease" class="accessibility-step glyph-button" type="button" aria-label="Decrease text size"></button>
<output id="accessibility-size-value" aria-live="polite">16</output>
<button id="accessibility-size-increase" class="accessibility-step glyph-button" type="button" aria-label="Increase text size">+</button>
</div>
<button id="accessibility-reset" class="accessibility-reset glyph-button" type="button" aria-label="Reset accessibility settings"></button>
</div>
<div id="accessibility-font-options" class="accessibility-font-options" role="group" aria-label="Font family">
<button type="button" data-accessibility-font="system-ui" aria-pressed="true">system-ui</button>
<button type="button" data-accessibility-font="sans-serif" aria-pressed="false">sans-serif</button>
<button type="button" data-accessibility-font="serif" aria-pressed="false">serif</button>
<button type="button" data-accessibility-font="monospace" aria-pressed="false">monospace</button>
</div>
<article class="accessibility-preview" aria-label="Preview">
<h1>Header 1</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer vitae justo sed arcu posuere feugiat.</p>
<h2>Header 2</h2>
<p>Praesent commodo, sapien at aliquet posuere, neque erat feugiat mauris, vitae luctus orci lorem sed nulla.</p>
</article>`;
(elements.pageTransition || elements.pageSurface).append(view);
}
elements.accessibilityButton = launcher;
elements.mobileAccessibilityButton = mobileLauncher;
elements.accessibilityView = view;
elements.accessibilityBack = document.getElementById("accessibility-back");
elements.accessibilitySizeValue = document.getElementById("accessibility-size-value");
elements.accessibilitySizeDecrease = document.getElementById("accessibility-size-decrease");
elements.accessibilitySizeIncrease = document.getElementById("accessibility-size-increase");
elements.accessibilityReset = document.getElementById("accessibility-reset");
elements.accessibilityFontOptions = document.getElementById("accessibility-font-options");
}
function applyAccessibilityPreferences({ save = true } = {}) {
accessibilitySize = Math.min(28, Math.max(12, Number(accessibilitySize) || 16));
if (!accessibilityFonts.has(accessibilityFont)) accessibilityFont = "system-ui";
document.documentElement.style.setProperty("--accessibility-font-size", `${accessibilitySize}px`);
document.documentElement.style.setProperty("--accessibility-font-family", accessibilityFont);
if (elements.accessibilitySizeValue) elements.accessibilitySizeValue.value = String(accessibilitySize);
if (elements.accessibilitySizeDecrease) elements.accessibilitySizeDecrease.disabled = accessibilitySize <= 12;
if (elements.accessibilitySizeIncrease) elements.accessibilitySizeIncrease.disabled = accessibilitySize >= 28;
elements.accessibilityFontOptions?.querySelectorAll("[data-accessibility-font]").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.accessibilityFont === accessibilityFont));
});
if (!save) return;
try {
localStorage.setItem("docslib-accessibility-size", String(accessibilitySize));
localStorage.setItem("docslib-accessibility-font", accessibilityFont);
} catch (_) { /* preferences remain active for this page */ }
}
function loadAccessibilityPreferences() {
try {
accessibilitySize = Number(localStorage.getItem("docslib-accessibility-size")) || 16;
accessibilityFont = localStorage.getItem("docslib-accessibility-font") || "system-ui";
} catch (_) {
accessibilitySize = 16;
accessibilityFont = "system-ui";
}
applyAccessibilityPreferences({ save: false });
}
function setAccessibilityOpen(open, { restoreFocus = true } = {}) {
if (!elements.accessibilityView) return;
document.body.classList.toggle("accessibility-open", open);
elements.accessibilityView.hidden = !open;
elements.accessibilityButton?.setAttribute("aria-expanded", String(open));
elements.mobileAccessibilityButton?.setAttribute("aria-expanded", String(open));
if (open) {
closeSearch({ clear: true });
setMobileMode("content");
elements.pageSurface.scrollTo({ top: 0, behavior: "instant" });
requestAnimationFrame(() => elements.accessibilityBack?.focus());
} else if (restoreFocus) {
(mobileLayout.matches ? elements.mobileMenuButton : elements.accessibilityButton)?.focus();
}
}
function closeSearch({ clear = false } = {}) {
elements.desktopSearchShell.classList.remove("open");
if (clear) {
@ -146,11 +294,15 @@
function navigate(number, replace = false) {
const hash = `#/page/${encodeURIComponent(number)}`;
if (replace) history.replaceState(null, "", hash);
else if (location.hash !== hash) location.hash = hash;
if (mobileLayout.matches && mobileMode === "menu") skipNextPageAnimation = true;
if (document.body.classList.contains("accessibility-open")) setAccessibilityOpen(false, { restoreFocus: false });
closeSearch({ clear: true });
setMobileMode("content");
renderPage(number);
if (replace) {
history.replaceState(null, "", hash);
renderPage(number);
} else if (location.hash !== hash) location.hash = hash;
else renderPage(number);
}
function buildTree(container) {
@ -226,15 +378,98 @@
return asset ? `data:${asset.mime};base64,${asset.data}` : "";
}
function hydrateAssets() {
elements.content.querySelectorAll("[data-docslib-asset]").forEach((element) => {
function hydrateAssets(root = elements.content, { eager = false } = {}) {
root.querySelectorAll("[data-docslib-asset]").forEach((element) => {
const name = element.dataset.docslibAsset;
const uri = assetDataUri(name);
if (uri && element.tagName === "IMG") element.src = uri;
if (uri && element.tagName === "IMG") {
if (eager) element.loading = "eager";
element.src = uri;
}
else if (!uri) element.setAttribute("title", name);
});
}
function removeFullPrintDocument() {
document.body.classList.remove("docslib-print-all");
document.getElementById("docslib-full-print")?.remove();
}
function showPrintLoading() {
document.getElementById("docslib-print-loading")?.remove();
const overlay = document.createElement("div");
overlay.id = "docslib-print-loading";
overlay.className = "print-loading-overlay";
overlay.setAttribute("aria-hidden", "true");
const spinner = document.createElement("div");
spinner.className = "print-loading-spinner";
overlay.append(spinner);
document.body.append(overlay);
return overlay;
}
async function waitForPrintAssets(root) {
const images = [...root.querySelectorAll("img")];
await Promise.all(images.map(async (image) => {
if (!image.complete) {
await new Promise((resolve) => {
image.addEventListener("load", resolve, { once: true });
image.addEventListener("error", resolve, { once: true });
});
}
if (typeof image.decode === "function" && image.naturalWidth > 0) {
await image.decode().catch(() => {});
}
}));
if (document.fonts && document.fonts.ready) await document.fonts.ready;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
async function PrintFullDocumentation() {
const overlay = showPrintLoading();
removeFullPrintDocument();
try {
const printable = document.createElement("div");
printable.id = "docslib-full-print";
printable.className = "full-print-document";
manifest.pages.forEach((page) => {
const article = document.createElement("article");
article.className = "content print-page";
article.dataset.page = page.number;
const heading = document.createElement("header");
heading.className = "print-page-heading";
const number = document.createElement("span");
number.className = "print-page-number";
number.textContent = page.number;
const title = document.createElement("h1");
title.textContent = page.title;
heading.append(number, title);
const body = document.createElement("div");
body.className = "print-page-content";
body.innerHTML = page.content || "";
article.append(heading, body);
hydrateAssets(article, { eager: true });
printable.append(article);
});
document.body.append(printable);
await waitForPrintAssets(printable);
document.body.classList.add("docslib-print-all");
window.print();
} catch (error) {
removeFullPrintDocument();
throw error;
} finally {
overlay.remove();
}
}
globalThis.PrintFullDocumentation = PrintFullDocumentation;
window.addEventListener("afterprint", removeFullPrintDocument);
function updateActiveNavigation(number) {
document.querySelectorAll(".tree-link[data-page]").forEach((button) => {
button.classList.toggle("active", button.dataset.page === number);
@ -259,15 +494,18 @@
button.onclick = () => navigate(page.number);
}
function renderPage(number) {
if (!manifest.pages.length) {
elements.content.replaceChildren();
elements.previous.disabled = elements.next.disabled = true;
return;
}
let index = manifest.pages.findIndex((page) => page.number === number);
if (index < 0) index = 0;
const page = manifest.pages[index];
function applyPage(page, index) {
const previousPage = manifest.pages.find((candidate) => candidate.number === currentNumber) || null;
document.dispatchEvent(new CustomEvent("on_page_change", {
bubbles: true,
detail: {
page,
previousPage,
number: page.number,
title: page.title,
index,
},
}));
currentNumber = page.number;
if (routeNumber() !== page.number) history.replaceState(null, "", `#/page/${encodeURIComponent(page.number)}`);
document.title = `${page.title} · ${manifest.title}`;
@ -286,6 +524,56 @@
elements.pageSurface.scrollTo({ top: 0, behavior: "instant" });
}
async function renderPage(number) {
if (!manifest.pages.length) {
elements.content.replaceChildren();
elements.previous.disabled = elements.next.disabled = true;
return;
}
let index = manifest.pages.findIndex((page) => page.number === number);
if (index < 0) index = 0;
const page = manifest.pages[index];
const skipAnimation = skipNextPageAnimation;
skipNextPageAnimation = false;
if (page.number === currentNumber && elements.content.childNodes.length) return;
const currentIndex = manifest.pages.findIndex((candidate) => candidate.number === currentNumber);
const direction = currentIndex >= 0 && index < currentIndex ? -1 : 1;
const transitionElement = elements.pageTransition || elements.content;
const animate = currentIndex >= 0 && !skipAnimation && !reducedMotion.matches && typeof transitionElement.animate === "function";
const transitionId = ++pageTransitionId;
pageAnimation?.cancel();
pageAnimation = null;
if (animate) {
const outgoing = transitionElement.animate(
[
{ opacity: 1, transform: "translateX(0)" },
{ opacity: 0, transform: `translateX(${-30 * direction}px)` },
],
{ duration: 150, easing: "ease-in", fill: "forwards" },
);
pageAnimation = outgoing;
await outgoing.finished.catch(() => {});
if (transitionId !== pageTransitionId) return;
outgoing.cancel();
}
applyPage(page, index);
if (!animate || transitionId !== pageTransitionId) return;
const incoming = transitionElement.animate(
[
{ opacity: 0, transform: `translateX(${30 * direction}px)` },
{ opacity: 1, transform: "translateX(0)" },
],
{ duration: 210, easing: "ease-out" },
);
pageAnimation = incoming;
incoming.finished.catch(() => {}).finally(() => {
if (pageAnimation === incoming) pageAnimation = null;
});
}
function plainText(html) {
const node = document.createElement("div");
node.innerHTML = html;
@ -424,10 +712,16 @@
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
elements.themeIcon.textContent = theme === "dark" ? "☀" : "◐";
const icon = theme === "dark" ? "☀" : "◐";
elements.themeIcon.textContent = icon;
if (elements.mobileThemeIcon) elements.mobileThemeIcon.textContent = icon;
localStorage.setItem("docslib-theme", theme);
}
function toggleTheme() {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
}
function renderAll() {
window.__DOCSLIB_MANIFEST__ = manifest;
document.documentElement.lang = "und";
@ -554,12 +848,15 @@
async function start() {
applyTheme(localStorage.getItem("docslib-theme") || "dark");
loadAccessibilityPreferences();
renderAll();
announceRendered();
syncManifest();
if (config.livePreview) setInterval(pollLive, 1200);
}
ensureAccessibilityUi();
elements.desktopSearchInput.addEventListener("input", handleDesktopSearch);
elements.desktopSearchButton.addEventListener("click", () => elements.desktopSearchInput.focus());
elements.mobileSearchInput.addEventListener("input", handleMobileSearch);
@ -569,8 +866,34 @@
elements.mobileSearchResults.replaceChildren();
setMobileMode(mobileMode === "menu" ? "content" : "menu");
});
document.getElementById("theme-button").addEventListener("click", () => {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
try {
elements.desktopSidebarToggle.addEventListener("click", () => {
setDesktopSidebar(document.body.classList.contains("desktop-sidebar-collapsed"));
});
} catch (_) { /* older compiled HTML may not include the sidebar toggle */ }
document.getElementById("theme-button").addEventListener("click", toggleTheme);
if (elements.mobileThemeButton) elements.mobileThemeButton.addEventListener("click", toggleTheme);
elements.accessibilityButton?.addEventListener("click", () => setAccessibilityOpen(true));
elements.mobileAccessibilityButton?.addEventListener("click", () => setAccessibilityOpen(true));
elements.accessibilityBack?.addEventListener("click", () => setAccessibilityOpen(false));
elements.accessibilitySizeDecrease?.addEventListener("click", () => {
accessibilitySize -= 2;
applyAccessibilityPreferences();
});
elements.accessibilitySizeIncrease?.addEventListener("click", () => {
accessibilitySize += 2;
applyAccessibilityPreferences();
});
elements.accessibilityReset?.addEventListener("click", () => {
accessibilitySize = 16;
accessibilityFont = "system-ui";
applyAccessibilityPreferences();
});
elements.accessibilityFontOptions?.addEventListener("click", (event) => {
const button = event.target.closest("[data-accessibility-font]");
if (!button || !elements.accessibilityFontOptions.contains(button)) return;
accessibilityFont = button.dataset.accessibilityFont;
applyAccessibilityPreferences();
});
elements.refresh.addEventListener("pointerenter", () => elements.toolbar.classList.add("refresh-expanded"));
elements.refresh.addEventListener("pointerleave", () => elements.toolbar.classList.remove("refresh-expanded"));
@ -589,7 +912,9 @@
event.preventDefault();
(mobileLayout.matches ? elements.mobileSearchInput : elements.desktopSearchInput).focus();
}
if (event.key === "Escape") closeSearch({ clear: true });
if (event.key === "Escape" && document.body.classList.contains("accessibility-open")) {
setAccessibilityOpen(false);
} else if (event.key === "Escape") closeSearch({ clear: true });
});
mobileLayout.addEventListener?.("change", () => {
closeSearch({ clear: true });

View file

@ -22,7 +22,10 @@
<div id="docslib-loading" aria-hidden="true"><div class="docslib-loading-spinner"></div></div>
<div id="app" class="app-shell">
<header class="desktop-header">
<h2 id="desktop-site-title" class="desktop-title">{{DOCSLIB_TITLE}}</h2>
<div class="desktop-title-group">
<button id="desktop-sidebar-toggle" class="desktop-sidebar-toggle glyph-button" aria-label="☰" aria-expanded="true"></button>
<h2 id="desktop-site-title" class="desktop-title">{{DOCSLIB_TITLE}}</h2>
</div>
<div id="desktop-toolbar" class="desktop-toolbar">
<div id="desktop-search-shell" class="desktop-search-shell">
<div class="search-control">
@ -48,16 +51,19 @@
<section class="workspace">
<section id="page-surface" class="page-surface">
<main id="content" class="content" tabindex="-1"></main>
<footer class="page-navigation">
<button id="previous-page" class="nav-card"></button>
<button id="next-page" class="nav-card"></button>
</footer>
<div id="page-transition" class="page-transition">
<main id="content" class="content" tabindex="-1"></main>
<footer class="page-navigation">
<button id="previous-page" class="nav-card"></button>
<button id="next-page" class="nav-card"></button>
</footer>
</div>
</section>
<section id="mobile-menu-view" class="mobile-view mobile-menu-view">
<h1 id="mobile-site-title" class="mobile-site-title">{{DOCSLIB_TITLE}}</h1>
<nav id="mobile-header-buttons" class="mobile-header-buttons" aria-label="⋯"></nav>
<button id="mobile-theme-button" class="mobile-theme-button glyph-button" aria-label="◐"><span id="mobile-theme-icon"></span></button>
<nav id="mobile-page-tree" class="page-tree mobile-page-tree" aria-label="≡"></nav>
</section>

View file

@ -28,6 +28,30 @@ def test_loader_renders_indexeddb_before_background_sync():
assert "manifest = remote" not in client
def test_accessibility_ui_is_generated_by_runtime_for_existing_html():
templates = Path(docslib.__file__).with_name("templates")
index = (templates / "index.html").read_text(encoding="utf-8")
client = (templates / "client.js").read_text(encoding="utf-8")
css = (templates / "client.css").read_text(encoding="utf-8")
assert 'id="accessibility-button"' not in index
assert 'id="mobile-accessibility-button"' not in index
assert 'id="accessibility-view"' not in index
assert "function ensureAccessibilityUi()" in client
assert 'createAccessibilityButton("accessibility-button"' in client
assert 'createAccessibilityButton("mobile-accessibility-button"' in client
assert 'viewBox="0 -960 960 960" fill="currentColor"' in client
assert "M423.5-743.5Q400-767" in client
assert '<circle cx="12" cy="4.5"' not in client
assert "document.body.append(launcher)" in client
assert "elements.mobileTree.before(mobileActions)" in client
assert "(elements.pageTransition || elements.pageSurface).append(view)" in client
assert "--accessibility-font-family: system-ui" in css
assert "--accessibility-font-size: 16px" in css
assert ".accessibility-glyph path { fill: none" not in css
assert "@font-face" not in css
def test_all_endpoints_and_self_contained_build(tmp_path):
assets = tmp_path / "assets"
assets.mkdir()
@ -97,7 +121,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
assert html_response.status_code == 200
assert "http://127.0.0.1:9000/manifest.json" in html
assert "http://127.0.0.1:9000/version" in html
assert 'globalThis.COMPILED_FROM_VERSION = "0.3.7";' in html
assert 'globalThis.COMPILED_FROM_VERSION = "0.5.1";' in html
assert ".content { --custom-test: yes; }" in html
assert r'document.body.dataset.customTest = \"yes\";' in html
assert 'id="docslib-loading"' in html
@ -116,6 +140,12 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
assert "cached," in system_js
assert "applied: false" in system_js
assert 'new CustomEvent("on_update_available"' in system_js
assert 'new CustomEvent("on_page_change"' in system_js
assert "page," in system_js
assert "previousPage," in system_js
assert "number: page.number" in system_js
assert "title: page.title" in system_js
assert system_js.index('new CustomEvent("on_page_change"') < system_js.index("currentNumber = page.number")
assert "currentRevision: lastSyncedRevision" in system_js
assert 'config.versionUrl || siblingResourceUrl(config.manifestUrl, "version")' in system_js
assert 'target.searchParams.set(' in system_js
@ -150,28 +180,55 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
assert "--elevated: #d4d4d4" in html
assert "--shadow: 0 24px 70px rgba(0, 0, 0, .4)" in html
assert "elements.pageSurface.scrollTo" in html
assert "font-family: system-ui; font-size: 16px" in html
assert "font-family: var(--accessibility-font-family); font-size: 16px" in html
assert ".glyph-button, .refresh-glyph { font-family: Arial" in html
assert ".nav-arrow { display: block; width: 10px; height: 10px; flex: 0 0 10px; border: 0 solid currentColor; transform: rotate(45deg); }" in html
assert ".nav-arrow-previous { border-bottom-width: 2px; border-left-width: 2px; }" in html
assert ".nav-arrow-next { border-top-width: 2px; border-right-width: 2px; }" in html
assert 'arrow.textContent = direction' not in html
assert "globalThis.PrintFullDocumentation = PrintFullDocumentation" in system_js
assert 'printable.id = "docslib-full-print"' in system_js
assert "manifest.pages.forEach((page) =>" in system_js
assert 'window.addEventListener("afterprint", removeFullPrintDocument)' in system_js
assert "async function PrintFullDocumentation()" in system_js
assert "await waitForPrintAssets(printable)" in system_js
assert 'if (eager) element.loading = "eager"' in system_js
assert "hydrateAssets(article, { eager: true })" in system_js
assert 'image.addEventListener("load", resolve, { once: true })' in system_js
assert 'await image.decode().catch(() => {})' in system_js
assert "await document.fonts.ready" in system_js
system_css = manifest["client"]["css"]["system"]
assert 'body.docslib-print-all > .full-print-document { display: block; }' in system_css
assert 'color: #000 !important; background: #fff !important;' in system_css
assert ".print-page { break-after: page; page-break-after: always; }" in system_css
assert ".print-loading-overlay { position: fixed; inset: 0; z-index: 2147483647; display: grid; width: 100vw; height: 100dvh" in system_css
assert ".print-page-heading h1 { margin: 0; color: #000 !important; font-size: 1.35rem !important" in system_css
assert ".print-page-content h1 { font-size: 1.8rem !important" in system_css
assert "--shell: #000000" in html
assert 'id="desktop-search-input"' in html
assert '<header class="desktop-header">' in html
assert 'id="page-transition" class="page-transition"' in html
assert 'id="desktop-sidebar-toggle"' in html
assert 'aria-label="" aria-expanded="true">☰</button>' in html
assert '<aside class="desktop-sidebar">' in html
assert 'class="sidebar-header-buttons"' in html
assert 'id="mobile-search-input"' in html
assert 'id="mobile-menu-view"' in html
assert 'id="mobile-theme-button"' in html
assert 'id="mobile-theme-icon"' in html
assert 'id="mobile-search-view"' in html
assert "placeholderTick" in html
assert '".".repeat(dots)' in manifest["client"]["js"]["system"]
assert "setTimeout(placeholderTick, 5000)" in html
assert '.sidebar-header-buttons .header-link:not(.active):first-child' not in html
assert "border: 1px solid transparent" not in html
assert ".sidebar-header-buttons { border: 1px solid transparent" not in html
assert ".sidebar-header-buttons { display: flex; flex: 0 0 auto; flex-direction: column; gap: 4px; margin-bottom: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }" in html
assert ".desktop-sidebar > .page-tree { flex: 1; padding: 24px 2px 70px; mask-image: linear-gradient(transparent 0px, #000 24px, #000 calc(100% - 100px), transparent); scrollbar-width: none; }" in manifest["client"]["css"]["system"]
assert ".mobile-header-buttons { display: flex; flex-direction: column; gap: 6px; margin-bottom: 38px; border: 1px solid var(--border); border-radius: 24px; background: var(--surface); }" in html
assert ".mobile-header-buttons { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; border: 1px solid var(--border); border-radius: 24px; background: var(--surface); }" in html
assert ".mobile-theme-button, .mobile-accessibility-button { display: grid; width: 54px; height: 54px; margin: 0; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--text); background: var(--surface); font-size: 1.35rem; }" in html
assert 'elements.mobileThemeButton.addEventListener("click", toggleTheme)' in system_js
assert 'if (mobileLayout.matches && mobileMode === "menu") skipNextPageAnimation = true' in system_js
assert "currentIndex >= 0 && !skipAnimation && !reducedMotion.matches" in system_js
assert ".sidebar-header-buttons .header-link { color: var(--text); }" in html
assert ".sidebar-header-buttons .header-link:hover { background: var(--control); }" in html
assert ".sidebar-header-buttons .header-link.active { color: #050505; background: #f0f0f0; }" not in html
@ -185,6 +242,36 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
assert "background: transparent; font-size: 2.3rem" in html
assert ".mobile-page-tree .tree-link { min-height: 46px; padding: 9px 14px; border-radius: 24px" in html
assert ".mobile-menu-button { width: 72px; height: 72px; flex: 0 0 72px; border-radius: 50%; color: var(--text); background: var(--control); font-size: 1.6rem; }" in html
assert ".desktop-sidebar-toggle { display: grid; width: 44px; height: 44px; flex: 0 0 44px" in html
assert ".desktop-sidebar > * { width: calc(var(--desktop-sidebar-width) - 36px); min-width: calc(var(--desktop-sidebar-width) - 36px); }" in system_css
assert "body.desktop-sidebar-collapsed .desktop-sidebar { width: 0; flex-basis: 0;" in html
assert 'elements.desktopSidebarToggle.addEventListener("click"' in system_js
assert 'try {\n elements.desktopSidebarToggle.addEventListener("click"' in system_js
assert "older compiled HTML may not include the sidebar toggle" in system_js
assert "const transitionElement = elements.pageTransition || elements.content" in system_js
assert "if (elements.mobileThemeIcon) elements.mobileThemeIcon.textContent = icon" in system_js
assert "if (elements.mobileThemeButton) elements.mobileThemeButton.addEventListener" in system_js
assert "ensureAccessibilityUi();" in system_js
assert 'new Set(["system-ui", "sans-serif", "serif", "monospace"])' in system_js
assert 'localStorage.setItem("docslib-accessibility-size"' in system_js
assert 'localStorage.setItem("docslib-accessibility-font"' in system_js
assert 'document.documentElement.style.setProperty("--accessibility-font-size"' in system_js
assert 'document.documentElement.style.setProperty("--accessibility-font-family"' in system_js
assert 'body.accessibility-open .content, body.accessibility-open .page-navigation { display: none; }' in system_css
assert ".accessibility-button { position: fixed; z-index: 40;" in system_css
assert ".accessibility-preview { max-width: 920px; margin-top: 56px; font-size: var(--accessibility-font-size); }" in system_css
assert ".glyph-button, .refresh-glyph { font-family: Arial" in system_css
assert 'document.body.classList.toggle("desktop-sidebar-collapsed", !open)' in system_js
assert "localStorage.setItem" not in system_js[system_js.index("function setDesktopSidebar"):system_js.index("function createAccessibilityButton")]
assert "animation: mobile-menu-enter .26s" in html
assert "transform: translateX(-100%)" in html
assert "const direction = currentIndex >= 0 && index < currentIndex ? -1 : 1" in system_js
assert 'const outgoing = transitionElement.animate(' in system_js
assert 'const incoming = transitionElement.animate(' in system_js
assert 'transform: `translateX(${-30 * direction}px)`' in system_js
assert 'transform: `translateX(${30 * direction}px)`' in system_js
assert 'opacity: 0' in system_js
assert "!reducedMotion.matches" in system_js
assert ".nav-card { display: flex; min-height: 48px; align-items: center; gap: 8px; padding: 7px 20px; border: 0; border-radius: 30px" in html
assert "background: linear-gradient(0deg, var(--shell) 0%, transparent)" in html
assert "<script src=" not in html
@ -200,7 +287,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
def test_build_writes_static_bundle(tmp_path):
assert __version__ == "0.3.7"
assert __version__ == "0.5.1"
@Page("1", "Static page")
def static_page():