@dogsbay/ui 0.2.0-beta.1 → 0.2.0-beta.100

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/package.json CHANGED
@@ -1,25 +1,28 @@
1
1
  {
2
2
  "name": "@dogsbay/ui",
3
- "version": "0.2.0-beta.1",
3
+ "version": "0.2.0-beta.100",
4
4
  "description": "Accessible UI components for Astro, inspired by Base UI and shadcn",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./*": "./src/*"
8
8
  },
9
9
  "dependencies": {
10
- "tailwind-variants": "^0.3.0"
10
+ "tailwind-variants": "^0.3.1"
11
+ },
12
+ "devDependencies": {
13
+ "vitest": "^4.1.10"
11
14
  },
12
15
  "peerDependencies": {
13
- "astro": "^5.0.0 || ^6.0.0",
14
- "@dogsbay/elements": "0.2.0-beta.1",
15
- "@dogsbay/primitives": "0.2.0-beta.1",
16
- "@dogsbay/icons": "0.2.0-beta.1"
16
+ "astro": "^5.0.0 || ^6.0.0 || ^7.0.0",
17
+ "@dogsbay/elements": "0.2.0-beta.100",
18
+ "@dogsbay/icons": "0.2.0-beta.100",
19
+ "@dogsbay/primitives": "0.2.0-beta.100"
17
20
  },
18
21
  "optionalDependencies": {
19
- "shiki": "^4.0.0",
20
- "@shikijs/transformers": "^4.0.0",
21
- "maplibre-gl": "^5.0.0",
22
- "katex": "^0.16.0"
22
+ "@shikijs/transformers": "^4.3.1",
23
+ "katex": "^0.16.47",
24
+ "maplibre-gl": "^5.24.0",
25
+ "shiki": "^4.3.1"
23
26
  },
24
27
  "files": [
25
28
  "src",
@@ -34,5 +37,8 @@
34
37
  "homepage": "https://github.com/dogsbay/dogsbay/tree/main/packages/ui",
35
38
  "bugs": {
36
39
  "url": "https://github.com/dogsbay/dogsbay/issues"
40
+ },
41
+ "scripts": {
42
+ "test": "vitest run"
37
43
  }
38
44
  }
@@ -18,6 +18,11 @@ const wrapper = tv({
18
18
  variants: {
19
19
  variant: {
20
20
  default: "border-border",
21
+ // `plain` = a disclosure that is CHROME, not an alert: no tint, no
22
+ // icon, no ARIA role. Used for collapsed context (e.g. the
23
+ // comparison view's unchanged-section folds), where announcing a
24
+ // "note" or a live-region "status" would be a lie.
25
+ plain: "border-border",
21
26
  destructive: "border-destructive/50",
22
27
  note: "border-note/50",
23
28
  abstract: "border-abstract/50",
@@ -41,6 +46,7 @@ type Variant = VariantProps<typeof wrapper>["variant"];
41
46
  /** Title bar: stronger tint (~10%) */
42
47
  const titleBg: Record<string, string> = {
43
48
  default: "bg-muted",
49
+ plain: "bg-transparent",
44
50
  destructive: "bg-destructive/10 text-destructive",
45
51
  note: "bg-note/10", abstract: "bg-abstract/10", info: "bg-info/10",
46
52
  tip: "bg-tip/10", success: "bg-success/10", question: "bg-question/10",
@@ -51,6 +57,7 @@ const titleBg: Record<string, string> = {
51
57
  /** Body: subtle tint (~3%) */
52
58
  const bodyBg: Record<string, string> = {
53
59
  default: "bg-background",
60
+ plain: "bg-transparent",
54
61
  destructive: "bg-destructive/3",
55
62
  note: "bg-note/3", abstract: "bg-abstract/3", info: "bg-info/3",
56
63
  tip: "bg-tip/3", success: "bg-success/3", question: "bg-question/3",
@@ -79,14 +86,23 @@ interface Props {
79
86
  const { variant = "note", title, open = false, class: className } = Astro.props;
80
87
  const v = variant || "note";
81
88
 
82
- const role = contentVariants.has(v) ? "note" : "status";
89
+ // `plain` is chrome — a disclosure, nothing more. No role at all:
90
+ // role="note"/"status" on collapsed context misinforms assistive tech
91
+ // (and "status" would make it a live region).
92
+ const role = v === "plain" ? undefined : contentVariants.has(v) ? "note" : "status";
83
93
  const icon = contentVariants.has(v) ? icons[v] : null;
84
94
  ---
85
95
 
96
+ {/*
97
+ The role belongs on the CONTENT, not on <details>. A <details> element
98
+ has an implicit `group` role, and ARIA does not permit overriding it
99
+ with `note`/`status` — axe flags it (aria-allowed-role), which is
100
+ exactly what an accessibility pass found on the comparison site's
101
+ collapsed sections. Moving it to the body keeps the semantics legal.
102
+ */}
86
103
  <details
87
104
  class:list={[wrapper({ variant, class: className }), "group/collapsible"]}
88
105
  data-variant={variant}
89
- role={role}
90
106
  open={open}
91
107
  >
92
108
  <summary class:list={[
@@ -97,7 +113,7 @@ const icon = contentVariants.has(v) ? icons[v] : null;
97
113
  <span class="flex-1 text-foreground" data-part="title">{title || v.charAt(0).toUpperCase() + v.slice(1)}</span>
98
114
  <span class="shrink-0 text-muted-foreground group-open/collapsible:rotate-180 transition-transform duration-200" set:html={chevronSvg} />
99
115
  </summary>
100
- <div class:list={["px-4 py-3 text-sm [&_p]:leading-relaxed", bodyBg[v]]}>
116
+ <div class:list={["px-4 py-3 text-sm [&_p]:leading-relaxed", bodyBg[v]]} role={role}>
101
117
  <slot />
102
118
  </div>
103
119
  </details>
@@ -12,17 +12,41 @@
12
12
  */
13
13
  interface Props {
14
14
  class?: string;
15
+ /**
16
+ * "page" (default): a dedicated API operation page — full-viewport
17
+ * min-height, sticky code rail.
18
+ * "embedded": the layout sits inline among prose (imported MDX docs
19
+ * with several endpoints per page) — natural height, no sticky.
20
+ */
21
+ variant?: "page" | "embedded";
15
22
  }
16
23
 
17
- const { class: className } = Astro.props;
24
+ const { class: className, variant = "page" } = Astro.props;
25
+ const isPage = variant === "page";
18
26
  ---
19
27
 
20
- <div class:list={["grid grid-cols-1 lg:grid-cols-2 lg:min-h-[calc(100vh-3rem)]", className]}>
21
- <div class="px-6 py-10 lg:border-r">
28
+ {/* dba-api marks an API-reference region: the generated .docs-prose
29
+ typography rules skip everything inside it via :not(.dba-api *), so
30
+ endpoint cards keep their own utility-class typography even when the
31
+ page body is wrapped in the prose article. */}
32
+ <div
33
+ class:list={[
34
+ "dba-api grid grid-cols-1 lg:grid-cols-2",
35
+ isPage ? "lg:min-h-[calc(100vh-3rem)]" : "my-6 rounded-lg border overflow-hidden",
36
+ className,
37
+ ]}
38
+ >
39
+ <div class:list={[isPage ? "px-6 py-10" : "p-6", "lg:border-r"]}>
22
40
  <slot name="description" />
23
41
  </div>
24
42
  <div class="bg-[var(--api-panel-bg)] text-[var(--api-panel-fg)]">
25
- <div class="px-6 py-10 lg:sticky lg:top-12 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
43
+ <div
44
+ class:list={[
45
+ isPage
46
+ ? "px-6 py-10 lg:sticky lg:top-12 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto"
47
+ : "p-6",
48
+ ]}
49
+ >
26
50
  <slot name="code" />
27
51
  </div>
28
52
  </div>
@@ -42,6 +42,15 @@ const {
42
42
  ...rest
43
43
  } = Astro.props;
44
44
 
45
+ // AsciiDoc/docs language tokens that are not Shiki grammars → the nearest real
46
+ // one, so the block actually highlights (and data-language is meaningful)
47
+ // instead of silently falling back to plaintext. The header still shows the
48
+ // original label (`lang` below), only the highlighter input is aliased. #009.
49
+ const LANG_ALIASES: Record<string, string> = {
50
+ terminal: "bash",
51
+ };
52
+ const shikiLang = LANG_ALIASES[lang.toLowerCase()] ?? lang.toLowerCase();
53
+
45
54
  // Show header bar based on showTitle prop:
46
55
  // - true: show when there's a title or a declared language (default, original behavior)
47
56
  // - false: never show
@@ -107,7 +116,7 @@ const floatingCopy = copy && !showHeader;
107
116
  </div>
108
117
  )}
109
118
  <div class="relative overflow-x-auto [&_pre]:!m-0 [&_pre]:!rounded-none [&_pre]:!border-0 [&_pre]:px-4 [&_pre]:py-3">
110
- <Code code={code} lang={lang.toLowerCase()} themes={{ light: "github-light-default", dark: theme }} defaultColor={false} />
119
+ <Code code={code} lang={shikiLang} themes={{ light: "github-light-default", dark: theme }} defaultColor={false} />
111
120
  {floatingCopy && (
112
121
  <button
113
122
  class:list={[codeBlockCopy(), "absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"]}
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import "./code-rich.css";
3
- import { codeToHtml, type BundledLanguage, type BundledTheme } from "shiki";
3
+ import { stripMarkersForCopy, stripUnprocessableMarkers } from "./markers";
4
+ import { codeToHtml, bundledLanguages, type BundledLanguage, type BundledTheme } from "shiki";
4
5
  import {
5
6
  transformerNotationDiff,
6
7
  transformerNotationHighlight,
@@ -44,6 +45,21 @@ interface Props {
44
45
  wordHighlights?: string;
45
46
  /** Shiki theme */
46
47
  theme?: string;
48
+ /**
49
+ * Override the copy-button text. Diff-annotated blocks copy the
50
+ * post-change code only — the default (all lines minus annotation
51
+ * comments) would paste removed lines into the reader's editor.
52
+ */
53
+ copyText?: string;
54
+ /**
55
+ * Diff lines by NUMBER (1-based, e.g. "2,5-6") instead of `[!code]`
56
+ * comment markers. Markers only work in languages whose grammar has
57
+ * comments — in `plaintext` (or any unknown language) Shiki cannot
58
+ * recognize them and they render as literal text. Line numbers are
59
+ * language-independent, so a generated diff never leaks markers.
60
+ */
61
+ diffAdd?: string;
62
+ diffRemove?: string;
47
63
  class?: string;
48
64
  [key: string]: unknown;
49
65
  }
@@ -58,10 +74,28 @@ const {
58
74
  highlights,
59
75
  wordHighlights,
60
76
  theme = "github-dark-default",
77
+ copyText: copyTextOverride,
78
+ diffAdd,
79
+ diffRemove,
61
80
  class: className,
62
81
  ...rest
63
82
  } = Astro.props;
64
83
 
84
+ // "2,5-6" → Set{2,5,6}
85
+ const parseLineSpec = (spec?: string): Set<number> => {
86
+ const out = new Set<number>();
87
+ for (const part of (spec ?? "").split(",")) {
88
+ const range = part.trim().match(/^(\d+)(?:-(\d+))?$/);
89
+ if (!range) continue;
90
+ const start = Number(range[1]);
91
+ const end = range[2] ? Number(range[2]) : start;
92
+ for (let n = start; n <= end; n++) out.add(n);
93
+ }
94
+ return out;
95
+ };
96
+ const addLines = parseLineSpec(diffAdd);
97
+ const removeLines = parseLineSpec(diffRemove);
98
+
65
99
  // Build transformers list
66
100
  const transformers: ShikiTransformer[] = [
67
101
  transformerNotationDiff(),
@@ -71,6 +105,19 @@ const transformers: ShikiTransformer[] = [
71
105
  transformerNotationWordHighlight(),
72
106
  ];
73
107
 
108
+ // Line-number diffs (language-independent — see the prop docs)
109
+ if (addLines.size || removeLines.size) {
110
+ transformers.push({
111
+ name: "line-diff",
112
+ line(node, line) {
113
+ const kind = addLines.has(line) ? "add" : removeLines.has(line) ? "remove" : null;
114
+ if (!kind) return;
115
+ const cls = node.properties.class;
116
+ node.properties.class = `${Array.isArray(cls) ? cls.join(" ") : (cls ?? "")} diff ${kind}`.trim();
117
+ },
118
+ });
119
+ }
120
+
74
121
  // Meta-based highlights: ```ts {1,3-5}
75
122
  if (highlights) {
76
123
  transformers.push(transformerMetaHighlight());
@@ -136,14 +183,25 @@ let metaStr = "";
136
183
  if (highlights) metaStr += `{${highlights}} `;
137
184
  if (wordHighlights) metaStr += `/${wordHighlights}/ `;
138
185
 
139
- // Strip annotation comments from the copy text
140
- const copyText = code
141
- .replace(/\s*\/\/\s*\[!code\s[^\]]*\]\s*$/gm, "")
142
- .replace(/\s*\/\/\s*![a-z]+.*$/gm, "")
143
- .trim();
186
+ // Copy text: explicit override (diff blocks pass the post-change code)
187
+ // or the block's own code — either way every annotation form is
188
+ // stripped (see ./markers.ts).
189
+ const copyText = stripMarkersForCopy(copyTextOverride ?? code);
190
+
191
+ // Unknown languages (package-install, mdx-ish doc tokens) fall back
192
+ // to plaintext instead of throwing — same behavior as CodeBlock's
193
+ // <Code>. The header still shows the declared language label.
194
+ const requestedLang = lang.toLowerCase();
195
+ const isPlainGrammar =
196
+ !(requestedLang in bundledLanguages) || ["plaintext", "text", "txt", "ansi"].includes(requestedLang);
197
+ const shikiLang = isPlainGrammar ? "plaintext" : requestedLang;
198
+
199
+ // Markers Shiki cannot process would render as literal text — strip
200
+ // exactly those, leave the rest for the notation transformers.
201
+ const highlightCode = stripUnprocessableMarkers(code, isPlainGrammar);
144
202
 
145
- const html = await codeToHtml(code, {
146
- lang: lang.toLowerCase() as BundledLanguage,
203
+ const html = await codeToHtml(highlightCode, {
204
+ lang: shikiLang as BundledLanguage,
147
205
  themes: {
148
206
  light: "github-light-default" as BundledTheme,
149
207
  dark: (theme || "github-dark-default") as BundledTheme,
@@ -12,7 +12,12 @@
12
12
  min-width: 4ch;
13
13
  margin-right: 1.5ch;
14
14
  text-align: right;
15
- color: oklch(0.35 0 0 / 0.7);
15
+ /* Theme-aware, and NO alpha. This was a fixed dark grey at 70%
16
+ opacity: fine on a light code background, 1.3:1 against the dark
17
+ one — line numbers were effectively invisible in dark mode.
18
+ Opacity compounds it, since it blends toward whatever is behind.
19
+ --muted-foreground already flips per theme (L 0.45 / 0.708). */
20
+ color: var(--muted-foreground);
16
21
  user-select: none;
17
22
  flex-shrink: 0;
18
23
  }
@@ -23,6 +28,18 @@
23
28
  color: oklch(0.55 0.2 25);
24
29
  }
25
30
 
31
+ /* Dark mode: the diff gutters sit on TINTED line backgrounds, not on
32
+ the plain code background, and at L0.55 they measure 2.89:1 (add)
33
+ and 2.63:1 (remove) there — both well under AA. Light mode is
34
+ unaffected, so these are overrides rather than a changed base.
35
+ Raised to L0.72 for headroom over the 4.5:1 floor. */
36
+ .dark .code-rich-body .gutter.add {
37
+ color: oklch(0.72 0.2 145);
38
+ }
39
+ .dark .code-rich-body .gutter.remove {
40
+ color: oklch(0.72 0.14 25);
41
+ }
42
+
26
43
  /* ── Diff line backgrounds ────────────────────────────── */
27
44
  .code-rich-body .line.diff.add {
28
45
  background-color: oklch(0.75 0.18 145 / 0.15);
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `[!code …]` annotation hygiene.
3
+ *
4
+ * Shiki's notation transformers consume these markers — but only the
5
+ * ones they can SEE: a marker is recognized when the grammar tokenizes
6
+ * its comment AND the marker ends the line. Anything else reaches the
7
+ * reader as literal text in the code block. Two real cases from the
8
+ * better-auth corpus:
9
+ *
10
+ * - `package-install` (and every unknown language) falls back to
11
+ * `plaintext`, which has no comment tokens at all;
12
+ * - upstream authors mid-line markers — `// [!code highlight] // check
13
+ * if the user is allowed` — where the marker is not the last thing
14
+ * on the line.
15
+ *
16
+ * These helpers strip exactly what Shiki cannot process, and strip
17
+ * everything from copy text (a marker pasted into an editor is always
18
+ * wrong).
19
+ */
20
+
21
+ /** A `[!code …]` annotation carrying its own comment leader. */
22
+ const MARKER = /[ \t]*(?:\/\/|#|--|<!--|\/\*|;)[ \t]*\[!code[^\]]*\](?:[ \t]*(?:-->|\*\/))?/g;
23
+
24
+ /**
25
+ * A bare annotation — upstream also appends one INSIDE an existing
26
+ * comment (`// e.g. "us-east-1" [!code highlight]`), with no leader of
27
+ * its own.
28
+ */
29
+ const BARE_MARKER = /[ \t]*\[!code[^\]]*\]/g;
30
+
31
+ /**
32
+ * Remove the markers Shiki cannot process, leaving the ones it can for
33
+ * the notation transformers to consume.
34
+ *
35
+ * @param code the block's source
36
+ * @param isPlainGrammar true when highlighting falls back to plaintext
37
+ */
38
+ export function stripUnprocessableMarkers(code: string, isPlainGrammar: boolean): string {
39
+ return code
40
+ .split("\n")
41
+ .map((line) => {
42
+ // No grammar → no comment tokens → Shiki sees nothing to strip.
43
+ if (isPlainGrammar) return line.replace(MARKER, "").replace(BARE_MARKER, "");
44
+ let out = "";
45
+ let last = 0;
46
+ for (const m of line.matchAll(MARKER)) {
47
+ const end = m.index + m[0].length;
48
+ if (line.slice(end).trim() === "") break; // ends the line — Shiki handles it
49
+ out += line.slice(last, m.index);
50
+ last = end;
51
+ }
52
+ return out + line.slice(last);
53
+ })
54
+ .join("\n");
55
+ }
56
+
57
+ /** Strip EVERY annotation form — markers must never reach the clipboard. */
58
+ export function stripMarkersForCopy(code: string): string {
59
+ return code
60
+ .replace(MARKER, "")
61
+ .replace(BARE_MARKER, "")
62
+ .replace(/\s*\/\/\s*![a-z]+.*$/gm, "")
63
+ .trim();
64
+ }
@@ -126,7 +126,7 @@ function inlineToHtml(nodes: InlineNode[]): string {
126
126
  case "code": return `<code>${n.text || ""}</code>`;
127
127
  case "highlight": return `<mark>${inlineToHtml((n.children || []) as InlineNode[])}</mark>`;
128
128
  case "link": return `<a href="${n.href || ""}">${inlineToHtml((n.children || []) as InlineNode[])}</a>`;
129
- case "image": return `<img src="${n.src || ""}" alt="${n.alt || ""}">`;
129
+ case "image": return `<img src="${withBaseUrl(n.src as string | undefined)}" alt="${n.alt || ""}">`;
130
130
  case "kbd": return (n.keys as string[] || []).map((k: string) => `<kbd>${k}</kbd>`).join("+");
131
131
  case "html-inline": return String(n.html || "");
132
132
  case "break": return "<br>";
@@ -142,6 +142,26 @@ function resolveImage(src: string): ImageMetadata | undefined {
142
142
  const clean = normalized.replace(/#.*$/, "");
143
143
  return images[clean];
144
144
  }
145
+
146
+ /**
147
+ * Prefix Astro's `base` config (= urlBase from site.url's path)
148
+ * onto absolute image paths so they resolve on subpath-mounted
149
+ * deploys. Skip for external / data: / already-prefixed URLs.
150
+ * See plans/content-assets-folder.md.
151
+ */
152
+ function withBaseUrl(src: string | undefined): string {
153
+ if (!src) return "";
154
+ const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, "");
155
+ if (
156
+ !baseUrl ||
157
+ !src.startsWith("/") ||
158
+ src.startsWith(`${baseUrl}/`) ||
159
+ /^(?:[a-z]+:|\/\/)/i.test(src)
160
+ ) {
161
+ return src;
162
+ }
163
+ return `${baseUrl}${src}`;
164
+ }
145
165
  ---
146
166
 
147
167
  {/* ── Prose ──────────────────────────────────────────────── */}
@@ -157,7 +177,7 @@ function resolveImage(src: string): ImageMetadata | undefined {
157
177
  <Fragment set:html={
158
178
  `<h${node.props!.level} id="${node.props!.slug || ''}" class="group scroll-mt-20">` +
159
179
  `${node.inline ? inlineToHtml(node.inline) : (node.html?.replace(/<h\d[^>]*>|<\/h\d>/g, '') || node.props!.text || '')}` +
160
- `<a href="#${node.props!.slug || ''}" class="no-underline ml-2 opacity-0 group-hover:opacity-50 transition-opacity text-muted-foreground" aria-label="Link to this heading">¶</a>` +
180
+ `<a href="#${node.props!.slug || ''}" class="heading-anchor no-underline ml-2 opacity-0 group-hover:opacity-50 transition-opacity text-muted-foreground" aria-label="Link to this heading">¶</a>` +
161
181
  `</h${node.props!.level}>`
162
182
  } />
163
183
  )}
@@ -275,7 +295,18 @@ function resolveImage(src: string): ImageMetadata | undefined {
275
295
  {/* ── Table ──────────────────────────────────────────────── */}
276
296
  {node.type === "table" && (
277
297
  <Table class="my-4">
278
- {children.map(child => <Astro.self node={child} images={images} icons={icons} />)}
298
+ {(node.props?.headerRows as string[][] | undefined)?.length ? (
299
+ <TableHeader>
300
+ {(node.props!.headerRows as string[][]).map((row) => (
301
+ <TableRow>{row.map((cell) => <TableHead set:html={cell} />)}</TableRow>
302
+ ))}
303
+ </TableHeader>
304
+ ) : null}
305
+ <TableBody>
306
+ {((node.props?.bodyRows as string[][] | undefined) ?? []).map((row) => (
307
+ <TableRow>{row.map((cell) => <TableCell set:html={cell} />)}</TableRow>
308
+ ))}
309
+ </TableBody>
279
310
  </Table>
280
311
  )}
281
312
  {node.type === "thead" && <TableHeader>{children.map(child => <Astro.self node={child} images={images} icons={icons} />)}</TableHeader>}
@@ -296,11 +327,14 @@ function resolveImage(src: string): ImageMetadata | undefined {
296
327
  {node.type === "hr" && <hr class="my-8 border-border" />}
297
328
  {node.type === "html" && (() => {
298
329
  let html = node.html || "";
299
- if (html.includes("<img") && Object.keys(images).length > 0) {
330
+ if (html.includes("<img")) {
300
331
  html = html.replace(/src="([^"]+)"/g, (_match: string, src: string) => {
301
332
  const resolved = resolveImage(src);
302
333
  if (resolved) return `src="${resolved.src}" width="${resolved.width}" height="${resolved.height}"`;
303
- return _match;
334
+ // Not in the glob (SVG / PDF / build-without-optimization) —
335
+ // prefix BASE_URL so absolute paths resolve on subpath deploys.
336
+ const prefixed = withBaseUrl(src);
337
+ return prefixed === src ? _match : `src="${prefixed}"`;
304
338
  });
305
339
  }
306
340
  return <Fragment set:html={html} />;
@@ -47,6 +47,24 @@ function resolveImage(src: string): ImageMetadata | undefined {
47
47
 
48
48
  const resolvedImg = node.type === "image" ? resolveImage(node.src as string || "") : undefined;
49
49
 
50
+ // When the image isn't in the glob map (SVG, PDF, or built with
51
+ // imageOptimization: false), the `<img src={node.src}>` fallback
52
+ // fires below. Prefix Astro's `base` for absolute paths so they
53
+ // resolve on subpath-mounted deploys. See plans/content-assets-folder.md.
54
+ function withBaseUrl(src: string | undefined): string {
55
+ if (!src) return "";
56
+ const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, "");
57
+ if (
58
+ !baseUrl ||
59
+ !src.startsWith("/") ||
60
+ src.startsWith(`${baseUrl}/`) ||
61
+ /^(?:[a-z]+:|\/\/)/i.test(src)
62
+ ) {
63
+ return src;
64
+ }
65
+ return `${baseUrl}${src}`;
66
+ }
67
+
50
68
  // Icon resolution
51
69
  const iconSvg = node.type === "icon" && icons
52
70
  ? icons.resolve(node.library || "emoji", node.name, node.variant, node.size) as string | null
@@ -90,7 +108,7 @@ const isEmojiIcon = node.type === "icon" && (node.library === "emoji" || (iconSv
90
108
  )}
91
109
  {node.type === "image" && !resolvedImg && (
92
110
  <img
93
- src={node.src}
111
+ src={withBaseUrl(node.src as string | undefined)}
94
112
  alt={node.alt || ""}
95
113
  title={node.title}
96
114
  width={node.width}
@@ -99,10 +99,33 @@ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
99
99
  <title>{title} — {siteTitle}</title>
100
100
  <ClientRouter />
101
101
  <script is:inline>
102
- if (localStorage.getItem("theme") === "dark") {
103
- document.documentElement.classList.add("dark");
104
- }
102
+ // Explicit choice wins in BOTH directions; only its absence
103
+ // falls through to the OS. Kept in sync with
104
+ // packages/docs-layout/src/DocsLayout.astro — a test asserts no
105
+ // copy regresses to the localStorage-only form.
106
+ (function () {
107
+ var stored = null;
108
+ try {
109
+ stored = localStorage.getItem("theme");
110
+ } catch (e) {}
111
+ var dark =
112
+ stored === "dark" ||
113
+ (stored !== "light" &&
114
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
115
+ if (dark) document.documentElement.classList.add("dark");
116
+ })();
105
117
  </script>
118
+ <style is:global>
119
+ /*
120
+ The sticky-chrome contract. The docs header is exactly this tall,
121
+ and anything else that sticks (the comparison toolbar) offsets by
122
+ it — one variable, so a header change can never leave another bar
123
+ overlapping or floating.
124
+ */
125
+ :root {
126
+ --db-header-height: 3.25rem;
127
+ }
128
+ </style>
106
129
  <slot name="head" />
107
130
  </head>
108
131
  <body class="min-h-screen bg-background text-foreground antialiased">
@@ -131,7 +154,16 @@ const currentPath = Astro.url.pathname.replace(/\/$/, "") || "/";
131
154
  <SidebarRail />
132
155
  </Sidebar>
133
156
  <SidebarInset>
134
- <header class="sticky top-0 z-40 flex items-center gap-3 border-b border-border bg-background/95 px-4 py-3 backdrop-blur-sm">
157
+ {/*
158
+ The header's height comes from `--db-header-height` (declared on
159
+ :root below) — the CONTRACT that any other sticky chrome sticks
160
+ below, e.g. the comparison toolbar. Both read the same variable,
161
+ so they cannot drift; a magic offset copied into another
162
+ stylesheet would break the moment this padding changed.
163
+ */}
164
+ <header
165
+ class="sticky top-0 z-40 flex h-[var(--db-header-height)] items-center gap-3 border-b border-border bg-background/95 px-4 backdrop-blur-sm"
166
+ >
135
167
  <SidebarTrigger />
136
168
  <Separator orientation="vertical" class="h-5" />
137
169
  <span class="text-sm font-medium" data-page-title>{title}</span>
@@ -0,0 +1,84 @@
1
+ ---
2
+ /**
3
+ * Icon — render an icon by name from any Iconify pack (defaults
4
+ * to Lucide).
5
+ *
6
+ * Resolves at BUILD time via `@dogsbay/icons`'s registry — the
7
+ * SVG is inlined into the static HTML, so there's no client-side
8
+ * fetch and no FOUC. Tree-shakeable: only icons actually used in
9
+ * pages get inlined, since Astro statically evaluates the
10
+ * resolution per template instance.
11
+ *
12
+ * Usage:
13
+ *
14
+ * <Icon name="rocket" /> // bare → Lucide rocket
15
+ * <Icon name="lucide:book-open" /> // explicit pack
16
+ * <Icon name="mdi:home" /> // Material Design Icons
17
+ * <Icon name="simple-icons:github" /> // brand icons
18
+ * <Icon name="rocket" class="size-5 text-primary" />
19
+ *
20
+ * Bare names check the emoji map first (so `rocket` → 🚀
21
+ * matches existing inline-icon behaviour), then fall through to
22
+ * Lucide. When the icon doesn't exist anywhere, the component
23
+ * renders nothing — silent fallback rather than a broken-image
24
+ * box, since icons are decorative and consistent typography is
25
+ * preferable to a placeholder.
26
+ *
27
+ * Accessibility: when `alt` is supplied, the icon is announced
28
+ * to screen readers as a labelled image. Without `alt` (the
29
+ * default), the SVG is `aria-hidden` because most icons in this
30
+ * codebase are paired with adjacent text that already conveys
31
+ * the meaning. Pass `alt=""` explicitly to force the
32
+ * decorative-only state when needed by external tooling.
33
+ */
34
+ import { resolveIcon } from "@dogsbay/icons";
35
+
36
+ interface Props {
37
+ /**
38
+ * Icon shorthand. Either bare (`rocket`, `book-open`) or
39
+ * namespaced (`pack:name`). See file header for examples.
40
+ */
41
+ name: string;
42
+ /** Tailwind class string. Defaults to `size-4` for inline-text sizing. */
43
+ class?: string;
44
+ /**
45
+ * Accessible label. Set to a non-empty string for icons that
46
+ * convey information without surrounding text. Leave undefined
47
+ * (or pass empty string) for decorative icons paired with a
48
+ * text label.
49
+ */
50
+ alt?: string;
51
+ }
52
+
53
+ const { name, class: className = "size-4", alt } = Astro.props;
54
+ const svg = resolveIcon(name);
55
+ const labelled = typeof alt === "string" && alt.length > 0;
56
+ ---
57
+
58
+ {
59
+ svg ? (
60
+ svg.startsWith("<") ? (
61
+ <span
62
+ class:list={["inline-flex shrink-0", className]}
63
+ role={labelled ? "img" : undefined}
64
+ aria-label={labelled ? alt : undefined}
65
+ aria-hidden={labelled ? undefined : "true"}
66
+ set:html={svg}
67
+ data-icon={name}
68
+ />
69
+ ) : (
70
+ // Emoji fallback — `svg` is actually a Unicode character
71
+ // when the name resolved against the emoji map (e.g.
72
+ // `rocket` → 🚀). Render it as text so font emoji shows.
73
+ <span
74
+ class:list={["inline-flex shrink-0", className]}
75
+ role={labelled ? "img" : undefined}
76
+ aria-label={labelled ? alt : undefined}
77
+ aria-hidden={labelled ? undefined : "true"}
78
+ data-icon={name}
79
+ >
80
+ {svg}
81
+ </span>
82
+ )
83
+ ) : null
84
+ }
@@ -0,0 +1 @@
1
+ export { default as Icon } from "./Icon.astro";
@@ -47,6 +47,27 @@ const captionText = caption || title;
47
47
  const w = width || (imageData ? imageData.width : undefined);
48
48
  const h = height || (imageData ? imageData.height : undefined);
49
49
 
50
+ // When `imageData` is unavailable (image not in import.meta.glob —
51
+ // e.g. SVG, PDF, or build-time `imageOptimization: false`), the
52
+ // `<img src={src}>` fallback fires with the raw markdown src. For
53
+ // absolute paths like `/_assets/foo.png`, we need to prefix with
54
+ // Astro's `base` so the URL resolves on subpath-mounted deploys
55
+ // (GH Pages project pages, multi-mount Cloudflare).
56
+ //
57
+ // `<AstroImage>` does this automatically; raw `<img>` does not.
58
+ //
59
+ // Skip prefixing for: external URLs (http/https), data: URIs,
60
+ // and paths that already start with `import.meta.env.BASE_URL`
61
+ // (defensive; shouldn't normally happen).
62
+ const baseUrl = import.meta.env.BASE_URL.replace(/\/$/, "");
63
+ const resolvedSrc =
64
+ baseUrl &&
65
+ src.startsWith("/") &&
66
+ !src.startsWith(`${baseUrl}/`) &&
67
+ !/^(?:[a-z]+:|\/\/)/i.test(src)
68
+ ? `${baseUrl}${src}`
69
+ : src;
70
+
50
71
  const alignClass = align === "left"
51
72
  ? "float-left mr-4 mb-2"
52
73
  : align === "right"
@@ -71,7 +92,7 @@ const alignClass = align === "left"
71
92
  />
72
93
  ) : (
73
94
  <img
74
- src={src}
95
+ src={resolvedSrc}
75
96
  alt={alt}
76
97
  title={title}
77
98
  width={w}
@@ -102,7 +123,7 @@ const alignClass = align === "left"
102
123
  />
103
124
  ) : (
104
125
  <img
105
- src={src}
126
+ src={resolvedSrc}
106
127
  alt={alt}
107
128
  title={title}
108
129
  width={w}
@@ -0,0 +1,85 @@
1
+ ---
2
+ /**
3
+ * A nav state marker (changed / new / removed / moved).
4
+ *
5
+ * Accessibility contract — the reason this is a component and not a
6
+ * coloured dot inlined three times:
7
+ *
8
+ * - **Never colour alone** (WCAG 1.4.1): each kind gets a distinct
9
+ * GLYPH (+ • − →), so it is readable in greyscale and by anyone who
10
+ * does not perceive the hue difference. Colour only reinforces.
11
+ * - **Survives forced-colors** (Windows High Contrast strips
12
+ * background-color): the glyph is real text, so it always renders;
13
+ * `currentColor` keeps it visible when the palette is overridden.
14
+ * - **Announced, not implied**: a visually-hidden word rides along, so
15
+ * the row reads "MySQL, changed". `title` alone is not enough — it is
16
+ * unreliable in screen readers and invisible to touch users.
17
+ * - **aria-hidden on the glyph** so it is not announced twice.
18
+ */
19
+ interface Props {
20
+ kind: "added" | "changed" | "removed" | "moved";
21
+ /** Announced text; defaults to the kind (or "contains X" for a subtree). */
22
+ label?: string;
23
+ /** True when this summarizes DESCENDANTS (a collapsed group). */
24
+ subtree?: boolean;
25
+ class?: string;
26
+ }
27
+
28
+ const { kind, label, subtree = false, class: className } = Astro.props;
29
+
30
+ const GLYPH: Record<Props["kind"], string> = {
31
+ added: "+",
32
+ changed: "•",
33
+ removed: "−",
34
+ moved: "→",
35
+ };
36
+
37
+ const text = label ?? (subtree ? `contains ${kind}` : kind);
38
+ ---
39
+
40
+ <span
41
+ class:list={["db-nav-mark", className]}
42
+ data-nav-mark={kind}
43
+ data-nav-mark-subtree={subtree ? "" : undefined}
44
+ title={text}
45
+ >
46
+ <span aria-hidden="true">{GLYPH[kind]}</span>
47
+ <span class="sr-only">{text}</span>
48
+ </span>
49
+
50
+ <style is:global>
51
+ .db-nav-mark {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ flex-shrink: 0;
56
+ width: 1rem;
57
+ height: 1rem;
58
+ margin-left: auto; /* right-aligned: labels stay flush-left and scannable */
59
+ border-radius: 99px;
60
+ font-size: 0.75rem;
61
+ line-height: 1;
62
+ font-weight: 700;
63
+ /* colour REINFORCES the glyph; it never carries the meaning alone */
64
+ color: var(--color-muted-foreground, currentColor);
65
+ }
66
+ .db-nav-mark[data-nav-mark="added"] { color: var(--color-success, oklch(0.72 0.19 150)); }
67
+ .db-nav-mark[data-nav-mark="changed"] { color: var(--color-warning, oklch(0.8 0.16 85)); }
68
+ .db-nav-mark[data-nav-mark="removed"] { color: var(--color-destructive, oklch(0.64 0.21 25)); }
69
+ .db-nav-mark[data-nav-mark="moved"] { color: var(--color-info, oklch(0.7 0.14 240)); }
70
+ /* A subtree marker is a hint, not a claim about this row — mute it. */
71
+ .db-nav-mark[data-nav-mark-subtree] { opacity: 0.55; }
72
+
73
+ /* Removed pages get a second non-colour signal — but ONLY a page that
74
+ is itself removed. A subtree aggregate is a claim about descendants;
75
+ striking through a surviving group would tell the reader the whole
76
+ section was deleted. */
77
+ [data-nav-mark-row="removed"]:not([data-nav-mark-subtree]) {
78
+ text-decoration: line-through;
79
+ opacity: 0.7;
80
+ }
81
+
82
+ @media (forced-colors: active) {
83
+ .db-nav-mark { color: CanvasText; }
84
+ }
85
+ </style>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import SidebarNavMark from "./SidebarNavMark.astro";
2
3
  /**
3
4
  * Recursive sidebar navigation tree.
4
5
  * Renders a data-driven tree with native <details>/<summary> for zero-JS expand/collapse.
@@ -8,11 +9,18 @@
8
9
  * <SidebarNavTree items={navItems} currentPath={Astro.url.pathname} />
9
10
  */
10
11
 
12
+ interface NavMark {
13
+ kind: "added" | "changed" | "removed" | "moved";
14
+ label?: string;
15
+ subtree?: boolean;
16
+ }
17
+
11
18
  interface NavItem {
12
19
  label: string;
13
20
  href?: string;
14
21
  icon?: string; // Raw SVG string (rendered via set:html)
15
22
  children?: NavItem[];
23
+ mark?: NavMark;
16
24
  }
17
25
 
18
26
  interface Props {
@@ -24,6 +32,13 @@ interface Props {
24
32
 
25
33
  const { items, currentPath, level = 0, class: className } = Astro.props;
26
34
 
35
+ /**
36
+ * Markers are rendered by SidebarNavMark, which owns the accessibility
37
+ * contract (distinct glyph so colour is never the sole channel,
38
+ * visually-hidden text so the row announces "MySQL, changed",
39
+ * forced-colors survival). See that component.
40
+ */
41
+
27
42
  const normalizedPath = currentPath.replace(/\/$/, "") || "/";
28
43
 
29
44
  function isActive(href: string) {
@@ -54,24 +69,78 @@ const paddingLeft = `${8 + level * 12}px`;
54
69
  const active = item.href ? isActive(item.href) : false;
55
70
  const hasChildren = item.children && item.children.length > 0;
56
71
  const shouldOpen = hasChildren && hasActiveDescendant(item);
72
+ const open = shouldOpen || active;
73
+ // A branch that is ALSO a page (has an href) is a section landing
74
+ // page; it uses the button-driven disclosure below and needs a
75
+ // stable id linking its toggle button to its submenu.
76
+ const submenuId = item.href
77
+ ? `nav-sub-${item.href.replace(/[^a-z0-9]+/gi, "-")}-${level}`
78
+ : undefined;
57
79
 
58
80
  return (
59
81
  <li data-sidebar="nav-tree-item">
60
- {hasChildren ? (
82
+ {hasChildren && item.href ? (
83
+ /*
84
+ Section landing page: APG "disclosure navigation" pattern — a
85
+ real link (navigates) + a SEPARATE toggle <button> (expands),
86
+ as siblings. Nesting a focusable <a> inside the interactive
87
+ <summary> is an axe `nested-interactive` violation, and native
88
+ <details> can't show a header link when collapsed without that
89
+ nesting. Progressive enhancement: the submenu's server state
90
+ (hidden unless open) is correct with no JS — the link still
91
+ works and the active section is expanded — and the inline
92
+ script wires the toggle button when JS is available.
93
+ */
94
+ <Fragment>
95
+ <div
96
+ class:list={[
97
+ "flex w-full min-w-0 items-center gap-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
98
+ level === 0 ? "h-8" : "h-7",
99
+ active && "bg-sidebar-accent font-medium text-sidebar-accent-foreground",
100
+ ]}
101
+ style={`padding-left: ${paddingLeft};`}
102
+ >
103
+ <button
104
+ type="button"
105
+ data-nav-toggle
106
+ aria-controls={submenuId}
107
+ aria-expanded={open ? "true" : "false"}
108
+ aria-label={`Toggle ${item.label} section`}
109
+ class="shrink-0 rounded outline-none ring-sidebar-ring focus-visible:ring-2"
110
+ >
111
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6" /></svg>
112
+ </button>
113
+ {item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
114
+ <a
115
+ href={item.href}
116
+ class="min-w-0 flex-1 truncate text-inherit no-underline outline-none ring-sidebar-ring focus-visible:ring-2"
117
+ data-nav-href={item.href}
118
+ data-active={active || undefined}
119
+ data-nav-mark-row={item.mark?.subtree ? undefined : item.mark?.kind}
120
+ >{item.label}</a>
121
+ {item.mark && (
122
+ <SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
123
+ )}
124
+ </div>
125
+ <div id={submenuId} data-nav-submenu hidden={!open}>
126
+ <Astro.self items={item.children!} currentPath={currentPath} level={level + 1} />
127
+ </div>
128
+ </Fragment>
129
+ ) : hasChildren ? (
61
130
  <details open={shouldOpen || active}>
62
131
  <summary
63
132
  class:list={[
64
133
  "flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none [list-style:none] ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&::-webkit-details-marker]:hidden",
65
134
  level === 0 ? "h-8" : "h-7",
66
- active && "bg-sidebar-accent font-medium text-sidebar-accent-foreground",
67
135
  ]}
68
136
  style={`padding-left: ${paddingLeft};`}
69
- data-nav-href={item.href || undefined}
70
- data-active={active || undefined}
71
137
  >
72
138
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6" /></svg>
73
139
  {item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
74
- <span class="truncate">{item.label}</span>
140
+ <span class="min-w-0 flex-1 truncate">{item.label}</span>
141
+ {item.mark && (
142
+ <SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
143
+ )}
75
144
  </summary>
76
145
  <Astro.self items={item.children!} currentPath={currentPath} level={level + 1} />
77
146
  </details>
@@ -86,12 +155,22 @@ const paddingLeft = `${8 + level * 12}px`;
86
155
  style={`padding-left: ${paddingLeft};`}
87
156
  data-active={active || undefined}
88
157
  data-nav-href={item.href}
158
+ {/*
159
+ A SUBTREE mark is a claim about descendants, never about
160
+ this row — striking through a surviving group because one
161
+ child was deleted tells the reader the whole section is
162
+ gone (code review, 2026-07-13).
163
+ */}
164
+ data-nav-mark-row={item.mark?.subtree ? undefined : item.mark?.kind}
89
165
  >
90
166
  {/* Spacer to align with chevron in branch items */}
91
167
  <span class="size-4 shrink-0" />
92
168
  {/* Icon (level 0 only) */}
93
169
  {item.icon && <span class="shrink-0 [&>svg]:size-4" set:html={item.icon} />}
94
- <span class="truncate">{item.label}</span>
170
+ <span class="min-w-0 flex-1 truncate">{item.label}</span>
171
+ {item.mark && (
172
+ <SidebarNavMark kind={item.mark.kind} label={item.mark.label} subtree={item.mark.subtree} />
173
+ )}
95
174
  </a>
96
175
  )}
97
176
  </li>
@@ -100,7 +179,32 @@ const paddingLeft = `${8 + level * 12}px`;
100
179
  </ul>
101
180
 
102
181
  <style is:global>
103
- details[open] > summary [data-chevron] {
182
+ details[open] > summary [data-chevron],
183
+ [data-nav-toggle][aria-expanded="true"] [data-chevron] {
104
184
  transform: rotate(90deg);
105
185
  }
106
186
  </style>
187
+
188
+ <script>
189
+ // Wire the section-landing disclosure toggles (APG disclosure-nav).
190
+ // The link navigates on its own; this button just expands/collapses
191
+ // the sibling submenu. Server-rendered state is already correct, so
192
+ // with no JS the link still works and the active section is open.
193
+ // Idempotent + re-run after view transitions.
194
+ function wireNavToggles() {
195
+ document
196
+ .querySelectorAll<HTMLButtonElement>("[data-nav-toggle]:not([data-wired])")
197
+ .forEach((btn) => {
198
+ btn.dataset.wired = "1";
199
+ const id = btn.getAttribute("aria-controls");
200
+ const submenu = id ? document.getElementById(id) : null;
201
+ btn.addEventListener("click", () => {
202
+ const isOpen = btn.getAttribute("aria-expanded") === "true";
203
+ btn.setAttribute("aria-expanded", String(!isOpen));
204
+ if (submenu) submenu.hidden = isOpen;
205
+ });
206
+ });
207
+ }
208
+ wireNavToggles();
209
+ document.addEventListener("astro:page-load", wireNavToggles);
210
+ </script>
@@ -11,14 +11,17 @@ const table = tv({
11
11
 
12
12
  interface Props {
13
13
  class?: string;
14
+ /** Optional table caption, rendered as a `<caption>` (bottom-aligned). */
15
+ caption?: string;
14
16
  [key: string]: unknown;
15
17
  }
16
18
 
17
- const { class: className, ...rest } = Astro.props;
19
+ const { class: className, caption, ...rest } = Astro.props;
18
20
  ---
19
21
 
20
22
  <div class={tableContainer()}>
21
23
  <table class={table({ class: className })} {...rest}>
24
+ {caption && <caption class="text-sm text-muted-foreground mt-2">{caption}</caption>}
22
25
  <slot />
23
26
  </table>
24
27
  </div>
@@ -2,7 +2,7 @@
2
2
  import { tv } from "tailwind-variants";
3
3
 
4
4
  const tableCell = tv({
5
- base: "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
5
+ base: "p-2 align-top [&:has([role=checkbox])]:pr-0",
6
6
  });
7
7
 
8
8
  interface Props {
@@ -2,7 +2,7 @@
2
2
  import { tv } from "tailwind-variants";
3
3
 
4
4
  const tableHead = tv({
5
- base: "h-10 px-2 text-left align-middle font-semibold text-muted-foreground whitespace-nowrap [&:has([role=checkbox])]:pr-0",
5
+ base: "px-2 py-2 text-left align-bottom font-semibold text-muted-foreground [&:has([role=checkbox])]:pr-0",
6
6
  });
7
7
 
8
8
  interface Props {