From 23621bb3db0c36ee8ce538b71cad65ff284ce6e9 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 10 Sep 2026 04:38:29 -0500 Subject: [PATCH 1/2] fix(specimens): restore catalog layout and simplify density Restore the original sidebar and content proportions with complete grouped navigation, responsive component selection, and reliable fragment navigation. Make compact sizing permanent in Library and Lab without density controls or labels. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 11 + apps/specimens/index.html | 2 +- apps/specimens/src/app.tsx | 326 +++++++++++++----- apps/specimens/src/lab.css | 3 - apps/specimens/src/specimens-fixes.css | 25 -- apps/specimens/src/specimens.css | 218 ++++++++---- packages/ui/tests/catalog-navigation.test.tsx | 207 +++++++++++ scripts/mobile-quality-review.mjs | 74 ++-- scripts/verify-contracts.mjs | 49 +-- scripts/visual-review.mjs | 204 +++++++++-- 10 files changed, 853 insertions(+), 266 deletions(-) create mode 100644 packages/ui/tests/catalog-navigation.test.tsx diff --git a/README.md b/README.md index 0ca33ae..f39de46 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,17 @@ pnpm dev The specimen app opens at `http://127.0.0.1:5173/`. Its assembled lab is at `http://127.0.0.1:5173/lab`. +The catalog retains the original `index.html` proportions: a 248px component +sidebar, flexible main column, and 216px context rail within a 1680px shell. +Main gutters follow the original `clamp(24px, 5vw, 72px)`. Below 1281px the +context rail disappears and the sidebar becomes 232px; at 880px and below, a +native grouped picker replaces the sidebar. All 16 component and block links +share the rendered catalog's inventory and search results. Hash links support +direct navigation, while the selected entry follows page scrolling. + +Library and Lab use one fixed compact layout, without density controls or +density labels. Public component density props remain available to consumers. + The library presents one specimen per row, with bounded live surfaces, responsive block layouts, and a compact installation strip. It keeps live previews above syntax-highlighted install commands, imports, and full component source, with copy controls and explicit diff --git a/apps/specimens/index.html b/apps/specimens/index.html index a3150de..3d576d3 100644 --- a/apps/specimens/index.html +++ b/apps/specimens/index.html @@ -1,5 +1,5 @@ - + diff --git a/apps/specimens/src/app.tsx b/apps/specimens/src/app.tsx index c228905..7b26c3c 100644 --- a/apps/specimens/src/app.tsx +++ b/apps/specimens/src/app.tsx @@ -1,3 +1,5 @@ +/// + import { ActivityItem, AttachmentChip, @@ -34,7 +36,7 @@ import { SendControlExample, } from "./examples"; -type Density = "default" | "compact"; +const density = "compact"; type Scheme = "light" | "dark"; type SpecimenGroup = "Composer" | "Run rail" | "Blocks"; @@ -104,6 +106,7 @@ function SpecimenCard({ specimen }: { specimen: Specimen }) { id={specimen.id} aria-labelledby={headingId} data-kind={sourceKind} + tabIndex={-1} >
@@ -163,10 +166,10 @@ function SpecimenCard({ specimen }: { specimen: Specimen }) { ); } -function Library({ density, query }: { density: Density; query: string }) { +function useSpecimens() { const [mode, setMode] = useState("do"); - const specimens = useMemo( + return useMemo( () => [ { id: "mode-switch", @@ -425,17 +428,12 @@ function Library({ density, query }: { density: Density; query: string }) { ), }, ], - [density, mode], - ); - - const normalizedQuery = query.trim().toLowerCase(); - const filtered = specimens.filter((specimen) => - `${specimen.title} ${specimen.group} ${specimen.description} ${specimen.states}` - .toLowerCase() - .includes(normalizedQuery), + [mode], ); +} - if (filtered.length === 0) { +function Library({ specimens }: { specimens: Specimen[] }) { + if (specimens.length === 0) { return (
- + {!isLab ? ( + + ) : null}
-
+

{isLab @@ -709,19 +828,48 @@ function App() {

Schemes
02
-
-
Densities
-
02
-
{isLab ? ( ) : ( - + )}
+ {!isLab ? ( + + ) : null}
diff --git a/apps/specimens/src/lab.css b/apps/specimens/src/lab.css index fe5c862..f75764b 100644 --- a/apps/specimens/src/lab.css +++ b/apps/specimens/src/lab.css @@ -514,9 +514,6 @@ padding-inline: 0.5rem; gap: 0.25rem; } - .specimen-app--lab .density-control button { - padding-inline: 0.3rem; - } .specimen-app--lab .specimen-brand { gap: 0; } diff --git a/apps/specimens/src/specimens-fixes.css b/apps/specimens/src/specimens-fixes.css index 378f841..4b704de 100644 --- a/apps/specimens/src/specimens-fixes.css +++ b/apps/specimens/src/specimens-fixes.css @@ -1,10 +1,4 @@ /* Responsive guards for the catalog and its source panels. */ -body:not(:has(#group-composer)) .specimen-rail a[href="#group-composer"], -body:not(:has(#group-run-rail)) .specimen-rail a[href="#group-run-rail"], -body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { - display: none; -} - @media (max-width: 68rem) { .specimen-topbar__inner { display: flex; @@ -58,29 +52,10 @@ body:not(:has(#group-blocks)) .specimen-rail a[href="#group-blocks"] { } .specimen-stats :where(dt, dd), - .specimen-rail__context, .specimen-rail__package { overflow-wrap: anywhere; } - .specimen-rail__nav { - display: grid; - width: 100%; - min-width: 0; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 5.5rem), 1fr)); - } - - .specimen-rail__nav a { - min-width: 0; - justify-content: center; - gap: min(0.375rem, 8px); - padding-inline: 4px; - } - - .specimen-rail__nav small { - display: none; - } - .specimen-card > [data-slot="tabs"] { width: 100%; min-width: 0; diff --git a/apps/specimens/src/specimens.css b/apps/specimens/src/specimens.css index b057834..93dbcf1 100644 --- a/apps/specimens/src/specimens.css +++ b/apps/specimens/src/specimens.css @@ -42,7 +42,7 @@ body { display: flex; flex-wrap: wrap; min-height: 3.75rem; - max-width: 96rem; + max-width: 1680px; align-items: center; gap: 0.75rem; margin-inline: auto; @@ -93,8 +93,7 @@ body { text-transform: uppercase; } -.surface-switcher, -.density-control { +.surface-switcher { display: inline-flex; align-items: center; gap: 0.125rem; @@ -104,8 +103,7 @@ body { background: var(--card); } -.surface-switcher a, -.density-control button { +.surface-switcher a { border: 0; border-radius: calc(var(--radius-2) - 2px); background: transparent; @@ -118,14 +116,12 @@ body { white-space: nowrap; } -.surface-switcher a:hover, -.density-control button:hover { +.surface-switcher a:hover { background: var(--muted); color: var(--foreground); } -.surface-switcher a[aria-current="page"], -.density-control button[aria-pressed="true"] { +.surface-switcher a[aria-current="page"] { background: var(--foreground); color: var(--background); box-shadow: var(--elevation-1); @@ -159,9 +155,9 @@ body { .specimen-shell { display: grid; - max-width: 96rem; + max-width: 1680px; min-height: calc(100vh - var(--specimen-topbar-height)); - grid-template-columns: 13.5rem minmax(0, 1fr); + grid-template-columns: 248px minmax(0, 1fr) 216px; margin-inline: auto; } @@ -173,28 +169,10 @@ body { overflow-y: auto; border-inline-end: 1px solid var(--border); background: color-mix(in srgb, var(--card) 72%, var(--background)); - padding: 1.5rem 0.875rem; + padding: 28px 16px 40px 20px; scrollbar-width: thin; } -.specimen-rail__context { - padding-inline: 0.625rem; -} - -.specimen-rail__context h2 { - margin: 0.4rem 0 0; - font-size: 1rem; - font-weight: 750; - letter-spacing: -0.015em; -} - -.specimen-rail__context p:last-child { - margin: 0.625rem 0 0; - color: var(--muted-foreground); - font-size: 0.75rem; - line-height: 1.55; -} - .specimen-kicker { margin: 0; color: var(--presence); @@ -206,21 +184,52 @@ body { .specimen-rail__nav { display: grid; - gap: 0.25rem; - margin-block-start: 1.25rem; + gap: 24px; } -.specimen-rail__nav a { +.specimen-rail__group { + --nav-color: var(--presence); + min-width: 0; +} + +.specimen-rail__group[data-group="Run rail"] { + --nav-color: var(--information); +} + +.specimen-rail__group[data-group="Blocks"] { + --nav-color: var(--success); +} + +.specimen-rail__group h2 { display: flex; - align-items: center; + align-items: baseline; justify-content: space-between; - gap: 0.75rem; - border-radius: var(--radius-2); - color: var(--muted-foreground); - padding: 0.5rem 0.625rem; - font-size: 0.8125rem; + gap: 8px; + margin: 0 12px 8px; + color: var(--nav-color); + font-size: 0.625rem; font-weight: 650; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.specimen-rail__group ul { + margin: 0; + padding: 0; + list-style: none; +} + +.specimen-rail__nav a { + display: block; + border-inline-start: 2px solid transparent; + border-radius: 0 var(--radius-1) var(--radius-1) 0; + color: var(--muted-foreground); + padding: 6px 12px; + font-size: 0.84375rem; + font-weight: 450; + line-height: 1.4; text-decoration: none; + overflow-wrap: anywhere; } .specimen-rail__nav a:hover { @@ -228,11 +237,71 @@ body { color: var(--foreground); } +.specimen-rail__nav a[aria-current] { + border-inline-start-color: var(--nav-color); + background: color-mix(in srgb, var(--nav-color) 9%, var(--card)); + color: var(--foreground); + font-weight: 650; +} + .specimen-rail__nav small { color: color-mix(in srgb, var(--muted-foreground) 76%, transparent); font-size: 0.625rem; } +.specimen-mobile-nav { + display: none; +} + +.specimen-mobile-nav select { + display: block; + width: 100%; + min-width: 0; + min-height: 2.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-2); + background: var(--card); + color: var(--foreground); + padding: 0.5rem 0.75rem; + font: inherit; + font-size: 0.875rem; + text-overflow: ellipsis; +} + +.specimen-toc { + position: sticky; + inset-block-start: var(--specimen-topbar-height); + align-self: start; + max-height: calc(100vh - var(--specimen-topbar-height)); + overflow-y: auto; + padding: 32px 20px; + scrollbar-width: thin; +} + +.specimen-toc nav { + display: grid; + gap: 4px; +} + +.specimen-toc .specimen-kicker { + margin-block-end: 8px; +} + +.specimen-toc a { + border-inline-start: 1px solid var(--border); + color: var(--muted-foreground); + padding: 6px 12px; + font-size: 0.75rem; + text-decoration: none; + overflow-wrap: anywhere; +} + +.specimen-toc a:hover, +.specimen-toc a[aria-current] { + border-inline-start-color: var(--presence); + color: var(--presence); +} + .specimen-rail__package { margin-block-start: 1.5rem; border-block-start: 1px solid var(--border); @@ -261,14 +330,18 @@ body { } .specimen-main__inner { - width: min(100%, 76rem); + width: 100%; margin-inline: auto; - padding: var(--oc-space-8) var(--oc-space-8) var(--oc-space-16); + padding: var(--oc-space-12) clamp(24px, 5vw, 72px) 120px; +} + +[data-density="compact"] .specimen-main__inner { + padding-block-start: var(--oc-space-8); } .specimen-hero { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(15rem, 20rem); + display: flex; + flex-wrap: wrap; align-items: center; gap: 1.5rem; margin-block-end: clamp(2rem, 4vw, 3rem); @@ -277,7 +350,8 @@ body { } .specimen-hero__copy { - max-width: 52rem; + min-width: 0; + flex: 1 1 24rem; } .specimen-hero h1 { @@ -285,7 +359,7 @@ body { overflow-wrap: anywhere; margin: 0.5rem 0 0; font-family: var(--font-display); - font-size: var(--oc-type-4); + font-size: var(--oc-type-3); font-weight: 700; letter-spacing: var(--oc-tracking-heading); line-height: var(--oc-leading-heading); @@ -302,10 +376,6 @@ body { color: var(--information); } -.specimen-stats div:nth-child(3) dd { - color: var(--success); -} - .specimen-hero__copy > p:last-child { max-width: var(--oc-reading-measure); margin: var(--oc-space-4) 0 0; @@ -316,7 +386,8 @@ body { .specimen-stats { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + flex: 0 1 16rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.5rem; margin: 0; } @@ -1032,46 +1103,47 @@ p > .hljs { display: grid; width: 100%; grid-column: 1 / -1; - grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-columns: minmax(0, 1fr) auto; } .specimen-search { width: 100%; } +} + +@media (max-width: 1280px) { + .specimen-shell { + grid-template-columns: 232px minmax(0, 1fr); + } + .specimen-toc { + display: none; + } +} + +@media (max-width: 880px) { .specimen-shell { min-height: 0; - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); } .specimen-rail { position: static; display: block; height: auto; - overflow-x: auto; + overflow: visible; border-inline-end: 0; border-block-end: 1px solid var(--border); - padding: 0.5rem clamp(1rem, 4vw, 2rem); + padding: 4px clamp(1rem, 4vw, 2rem); } - .specimen-rail__context, + .specimen-rail__nav, .specimen-rail__package { display: none; } - .specimen-rail__nav { - display: flex; - width: max-content; - min-width: 100%; - gap: 0.375rem; - margin: 0; - } - - .specimen-rail__nav a { - min-width: 9rem; - flex: 1 0 auto; - border: 1px solid transparent; - background: color-mix(in srgb, var(--card) 72%, var(--background)); + .specimen-mobile-nav { + display: block; } .specimen-main__inner { @@ -1081,11 +1153,11 @@ p > .hljs { @media (max-width: 48rem) { .specimen-hero { - grid-template-columns: 1fr; align-items: start; } .specimen-stats { + flex-basis: 100%; width: 100%; } @@ -1118,7 +1190,7 @@ p > .hljs { } .specimen-topbar__actions { - grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-columns: minmax(0, 1fr) auto; } .specimen-search { @@ -1186,8 +1258,7 @@ p > .hljs { height: 2rem; } - .surface-switcher a, - .density-control button { + .surface-switcher a { padding-inline: 0.45rem; font-size: 0.6875rem; } @@ -1241,8 +1312,7 @@ p > .hljs { } @media (forced-colors: active) { - .surface-switcher a[aria-current="page"], - .density-control button[aria-pressed="true"] { + .surface-switcher a[aria-current="page"] { border: 1px solid Highlight; } } diff --git a/packages/ui/tests/catalog-navigation.test.tsx b/packages/ui/tests/catalog-navigation.test.tsx new file mode 100644 index 0000000..41221ff --- /dev/null +++ b/packages/ui/tests/catalog-navigation.test.tsx @@ -0,0 +1,207 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; + +import { App } from "../../../apps/specimens/src/app"; + +describe("catalog navigation", () => { + beforeEach(() => { + window.history.replaceState(null, "", "/"); + localStorage.clear(); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + unobserve() {} + }, + ); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); + }); + + it.each([ + ["/", "default"], + ["/", "compact"], + ["/lab", "default"], + ["/lab", "compact"], + ])( + "uses only compact sizing on %s with a saved %s preference", + (path, savedDensity) => { + window.history.replaceState(null, "", path); + localStorage.setItem("coven-ui:density", savedDensity); + render(); + expect(document.documentElement).toHaveAttribute( + "data-density", + "compact", + ); + expect( + screen.queryByRole("group", { name: "Display density" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /^(Cozy|Compact)$/ }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Densities")).not.toBeInTheDocument(); + expect(localStorage.getItem("coven-ui:density")).toBeNull(); + }, + ); + + it("lists every rendered component and block once, in the same groups", () => { + const { container } = render(); + const navigation = screen.getByRole("navigation", { + name: "Component navigation", + }); + const cards = [...container.querySelectorAll(".specimen-card")]; + expect(cards).toHaveLength(16); + expect(within(navigation).getAllByRole("link")).toHaveLength(17); + for (const card of cards) { + const title = card.querySelector("h3")!.textContent!; + expect( + within(navigation).getByRole("link", { name: title }), + ).toHaveAttribute("href", `#${card.id}`); + } + for (const [name, count] of [ + ["Composer", 4], + ["Run rail", 8], + ["Blocks", 4], + ] as const) { + expect( + within(within(navigation).getByRole("list", { name })).getAllByRole( + "link", + ), + ).toHaveLength(count); + } + expect( + screen.getByRole("combobox", { name: "Jump to component" }), + ).toHaveValue("library-overview"); + expect(screen.getAllByRole("option")).toHaveLength(17); + }); + + it("keeps sidebar, mobile choices and context links aligned with search results", () => { + const { container } = render(); + fireEvent.change(screen.getByPlaceholderText("Search components…"), { + target: { value: "Completion palette" }, + }); + expect(container.querySelectorAll(".specimen-card")).toHaveLength(1); + const navigation = screen.getByRole("navigation", { + name: "Component navigation", + }); + expect(within(navigation).getAllByRole("link")).toHaveLength(2); + expect(screen.getAllByRole("option")).toHaveLength(2); + for (const link of container.querySelectorAll( + '.specimen-rail a[href^="#"], .specimen-toc a[href^="#"]', + )) { + expect( + document.getElementById(link.getAttribute("href")!.slice(1)), + ).not.toBeNull(); + } + fireEvent.change(screen.getByPlaceholderText("Search components…"), { + target: { value: "no-such-specimen" }, + }); + expect(screen.getByText("No matching specimens")).toBeVisible(); + expect(within(navigation).getAllByRole("link")).toHaveLength(1); + expect(screen.getAllByRole("option")).toHaveLength(1); + }); + + it("navigates from the mobile picker and follows hash history without remounting previews", async () => { + const { container } = render(); + const mode = within( + container.querySelector("#mode-switch")! as HTMLElement, + ); + fireEvent.click(mode.getByRole("button", { name: "chat" })); + fireEvent.change( + screen.getByRole("combobox", { name: "Jump to component" }), + { + target: { value: "send-control" }, + }, + ); + expect(window.location.hash).toBe("#send-control"); + const navigation = screen.getByRole("navigation", { + name: "Component navigation", + }); + await waitFor(() => + expect( + within(navigation).getByRole("link", { name: "Send control" }), + ).toHaveAttribute("aria-current", "location"), + ); + act(() => { + window.history.replaceState(null, "", "#mode-switch"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + expect( + screen.getByRole("combobox", { name: "Jump to component" }), + ).toHaveValue("mode-switch"); + expect(mode.getByRole("button", { name: "chat" })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("keeps the selected link in step with manual page scrolling", async () => { + let scrollOffset = 0; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + const top = + ({ + "library-overview": 100, + "mode-switch": 500, + "send-control": 1000, + }[this.id] ?? 1500) - scrollOffset; + return new DOMRect(0, top, 832, 200); + }, + ); + render(); + scrollOffset = 1000; + fireEvent.scroll(window); + await waitFor(() => + expect( + within( + screen.getByRole("navigation", { name: "Component navigation" }), + ).getByRole("link", { name: "Send control" }), + ).toHaveAttribute("aria-current", "location"), + ); + expect(window.location.hash).toBe(""); + }); + + it("resolves an initial fragment after the client-rendered catalog mounts", async () => { + window.history.replaceState(null, "", "#session-header"); + render(); + const target = screen.getByRole("article", { name: "Session header" }); + target.scrollIntoView = vi.fn(); + await waitFor(() => + expect(target.scrollIntoView).toHaveBeenCalledWith({ block: "start" }), + ); + expect(target).toHaveFocus(); + }); + + it("selects the last component at the page end even when it cannot reach the top", async () => { + vi.stubGlobal("innerHeight", 1000); + vi.stubGlobal("scrollY", 2000); + vi.spyOn(document.documentElement, "scrollHeight", "get").mockReturnValue( + 3000, + ); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 250, 832, 635), + ); + render(); + fireEvent.scroll(window); + await waitFor(() => + expect( + within( + screen.getByRole("navigation", { name: "Component navigation" }), + ).getByRole("link", { name: "Session header" }), + ).toHaveAttribute("aria-current", "location"), + ); + }); +}); diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs index ddd8f56..4483c1c 100644 --- a/scripts/mobile-quality-review.mjs +++ b/scripts/mobile-quality-review.mjs @@ -11,62 +11,62 @@ const outputDir = path.resolve( const port = Number(process.env.MOBILE_CHROME_PORT ?? 9233); const cases = [ { - name: "mobile-320-dark-cozy", + name: "mobile-320-dark", width: 320, scheme: "dark", - density: "default", + legacyDensity: "default", }, { - name: "mobile-375-light-compact", + name: "mobile-375-light", width: 375, scheme: "light", - density: "compact", + legacyDensity: "compact", }, { - name: "mobile-390-dark-cozy", + name: "mobile-390-dark", width: 390, scheme: "dark", - density: "default", + legacyDensity: "default", }, { - name: "mobile-430-light-cozy", + name: "mobile-430-light", width: 430, scheme: "light", - density: "default", + legacyDensity: "default", }, { name: "mobile-390-dark-rtl", width: 390, scheme: "dark", - density: "compact", + legacyDensity: "compact", rtl: true, }, { name: "mobile-390-dark-text-200", width: 390, scheme: "dark", - density: "default", + legacyDensity: "default", textScale: 2, }, { name: "mobile-320-dark-text-200", width: 320, scheme: "dark", - density: "default", + legacyDensity: "default", textScale: 2, }, { name: "mobile-430-dark-text-200", width: 430, scheme: "dark", - density: "default", + legacyDensity: "default", textScale: 2, }, { name: "mobile-320-dark-wide-display", width: 320, scheme: "dark", - density: "default", + legacyDensity: "default", textScale: 2, wideDisplayFont: true, }, @@ -284,7 +284,7 @@ try { await navigate(new URL("/", baseUrl).href); await evaluate(`(() => { localStorage.setItem("coven-ui:scheme", ${JSON.stringify(scenario.scheme)}); - localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.density)}); + localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.legacyDensity)}); })()`); await navigate(new URL("/", baseUrl).href); @@ -310,11 +310,11 @@ try { const topbar = document.querySelector(".specimen-topbar"); const rail = document.querySelector(".specimen-rail"); const railLinks = [ - ...document.querySelectorAll(".specimen-rail__nav a"), + ...document.querySelectorAll("#component-picker"), ]; const topbarControls = [ ...document.querySelectorAll( - ".specimen-brand, .surface-switcher, .specimen-search, .specimen-search input, .specimen-search kbd, .density-control, .scheme-control", + ".specimen-brand, .surface-switcher, .specimen-search, .specimen-search input, .specimen-search kbd, .scheme-control", ), ]; const firstCard = document.querySelector(".specimen-card"); @@ -398,11 +398,13 @@ try { }; scrollTo(0, initialScrollY); await new Promise((resolve) => requestAnimationFrame(resolve)); - const railLink = document.querySelector(".specimen-rail__nav a"); - const railTarget = railLink - ? document.querySelector(railLink.getAttribute("href")) - : null; - railLink?.click(); + const picker = document.querySelector("#component-picker"); + const targetId = picker?.querySelector("optgroup option")?.value; + const railTarget = targetId ? document.getElementById(targetId) : null; + if (picker && targetId) { + picker.value = targetId; + picker.dispatchEvent(new Event("change", { bubbles: true })); + } await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)), ); @@ -413,11 +415,22 @@ try { ? targetBounds.top - Math.max(0, topbarBounds?.bottom ?? 0) : null; })(); + const componentNavigation = { + visible: Boolean(picker?.getBoundingClientRect().height), + targets: [...(picker?.querySelectorAll("option") ?? [])].map(option => option.value), + selected: picker?.value, + targetId, + hash: location.hash, + minHeight: rect(picker)?.height ?? 0, + }; scrollTo(0, initialScrollY); await new Promise((resolve) => requestAnimationFrame(resolve)); return { viewport: root.clientWidth, + fixedSizing: root.dataset.density === "compact" && + !document.querySelector('.density-control, [aria-label="Display density"]') && + ![...document.querySelectorAll(".specimen-stats dt")].some(label => /densit|compact|cozy/i.test(label.textContent)), documentOverflow: Math.max(0, root.scrollWidth - root.clientWidth), chromeBottom: Math.max( rect(topbar)?.bottom ?? 0, @@ -425,7 +438,7 @@ try { ), firstCardTop: rect(firstCard)?.top ?? null, railNavOverflow: clipped( - document.querySelector(".specimen-rail__nav"), + document.querySelector(".specimen-mobile-nav"), ), maxRailLinkContentOverflow: Math.max( 0, @@ -499,11 +512,28 @@ try { reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, skipNavigation, railTargetClearance, + componentNavigation, overflowingElements, }; })()`); const failures = []; + if (!measurement.fixedSizing) { + failures.push("expected fixed compact sizing without controls or labels"); + } + if ( + !measurement.componentNavigation.visible || + measurement.componentNavigation.targets.length !== 17 || + measurement.componentNavigation.selected !== + measurement.componentNavigation.targetId || + measurement.componentNavigation.hash !== + "#" + measurement.componentNavigation.targetId || + measurement.componentNavigation.minHeight < 44 + ) { + failures.push( + `mobile component picker failed: ${JSON.stringify(measurement.componentNavigation)}`, + ); + } if ( measurement.documentOverflow > 1 || measurement.maxRailLinkContentOverflow > 1 diff --git a/scripts/verify-contracts.mjs b/scripts/verify-contracts.mjs index 23dc885..8863282 100644 --- a/scripts/verify-contracts.mjs +++ b/scripts/verify-contracts.mjs @@ -45,8 +45,8 @@ const manifest = JSON.parse(packageJson); const portable = JSON.parse(portableJson); const vectors = JSON.parse(vectorsJson); const specimenStyles = `${specimenCss}\n${specimenFixes}`; -const specimenAt68 = specimenCss.slice( - specimenCss.indexOf("@media (max-width: 68rem)"), +const specimenAt880 = specimenCss.slice( + specimenCss.indexOf("@media (max-width: 880px)"), specimenCss.indexOf("@media (max-width: 48rem)"), ); const assertions = [ @@ -173,9 +173,11 @@ const assertions = [ specimenApp.includes('language="bash"'), ], [ - "density control is explicit", - specimenApp.includes('aria-label="Display density"') && - !specimenApp.includes("nth-child(2)"), + "the app uses compact sizing without density controls or labels", + specimenApp.includes('const density = "compact";') && + !specimenApp.includes("DensityControl") && + !specimenApp.includes("
Densities
") && + !specimenStyles.includes(".density-control"), ], [ "mobile layout covers 390px", @@ -189,12 +191,20 @@ const assertions = [ /html\s*\{[^}]*min-width:\s*320px/.test(specimenStyles), ], [ - "responsive rail becomes compact navigation", - specimenAt68.startsWith("@media (max-width: 68rem)") && - /\.specimen-shell\s*\{[^}]*grid-template-columns:\s*1fr;/.test( - specimenAt68, + "catalog matches the original desktop shell and reading gutters", + /\.specimen-shell\s*\{[^}]*max-width:\s*1680px;[^}]*grid-template-columns:\s*248px minmax\(0,\s*1fr\) 216px;/.test( + specimenCss, + ) && + specimenCss.includes("clamp(24px, 5vw, 72px)") && + specimenCss.includes("grid-template-columns: 232px minmax(0, 1fr);"), + ], + [ + "responsive rail becomes a compact component picker at the original breakpoint", + specimenAt880.startsWith("@media (max-width: 880px)") && + /\.specimen-shell\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\);/.test( + specimenAt880, ) && - /\.specimen-rail__nav\s*\{[^}]*display:\s*flex;/.test(specimenAt68), + /\.specimen-mobile-nav\s*\{[^}]*display:\s*block;/.test(specimenAt880), ], [ "responsive grids remove intrinsic sizing floors", @@ -204,10 +214,10 @@ const assertions = [ ), ], [ - "mobile catalog navigation exposes every section", - /\.specimen-rail__nav\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(\s*auto-fit,\s*minmax\(min\(100%,\s*5\.5rem\),\s*1fr\)\s*\);/.test( - specimenFixes, - ) && /\.specimen-rail__nav a\s*\{[^}]*min-width:\s*0;/.test(specimenFixes), + "mobile catalog navigation exposes the same grouped component inventory", + specimenApp.includes("") && + specimenApp.includes("