tosijs-ui 1.16.38 → 1.16.39

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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.39
4
+
5
+ - **The doc-site frame passes WCAG 2.2 AA where an axe scan said it did not (#3520).** An
6
+ adopter's scan found four failures in the frame, so every doc site had them:
7
+ - The header's "Search documentation" trigger and its ⌘K hint measured 1.88:1 (AA asks
8
+ 4.5:1). `--placeholder-color` was the brand at 40% opacity; it is now the brand darkened
9
+ (or, in dark mode, brightened) just far enough to reach 4.5:1 on the input, for any accent.
10
+ It colours every `::placeholder` on a doc site, live-example inputs included, so
11
+ placeholders are darker everywhere.
12
+ - Code (inline and in blocks) was the brand colour, 4.56:1 on the page and 3.72:1 inside a
13
+ blockquote. It is now `--code-color`: the brand made readable on the blockquote and code
14
+ backgrounds. It is computed from your config's `accent`, so code no longer follows a
15
+ runtime override of `--brand-color`; set `--code-color` too if you do that.
16
+ - Each nav section was `<details><summary><a>`, a link inside a control, which a screen reader
17
+ announces as one and loses the other. A section is now a toggle `<button>` beside its link,
18
+ with `aria-expanded` and `aria-controls`, at least 24px square (the old triangle was under
19
+ WCAG 2.2's target size). Without JavaScript every section is open, since nothing could open
20
+ one. Until the bundle loads, a small script in each generated page opens and closes
21
+ sections, so a slow or failed bundle does not strand a reader; it also marks `<html>` with
22
+ `tosi-js`. A doc browser made with `createDocBrowser` outside `<tosi-doc-system>` brings
23
+ its own section styles. **If your site's CSS targets `.doc-nav details` or
24
+ `.doc-nav summary`, it no longer matches**: use `.doc-nav .nav-branch` (`.-open` when
25
+ open), `.nav-branch-head` and `.nav-toggle`.
26
+ - A code block that scrolls sideways could not be reached by keyboard. Static code blocks are
27
+ now focusable once the page hydrates.
28
+ - **`devServer()` builds on start when it is watching (#3524).** The documented `bin/site.ts`
29
+ one-liner never built: it served whatever the previous run left in the output folder, so a
30
+ config change (a theme accent, an ePub cover) showed nothing until a source file changed, and
31
+ nothing said the page was stale. (`DEV_NO_WATCH=1` and `{ test: true }` still serve what is
32
+ there.) **If your script builds and then calls `devServer`, as the full-pipeline example in
33
+ the docs used to, it now builds twice on start**: pass `initialBuild: false`, or drop your
34
+ own build. That build is your `build` function when you pass one, so the dependency audit
35
+ inside `buildSite` runs there too, and a blocking advisory leaves the server up with the
36
+ failure on the page rather than exiting. The config file itself is not watched: restart the
37
+ server after editing it.
38
+ - **A table has a margin (#3553).** Tables were the one block element without one, so a
39
+ blockquote after one sat flush against it. The ePub was already right.
40
+
3
41
  ## 1.16.38
4
42
 
5
43
  - **The ePub carries its images (#3519).** Every local image went into the book as a site path
@@ -132,4 +132,5 @@ export interface DocBrowserOptions {
132
132
  */
133
133
  contentElement?: HTMLElement;
134
134
  }
135
+ export declare function focusableCodeBlocks(root: ParentNode): void;
135
136
  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';
@@ -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,9 +375,25 @@ 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
398
  const { docs, context = {}, projectName = '', projectLinks = {}, logo, navSize = 200, minSize = 600, routing = 'query', initialRoute, aliases = {}, onRouteChange, searchShortcut = 'mod+k', navbarLinks, contentElement, } = options;
382
399
  // Memory routing is fully self-contained: it never reads or writes
@@ -1094,6 +1111,7 @@ export function createDocBrowser(options) {
1094
1111
  // Stamp each example with its source file (for the source↔doc map). doc.path
1095
1112
  // is the extracted file (.md, or a source file with doc comments).
1096
1113
  LiveExample.insertExamples(docContent, context, doc.path || undefined);
1114
+ focusableCodeBlocks(docContent);
1097
1115
  /*
1098
1116
  Highlight whatever is left — the static blocks that are not live examples.
1099
1117
 
@@ -1327,6 +1345,7 @@ export function createDocBrowser(options) {
1327
1345
  docContent.innerHTML = renderDocMarkdown(docMarkdownFromSource(editUI.editor.value, editUI.doc.path), { wikilinks });
1328
1346
  rewriteContentLinks();
1329
1347
  LiveExample.insertExamples(docContent, context, editUI.doc.path || undefined);
1348
+ focusableCodeBlocks(docContent);
1330
1349
  }
1331
1350
  else if (view === 'diff') {
1332
1351
  docContent.replaceChildren(tosiDiff({
@@ -1506,7 +1525,7 @@ export function createDocBrowser(options) {
1506
1525
  return menuItems;
1507
1526
  };
1508
1527
  // ── Hierarchical nav (path routing) ───────────────────────────────────────
1509
- // Build nested <details> from the doc tree; current-highlight, test status,
1528
+ // Build nested sections from the doc tree; current-highlight, test status,
1510
1529
  // search visibility, and auto-open are applied imperatively by refreshNav.
1511
1530
  const navStyle = {
1512
1531
  slot: 'nav',
@@ -1523,6 +1542,8 @@ export function createDocBrowser(options) {
1523
1542
  };
1524
1543
  const buildHierarchicalNav = () => {
1525
1544
  const roots = buildNavTree(docs, slugMap);
1545
+ // Ids for aria-controls, unique per browser: an embedded doc browser shares the page.
1546
+ const navIdPrefix = `tosi-nav-${(navIdCounter += 1)}-`;
1526
1547
  const leaves = new Map();
1527
1548
  const branches = [];
1528
1549
  const subtreeFilenames = (node) => {
@@ -1560,18 +1581,39 @@ export function createDocBrowser(options) {
1560
1581
  leaves.set(node.doc.filename, { li: item, link });
1561
1582
  return item;
1562
1583
  }
1563
- const det = details(summary(link), ul(...node.children.map(renderNode)));
1564
- const item = li(det);
1584
+ /*
1585
+ A toggle button BESIDE the link, not a <summary> around it (#3520): a link inside a
1586
+ summary is a control inside a control. Same markup as the static nav
1587
+ (`navToggleHtml` in generate-site.ts), so hydration does not move anything.
1588
+ */
1589
+ const listId = `${navIdPrefix}${node.slug || 'home'}`;
1590
+ const toggle = button({
1591
+ type: 'button',
1592
+ class: 'nav-toggle',
1593
+ ariaExpanded: 'false',
1594
+ ariaControls: listId,
1595
+ ariaLabel: String(node.doc.title),
1596
+ onClick: () => section.setOpen(!item.classList.contains('-open')),
1597
+ });
1598
+ const item = li({ class: 'nav-branch' }, div({ class: 'nav-branch-head' }, toggle, link), ul({ id: listId }, ...node.children.map(renderNode)));
1599
+ const section = {
1600
+ setOpen(open) {
1601
+ item.classList.toggle('-open', open);
1602
+ toggle.setAttribute('aria-expanded', String(open));
1603
+ },
1604
+ };
1565
1605
  branches.push({
1566
1606
  li: item,
1567
- el: det,
1607
+ el: section,
1568
1608
  link,
1569
1609
  filename: node.doc.filename,
1570
1610
  subtree: subtreeFilenames(node),
1571
1611
  });
1572
1612
  return item;
1573
1613
  };
1574
- const root = div(navStyle, ul(...roots.map(renderNode)));
1614
+ // `-live`: these sections have working toggles, so closed ones collapse even where no
1615
+ // pre-paint script marked <html> (a browser embedded outside a generated page).
1616
+ const root = div({ ...navStyle, class: `${navStyle.class} -live` }, ul(...roots.map(renderNode)));
1575
1617
  const applyStatus = (link, filename, current) => {
1576
1618
  link.classList.toggle('current', filename === current);
1577
1619
  const r = pageTestResults[filename];
@@ -1590,9 +1632,9 @@ export function createDocBrowser(options) {
1590
1632
  // On navigation (resetOpen), collapse everything except the current doc's section.
1591
1633
  // Otherwise (e.g. test-status refresh) leave user toggles alone.
1592
1634
  if (resetOpen)
1593
- el.open = subtree.includes(current);
1635
+ el.setOpen(subtree.includes(current));
1594
1636
  else if (subtree.includes(current))
1595
- el.open = true;
1637
+ el.setOpen(true);
1596
1638
  }
1597
1639
  };
1598
1640
  refreshNav(true);
@@ -1691,6 +1733,7 @@ export function createDocBrowser(options) {
1691
1733
  // Header brand-mark sizing (icon / <img> / inline-<svg>), one CSS var each.
1692
1734
  StyleSheet('doc-logo-mark', logoMarkStyleSpec);
1693
1735
  StyleSheet('doc-nav-search', navSearchStyleSpec);
1736
+ StyleSheet('doc-nav-sections', navSectionStyleSpec);
1694
1737
  /*
1695
1738
  The far end has something to tell you.
1696
1739
 
@@ -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 documentation" 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',
@@ -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) ─────────────────
@@ -105,6 +105,13 @@ export interface GenerateSiteConfig {
105
105
  /** extra lines injected into every <head> (favicon, analytics, etc.) */
106
106
  headExtra?: string;
107
107
  }
108
+ /**
109
+ * A section's disclosure toggle. A real button BESIDE the link, not a `<summary>` around it:
110
+ * a link inside a summary is a control inside a control, which a screen reader announces as
111
+ * one and loses the other (axe `nested-interactive`, #3520). Named by the section title, so
112
+ * it reads "Components, expanded, button". The hydrated nav builds the same markup.
113
+ */
114
+ export declare function navToggleHtml(title: string, listId: string, open: boolean): string;
108
115
  /**
109
116
  * Prefix a root-relative path with basePath. No-op for '/', empty, protocol-
110
117
  * relative (`//…`), or absolute (`https://…`) URLs.
@@ -25,10 +25,20 @@ const escapeAttr = (s) => s
25
25
  .replace(/'/g, '&#39;');
26
26
  const escapeText = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
27
27
  /**
28
- * Build the hierarchical nav as nested <details>. Parents render as
29
- * <details><summary><a>…</a></summary><ul>…children…</ul></details>, with the
30
- * <details> on the path to `currentFilename` opened. `currentFilename` gets
31
- * aria-current. Crawlable + no-JS: every node is a real <a>.
28
+ * A section's disclosure toggle. A real button BESIDE the link, not a `<summary>` around it:
29
+ * a link inside a summary is a control inside a control, which a screen reader announces as
30
+ * one and loses the other (axe `nested-interactive`, #3520). Named by the section title, so
31
+ * it reads "Components, expanded, button". The hydrated nav builds the same markup.
32
+ */
33
+ export function navToggleHtml(title, listId, open) {
34
+ return `<button type="button" class="nav-toggle" aria-expanded="${open}" aria-controls="${escapeAttr(listId)}" aria-label="${escapeAttr(title)}"></button>`;
35
+ }
36
+ /**
37
+ * Build the hierarchical nav. Parents render as
38
+ * <li class="nav-branch"><div class="nav-branch-head"><button/><a/></div><ul/></li>, with
39
+ * `-open` on the sections on the path to `currentFilename`. `currentFilename` gets
40
+ * aria-current. Crawlable + no-JS: every node is a real <a>, and without JS (no `tosi-js`
41
+ * class on <html>) no section collapses, since nothing could open it.
32
42
  */
33
43
  function navHtml(docs, slugMap, currentFilename, depth) {
34
44
  const roots = buildNavTree(docs, slugMap);
@@ -48,13 +58,15 @@ function navHtml(docs, slugMap, currentFilename, depth) {
48
58
  if (node.children.length === 0) {
49
59
  return `${indent}<li>${link}</li>`;
50
60
  }
51
- const isOpen = open.has(node.doc.filename) ? ' open' : '';
61
+ const isOpen = open.has(node.doc.filename);
52
62
  const kids = node.children
53
63
  .map((c) => renderNode(c, indent + ' '))
54
64
  .join('\n');
55
- return (`${indent}<li><details${isOpen}><summary>${link}</summary>\n` +
56
- `${indent}<ul>\n${kids}\n${indent}</ul>\n` +
57
- `${indent}</details></li>`);
65
+ // Prefixed so it cannot collide with a heading id (`## Nav Components` → nav-components).
66
+ const listId = `tosi-nav-${node.slug || 'home'}`;
67
+ return (`${indent}<li class="nav-branch${isOpen ? ' -open' : ''}">` +
68
+ `<div class="nav-branch-head">${navToggleHtml(node.doc.title, listId, isOpen)}${link}</div>\n` +
69
+ `${indent}<ul id="${escapeAttr(listId)}">\n${kids}\n${indent}</ul></li>`);
58
70
  };
59
71
  const items = roots.map((n) => renderNode(n, ' ')).join('\n');
60
72
  return ` <nav class="doc-nav" aria-label="Documentation">\n <ul>\n${items}\n </ul>\n </nav>`;
@@ -236,6 +248,12 @@ at = slugMap[doc.filename] ?? '') {
236
248
  until the bundle lands and sets body.darkmode. CSS alone can't do this: an explicit theme
237
249
  choice lives in localStorage. Mirrors applyThemePrefs() (doc-system.ts) — keep the two in
238
250
  step. Fails safe: any error leaves the light default.
251
+
252
+ 3. It marks <html> with `tosi-js`, which is what lets a closed nav section collapse (#3520):
253
+ without JavaScript nothing could open one, so none collapses. And it opens and closes the
254
+ static nav's sections until the doc system upgrades, so a reader on a slow bundle, or one
255
+ whose bundle failed, can still use them. Once the element is defined the hydrated nav
256
+ handles its own toggles and this stands down, so a click never toggles twice.
239
257
  */
240
258
  return `<!DOCTYPE html>
241
259
  <html lang="${escapeAttr(lang)}">
@@ -243,6 +261,14 @@ at = slugMap[doc.filename] ?? '') {
243
261
  <meta charset="utf-8" />
244
262
  <meta name="viewport" content="width=device-width, initial-scale=1" />
245
263
  <script>
264
+ document.documentElement.classList.add('tosi-js')
265
+ document.addEventListener('click', function (e) {
266
+ if (customElements.get('tosi-doc-system')) return
267
+ var b = e.target.closest && e.target.closest('.doc-nav .nav-toggle')
268
+ if (!b) return
269
+ var open = b.closest('.nav-branch').classList.toggle('-open')
270
+ b.setAttribute('aria-expanded', String(open))
271
+ })
246
272
  try {
247
273
  var p = JSON.parse(localStorage.getItem('tosi-doc-system-prefs') || '{}')
248
274
  var t = p.theme || 'system'