From 329dc178a85226e6b81063778a1914e3a088fb8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 11:02:42 -0300 Subject: [PATCH 1/2] chore: start #548 From 2b749734771ee01f50d7396124196dca6a630038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 11:12:15 -0300 Subject: [PATCH 2/2] fix: unique docs heading ids per page --- packages/ui/tests/heading-ids.test.ts | 64 +++++++++++++++++++ site/src/components/docs/Demo.astro | 9 ++- site/src/components/docs/Props.astro | 3 +- site/src/content/docs/display/carousel.mdx | 2 +- site/src/content/docs/feedback/skeleton.mdx | 2 +- .../content/docs/foundation/drag-and-drop.mdx | 2 +- .../content/docs/foundation/typography.mdx | 2 +- .../content/docs/getting-started/theming.mdx | 2 +- .../docs/getting-started/workarounds.mdx | 4 +- site/src/content/docs/layout/app-shell.mdx | 2 +- .../docs/navigation/command-palette.mdx | 2 +- site/src/content/docs/navigation/menu.mdx | 6 +- site/src/lib/heading-ids.ts | 59 +++++++++++++++++ site/src/pages/docs/[...slug].astro | 3 + site/tests/dist-html.spec.ts | 33 +--------- 15 files changed, 148 insertions(+), 47 deletions(-) create mode 100644 packages/ui/tests/heading-ids.test.ts create mode 100644 site/src/lib/heading-ids.ts diff --git a/packages/ui/tests/heading-ids.test.ts b/packages/ui/tests/heading-ids.test.ts new file mode 100644 index 00000000..e5aa4f51 --- /dev/null +++ b/packages/ui/tests/heading-ids.test.ts @@ -0,0 +1,64 @@ +/** + * Heading ids of a docs page are unique (#548): the markdown body, `` and `` share one + * registry. `site/tests/dist-html.spec.ts` proves it on the built pages; this proves the rules. + */ +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { demoTitles, headingId, slugOf, startPage } from "../../../site/src/lib/heading-ids.ts"; + +describe("heading ids", () => { + it("numbers the repeated titles of one page", () => { + startPage("/a/", [], ""); + expect([1, 2, 3].map(() => headingId("/a/", "Props"))).toEqual(["props", "props-1", "props-2"]); + }); + + it("gives a body heading precedence and numbers the component twin", () => { + startPage("/b/", ["props"], ""); + expect(headingId("/b/", "Props")).toBe("props-1"); + }); + + it("numbers a repeated demo title and keeps the first on the catalog slug", () => { + startPage("/c/", [], ''); + expect(headingId("/c/", "Autoplay", true)).toBe("autoplay"); + expect(headingId("/c/", "Autoplay")).toBe("autoplay-1"); + }); + + it("reserves a demo slug so a title cannot take it", () => { + startPage("/d/", [], ''); + expect(headingId("/d/", "Props")).toBe("props-1"); + expect(headingId("/d/", "Props", true)).toBe("props"); + }); + + it("is independent per page", () => { + startPage("/e/", [], ""); + startPage("/f/", [], ""); + expect(headingId("/e/", "Props")).toBe("props"); + expect(headingId("/f/", "Props")).toBe("props"); + }); + + it("slugs like the catalog", () => { + expect(slugOf("Dense, pinned and grouped")).toBe("dense-pinned-and-grouped"); + expect(demoTitles('')).toEqual(["A b", "C"]); + }); +}); + +describe("docs sources", () => { + it("never title a body heading like a of the same page", () => { + const walk = (d: string): string[] => + readdirSync(d).flatMap((f) => { + const p = join(d, f); + return statSync(p).isDirectory() ? walk(p) : p.endsWith(".mdx") ? [p] : []; + }); + const root = join(import.meta.dir, "../../../site/src/content/docs"); + const clashes: string[] = []; + for (const f of walk(root)) { + const src = readFileSync(f, "utf8").replace(/^[ \t]*```[\s\S]*?^[ \t]*```/gm, ""); + const demos = new Set(demoTitles(src).map(slugOf)); + for (const m of src.matchAll(/^#{2,4}\s+(.+)$/gm)) + if (demos.has(slugOf(m[1].replace(/[`*]/g, "")))) + clashes.push(`${f.slice(root.length)}: ${m[1]}`); + } + expect(clashes).toEqual([]); + }); +}); diff --git a/site/src/components/docs/Demo.astro b/site/src/components/docs/Demo.astro index e148cd07..f914a4e4 100644 --- a/site/src/components/docs/Demo.astro +++ b/site/src/components/docs/Demo.astro @@ -1,6 +1,7 @@ --- import { CodeSnippet } from "@codeatlasdev/ui/code"; import catalog from "../../../public/api/v1/catalog.json"; +import { headingId, slugOf } from "../../lib/heading-ids"; interface Props { title: string; @@ -11,11 +12,13 @@ interface Props { stage?: boolean; } const { title, description, src, stage = false } = Astro.props; -// Same transform as the catalog slug (packages/ui/scripts/build-manifest.ts): heading id = catalog slug. -const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +// The catalog slug (packages/ui/scripts/build-manifest.ts) is slugOf(title); the heading id is that slug +// unless the page already uses it (see lib/heading-ids.ts). +const slug = slugOf(title); +const id = headingId(Astro.url.pathname, title, true); // `/docs///` identifies the page; with the slug it identifies the numbered demo. const [, , group, page] = Astro.url.pathname.split("/"); -const num = catalog.elements.find((e) => e.group === group && e.page === page && e.slug === id)?.id; +const num = catalog.elements.find((e) => e.group === group && e.page === page && e.slug === slug)?.id; ---
{num && #{num}} diff --git a/site/src/components/docs/Props.astro b/site/src/components/docs/Props.astro index d6752c3c..29c3fe30 100644 --- a/site/src/components/docs/Props.astro +++ b/site/src/components/docs/Props.astro @@ -1,5 +1,6 @@ --- import { SimpleTable } from "@codeatlasdev/ui"; +import { headingId } from "../../lib/heading-ids"; interface Row { name: string; @@ -12,7 +13,7 @@ interface Props { title?: string; } const { rows, title = "Props" } = Astro.props; -const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +const id = headingId(Astro.url.pathname, title); ---

{title}

diff --git a/site/src/content/docs/display/carousel.mdx b/site/src/content/docs/display/carousel.mdx index 8851ef49..feaa1705 100644 --- a/site/src/content/docs/display/carousel.mdx +++ b/site/src/content/docs/display/carousel.mdx @@ -35,7 +35,7 @@ The replacement for shadcn `Carousel` (Embla). The strip is native `scroll-snap` It follows the WAI-ARIA carousel pattern. The carousel is a labelled `role="region"` with `aria-roledescription="carousel"`; each slide is a `role="group"` named "2 of 4". Slides outside the view are `inert`, so their links and buttons leave the tab order and the reading order. The dots are labelled buttons with `aria-current` on the current one (the slides are not tab panels, so they are not a tablist). The slide strip is the one tab stop (it scrolls, so it must be reachable without a pointer, even when no slide holds a control). With it focused, Left and Right (mirrored in RTL), Home and End move the slides. -## Autoplay +## Autoplay behavior Off unless you set `autoplay` (milliseconds). When on, a Pause / Start button appears, rotation stops while focus or the pointer is inside, and the strip is `aria-live="off"` while it rotates (a rotating live region would read every slide) and `polite` once it stops. A manual move stops it for good. Under `prefers-reduced-motion: reduce` it never runs, the button is not shown and scrolling is instant. Without `loop` it stops on the last slide. diff --git a/site/src/content/docs/feedback/skeleton.mdx b/site/src/content/docs/feedback/skeleton.mdx index a1447f1f..327bd71d 100644 --- a/site/src/content/docs/feedback/skeleton.mdx +++ b/site/src/content/docs/feedback/skeleton.mdx @@ -27,7 +27,7 @@ import ImageSrc from "~/demos/feedback/skeleton/Image.svelte?raw"; Skeletons are `aria-hidden`. Announce the loading state on the container, for example with `aria-busy="true"`, and leave the placeholders silent. -## The `skeleton` prop +## Keeping the layout `Avatar`, `KpiCard`, `TextField`, `Table`, `Card` and `GlassCard` take `skeleton`. The component keeps its real DOM, so the placeholder has exactly the loaded size (no layout shift): content is laid out but hidden, the host paints the same shimmer as ``, and it is announced with `aria-busy="true"`. A skeleton `TextField` is disabled and an `Avatar` makes no image request. For `Table`, render placeholder rows so the height matches. Under `prefers-reduced-motion` the sweep stops and the placeholder is dimmed. diff --git a/site/src/content/docs/foundation/drag-and-drop.mdx b/site/src/content/docs/foundation/drag-and-drop.mdx index 0813db1c..721d7b15 100644 --- a/site/src/content/docs/foundation/drag-and-drop.mdx +++ b/site/src/content/docs/foundation/drag-and-drop.mdx @@ -58,7 +58,7 @@ Lists that share a `group` accept each other's items. `applyMove` applies a drop -## Drop zones +## Drop targets A drop zone is a target that is not a sortable list: a calendar day, a folder, a trash can. A standalone item uses `dnd.draggable`; the zone gets `data-dnd-over` while an item is above it. Return `false` from `accepts` to refuse an item. diff --git a/site/src/content/docs/foundation/typography.mdx b/site/src/content/docs/foundation/typography.mdx index 2d491b7b..7a2c923a 100644 --- a/site/src/content/docs/foundation/typography.mdx +++ b/site/src/content/docs/foundation/typography.mdx @@ -26,7 +26,7 @@ export const theme = defineTheme({ }); ``` -## Type roles +## Roles A screen needs the same few text roles everywhere. Each role is a bundle of size, leading, tracking and weight, so you pick one name instead of four loose properties: `title`, `heading`, `body`, `label`, `caption` and `num` (counts, times and prices, with tabular digits). diff --git a/site/src/content/docs/getting-started/theming.mdx b/site/src/content/docs/getting-started/theming.mdx index b9dba84f..b997a8bb 100644 --- a/site/src/content/docs/getting-started/theming.mdx +++ b/site/src/content/docs/getting-started/theming.mdx @@ -46,7 +46,7 @@ Pass the same object to `caThemeHandle` and `caThemePlugin` (SvelteKit), `caUI` | `border` | `{ width: 1, strong: 2 }` | Border weights in px (`--ca-border-width`, `--ca-border-width-strong`). | | `elevation` | `"soft"` | How shadows are drawn: `"soft"`, `"none"`, `"hard"` (offset, no blur) or `"glow"`. | | `typography` | `{}` | `scale`, `weights` (`heading` defaults to `600`), `headingTracking`, `headingCase`, `displayTracking`, `displayLeading`, `labelTracking`, `labelCase`, `lineHeight` and `measure`. | -| `type` | `{}` | The type scale by role: `title`, `heading`, `body`, `label`, `caption`, `num`, each with `size`, `leading`, `tracking` and `weight`. Overrides the role everywhere, library components included. See [Typography](/docs/foundation/typography/#type-roles). | +| `type` | `{}` | The type scale by role: `title`, `heading`, `body`, `label`, `caption`, `num`, each with `size`, `leading`, `tracking` and `weight`. Overrides the role everywhere, library components included. See [Typography](/docs/foundation/typography/#roles). | | `motion` | `{}` | `scale` (multiplier on every duration), `easing` (`"standard"`, `"emphasized"`, `"spring"`, `"linear"` or `"steps"`) and `enter` (`"default"` or `"none"`). `enter: "none"` makes dialogs, drawers, sheets, menus, popovers, tooltips and toasts appear and leave in place while hover and focus color transitions keep `scale` (it sets `--ca-enter-scale: 0`); `MotionStage` demos render their final state. | | `noise` | `0` | Film-grain over the page background, from `0` to `0.12`. | | `components` | `{}` | [Component tokens](#component-tokens) of `button`, `iconButton`, `kbd`, `avatar`, `field`, `textArea`, `card`, `segmented` and `emptyState`. | diff --git a/site/src/content/docs/getting-started/workarounds.mdx b/site/src/content/docs/getting-started/workarounds.mdx index 2d69a12d..8991c342 100644 --- a/site/src/content/docs/getting-started/workarounds.mdx +++ b/site/src/content/docs/getting-started/workarounds.mdx @@ -74,8 +74,8 @@ Imports are from `@codeatlasdev/ui` unless the last column names a subpath. | A `domain.css` of `--app-status-*`, `--app-tier-*` or `--app-channel-*` colors | `tokens: { status: { new: { light, dark } } }` on the preset: `--ca-token-status-new` (+ `-subtle`, `-contrast`) follows the scheme and the preset | [Domain tokens](/docs/getting-started/theming/#domain-tokens-status-tier-channel) | | A 1x1 `` that resolves `getComputedStyle(root).getPropertyValue(token)` to `[r, g, b, a]` | `tokenRgba("status.new")` or `tokenRgba("--ca-brand-main")` | [Domain tokens](/docs/getting-started/theming/#domain-tokens-status-tier-channel), `@codeatlasdev/ui/tokens` | | A hex computed by hand to flatten a translucent text or border color onto one background | The CSS color as written: `textSecondary: "oklch(1 0 0 / 0.62)"` or `borderSubtle: { color: "#fff", alpha: 0.1 }`. The build checks text contrast after compositing | [Porting a palette by role](/docs/getting-started/theming/#porting-a-palette-by-role) | -| A parallel `--app-text-*` scale or `.app-label` classes of `font-size`, `line-height` and `letter-spacing` | The type roles: ``, `.ca-text-label`, `font: var(--ca-text-label)`; restyle them once with `type` in the theme | [Type roles](/docs/foundation/typography/#type-roles) | -| `font-variant-numeric: tabular-nums` and a weight on every counter or clock | `` or `.ca-text-num` | [Type roles](/docs/foundation/typography/#type-roles) | +| A parallel `--app-text-*` scale or `.app-label` classes of `font-size`, `line-height` and `letter-spacing` | The type roles: ``, `.ca-text-label`, `font: var(--ca-text-label)`; restyle them once with `type` in the theme | [Type roles](/docs/foundation/typography/#roles) | +| `font-variant-numeric: tabular-nums` and a weight on every counter or clock | `` or `.ca-text-num` | [Type roles](/docs/foundation/typography/#roles) | diff --git a/site/src/content/docs/layout/app-shell.mdx b/site/src/content/docs/layout/app-shell.mdx index f83823f4..5a8c9988 100644 --- a/site/src/content/docs/layout/app-shell.mdx +++ b/site/src/content/docs/layout/app-shell.mdx @@ -125,7 +125,7 @@ Map-first products keep one live map behind every screen: the camera, the WebGL `` paints the page preset's surface, so a public screen wrapped in its own preset would leave the overscroll area and the mobile toolbar in the wrong color. Use `` (`ThemeIsland`, from the theme work in #352): it mirrors the island's preset and scheme onto `` while it is mounted, so the root background follows with no copied color. -## Panel width +## Resizing the panel The panel is 200px by default. For lists with long names pass `panelWidth` (px, held between 160 and 480): the content offset and the open and close animation follow it, so the page never misaligns. Add `resizablePanel` to let people choose: a handle sits on the panel's trailing edge and is a keyboard-operable separator (`role="separator"` with `aria-valuenow`, `aria-valuemin` and `aria-valuemax`). Arrow keys move it by 8px, Shift by 32px, Home and End jump to the bounds. While the pointer holds the edge the panel follows it with no spring in between. With `persistPanel` the width is stored with the open state and restored before the first paint; `panelWidth` is the width used until the user picks one. A stored width is ignored when `resizablePanel` is off, so the prop stays the contract. diff --git a/site/src/content/docs/navigation/command-palette.mdx b/site/src/content/docs/navigation/command-palette.mdx index 12f55800..dd4983f0 100644 --- a/site/src/content/docs/navigation/command-palette.mdx +++ b/site/src/content/docs/navigation/command-palette.mdx @@ -28,7 +28,7 @@ The palette searches the same `NavModule[]` you pass to [AppShellLayout](/docs/l -## Async results +## Server-side search Pass `search` to add server-side results under the local matches. It runs once the user stops typing for `searchDebounce` ms, receives the trimmed query and an `AbortSignal`, and returns `CommandResult[]` (`{ id, label, group?, path }`). A newer query, closing the palette or unmounting aborts the pending call, so forward the signal to `fetch` and never apply a stale answer. While it runs the list is `aria-busy` and a "Searching…" row shows; a rejection shows "Search failed" without hiding local matches; the empty state appears only after the search settles. Progress, the result count and failures are announced through `announce()`. diff --git a/site/src/content/docs/navigation/menu.mdx b/site/src/content/docs/navigation/menu.mdx index 0b47f2e1..9d21096a 100644 --- a/site/src/content/docs/navigation/menu.mdx +++ b/site/src/content/docs/navigation/menu.mdx @@ -41,7 +41,7 @@ import SelectionSrc from "~/demos/navigation/menu/Selection.svelte?raw"; Keyboard support follows the WAI-ARIA menu pattern: Enter, Space or ArrowDown opens the menu on the first item, ArrowUp opens it on the last, arrows then move, Enter or Space selects, Escape closes and focus returns to the trigger. Tab closes the menu. Selecting an item closes it. The menu is a popover layer in the browser top layer, positioned with CSS anchor positioning where supported, so it stays on top of a dialog it is opened from. Pass an interactive trigger (`Button`, `IconButton`) through `triggerChild` and spread the received `props` on it. Use `trigger` only for non-interactive content such as text or an icon. Use `DropdownMenuLabel` for a non-interactive group heading and `DropdownMenuSeparator` for a divider. -### Checkbox and radio items +### Checkable items `DropdownMenuCheckboxItem` renders `role="menuitemcheckbox"` with `aria-checked` and a check indicator; bind `checked` or listen to `onCheckedChange`. `DropdownMenuRadioGroup` wraps `DropdownMenuRadioItem`s (`role="menuitemradio"`): bind the group's `value`, each item sets it with its own `value`. Enter, Space or a click toggles the item and the menu stays open so several options can be set in one visit (the WAI-ARIA menu pattern); pass `closeOnSelect` to close after one pick. Arrow keys, Home, End and typeahead reach these items like any other, and disabled ones are skipped. Give the group an `aria-label` or put a `DropdownMenuLabel` first and the group is named for screen readers. @@ -49,11 +49,11 @@ Keyboard support follows the WAI-ARIA menu pattern: Enter, Space or ArrowDown op `bind:open` and `onOpenChange` control the menu from outside: set `open = true` from any handler and it opens; `onOpenChange` fires for user-driven changes (a trigger press, Escape, an outside press, picking an item), like `Dialog`. For a right-click menu use [ContextMenu](/docs/navigation/context-menu/), which wires right-click, long-press and Shift+F10 for you. To wire it yourself, pass `anchor={{ x, y }}` (viewport coordinates) together with `open = true` and leave out `trigger`: the menu opens at that point on the first item and focus returns to the element that had it when the menu closes. Setting `anchor` again while the menu is open moves it instead of toggling it, so a second right-click relocates the menu. Handle Shift+F10 and the ContextMenu key in your own `onkeydown` the same way (the demo shows both); on the built-in trigger they open the menu on its first item. -### Submenus +### Nested menus Put a `DropdownMenuSub` among the items. Its `trigger` snippet holds a `DropdownMenuSubTrigger` (an item with a chevron, `aria-haspopup="menu"` and `aria-expanded`) and its children are the submenu's items, labels and further `DropdownMenuSub`s, nested to any depth. ArrowRight, Enter or Space on the trigger opens the submenu with focus on its first enabled item; ArrowLeft or Escape closes only that level and returns focus to the trigger; arrows, Home, End and typeahead stay inside the open level. Hovering the trigger opens it after `openDelay` (120 ms) and it stays open for `closeDelay` (200 ms) after the pointer leaves, so the pointer can cross the gap. Selecting any item closes every level and returns focus to the root trigger. A disabled sub-trigger neither opens nor takes ArrowRight. In a right-to-left layout use `side="left"` on the sub, and the chevron mirrors. -### Leading icons and brand logos +### Item icons Pass a leading icon with the `icon` snippet instead of placing it in `children`: it gets a fixed 14px box, the standard gap and `aria-hidden` (the label stays the accessible name), so icon size and alignment never drift between menus. Any `svg` or `img` inside the box fills it, including one wrapped in `SchemeAsset`, whatever `width` or `height` the asset declares. A plain icon (Lucide, any `currentColor` glyph) follows the item's text color, including the highlighted, disabled and destructive states. diff --git a/site/src/lib/heading-ids.ts b/site/src/lib/heading-ids.ts new file mode 100644 index 00000000..97ceaa24 --- /dev/null +++ b/site/src/lib/heading-ids.ts @@ -0,0 +1,59 @@ +/** + * Heading ids of one docs page. Three things emit headings: the markdown body (`## Foo`, slugged by + * Astro, and the table of contents links to those), `` and ``. Each slugged on + * its own, so two headings called "Props" or "Autoplay" shared an id and the in-page link and the + * table of contents hit the first of them (#548). + * + * The docs route calls `startPage` with the body's own slugs before `` renders; the + * components then take their id from `headingId`, which never returns one already taken on that page. + * Body ids are never rewritten (the TOC points at them). A `` keeps the plain catalog slug (it + * is published as `…/#slug` in `catalog.json`), so a body heading that clashes with a demo title has + * its component twin numbered, never the other way round: the demo slugs are claimed up front. + */ +export const slugOf = (title: string): string => + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +/** `title="…"` literals of the `` elements of an MDX body. */ +export const demoTitles = (body: string): string[] => + [...body.matchAll(/]*?\btitle="([^"]+)"/g)].map((m) => m[1]); + +interface Page { + taken: Set; + /** Demo slugs not rendered yet: reserved for their ``. */ + reserved: Set; +} +const pages = new Map(); + +/** + * @param bodyIds ids Astro gave the markdown headings + * @param body raw MDX source, to reserve the demo slugs + */ +export function startPage(path: string, bodyIds: string[], body: string): void { + pages.set(path, { taken: new Set(bodyIds), reserved: new Set(demoTitles(body).map(slugOf)) }); +} + +const unique = (page: Page, base: string, own: boolean): string => { + const free = (id: string) => !page.taken.has(id) && (own || !page.reserved.has(id)); + let id = base; + for (let n = 1; !free(id); n++) id = `${base}-${n}`; + page.taken.add(id); + return id; +}; + +/** + * Id for the heading a component renders on `path`. `demo` is true for ``: it may take its + * own reserved slug (the first time) and, like any heading, takes the next free number otherwise. + */ +export function headingId(path: string, title: string, demo = false): string { + const base = slugOf(title); + const page = pages.get(path); + if (!page) return base; + if (demo && page.reserved.has(base) && !page.taken.has(base)) { + page.taken.add(base); + return base; + } + return unique(page, base, false); +} diff --git a/site/src/pages/docs/[...slug].astro b/site/src/pages/docs/[...slug].astro index 91d081ed..58da270e 100644 --- a/site/src/pages/docs/[...slug].astro +++ b/site/src/pages/docs/[...slug].astro @@ -4,6 +4,7 @@ import SiteDocsShell from "~/components/docs/SiteDocsShell.svelte"; import SiteFooter from "~/components/site/SiteFooter.astro"; import Base from "~/layouts/Base.astro"; import { loadDocs } from "~/lib/docs"; +import { startPage } from "~/lib/heading-ids"; import { docHref, SECTIONS } from "~/lib/nav"; export async function getStaticPaths() { @@ -14,6 +15,8 @@ export async function getStaticPaths() { const { entry } = Astro.props; const { groups, flat } = await loadDocs(); const { Content, headings } = await render(entry); +// and draw their heading ids from this registry while renders. +startPage(Astro.url.pathname, headings.map((h) => h.slug), entry.body ?? ""); const toc = headings .filter((h) => h.depth === 2 || h.depth === 3) .map((h) => ({ id: h.slug, text: h.text, depth: h.depth as 2 | 3 })); diff --git a/site/tests/dist-html.spec.ts b/site/tests/dist-html.spec.ts index 98ad5a98..7e3681fd 100644 --- a/site/tests/dist-html.spec.ts +++ b/site/tests/dist-html.spec.ts @@ -15,34 +15,6 @@ const PAGES = ROUTES.filter( (r) => !readFileSync(join(DIST, r, "index.html"), "utf8").includes('http-equiv="refresh"') ); -/** - * Duplicate ids that exist today: a docs heading and a demo section slug to the same anchor, so the - * in-page link and the table of contents point at the first of the two. axe never reported them (its - * `duplicate-id` rules are outside the WCAG tag set the axe project runs). Listed so a NEW duplicate - * fails; remove an entry when its page is fixed. A fixed page still listed also fails. Fixing all of - * them and emptying this list is tracked in #548. - */ -const KNOWN_DUPLICATE_IDS: Record = { - "/docs/display/carousel/": ["autoplay"], - "/docs/display/table/": ["props"], - "/docs/docs-kit/docs/": ["docssearch"], - "/docs/feedback/skeleton/": ["the-skeleton-prop"], - "/docs/foundation/drag-and-drop/": ["drop-zones"], - "/docs/foundation/typography/": ["type-roles"], - "/docs/layout/app-shell/": ["panel-width"], - "/docs/layout/resizable-panels/": ["props"], - "/docs/map/": ["props"], - "/docs/navigation/command-palette/": ["async-results"], - "/docs/navigation/loading-boundary/": ["props"], - "/docs/navigation/menu/": [ - "checkbox-and-radio-items", - "submenus", - "leading-icons-and-brand-logos", - ], - "/docs/navigation/menubar/": ["props"], - "/docs/navigation/navigation-menu/": ["props"], -}; - // happy-dom's nodes are not the DOM lib's `Element`: type only what is read. type Named = { textContent: string | null; @@ -93,9 +65,8 @@ test.describe("prerendered HTML", () => { readFileSync(join(DIST, route, "index.html"), "utf8") ); expect(found).toEqual([]); - expect(duplicateIds, "duplicate ids (known ones are listed in this file)").toEqual( - KNOWN_DUPLICATE_IDS[route] ?? [] - ); + // Heading ids come from one per-page registry (src/lib/heading-ids.ts, #548). + expect(duplicateIds, "duplicate ids").toEqual([]); }); } });