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 +38 -0
- package/dist/doc-browser.d.ts +1 -0
- package/dist/doc-browser.js +51 -8
- package/dist/doc-system/doc-system-styles.d.ts +12 -1
- package/dist/doc-system/doc-system-styles.js +53 -48
- package/dist/doc-system/nav-styles.d.ts +2 -0
- package/dist/doc-system/nav-styles.js +65 -0
- package/dist/doc-system/site/dev-server.d.ts +6 -0
- package/dist/doc-system/site/dev-server.js +9 -0
- package/dist/doc-system/site/generate-site.d.ts +7 -0
- package/dist/doc-system/site/generate-site.js +34 -8
- package/dist/iife.js +106 -106
- package/dist/iife.js.map +7 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +2 -1
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
|
package/dist/doc-browser.d.ts
CHANGED
package/dist/doc-browser.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
1564
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
1635
|
+
el.setOpen(subtree.includes(current));
|
|
1594
1636
|
else if (subtree.includes(current))
|
|
1595
|
-
el.
|
|
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
|
-
|
|
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>
|
|
285
|
-
//
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,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, ''');
|
|
26
26
|
const escapeText = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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)
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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'
|