diff --git a/.agents/skills/codeatlas-ui/API.md b/.agents/skills/codeatlas-ui/API.md index ebe4fa50..61ac5eea 100644 --- a/.agents/skills/codeatlas-ui/API.md +++ b/.agents/skills/codeatlas-ui/API.md @@ -1119,6 +1119,27 @@ Parts: `ca-dnd-indicator` `ca-dnd-overlay` Data attributes: `data-axis` (`x` `y`), `data-dnd-active`, `data-dnd-dragging`, `data-dnd-item`, `data-dnd-over`, `data-dnd-pressing`, `data-dnd-zone` +## DocsHeader + +`import { DocsHeader } from "@codeatlasdev/ui"` + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `sections` | `DocsSection[]` | | | +| `active?` | `string` | | `id` of the current section. | +| `groups?` | `DocsNavGroup[]` | `[]` | Sidebar tree of the current section, shown in the phone menu. | +| `pathname` | `string` | | Current pathname, to mark the current page in the phone menu tree. | +| `searchIndex` | `DocsSearchEntry[] \| (() => Promise)` | | | +| `onnavigate` | `(href: string) => void` | | | +| `brandHref?` | `string` | `'/'` | Link target of the brand (default "/"). | +| `brandLabel?` | `string` | | | + +Snippets: `brand`: Brand mark + name inside the header link.; `actions`: Extra header actions on the right (e.g. a GitHub link). Keep to 1–2 icon buttons.; `menuFooter`: Content below the sidebar tree inside the mobile Sheet (e.g. external links). + +Parts: `ca-docs-actions` `ca-docs-brand` `ca-docs-header` `ca-docs-header__inner` `ca-docs-icon` `ca-docs-search-btn` `ca-docs-search-btn__label` `ca-docs-section` `ca-docs-sections` `ca-docs-sheet-sections` `ca-docs-spacer` `ca-docs-theme` `ca-rail-btn` + +Variables: `--ca-docs-header-h` `--ca-docs-max-w` + ## DocsNav `import { DocsNav } from "@codeatlasdev/ui"` @@ -1174,7 +1195,7 @@ Tokens: `--ca-dialog-split-columns` `--ca-dialog-split-height` `--ca-dialog-spli Snippets: `brand`: Brand mark + name inside the header link.; `actions`: Extra header actions on the right (e.g. a GitHub link). Keep to 1–2 icon buttons.; `menuFooter`: Content below the sidebar tree inside the mobile Sheet (e.g. external links).; `children` -Parts: `ca-docs` `ca-docs--bare` `ca-docs-actions` `ca-docs-brand` `ca-docs-header` `ca-docs-header__inner` `ca-docs-icon` `ca-docs-main` `ca-docs-pager` `ca-docs-pager__link` `ca-docs-pager__link--next` `ca-docs-pager__text` `ca-docs-pager__title` `ca-docs-search-btn` `ca-docs-search-btn__label` `ca-docs-section` `ca-docs-sections` `ca-docs-sheet-sections` `ca-docs-sidebar` `ca-docs-spacer` `ca-docs-theme` `ca-docs-toc` `ca-docs-toc-inline` `ca-rail-btn` +Parts: `ca-docs` `ca-docs--bare` `ca-docs-main` `ca-docs-pager` `ca-docs-pager__link` `ca-docs-pager__link--next` `ca-docs-pager__text` `ca-docs-pager__title` `ca-docs-sidebar` `ca-docs-toc` `ca-docs-toc-inline` Variables: `--ca-docs-header-h` `--ca-docs-max-w` `--ca-docs-sidebar-w` `--ca-docs-toc-w` diff --git a/.changeset/docs-header-standalone.md b/.changeset/docs-header-standalone.md new file mode 100644 index 00000000..495e3d1a --- /dev/null +++ b/.changeset/docs-header-standalone.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +`DocsHeader` is the top bar of `DocsShell` as its own component, so the pages of a site that are not a docs frame (a landing page, a template gallery) render the same header as the docs: one height, container width, brand position, section tabs with an underline for the current one, search (⌘K), actions and theme toggle, and the same phone menu. The bar is now opaque (it was an 82% translucent fill with a blur), so content scrolled under it no longer shows through. diff --git a/AGENTS.md b/AGENTS.md index 58b73204..24e56b4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -523,7 +523,7 @@ import { DataTable, createCaTable, createCaColumnHelper, caTableFeatures } from `Tabs` · `TabsList` · `TabsTrigger` · `TabsContent` · `Breadcrumb` · `Pagination` · `Stepper` ### Docs (documentation sites) -`DocsShell` (header + sidebar + page index; on mobile a compact header and menu in a `Sheet`) · `DocsNav` · `TableOfContents` (`variant="rail" | "inline"`) · `DocsSearch` + `searchDocs` · `CodeTabs`/`CodeTab` (copy, `sync` across groups) · `Callout` · `Steps` · `LinkCard` · `CardGrid` · `MethodBadge`. Markdown typography: wrap the HTML in `
`; tables in `.ca-table-wrap`, code in `.ca-code` (shiki dual theme). Syntax highlighting is up to the consumer (shiki at build). +`DocsShell` (header + sidebar + page index; on mobile a compact header and menu in a `Sheet`) · `DocsHeader` (that header alone, so a landing or template page shares one top bar with the docs; opaque, no `
`) · `DocsNav` · `TableOfContents` (`variant="rail" | "inline"`) · `DocsSearch` + `searchDocs` · `CodeTabs`/`CodeTab` (copy, `sync` across groups) · `Callout` · `Steps` · `LinkCard` · `CardGrid` · `MethodBadge`. Markdown typography: wrap the HTML in `
`; tables in `.ca-table-wrap`, code in `.ca-code` (shiki dual theme). Syntax highlighting is up to the consumer (shiki at build). ```svelte import { createRawSnippet } from 'svelte'; + import DocsHeader from './DocsHeader.svelte'; import DocsShell from './DocsShell.svelte'; import CodeTabs from './CodeTabs.svelte'; import CodeTab from './CodeTab.svelte'; @@ -15,7 +16,7 @@ searchIndex, ...rest }: { - kind: 'shell' | 'codetabs' | 'codetabs-pair' | 'steps' | 'callout' | 'cardgrid'; + kind: 'shell' | 'header' | 'codetabs' | 'codetabs-pair' | 'steps' | 'callout' | 'cardgrid'; onnavigate?: (href: string) => void; searchIndex?: DocsSearchEntry[] | (() => Promise); [k: string]: unknown; @@ -29,7 +30,18 @@ const brand = createRawSnippet(() => ({ render: () => `Atlas Docs` })); -{#if kind === 'shell'} +{#if kind === 'header'} + +
page
+{:else if kind === 'shell'} + import { primeKeyboard } from '../../actions/autofocus.js'; + import { useMessages } from '../../i18n/state.svelte.js'; + import { docs } from '../../i18n/groups/docs.js'; + import type { Snippet } from 'svelte'; + import { onMount } from 'svelte'; + import { Menu, Search } from '@lucide/svelte'; + import Kbd from '../Kbd/Kbd.svelte'; + import Sheet from '../Sheet/Sheet.svelte'; + import ThemeToggle from '../ThemeToggle/ThemeToggle.svelte'; + import DocsNav from './DocsNav.svelte'; + import DocsSearch from './DocsSearch.svelte'; + import type { DocsNavGroup, DocsSearchEntry, DocsSection } from './types.js'; + const t = useMessages(docs); + + /** + * DocsHeader: the sticky top bar of a docs-style site, on its own. + * + * `DocsShell` renders it above its sidebar grid; render it directly on pages that are not a docs + * frame (a landing page, a template gallery) so every page of a site shares one header: the same + * height, container width, brand position, section tabs with an underline for the current one, + * search (⌘K / Ctrl+K / "/"), theme toggle and phone menu. The bar is opaque: content never shows + * through it. + * + * + * {#snippet brand()} Acme{/snippet} + * + * + * It renders no `
` and no skip link: the page keeps its own `
` (and its + * `SkipLink`, or `DocsShell`'s). + */ + interface Props { + sections: DocsSection[]; + /** `id` of the current section. */ + active?: string; + /** Sidebar tree of the current section, shown in the phone menu. */ + groups?: DocsNavGroup[]; + /** Current pathname, to mark the current page in the phone menu tree. */ + pathname: string; + searchIndex: DocsSearchEntry[] | (() => Promise); + onnavigate: (href: string) => void; + /** Link target of the brand (default "/"). */ + brandHref?: string; + brandLabel?: string; + /** Brand mark + name inside the header link. */ + brand: Snippet; + /** Extra header actions on the right (e.g. a GitHub link). Keep to 1–2 icon buttons. */ + actions?: Snippet; + /** Content below the sidebar tree inside the mobile Sheet (e.g. external links). */ + menuFooter?: Snippet; + } + + let { + sections, + active, + groups = [], + pathname, + searchIndex, + onnavigate, + brandHref = '/', + brandLabel, + brand, + actions, + menuFooter, + }: Props = $props(); + + let searchOpen = $state(false); + let menuOpen = $state(false); + // "⌘K" on Apple platforms, "Ctrl K" elsewhere; resolved after mount so SSR and hydration agree. + let shortcut = $state('Ctrl K'); + + onMount(() => { + if (/Mac|iPhone|iPad/.test(navigator.platform)) shortcut = '⌘K'; + }); + + const hasSidebar = $derived(groups.some((g) => g.items.length)); + const activeSection = $derived(sections.find((s) => s.id === active)); + + function onkeydown(e: KeyboardEvent) { + const typing = e.target instanceof HTMLElement && e.target.closest('input, textarea, select, [contenteditable]'); + if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { + e.preventDefault(); + searchOpen = true; + } + } + + + + +
+
+ + + {@render brand()} + + + +
+ + + + + {#if actions}
{@render actions()}
{/if} + +
+
+ + + {#if sections.length > 1} + + {/if} + {#if hasSidebar} + (menuOpen = false)} /> + {/if} + {@render menuFooter?.()} + + + + + diff --git a/packages/ui/src/lib/components/Docs/DocsShell.svelte b/packages/ui/src/lib/components/Docs/DocsShell.svelte index 70016e9b..85c8bb9d 100644 --- a/packages/ui/src/lib/components/Docs/DocsShell.svelte +++ b/packages/ui/src/lib/components/Docs/DocsShell.svelte @@ -1,16 +1,11 @@ - + {t().docs.skipToContent} -
-
- - - {@render brand()} - - - -
- - - - - {#if actions}
{@render actions()}
{/if} - -
-
+
{#if hasSidebar} @@ -198,174 +148,12 @@ {/if}
- - {#if sections.length > 1} - - {/if} - {#if hasSidebar} - (menuOpen = false)} /> - {/if} - {@render menuFooter?.()} - - - - diff --git a/packages/ui/src/lib/components/Docs/DocsShell.svelte.test.ts b/packages/ui/src/lib/components/Docs/DocsShell.svelte.test.ts index 0931a1e3..aa26795e 100644 --- a/packages/ui/src/lib/components/Docs/DocsShell.svelte.test.ts +++ b/packages/ui/src/lib/components/Docs/DocsShell.svelte.test.ts @@ -121,6 +121,52 @@ describe("DocsShell", () => { }); }); +describe("DocsHeader", () => { + it("renders the bar alone: brand, sections, search, theme; no main; passes axe", async () => { + await page.viewport(...VIEWPORTS.desktop); + const screen = await render(DocsHarness, { kind: "header" }); + await expect.element(screen.getByRole("banner")).toBeVisible(); + await expect + .element(screen.getByRole("link", { name: "Atlas Docs" })) + .toHaveAttribute("href", "/"); + await expect + .element(screen.getByRole("link", { name: "Guide", exact: true })) + .toHaveAttribute("aria-current", "page"); + await expect + .element(screen.getByRole("button", { name: /Buscar na documentação/ })) + .toBeVisible(); + expect(document.querySelector("main")).toBeNull(); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); + + it("is opaque: the background has no alpha, so content never shows through", async () => { + await page.viewport(...VIEWPORTS.desktop); + await render(DocsHarness, { kind: "header" }); + const header = document.querySelector(".ca-docs-header") as HTMLElement; + const style = getComputedStyle(header); + expect(style.backgroundColor).not.toMatch(/rgba|\/ /); + expect(style.backdropFilter === "none" || style.backdropFilter === "").toBe(true); + }); + + it("Ctrl+K opens search from the standalone bar", async () => { + await page.viewport(...VIEWPORTS.desktop); + await render(DocsHarness, { kind: "header" }); + await userEvent.keyboard("{Control>}k{/Control}"); + await expect + .element(page.getByRole("dialog", { name: "Buscar na documentação" })) + .toBeVisible(); + }); + + it("on phones the menu button opens the section switcher in a Sheet", async () => { + await page.viewport(...VIEWPORTS.mobile); + const screen = await render(DocsHarness, { kind: "header" }); + await screen.getByRole("button", { name: "Abrir navegação" }).click(); + const sheet = page.getByRole("dialog", { name: "Navegação" }); + await expect.element(sheet).toBeVisible(); + await expect.element(sheet.getByRole("link", { name: "API", exact: true })).toBeVisible(); + }); +}); + // A docs site that reloads on every page (static MPA) must not throw the reader back to the top // of the sidebar: the position survives the load and the current page is always in view. describe("DocsShell sidebar position", () => { diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts index 333fa8aa..cd42fc29 100644 --- a/packages/ui/src/lib/index.ts +++ b/packages/ui/src/lib/index.ts @@ -109,6 +109,7 @@ export { default as Callout } from "./components/Docs/Callout.svelte"; export { default as CardGrid } from "./components/Docs/CardGrid.svelte"; export { default as CodeTab } from "./components/Docs/CodeTab.svelte"; export { type CodeTabItem, default as CodeTabs } from "./components/Docs/CodeTabs.svelte"; +export { default as DocsHeader } from "./components/Docs/DocsHeader.svelte"; export { default as DocsNav } from "./components/Docs/DocsNav.svelte"; export { default as DocsSearch } from "./components/Docs/DocsSearch.svelte"; export { default as DocsShell } from "./components/Docs/DocsShell.svelte"; diff --git a/packages/ui/src/lib/theme/surface-migration.baseline.json b/packages/ui/src/lib/theme/surface-migration.baseline.json index a543d5c6..db4415b3 100644 --- a/packages/ui/src/lib/theme/surface-migration.baseline.json +++ b/packages/ui/src/lib/theme/surface-migration.baseline.json @@ -5995,12 +5995,12 @@ }, "docs/glass/light/wide": { "header": { - "backdropFilter": "blur(14px) saturate(1.75)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6039,12 +6039,12 @@ }, "docs/glass/light/narrow": { "header": { - "backdropFilter": "blur(14px) saturate(1.75)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6083,12 +6083,12 @@ }, "docs/glass/dark/wide": { "header": { - "backdropFilter": "blur(14px) saturate(1.65) brightness(1.02)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6127,12 +6127,12 @@ }, "docs/glass/dark/narrow": { "header": { - "backdropFilter": "blur(14px) saturate(1.65) brightness(1.02)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6171,12 +6171,12 @@ }, "docs/frosted/light/wide": { "header": { - "backdropFilter": "blur(8px) saturate(1.55)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6215,12 +6215,12 @@ }, "docs/frosted/light/narrow": { "header": { - "backdropFilter": "blur(8px) saturate(1.55)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6259,12 +6259,12 @@ }, "docs/frosted/dark/wide": { "header": { - "backdropFilter": "blur(8px) saturate(1.55)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6303,12 +6303,12 @@ }, "docs/frosted/dark/narrow": { "header": { - "backdropFilter": "blur(8px) saturate(1.55)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6352,7 +6352,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6396,7 +6396,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6440,7 +6440,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6484,7 +6484,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6528,7 +6528,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6572,7 +6572,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6616,7 +6616,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6660,7 +6660,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6704,7 +6704,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6748,7 +6748,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6792,7 +6792,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6836,7 +6836,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6880,7 +6880,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6924,7 +6924,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -6968,7 +6968,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7012,7 +7012,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7056,7 +7056,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7100,7 +7100,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7144,7 +7144,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7188,7 +7188,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7227,12 +7227,12 @@ }, "docs/chrome/light/wide": { "header": { - "backdropFilter": "blur(14px) saturate(1.75)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7271,12 +7271,12 @@ }, "docs/chrome/light/narrow": { "header": { - "backdropFilter": "blur(14px) saturate(1.75)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7315,12 +7315,12 @@ }, "docs/chrome/dark/wide": { "header": { - "backdropFilter": "blur(14px) saturate(1.65) brightness(1.02)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7359,12 +7359,12 @@ }, "docs/chrome/dark/narrow": { "header": { - "backdropFilter": "blur(14px) saturate(1.65) brightness(1.02)", + "backdropFilter": "none", "webkitBackdropFilter": "", "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7408,7 +7408,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7452,7 +7452,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.955 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.955 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7496,7 +7496,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", @@ -7540,7 +7540,7 @@ "willChange": "auto", "isolation": "auto", "position": "sticky", - "backgroundColor": "oklab(0.15 0.00114715 0.0016383 / 0.82)", + "backgroundColor": "oklch(0.15 0.002 55)", "backgroundImage": "none", "boxShadow": "none", "borderTopWidth": "0px", diff --git a/site/src/components/docs/SiteDocsShell.svelte b/site/src/components/docs/SiteDocsShell.svelte index cee01e72..49f6fcbd 100644 --- a/site/src/components/docs/SiteDocsShell.svelte +++ b/site/src/components/docs/SiteDocsShell.svelte @@ -6,11 +6,12 @@ type DocsHeading, type DocsNavGroup, type DocsPagerLink, - type DocsSearchEntry, type DocsSection, } from '@codeatlasdev/ui'; import type { Snippet } from 'svelte'; - import Logo from '../Logo.svelte'; + import SiteActions from '../site/SiteActions.svelte'; + import SiteBrand from '../site/SiteBrand.svelte'; + import { siteHeader } from '../site/site-header'; interface Props { sections: DocsSection[]; @@ -22,8 +23,6 @@ children: Snippet; } let { sections, active, groups = [], pathname, headings = [], pager, children }: Props = $props(); - - const searchIndex = (): Promise => fetch('/search-index.json').then((r) => r.json()); location.assign(href)} - brandLabel="CodeAtlas UI home" > - {#snippet brand()} CodeAtlas UI sketchbook{/snippet} - {#snippet actions()} - - - - {/snippet} + {#snippet brand()}{/snippet} + {#snippet actions()}{/snippet} {@render children()} diff --git a/site/src/components/site/SiteActions.svelte b/site/src/components/site/SiteActions.svelte new file mode 100644 index 00000000..c3af80f9 --- /dev/null +++ b/site/src/components/site/SiteActions.svelte @@ -0,0 +1,3 @@ + + + diff --git a/site/src/components/site/SiteBar.astro b/site/src/components/site/SiteBar.astro deleted file mode 100644 index 6e2908c5..00000000 --- a/site/src/components/site/SiteBar.astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -import { Button } from "@codeatlasdev/ui"; -import Logo from "~/components/Logo.svelte"; -import { SECTIONS } from "~/lib/nav"; - -interface Props { - active?: "docs" | "gallery" | "templates" | "theme" | "changelog"; -} -const { active } = Astro.props; -const REPO = "https://github.com/codeatlasdev/ui"; ---- -
- - - CodeAtlas UI - sketchbook - - -
diff --git a/site/src/components/site/SiteBrand.svelte b/site/src/components/site/SiteBrand.svelte new file mode 100644 index 00000000..7970d79f --- /dev/null +++ b/site/src/components/site/SiteBrand.svelte @@ -0,0 +1,5 @@ + + + CodeAtlas UI sketchbook diff --git a/site/src/components/site/SiteHeader.svelte b/site/src/components/site/SiteHeader.svelte new file mode 100644 index 00000000..4a7fce74 --- /dev/null +++ b/site/src/components/site/SiteHeader.svelte @@ -0,0 +1,21 @@ + + + location.assign(href)}> + {#snippet brand()}{/snippet} + {#snippet actions()}{/snippet} + diff --git a/site/src/components/site/site-header.ts b/site/src/components/site/site-header.ts new file mode 100644 index 00000000..03229b12 --- /dev/null +++ b/site/src/components/site/site-header.ts @@ -0,0 +1,7 @@ +import type { DocsSearchEntry } from "@codeatlasdev/ui"; + +/** Props every site header shares, whatever the page: one brand label, one search index. */ +export const siteHeader = { + brandLabel: "CodeAtlas UI home", + searchIndex: (): Promise => fetch("/search-index.json").then((r) => r.json()), +}; diff --git a/site/src/content/docs/docs-kit/docs.mdx b/site/src/content/docs/docs-kit/docs.mdx index 0ca303c4..41927df1 100644 --- a/site/src/content/docs/docs-kit/docs.mdx +++ b/site/src/content/docs/docs-kit/docs.mdx @@ -17,7 +17,7 @@ import NavTocSrc from "~/demos/docs-kit/docs/NavToc.svelte?raw"; import Search from "~/demos/docs-kit/docs/Search.svelte"; import SearchSrc from "~/demos/docs-kit/docs/Search.svelte?raw"; -This site is built with these primitives. The kit covers `DocsShell` (header, sidebar, page index and a mobile Sheet menu), `DocsNav`, `TableOfContents`, `DocsSearch`, `CodeTabs`, `Callout`, `Steps`, `LinkCard`, `CardGrid`, `MethodBadge` and the `ca-prose` typography. +This site is built with these primitives. The kit covers `DocsShell` (header, sidebar, page index and a mobile Sheet menu), `DocsHeader` (that header on its own), `DocsNav`, `TableOfContents`, `DocsSearch`, `CodeTabs`, `Callout`, `Steps`, `LinkCard`, `CardGrid`, `MethodBadge` and the `ca-prose` typography. ```svelte - + diff --git a/site/src/pages/templates/index.astro b/site/src/pages/templates/index.astro index d4d77588..dd85681e 100644 --- a/site/src/pages/templates/index.astro +++ b/site/src/pages/templates/index.astro @@ -1,7 +1,8 @@ --- import { CardGrid, LinkCard } from "@codeatlasdev/ui"; -import SiteBar from "~/components/site/SiteBar.astro"; +import SiteHeader from "~/components/site/SiteHeader.svelte"; import SiteFooter from "~/components/site/SiteFooter.astro"; +import { SECTIONS } from "~/lib/nav"; import Base from "~/layouts/Base.astro"; const groups = [ @@ -64,7 +65,7 @@ const groups = [ title="Templates" description="Full-page templates built with CodeAtlas UI: admin app, auth flows, error pages and a documentation layout. Open them live and copy what you need." > - +
← Documentation diff --git a/site/src/styles/sketch.css b/site/src/styles/sketch.css index 11fbfaf0..aeeb4597 100644 --- a/site/src/styles/sketch.css +++ b/site/src/styles/sketch.css @@ -76,74 +76,18 @@ font-family: var(--ca-font-display); font-style: italic; } -/* The docs header brand never wraps: on a phone it has no room for the tag. */ +/* The header brand never wraps: on a phone it has no room for the tag. */ @media (max-width: 899.98px) { .site-brand__tag { display: none; } } -/* ── Site bar ────────────────────────────────────────────────────────── */ -/* Sticky, full-bleed and OPAQUE: content never scrolls behind the links, and browsers tint - the status bar from it (Safari 26 samples sticky edge elements; theme-color sync elsewhere). */ -.bar { - position: sticky; - top: 0; - z-index: var(--ca-z-sticky); - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--ca-space-4); - padding: - calc(var(--ca-safe-top) + var(--ca-space-3)) - max(var(--lp-gutter), calc((100% - var(--lp-max)) / 2 + var(--lp-gutter))) - var(--ca-space-3); - background-color: var(--ca-surface-base); - box-shadow: inset 0 -1px 0 var(--ca-outline-subtle); -} -.bar__brand { - display: inline-flex; - align-items: center; - gap: var(--ca-space-2); - color: var(--ca-text-primary); - font-family: var(--ca-font-display); - font-weight: 650; - font-size: var(--ca-font-size-lg); - text-decoration: none; - min-block-size: 44px; -} -.bar__nav { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: var(--ca-space-1) var(--ca-space-5); -} -.bar__nav a:not(.ca-btn) { - color: var(--ca-text-secondary); - text-decoration: none; - font-size: var(--ca-font-size-body2); - min-block-size: 44px; - display: inline-flex; - align-items: center; -} -.bar__nav a:not(.ca-btn):hover, -.bar__nav a[aria-current="page"] { - color: var(--ca-text-primary); -} -.bar a:focus-visible, .foot a:focus-visible { outline: 2px solid var(--ca-focus-ring); outline-offset: 3px; border-radius: var(--ca-radius-sm); } -/* The bar links sit in a row only where there is room: the phone keeps brand + the main action - (every link is also in the footer). */ -@media (max-width: 899.98px) { - .bar__nav a:not(.ca-btn) { - display: none; - } -} /* ── Site footer ─────────────────────────────────────────────────────── */ .foot { diff --git a/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/home-dark.webp b/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/home-dark.webp index 0fece35a..ddc372d5 100644 Binary files a/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/home-dark.webp and b/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/home-dark.webp differ diff --git a/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/home-light.webp b/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/home-light.webp index e2262a22..b06d0047 100644 Binary files a/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/home-light.webp and b/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/home-light.webp differ diff --git a/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/templates-dark.webp b/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/templates-dark.webp index 50724eec..b3d94b9d 100644 Binary files a/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/templates-dark.webp and b/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/templates-dark.webp differ diff --git a/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/templates-light.webp b/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/templates-light.webp index 9a5344b8..efa7d34a 100644 Binary files a/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/templates-light.webp and b/site/tests/__screenshots__/visual-desktop/pages.visual.spec.ts/templates-light.webp differ diff --git a/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/home-dark.webp b/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/home-dark.webp index 7c9d7f15..64e6841d 100644 Binary files a/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/home-dark.webp and b/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/home-dark.webp differ diff --git a/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/home-light.webp b/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/home-light.webp index 2e30ba5f..359efdb3 100644 Binary files a/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/home-light.webp and b/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/home-light.webp differ diff --git a/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/templates-dark.webp b/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/templates-dark.webp index eff092b0..130d5669 100644 Binary files a/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/templates-dark.webp and b/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/templates-dark.webp differ diff --git a/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/templates-light.webp b/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/templates-light.webp index b470a3d9..8f041add 100644 Binary files a/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/templates-light.webp and b/site/tests/__screenshots__/visual-mobile/pages.visual.spec.ts/templates-light.webp differ diff --git a/site/tests/header.spec.ts b/site/tests/header.spec.ts new file mode 100644 index 00000000..fee1b555 --- /dev/null +++ b/site/tests/header.spec.ts @@ -0,0 +1,124 @@ +import { expect, gotoReady, test } from "./fixtures"; + +// One header for the whole site: the bar must not move, resize or change content between pages. +const ROUTES = ["/", "/templates/", "/docs/", "/changelog/", "/gallery/"]; + +interface Measure { + bar: { x: number; y: number; width: number; height: number }; + brand: { x: number; y: number; width: number; height: number }; + nav: { text: string; x: number; width: number; current: boolean }[]; + actions: string[]; + opaque: boolean; +} + +async function measure(page: import("@playwright/test").Page): Promise { + return page.evaluate(() => { + const r = (el: Element) => { + const b = el.getBoundingClientRect(); + return { x: b.x, y: b.y, width: b.width, height: b.height }; + }; + const header = document.querySelector("header.ca-docs-header"); + if (!header) throw new Error("no .ca-docs-header on the page"); + const brand = header.querySelector(".ca-docs-brand"); + if (!brand) throw new Error("no brand link"); + const nav = [...header.querySelectorAll(".ca-docs-section")] + .filter((a) => a.offsetParent) + .map((a) => ({ + text: a.textContent?.trim() ?? "", + x: a.getBoundingClientRect().x, + width: a.getBoundingClientRect().width, + current: a.getAttribute("aria-current") === "page", + })); + const actions = [ + ...header.querySelectorAll( + ".ca-docs-search-btn, .ca-docs-search-icon, .ca-docs-menu, .ca-docs-actions a, .ca-docs-theme" + ), + ] + .filter((el) => el.offsetParent) + .map((el) => el.getAttribute("aria-label") ?? el.className.split(" ")[0] ?? ""); + const bg = getComputedStyle(header).backgroundColor; + return { + bar: r(header), + brand: r(brand), + nav, + actions, + opaque: !/\/|rgba/.test(bg), + }; + }); +} + +for (const [name, width, height] of [ + ["1280", 1280, 800], + ["390", 390, 844], +] as const) { + test.describe(`site header at ${name}`, () => { + test.use({ viewport: { width, height } }); + + test(`same box, brand, nav and actions on ${ROUTES.join(" ")}`, async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "viewport is set by the test itself"); + const seen: Record = {}; + for (const route of ROUTES) { + await gotoReady(page, route); + seen[route] = await measure(page); + } + const [first, ...rest] = ROUTES; + const ref = seen[first as string] as Measure; + expect(ref.opaque).toBe(true); + // search, GitHub, theme (plus the menu button on a phone, where the search is an icon). + expect(ref.actions).toHaveLength(width >= 1200 ? 3 : 4); + if (width >= 1200) + expect(ref.nav.map((n) => n.text)).toEqual([ + "Docs", + "Gallery", + "Templates", + "Theme editor", + "Changelog", + ]); + for (const route of rest) { + const m = seen[route] as Measure; + expect(m.bar, `bar ${route}`).toEqual(ref.bar); + expect(m.brand, `brand ${route}`).toEqual(ref.brand); + expect(m.actions, `actions ${route}`).toEqual(ref.actions); + expect( + m.nav.map(({ text, x, width: w }) => ({ text, x, w })), + `nav ${route}` + ).toEqual(ref.nav.map(({ text, x, width: w }) => ({ text, x, w }))); + expect(m.opaque, `opaque ${route}`).toBe(true); + } + }); + }); +} + +test("the current section is underlined on its own page and nowhere else", async ({ + page, +}, info) => { + test.skip(info.project.name !== "desktop", "one run is enough"); + await page.setViewportSize({ width: 1280, height: 800 }); + const current: [string, string][] = [ + ["/templates/", "Templates"], + ["/docs/", "Docs"], + ["/changelog/", "Changelog"], + ["/gallery/", "Gallery"], + ]; + for (const [route, label] of current) { + await gotoReady(page, route); + const m = await measure(page); + expect(m.nav.filter((n) => n.current).map((n) => n.text)).toEqual([label]); + } + await gotoReady(page, "/"); + expect((await measure(page)).nav.some((n) => n.current)).toBe(false); +}); + +test("content scrolled under the header never shows through it", async ({ page }, info) => { + test.skip(info.project.name !== "desktop", "one run is enough"); + await page.setViewportSize({ width: 1280, height: 800 }); + await gotoReady(page, "/gallery/"); + await page.evaluate(() => window.scrollTo(0, 1500)); + const alpha = await page.evaluate(() => { + const h = document.querySelector("header.ca-docs-header") as HTMLElement; + const cs = getComputedStyle(h); + return { bg: cs.backgroundColor, filter: cs.backdropFilter }; + }); + expect(alpha.bg).not.toMatch(/\/|rgba/); + expect(["none", ""]).toContain(alpha.filter); +});