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..1a15c94 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 +834,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..b869ba2 --- /dev/null +++ b/packages/ui/tests/catalog-navigation.test.tsx @@ -0,0 +1,242 @@ +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("clears a stale catalog fragment when filtering removes its target", () => { + window.history.replaceState({ retained: true }, "", "/?navigation=keep"); + render(); + act(() => { + window.history.replaceState(window.history.state, "", "#mode-switch"); + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + fireEvent.change(screen.getByPlaceholderText("Search components…"), { + target: { value: "Context meter" }, + }); + expect(window.location.hash).toBe(""); + expect(window.location.search).toBe("?navigation=keep"); + expect(window.history.state).toEqual({ retained: true }); + fireEvent.change(screen.getByPlaceholderText("Search components…"), { + target: { value: "" }, + }); + expect(window.location.hash).toBe(""); + expect( + screen.getByRole("combobox", { name: "Jump to component" }), + ).toHaveValue("library-overview"); + }); + + it("navigates from the mobile picker and follows hash history without remounting previews", async () => { + render(); + const mode = within(screen.getByRole("article", { name: "Mode switch" })); + const composer = screen.getByRole("article", { name: "Composer block" }); + const draft = within(composer).getByRole("textbox", { name: "Message" }); + fireEvent.change(draft, { target: { value: "Keep this preview draft" } }); + fireEvent.click(within(composer).getByRole("tab", { name: "Source" })); + 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", + ); + expect(screen.getByRole("article", { name: "Composer block" })).toBe( + composer, + ); + expect( + within(composer).getByRole("tab", { name: "Source" }), + ).toHaveAttribute("aria-selected", "true"); + fireEvent.click(within(composer).getByRole("tab", { name: "Preview" })); + expect(within(composer).getByRole("textbox", { name: "Message" })).toBe( + draft, + ); + expect(draft).toHaveValue("Keep this preview draft"); + }); + + 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("