tosijs-ui 1.16.38 → 1.16.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.40
4
+
5
+ - **An example with no script runs under a strict CSP (#245, #246).** A pure-HTML example still
6
+ built an `AsyncFunction` from its empty script, and a dialect that runs its own source (`run`)
7
+ had one built before its `run` was called. Under a Content Security Policy without
8
+ `'unsafe-eval'` (`script-src 'self'`) the constructor throws, so each such example showed an
9
+ error and filed a violation report every time it ran. Neither now constructs a function at
10
+ all, so HTML examples, and `run` dialects that only sanitize and bind markup, work on a
11
+ CSP-protected host. Script examples still need `'unsafe-eval'`; a CSP-compatible path for
12
+ them, and an HTML-only example mode, are planned for 1.17.0.
13
+ - **The doc header's search says "Search", and a site can set it.** `searchPlaceholder` in the
14
+ site config (or `createDocBrowser`) sets the hint in the header's search field, and the
15
+ trigger's accessible name; the default was "Search documentation" (set
16
+ `searchPlaceholder: 'Search documentation'` to keep it, and update any test that looks the
17
+ field up by that name). It is localized through the site's strings, and its placeholder
18
+ follows the locale the reader picks. `<tosi-palette>` gained a `localizer` property for this: a function its placeholder
19
+ goes through, such as `localize`. It is a function rather than a `localized` attribute so the
20
+ palette does not import the localization module, and `<tosi-select>` with it, wherever it is
21
+ used.
22
+ - **The ⌘K hint in the search field has no outline.** An outline inside the field's own outline
23
+ read as a second control. The hints inside the open palette keep theirs.
24
+
25
+ ## 1.16.39
26
+
27
+ - **The doc-site frame passes WCAG 2.2 AA where an axe scan said it did not (#3520).** An
28
+ adopter's scan found four failures in the frame, so every doc site had them:
29
+ - The header's "Search documentation" trigger and its ⌘K hint measured 1.88:1 (AA asks
30
+ 4.5:1). `--placeholder-color` was the brand at 40% opacity; it is now the brand darkened
31
+ (or, in dark mode, brightened) just far enough to reach 4.5:1 on the input, for any accent.
32
+ It colours every `::placeholder` on a doc site, live-example inputs included, so
33
+ placeholders are darker everywhere.
34
+ - Code (inline and in blocks) was the brand colour, 4.56:1 on the page and 3.72:1 inside a
35
+ blockquote. It is now `--code-color`: the brand made readable on the blockquote and code
36
+ backgrounds. It is computed from your config's `accent`, so code no longer follows a
37
+ runtime override of `--brand-color`; set `--code-color` too if you do that.
38
+ - Each nav section was `<details><summary><a>`, a link inside a control, which a screen reader
39
+ announces as one and loses the other. A section is now a toggle `<button>` beside its link,
40
+ with `aria-expanded` and `aria-controls`, at least 24px square (the old triangle was under
41
+ WCAG 2.2's target size). Without JavaScript every section is open, since nothing could open
42
+ one. Until the bundle loads, a small script in each generated page opens and closes
43
+ sections, so a slow or failed bundle does not strand a reader; it also marks `<html>` with
44
+ `tosi-js`. A doc browser made with `createDocBrowser` outside `<tosi-doc-system>` brings
45
+ its own section styles. **If your site's CSS targets `.doc-nav details` or
46
+ `.doc-nav summary`, it no longer matches**: use `.doc-nav .nav-branch` (`.-open` when
47
+ open), `.nav-branch-head` and `.nav-toggle`.
48
+ - A code block that scrolls sideways could not be reached by keyboard. Static code blocks are
49
+ now focusable once the page hydrates.
50
+ - **`devServer()` builds on start when it is watching (#3524).** The documented `bin/site.ts`
51
+ one-liner never built: it served whatever the previous run left in the output folder, so a
52
+ config change (a theme accent, an ePub cover) showed nothing until a source file changed, and
53
+ nothing said the page was stale. (`DEV_NO_WATCH=1` and `{ test: true }` still serve what is
54
+ there.) **If your script builds and then calls `devServer`, as the full-pipeline example in
55
+ the docs used to, it now builds twice on start**: pass `initialBuild: false`, or drop your
56
+ own build. That build is your `build` function when you pass one, so the dependency audit
57
+ inside `buildSite` runs there too, and a blocking advisory leaves the server up with the
58
+ failure on the page rather than exiting. The config file itself is not watched: restart the
59
+ server after editing it.
60
+ - **A table has a margin (#3553).** Tables were the one block element without one, so a
61
+ blockquote after one sat flush against it. The ePub was already right.
62
+
3
63
  ## 1.16.38
4
64
 
5
65
  - **The ePub carries its images (#3519).** Every local image went into the book as a site path
@@ -118,6 +118,11 @@ export interface DocBrowserOptions {
118
118
  * for a host page that owns that key or embeds more than one browser.
119
119
  */
120
120
  searchShortcut?: string;
121
+ /**
122
+ * The hint in the header's search field (default `'Search'`). Localized through the site's
123
+ * strings, like every other piece of the doc system's own text.
124
+ */
125
+ searchPlaceholder?: string;
121
126
  /**
122
127
  * Header-bar links. When provided, these replace the legacy `projectLinks` icon
123
128
  * set in the header (each renders as an icon if `icon` names a known icon, else
@@ -132,4 +137,5 @@ export interface DocBrowserOptions {
132
137
  */
133
138
  contentElement?: HTMLElement;
134
139
  }
140
+ export declare function focusableCodeBlocks(root: ParentNode): void;
135
141
  export declare function createDocBrowser(options: DocBrowserOptions): HTMLElement;
@@ -102,6 +102,7 @@ or embeds more than one browser, passes `searchShortcut: ''` (or another key in
102
102
  import { elements, vars, varDefault, bindings, getListItem, tosi, StyleSheet, } from 'tosijs';
103
103
  import { buildSlugMap, pathForSlug, filenameForPath, homeDoc, wikilinkTargets, } from './doc-system/routing.js';
104
104
  import { buildNavTree } from './doc-system/nav-tree.js';
105
+ import { navSectionStyleSpec } from './doc-system/nav-styles.js';
105
106
  import { renderDocMarkdown } from './doc-system/render.js';
106
107
  import { pageTitle } from './doc-system/doc-title.js';
107
108
  import { unsettledExamples, tallyExamples, closePage, } from './doc-system/test-completion.js';
@@ -114,7 +115,7 @@ import { tosiSidenav, TosiSidenav } from './side-nav.js';
114
115
  import { icons } from './icons.js';
115
116
  import { tosiPalette } from './palette.js';
116
117
  import { buildDocIndex, searchDocs } from './doc-system/doc-search.js';
117
- import { tosiLocalized } from './localize.js';
118
+ import { tosiLocalized, localize, i18n } from './localize.js';
118
119
  import { popMenu } from './menu.js';
119
120
  import { codeEditor } from './code-editor.js';
120
121
  import { tosiDiff } from './diff.js';
@@ -148,7 +149,7 @@ export const hasTestBlock = (text, policy = 'auto') => typeof text === 'string'
148
149
  // Cheap reject first: lexing every doc in a large corpus is not free.
149
150
  text.includes('test') &&
150
151
  collectCodeTokens(text).some((t) => t.lang === 'test' && isLiveFence('test', t.mode, policy));
151
- const { div, span, a, header, button, template, h2, img, details, summary, ul, li, } = elements;
152
+ const { div, span, a, header, button, template, h2, img, ul, li } = elements;
152
153
  /*
153
154
  Test colors, and the text color that goes ON each — because contrast is a PAIR.
154
155
 
@@ -374,11 +375,27 @@ const logoMarkStyleSpec = {
374
375
  // Inline-<svg> logos are wrapped in a flex holder; scale the embedded svg to it.
375
376
  '.logo-mark > svg': { height: '100%', width: 'auto' },
376
377
  };
378
+ /*
379
+ A code block that scrolls sideways must be reachable by keyboard, or its overflow cannot be
380
+ read without a pointer (WCAG 2.1.1, axe `scrollable-region-focusable`, #3520). Every static
381
+ block gets it, not only the ones overflowing now: overflow changes with the window, and a
382
+ check at render time would go stale on the first resize. Done here rather than in the
383
+ markdown renderer so the pre-rendered page stays byte-identical to what it was.
384
+ */
385
+ export function focusableCodeBlocks(root) {
386
+ for (const pre of root.querySelectorAll('pre')) {
387
+ if (pre.hasAttribute('tabindex') || pre.closest('tosi-example'))
388
+ continue;
389
+ pre.tabIndex = 0;
390
+ }
391
+ }
377
392
  // Monotonic per-page counter so each createDocBrowser() call gets a distinct
378
393
  // tosi() registry key (see stateKey below) and two browsers can't share state.
379
394
  let docBrowserSeq = 0;
395
+ // Likewise for the nav's aria-controls ids.
396
+ let navIdCounter = 0;
380
397
  export function createDocBrowser(options) {
381
- const { docs, context = {}, projectName = '', projectLinks = {}, logo, navSize = 200, minSize = 600, routing = 'query', initialRoute, aliases = {}, onRouteChange, searchShortcut = 'mod+k', navbarLinks, contentElement, } = options;
398
+ const { docs, context = {}, projectName = '', projectLinks = {}, logo, navSize = 200, minSize = 600, routing = 'query', initialRoute, aliases = {}, onRouteChange, searchShortcut = 'mod+k', searchPlaceholder = 'Search', navbarLinks, contentElement, } = options;
382
399
  // Memory routing is fully self-contained: it never reads or writes
383
400
  // window.history/location, so an embedded or nested browser can't hijack the
384
401
  // host page's URL (or recurse into it).
@@ -733,11 +750,14 @@ export function createDocBrowser(options) {
733
750
  // In the header bar (see headerContent); the nav no longer holds a search field.
734
751
  const searchBox = tosiPalette({
735
752
  class: 'doc-search',
736
- placeholder: 'Search documentation',
753
+ placeholder: searchPlaceholder,
754
+ localizer: localize,
737
755
  shortcut: searchShortcut,
738
756
  sources: [docSearchSource],
739
757
  quickLinks,
740
758
  });
759
+ // The hint follows the reader's language, like the rest of the doc system's own text.
760
+ i18n.locale.observe(() => searchBox.queueRender());
741
761
  // Memory routing is decoupled from the page URL, so it ignores browser
742
762
  // back/forward (the host owns history, if any).
743
763
  if (!memoryRouting) {
@@ -1094,6 +1114,7 @@ export function createDocBrowser(options) {
1094
1114
  // Stamp each example with its source file (for the source↔doc map). doc.path
1095
1115
  // is the extracted file (.md, or a source file with doc comments).
1096
1116
  LiveExample.insertExamples(docContent, context, doc.path || undefined);
1117
+ focusableCodeBlocks(docContent);
1097
1118
  /*
1098
1119
  Highlight whatever is left — the static blocks that are not live examples.
1099
1120
 
@@ -1327,6 +1348,7 @@ export function createDocBrowser(options) {
1327
1348
  docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path), { wikilinks });
1328
1349
  rewriteContentLinks();
1329
1350
  LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
1351
+ focusableCodeBlocks(docContent);
1330
1352
  }
1331
1353
  else if (view === 'diff') {
1332
1354
  docContent.replaceChildren(tosiDiff({
@@ -1506,7 +1528,7 @@ export function createDocBrowser(options) {
1506
1528
  return menuItems;
1507
1529
  };
1508
1530
  // ── Hierarchical nav (path routing) ───────────────────────────────────────
1509
- // Build nested <details> from the doc tree; current-highlight, test status,
1531
+ // Build nested sections from the doc tree; current-highlight, test status,
1510
1532
  // search visibility, and auto-open are applied imperatively by refreshNav.
1511
1533
  const navStyle = {
1512
1534
  slot: 'nav',
@@ -1523,6 +1545,8 @@ export function createDocBrowser(options) {
1523
1545
  };
1524
1546
  const buildHierarchicalNav = () => {
1525
1547
  const roots = buildNavTree(docs, slugMap);
1548
+ // Ids for aria-controls, unique per browser: an embedded doc browser shares the page.
1549
+ const navIdPrefix = `tosi-nav-${(navIdCounter += 1)}-`;
1526
1550
  const leaves = new Map();
1527
1551
  const branches = [];
1528
1552
  const subtreeFilenames = (node) => {
@@ -1560,18 +1584,39 @@ export function createDocBrowser(options) {
1560
1584
  leaves.set(node.doc.filename, { li: item, link });
1561
1585
  return item;
1562
1586
  }
1563
- const det = details(summary(link), ul(...node.children.map(renderNode)));
1564
- const item = li(det);
1587
+ /*
1588
+ A toggle button BESIDE the link, not a <summary> around it (#3520): a link inside a
1589
+ summary is a control inside a control. Same markup as the static nav
1590
+ (`navToggleHtml` in generate-site.ts), so hydration does not move anything.
1591
+ */
1592
+ const listId = `${navIdPrefix}${node.slug || 'home'}`;
1593
+ const toggle = button({
1594
+ type: 'button',
1595
+ class: 'nav-toggle',
1596
+ ariaExpanded: 'false',
1597
+ ariaControls: listId,
1598
+ ariaLabel: String(node.doc.title),
1599
+ onClick: () => section.setOpen(!item.classList.contains('-open')),
1600
+ });
1601
+ const item = li({ class: 'nav-branch' }, div({ class: 'nav-branch-head' }, toggle, link), ul({ id: listId }, ...node.children.map(renderNode)));
1602
+ const section = {
1603
+ setOpen(open) {
1604
+ item.classList.toggle('-open', open);
1605
+ toggle.setAttribute('aria-expanded', String(open));
1606
+ },
1607
+ };
1565
1608
  branches.push({
1566
1609
  li: item,
1567
- el: det,
1610
+ el: section,
1568
1611
  link,
1569
1612
  filename: node.doc.filename,
1570
1613
  subtree: subtreeFilenames(node),
1571
1614
  });
1572
1615
  return item;
1573
1616
  };
1574
- const root = div(navStyle, ul(...roots.map(renderNode)));
1617
+ // `-live`: these sections have working toggles, so closed ones collapse even where no
1618
+ // pre-paint script marked <html> (a browser embedded outside a generated page).
1619
+ const root = div({ ...navStyle, class: `${navStyle.class} -live` }, ul(...roots.map(renderNode)));
1575
1620
  const applyStatus = (link, filename, current) => {
1576
1621
  link.classList.toggle('current', filename === current);
1577
1622
  const r = pageTestResults[filename];
@@ -1590,9 +1635,9 @@ export function createDocBrowser(options) {
1590
1635
  // On navigation (resetOpen), collapse everything except the current doc's section.
1591
1636
  // Otherwise (e.g. test-status refresh) leave user toggles alone.
1592
1637
  if (resetOpen)
1593
- el.open = subtree.includes(current);
1638
+ el.setOpen(subtree.includes(current));
1594
1639
  else if (subtree.includes(current))
1595
- el.open = true;
1640
+ el.setOpen(true);
1596
1641
  }
1597
1642
  };
1598
1643
  refreshNav(true);
@@ -1691,6 +1736,7 @@ export function createDocBrowser(options) {
1691
1736
  // Header brand-mark sizing (icon / <img> / inline-<svg>), one CSS var each.
1692
1737
  StyleSheet('doc-logo-mark', logoMarkStyleSpec);
1693
1738
  StyleSheet('doc-nav-search', navSearchStyleSpec);
1739
+ StyleSheet('doc-nav-sections', navSectionStyleSpec);
1694
1740
  /*
1695
1741
  The far end has something to tell you.
1696
1742
 
@@ -1,4 +1,4 @@
1
- import { XinStyleSheet, XinStyleRule } from 'tosijs';
1
+ import { XinStyleSheet, XinStyleRule, Color } from 'tosijs';
2
2
  export declare const SIDEBAR_WIDTH = 200;
3
3
  export declare const SIDEBAR_BREAKPOINT = 600;
4
4
  export interface DocSystemTheme {
@@ -22,6 +22,17 @@ export interface DocSystemTheme {
22
22
  */
23
23
  codeBg?: string;
24
24
  }
25
+ /**
26
+ * WCAG 2 contrast ratio between two opaque colors. Throws on a translucent one: its real
27
+ * contrast depends on what is behind it, and ignoring alpha measured the old 40%-opacity
28
+ * placeholder at 4.74:1 where axe, compositing it, measured 1.88:1.
29
+ */
30
+ export declare function contrastRatio(a: Color, b: Color): number;
31
+ /**
32
+ * `color`, darkened (on a light `bg`) or brightened (on a dark one) just far enough to reach
33
+ * `min` contrast against `bg`, so it keeps as much of its hue as AA allows.
34
+ */
35
+ export declare function readableOn(color: Color, bg: Color, min?: number): Color;
25
36
  /** Compute the full set of `:root` color variables from a few base colors. */
26
37
  export declare function docSystemColors(theme?: DocSystemTheme): XinStyleRule;
27
38
  /** Build the full doc-system stylesheet for a given base theme. */
@@ -10,6 +10,7 @@ Colors are computed from a small set of base colors via tosijs `Color` math, so
10
10
  palette can eventually be driven entirely by a few attributes on the element
11
11
  (accent/background/text) — most of the palette is derived from `accent`.
12
12
  */
13
+ import { navSectionStyleSpec } from './nav-styles.js';
13
14
  import { highlightStyleSpec } from './highlight-styles.js';
14
15
  import { vars, Color, invertLuminance, } from 'tosijs';
15
16
  import { icons, svg2DataUrl } from '../icons.js';
@@ -19,6 +20,36 @@ import { icons, svg2DataUrl } from '../icons.js';
19
20
  // `SIDEBAR_BREAKPOINT`. Keep them in step, or the page jumps as it hydrates.
20
21
  export const SIDEBAR_WIDTH = 200;
21
22
  export const SIDEBAR_BREAKPOINT = 600;
23
+ /**
24
+ * WCAG 2 contrast ratio between two opaque colors. Throws on a translucent one: its real
25
+ * contrast depends on what is behind it, and ignoring alpha measured the old 40%-opacity
26
+ * placeholder at 4.74:1 where axe, compositing it, measured 1.88:1.
27
+ */
28
+ export function contrastRatio(a, b) {
29
+ if (a.RGBA[3] < 1 || b.RGBA[3] < 1)
30
+ throw new Error('contrastRatio: composite a translucent color first');
31
+ const lum = (c) => {
32
+ const [r, g, bl] = c.RGBA.map((v) => v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
33
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
34
+ };
35
+ const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
36
+ return (hi + 0.05) / (lo + 0.05);
37
+ }
38
+ /**
39
+ * `color`, darkened (on a light `bg`) or brightened (on a dark one) just far enough to reach
40
+ * `min` contrast against `bg`, so it keeps as much of its hue as AA allows.
41
+ */
42
+ export function readableOn(color, bg, min = 4.5) {
43
+ const darkBg = contrastRatio(bg, Color.black) < contrastRatio(bg, Color.white);
44
+ // Measured on the hex the stylesheet will actually carry: rounding to 8-bit channels
45
+ // can drop a ratio just over `min` to just under it.
46
+ const shipped = (c) => Color.fromCss(c.html);
47
+ let c = shipped(color.opaque);
48
+ for (let step = 0; step < 20 && contrastRatio(c, bg) < min; step++) {
49
+ c = shipped(darkBg ? c.brighten(0.1) : c.darken(0.1));
50
+ }
51
+ return contrastRatio(c, bg) < min ? bg.contrasting() : c;
52
+ }
22
53
  /** Compute the full set of `:root` color variables from a few base colors. */
23
54
  export function docSystemColors(theme = {}) {
24
55
  const brandColor = Color.fromCss(theme.accent ?? '#d92270');
@@ -32,7 +63,12 @@ export function docSystemColors(theme = {}) {
32
63
  _navBg: brandColor.rotate(30).desaturate(0.5).brighten(0.9),
33
64
  _barColor: brandColor.opacity(0.4),
34
65
  _focusColor: brandColor.opacity(0.7),
35
- _placeholderColor: brandColor.opacity(0.4),
66
+ /*
67
+ Readable, not faint (#3520). This was the brand at 40% opacity, which an axe scan measured
68
+ at 1.88:1 on the input (#eda7c5 on #fdfdfd) where AA asks 4.5:1, and it colours the doc
69
+ header's search trigger, a real label, not just a hint.
70
+ */
71
+ _placeholderColor: readableOn(brandColor, Color.fromCss(theme.inputBg ?? '#fdfdfd')),
36
72
  /*
37
73
  `contrasting()` rather than a hand-tuned tint.
38
74
 
@@ -46,6 +82,10 @@ export function docSystemColors(theme = {}) {
46
82
  */
47
83
  _brandTextColor: brandColor.contrasting(),
48
84
  _insetBg: brandColor.rotate(45).brighten(0.8),
85
+ // The brand, made readable on the inset (blockquote) background, the darker of the two
86
+ // surfaces code sits on. The raw brand measured 3.72:1 there (#3520).
87
+ // Checked against the code background too: a custom `codeBg` can be the darker one.
88
+ _codeColor: readableOn(readableOn(brandColor, brandColor.rotate(45).brighten(0.8)), Color.fromCss(theme.codeBg ?? '#fdfdfd')),
49
89
  // Neutral off-white (not brand-tinted); dark mode inverts its luminance to
50
90
  // off-black, so the editor surface stays a clean neutral in both themes.
51
91
  _codeBg: theme.codeBg ?? '#fdfdfd',
@@ -182,6 +222,8 @@ export function docSystemStyleSpec(theme = {}) {
182
222
  brand, the luminance belongs to the background it sits on.
183
223
  */
184
224
  _brandColor: brandColor.brighten(0.18),
225
+ _codeColor: readableOn(readableOn(brandColor.brighten(0.18), brandColor.rotate(45).brighten(0.8).inverseLuminance), Color.fromCss(theme.codeBg ?? '#fdfdfd').inverseLuminance),
226
+ _placeholderColor: readableOn(brandColor.brighten(0.18), Color.fromCss(theme.inputBg ?? '#fdfdfd').inverseLuminance),
185
227
  _shadowColor: brandColor.opacity(0.5),
186
228
  _menuShadow: `0 0 0 2px ${brandColor.opacity(0.75)}`,
187
229
  _menuSeparatorColor: brandColor.opacity(0.5),
@@ -281,9 +323,9 @@ export function docSystemStyleSpec(theme = {}) {
281
323
  display: 'none',
282
324
  },
283
325
  // Wide enough for the sidebar: reserve its column, and show the REAL nav tree
284
- // in it. It's a plain <ul> with native <details> groups, so it navigates and
285
- // collapses without a line of JavaScript — a no-JS reader gets a working site,
286
- // and hydration merely upgrades it in place.
326
+ // in it. It's a plain <ul> of real links, every section expanded when there is no
327
+ // JavaScript to collapse one (no `tosi-js` on <html>) — a no-JS reader gets a
328
+ // working site, and hydration merely upgrades it in place.
287
329
  [`@media (min-width: ${SIDEBAR_BREAKPOINT}px)`]: {
288
330
  'tosi-doc-system:not(:defined)': {
289
331
  left: `${SIDEBAR_WIDTH}px`,
@@ -314,9 +356,6 @@ export function docSystemStyleSpec(theme = {}) {
314
356
  //
315
357
  // Only genuinely pre-hydration-specific rules belong in this block — the fixed
316
358
  // positioning above, which the upgraded component takes over itself.
317
- 'tosi-doc-system:not(:defined) .doc-nav summary': {
318
- cursor: 'pointer',
319
- },
320
359
  },
321
360
  '.center': {
322
361
  display: 'flex',
@@ -507,7 +546,7 @@ export function docSystemStyleSpec(theme = {}) {
507
546
  // `--text-color` token rather than setting a colour, so everything inside a `<pre>`
508
547
  // — including every syntax-highlighting token span — inherited a palette where the
509
548
  // theme's text colour had been replaced by the brand colour.
510
- color: vars.brandColor,
549
+ color: vars.codeColor,
511
550
  },
512
551
  /*
513
552
  Prism token colours for STATIC code blocks — TWO palettes, because there are two
@@ -554,7 +593,7 @@ export function docSystemStyleSpec(theme = {}) {
554
593
  boxShadow: 'none',
555
594
  color: vars.textColor,
556
595
  },
557
- '.current, summary:has(.current)': {
596
+ '.current, .nav-branch-head:has(.current)': {
558
597
  background: vars.background,
559
598
  },
560
599
  '.doc-link': {
@@ -584,8 +623,11 @@ export function docSystemStyleSpec(theme = {}) {
584
623
  "[class*='icon-']": {
585
624
  verticalAlign: 'middle',
586
625
  },
626
+ // Every other block element brings its own margin; without one a table sat flush
627
+ // against a following blockquote, whose top margin is 0 by design (#3553).
587
628
  table: {
588
629
  borderCollapse: 'collapse',
630
+ margin: `${vars.spacing} 0 ${vars.spacing200} 0`,
589
631
  },
590
632
  thead: {
591
633
  background: vars.brandColor,
@@ -601,7 +643,7 @@ export function docSystemStyleSpec(theme = {}) {
601
643
  padding: 'calc(var(--spacing) * 0.5) var(--spacing)',
602
644
  },
603
645
  // Nav lists carry semantics only — no list styling. Hierarchy indentation
604
- // comes from left-padding on each section's child list (inside <details>).
646
+ // comes from left-padding on each section's child list.
605
647
  '.doc-nav ul, .doc-nav li': {
606
648
  listStyle: 'none',
607
649
  margin: 0,
@@ -615,44 +657,7 @@ export function docSystemStyleSpec(theme = {}) {
615
657
  display: 'flex',
616
658
  flexDirection: 'column',
617
659
  },
618
- '.doc-nav details > ul': {
619
- paddingLeft: vars.spacing,
620
- },
621
- // Collapse a closed section's list SYNCHRONOUSLY. The UA collapses <details>
622
- // via `::details-content { content-visibility: hidden }`, which is applied
623
- // lazily and — for this static tree — non-deterministically on first paint:
624
- // sometimes the closed section is still laid out at full height for a frame
625
- // before it snaps shut. That frame is a visible flicker on refresh, and it made
626
- // the "nav does not move on hydration" test measure a 36px row that hydration
627
- // then collapses to 0. An explicit display:none collapses it in the first layout
628
- // pass with no content-visibility race, and still opens on a no-JS <summary>
629
- // toggle (the UA adds [open], so this rule stops matching).
630
- '.doc-nav details:not([open]) > ul': {
631
- display: 'none',
632
- },
633
- // <summary> is the section header (its triangle toggles; the link navigates).
634
- '.doc-nav summary': {
635
- cursor: 'pointer',
636
- // indent the disclosure marker a bit from the edge
637
- paddingLeft: 'calc(var(--spacing) * 0.5)',
638
- },
639
- '.doc-nav summary::marker': {
640
- color: vars.brandColor,
641
- },
642
- '.doc-nav summary > .doc-link': {
643
- display: 'inline-block',
644
- // push the summary text in a bit less than a normal doc-link
645
- paddingLeft: 'calc(var(--spacing) * 0.5)',
646
- /*
647
- Room for the disclosure triangle on the same line (#219). An inline-block as wide as
648
- its text does not fit beside the marker once the title is long, so the whole link
649
- dropped to the next line and left the triangle alone on a row of its own, which reads
650
- as an empty nav entry. Capped, it stays beside the triangle and wraps inside itself.
651
- */
652
- boxSizing: 'border-box',
653
- maxWidth: 'calc(100% - 1.5em)',
654
- verticalAlign: 'top',
655
- },
660
+ ...navSectionStyleSpec,
656
661
  // Declarative header link list — shown for no-JS, removed on hydration.
657
662
  '.doc-navbar': {
658
663
  listStyle: 'none',
@@ -608,6 +608,7 @@ export class TosiDocSystem extends withAttributes({
608
608
  projectLinks: config.projectLinks,
609
609
  logo: config.logo,
610
610
  navbarLinks: navbarLinks.length ? navbarLinks : undefined,
611
+ searchPlaceholder: config.searchPlaceholder,
611
612
  contentElement,
612
613
  });
613
614
  // Add the settings (theme + language) menu — except in a nested demo, where
@@ -0,0 +1,2 @@
1
+ import { type XinStyleSheet } from 'tosijs';
2
+ export declare const navSectionStyleSpec: XinStyleSheet;
@@ -0,0 +1,65 @@
1
+ import { vars } from 'tosijs';
2
+ /*
3
+ The doc nav's collapsible sections, in a module of their own because two places need them:
4
+ the site stylesheet (`docSystemStyleSpec`), which paints the pre-rendered nav before anything
5
+ hydrates, and `createDocBrowser`, which injects them itself. A browser created outside
6
+ <tosi-doc-system> (an embedded, memory-routed one) never gets the site stylesheet, and
7
+ before #3520 it did not need it: native <details> collapsed with no CSS at all.
8
+ */
9
+ export const navSectionStyleSpec = {
10
+ '.doc-nav .nav-branch > ul': {
11
+ paddingLeft: vars.spacing,
12
+ },
13
+ /*
14
+ A section is a toggle button BESIDE its link (#3520). It was <details><summary><a>, a link
15
+ inside a control, which a screen reader announces as one and loses the other (axe
16
+ `nested-interactive`), and its marker was under the 24px target size WCAG 2.2 asks.
17
+ A closed section collapses only when JavaScript is running (`tosi-js` is set on <html>
18
+ before first paint; a nav `createDocBrowser` built is `.-live`): without it nothing
19
+ could open the section, so every one stays expanded.
20
+ */
21
+ 'html.tosi-js .doc-nav .nav-branch:not(.-open) > ul, .doc-nav.-live .nav-branch:not(.-open) > ul': {
22
+ display: 'none',
23
+ },
24
+ '.doc-nav .nav-branch-head': {
25
+ display: 'flex',
26
+ alignItems: 'flex-start',
27
+ },
28
+ '.doc-nav .nav-toggle': {
29
+ flex: '0 0 auto',
30
+ minWidth: '24px',
31
+ minHeight: '24px',
32
+ padding: 0,
33
+ margin: 0,
34
+ border: 'none',
35
+ background: 'transparent',
36
+ boxShadow: 'none',
37
+ cursor: 'pointer',
38
+ color: vars.brandColor,
39
+ display: 'inline-flex',
40
+ alignItems: 'center',
41
+ justifyContent: 'center',
42
+ alignSelf: 'stretch',
43
+ maxHeight: vars.lineHeight,
44
+ },
45
+ // A disclosure triangle, pointing right when closed and down when open.
46
+ '.doc-nav .nav-toggle::before': {
47
+ content: "''",
48
+ borderStyle: 'solid',
49
+ borderColor: 'transparent transparent transparent currentcolor',
50
+ borderWidth: '0.3em 0 0.3em 0.5em',
51
+ transition: 'transform 0.15s ease-out',
52
+ },
53
+ '.doc-nav .nav-branch.-open > .nav-branch-head > .nav-toggle::before': {
54
+ transform: 'rotate(90deg)',
55
+ },
56
+ // Without JavaScript every section is open and nothing could close one, so the toggle is
57
+ // hidden: kept in the layout, out of the tab order and the accessibility tree.
58
+ 'html:not(.tosi-js) .doc-nav:not(.-live) .nav-toggle': {
59
+ visibility: 'hidden',
60
+ },
61
+ '.doc-nav .nav-branch-head > .doc-link': {
62
+ flex: '1 1 auto',
63
+ minWidth: 0,
64
+ },
65
+ };
@@ -179,4 +179,10 @@ export declare function isCompressible(filePath: string): boolean;
179
179
  export declare function devServer(config: SiteConfig, opts?: {
180
180
  test?: boolean;
181
181
  build?: () => unknown | Promise<unknown>;
182
+ /**
183
+ * Build once on start (default `true`). Pass `false` only when the caller has just
184
+ * built, as `bin/dev.ts` does; otherwise the server serves whatever the LAST run
185
+ * left in the output dir, built from whatever config that run had (#3524).
186
+ */
187
+ initialBuild?: boolean;
182
188
  }): Promise<void>;
@@ -1796,6 +1796,15 @@ export async function devServer(config, opts = {}) {
1796
1796
  }
1797
1797
  void rebuild();
1798
1798
  });
1799
+ /*
1800
+ Build on start (#3524). The documented one-liner, `… : devServer(config)`, never built:
1801
+ it served the previous run's output, so a config edit (a theme accent, an ePub cover)
1802
+ showed nothing until some watched file changed, and nothing said the page was stale.
1803
+ Started without awaiting, so the port binds at once; requests during the build fall
1804
+ back to the last good tree, as they do for every rebuild.
1805
+ */
1806
+ if (opts.initialBuild !== false)
1807
+ void rebuild();
1799
1808
  }
1800
1809
  /*
1801
1810
  ── dependency audit (NON-BLOCKING — the port comes up first) ─────────────────
@@ -8,6 +8,8 @@ export interface GenerateSiteConfig {
8
8
  docs: Doc[];
9
9
  /** `SiteConfig.aliases`: { alias path -> slug } (board #3513) */
10
10
  aliases?: Record<string, string>;
11
+ /** `SiteConfig.searchPlaceholder` */
12
+ searchPlaceholder?: string;
11
13
  /**
12
14
  * Mirrors `SiteConfig.liveExamples`. The static highlighter needs it so it skips the
13
15
  * fences that will become live examples — a highlighted block hands the example markup
@@ -105,6 +107,13 @@ export interface GenerateSiteConfig {
105
107
  /** extra lines injected into every <head> (favicon, analytics, etc.) */
106
108
  headExtra?: string;
107
109
  }
110
+ /**
111
+ * A section's disclosure toggle. A real button BESIDE the link, not a `<summary>` around it:
112
+ * a link inside a summary is a control inside a control, which a screen reader announces as
113
+ * one and loses the other (axe `nested-interactive`, #3520). Named by the section title, so
114
+ * it reads "Components, expanded, button". The hydrated nav builds the same markup.
115
+ */
116
+ export declare function navToggleHtml(title: string, listId: string, open: boolean): string;
108
117
  /**
109
118
  * Prefix a root-relative path with basePath. No-op for '/', empty, protocol-
110
119
  * relative (`//…`), or absolute (`https://…`) URLs.