diff --git a/.agents/skills/codeatlas-ui/API.md b/.agents/skills/codeatlas-ui/API.md index 61ac5eea..d9aad9d6 100644 --- a/.agents/skills/codeatlas-ui/API.md +++ b/.agents/skills/codeatlas-ui/API.md @@ -2521,6 +2521,28 @@ Internal (may change in any release): `ca-popover-in` `ca-popover-out` Data attributes: `data-state` (`closed`) +## PreviewStage + +`import { PreviewStage } from "@codeatlasdev/ui"` + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `width?` | `number \| 'fill'` | `'fill'` | `bind:` Frame width in CSS px, or `'fill'` for the whole stage. Bindable. | +| `widths?` | `number[]` | `[390, 768, 1280]` | Width presets in px; `Fill` is always offered. | +| `scheme?` | `'light' \| 'dark' \| 'inherit'` | `'inherit'` | Colour scheme of the content. `'inherit'` keeps the page's own. Bindable. | +| `preset?` | `string` | | `data-preset` of the content. Omit to keep the page's preset. | +| `label` | `string` | | Accessible name of the stage (what is shown). Required. | +| `src?` | `string` | | Same-origin URL of a page to show in an isolated ` + {#if !loaded}{t().previewStage.loading}{/if} + {/if} + + + + + + + + + diff --git a/packages/ui/src/lib/components/PreviewStage/PreviewStage.svelte.test.ts b/packages/ui/src/lib/components/PreviewStage/PreviewStage.svelte.test.ts new file mode 100644 index 00000000..fe912b00 --- /dev/null +++ b/packages/ui/src/lib/components/PreviewStage/PreviewStage.svelte.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; +import { render } from "vitest-browser-svelte"; +import { checkA11y } from "../../../../tests/a11y"; +import { q } from "../../../../tests/dom"; +import Harness from "./PreviewStageHarness.test.svelte"; + +const frame = (c: HTMLElement) => q(c, ".ca-pstage__frame"); +const handle = (c: HTMLElement) => q(c, '[role="separator"]'); +const px = (c: HTMLElement) => Math.round(frame(c).getBoundingClientRect().width); + +describe("PreviewStage width", () => { + it("fills the stage by default and a preset sets an exact width", async () => { + const screen = await render(Harness, {}); + expect(px(screen.container)).toBeGreaterThan(850); + await screen.getByRole("radio", { name: "390" }).click(); + expect(px(screen.container)).toBe(390); + expect(screen.getByTestId("w").element().textContent).toBe("390"); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); + + it("the splitter resizes with arrows, Shift and Home/End within its limits", async () => { + const screen = await render(Harness, { width: 768 }); + const h = handle(screen.container); + h.focus(); + await userEvent.keyboard("{ArrowRight}"); + expect(px(screen.container)).toBe(778); + await userEvent.keyboard("{Shift>}{ArrowLeft}{/Shift}"); + expect(px(screen.container)).toBe(678); + await userEvent.keyboard("{Home}"); + expect(px(screen.container)).toBe(280); + await userEvent.keyboard("{End}"); + // 1280 px of page scaled to the 900 px stage. + expect(px(screen.container)).toBeLessThanOrEqual(901); + expect(h.getAttribute("aria-valuenow")).toBe("1280"); + }); + + it("a frame wider than the stage is scaled whole, not clipped", async () => { + const screen = await render(Harness, { width: 1280 }); + const rect = frame(screen.container).getBoundingClientRect(); + expect(rect.width).toBeLessThanOrEqual(901); + expect(handle(screen.container).getAttribute("aria-valuenow")).toBe("1280"); + }); + + it("reports its readout", async () => { + const screen = await render(Harness, { width: 390 }); + expect(q(screen.container, "output.ca-pstage__readout").textContent).toBe("390 px"); + }); +}); + +describe("PreviewStage theming", () => { + it("a scheme scopes the content to its own island", async () => { + const screen = await render(Harness, { scheme: "dark", preset: "ocean" }); + const island = q(screen.container, ".ca-theme-island"); + expect(island.getAttribute("data-theme-scope")).toBe("dark"); + expect(island.getAttribute("data-preset")).toBe("ocean"); + expect(document.documentElement.getAttribute("data-theme-scope")).not.toBe("dark"); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); + + it("the island is as tall as the stage and centres its content", async () => { + const screen = await render(Harness, { scheme: "dark" }); + const frameRect = frame(screen.container).getBoundingClientRect(); + const probe = q(screen.container, ".probe").getBoundingClientRect(); + expect( + Math.abs(probe.top + probe.height / 2 - (frameRect.top + frameRect.height / 2)) + ).toBeLessThan(4); + }); + + it("inherit renders without an island", async () => { + const screen = await render(Harness, {}); + expect(screen.container.querySelector(".ca-theme-island")).toBeNull(); + }); +}); + +describe("PreviewStage iframe mode", () => { + it("shows a same-origin page in a titled, isolated iframe at the chosen width", async () => { + const screen = await render(Harness, { src: "about:blank", width: 390 }); + const f = q(screen.container, "iframe") as HTMLIFrameElement; + expect(f.title).toBe("Page"); + expect(Math.round(f.getBoundingClientRect().width)).toBe(390); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); +}); diff --git a/packages/ui/src/lib/components/PreviewStage/PreviewStageHarness.test.svelte b/packages/ui/src/lib/components/PreviewStage/PreviewStageHarness.test.svelte new file mode 100644 index 00000000..87c89370 --- /dev/null +++ b/packages/ui/src/lib/components/PreviewStage/PreviewStageHarness.test.svelte @@ -0,0 +1,30 @@ + + +
+ {#if src} + + {:else} + + + + {/if} + {width} +
+ + diff --git a/packages/ui/src/lib/i18n/all.ts b/packages/ui/src/lib/i18n/all.ts index eec541cb..db1f1732 100644 --- a/packages/ui/src/lib/i18n/all.ts +++ b/packages/ui/src/lib/i18n/all.ts @@ -51,6 +51,7 @@ import { pageFrame } from "./groups/pageFrame.js"; import { pageShell } from "./groups/pageShell.js"; import { pagination } from "./groups/pagination.js"; import { passwordStrength } from "./groups/passwordStrength.js"; +import { previewStage } from "./groups/previewStage.js"; import { resizablePanels } from "./groups/resizablePanels.js"; import { select } from "./groups/select.js"; import { sheet } from "./groups/sheet.js"; @@ -106,6 +107,7 @@ export const groups = { pageFrame, pagination, passwordStrength, + previewStage, table, resizablePanels, select, diff --git a/packages/ui/src/lib/i18n/groups/previewStage.ts b/packages/ui/src/lib/i18n/groups/previewStage.ts new file mode 100644 index 00000000..75e7298f --- /dev/null +++ b/packages/ui/src/lib/i18n/groups/previewStage.ts @@ -0,0 +1,18 @@ +import { defineGroup } from "../catalog.js"; + +export const previewStage = defineGroup("previewStage", { + en: { + device: "Preview width", + fill: "Fill", + resize: "Resize preview width", + readout: (px: number) => `${px} px`, + loading: "Loading preview", + }, + "pt-BR": { + device: "Largura da prévia", + fill: "Preencher", + resize: "Redimensionar largura da prévia", + readout: (px: number) => `${px} px`, + loading: "Carregando prévia", + }, +}); diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts index cd42fc29..08e06cd0 100644 --- a/packages/ui/src/lib/index.ts +++ b/packages/ui/src/lib/index.ts @@ -204,6 +204,7 @@ export type { PasswordScore } from "./components/PasswordStrength/score.js"; export { passwordScore } from "./components/PasswordStrength/score.js"; export { default as PinInput } from "./components/PinInput/PinInput.svelte"; export { default as Popover } from "./components/Popover/Popover.svelte"; +export { default as PreviewStage } from "./components/PreviewStage/PreviewStage.svelte"; export { default as Progress } from "./components/Progress/Progress.svelte"; export type { RadioItem } from "./components/RadioGroup/RadioGroup.svelte"; export { default as RadioGroup } from "./components/RadioGroup/RadioGroup.svelte"; diff --git a/packages/ui/tests/demos-catalog.test.ts b/packages/ui/tests/demos-catalog.test.ts index da2bdd36..834b507d 100644 --- a/packages/ui/tests/demos-catalog.test.ts +++ b/packages/ui/tests/demos-catalog.test.ts @@ -22,6 +22,10 @@ function svelteFiles(dir: string): string[] { ); } +// The manifest scan is the slow part (every component and demo); build it once for the file. +const manifest = buildManifest(); +const { demos, files } = buildDemos(manifest, CATALOG); + describe("demo catalog", () => { it("ids are unique, 3-digit and strictly increasing", () => { const ids = CATALOG.map((d) => d.id); @@ -37,9 +41,6 @@ describe("demo catalog", () => { for (const p of paths) expect(existsSync(join(DEMOS, `${p}.svelte`))).toBe(true); }); - const manifest = buildManifest(); - const { demos, files } = buildDemos(manifest, CATALOG); - it("emits one licensed reference per demo with real components", () => { expect(demos.length).toBe(CATALOG.length); const names = new Set(manifest.components.map((c) => c.name)); @@ -104,8 +105,6 @@ describe("demo meta sidecars", () => { }); it("emits spec lines, use and summary into the e/ and p/ files", () => { - const manifest = buildManifest(); - const { demos, files } = buildDemos(manifest, CATALOG); const d = demos[0]; expect(d.spec.length).toBeGreaterThan(0); expect(files[`en/e/${d.id}.txt`]).toContain(`Spec:\n- ${d.spec[0]}`); @@ -166,8 +165,6 @@ describe("motion sidecar fields", () => { }); it("emits Names and Motion in e/ and p/, and the exact-values line only with motion", () => { - const manifest = buildManifest(); - const { demos, files } = buildDemos(manifest, CATALOG); const plain = demos[0]; expect(files[`en/e/${plain.id}.txt`]).not.toContain("Motion:"); expect(files[`en/p/${plain.id}.txt`]).not.toContain("Reproduce the motion values exactly"); @@ -192,3 +189,36 @@ describe("motion sidecar fields", () => { ); }); }); + +describe("demo CSS isolation", () => { + // The gallery bundles every demo into one page set, so an unscoped `:global(.x)` rule reaches every + // demo that names a local class `.x` too (a context-menu `:global(.row)` padded the button page's `.row`). + const files = svelteFiles(DEMOS).map((p) => ({ + p, + css: + readFileSync(join(DEMOS, `${p}.svelte`), "utf8").match( + /]*>([\s\S]*?)<\/style>/ + )?.[1] ?? "", + })); + // A selector is bare when nothing but whitespace precedes its `:global(.x)` inside the selector. + const bare = (css: string) => + [...css.matchAll(/:global\(\.([\w-]+)[^)]*\)/g)] + .filter((m) => css.slice(0, m.index).split(/[{},]/).pop()?.trim() === "") + .map((m) => m[1]); + const local = (css: string) => + new Set([...css.matchAll(/(?:^|[\s,}>+~])\.([\w-]+)/gm)].map((m) => m[1])); + + it("no demo declares a bare :global(.x) that another demo uses as a local class", () => { + const locals = new Map(); + for (const f of files) + for (const c of local(f.css.replace(/:global\([^)]*\)/g, ""))) + locals.set(c, [...(locals.get(c) ?? []), f.p]); + const leaks: string[] = []; + for (const f of files) + for (const c of new Set(bare(f.css))) + for (const other of locals.get(c) ?? []) + if (other !== f.p) + leaks.push(`${f.p}: :global(.${c}) also styles the local .${c} of ${other}`); + expect(leaks).toEqual([]); + }); +}); diff --git a/site/public/api/v1/catalog.json b/site/public/api/v1/catalog.json index 6b29c526..63586e33 100644 --- a/site/public/api/v1/catalog.json +++ b/site/public/api/v1/catalog.json @@ -1,6 +1,6 @@ { "version": "4.5.0", - "count": 375, + "count": 377, "groups": [ { "slug": "inputs", @@ -35,8 +35,8 @@ { "slug": "layout", "first": "165", - "last": "985", - "count": 45 + "last": "987", + "count": 47 }, { "slug": "charts", @@ -16069,7 +16069,7 @@ "summary": "A dashed file area that opens a Rename, Pinned, Move to and Delete menu at the pointer on right-click, at the finger on a long touch press, or from the keyboard with Shift+F10.", "use": "Context menus on rows, files or canvases that reuse the DropdownMenu items, including checkbox items and submenus, without wiring any event handler.", "spec": [ - "`` wraps the region content and takes the items in a `{#snippet menu()}`", + "`` wraps the region content and takes the items in a `{#snippet menu()}`", "The menu holds `DropdownMenuItem`, a ``, a `DropdownMenuSub` with `DropdownMenuSubTrigger`, a `DropdownMenuSeparator` and a destructive Delete", "Each action item calls `function pick(action: string)`, which sets `last` and calls `announce`", "Pinned (yes or no) and the last action are shown below the area" @@ -16120,7 +16120,7 @@ "summary": "Three invoice rows, each its own context menu with a label, Open and Send reminder, and a 400 ms long-press delay for touch.", "use": "A menu per list row: each row is a focusable region, so keyboard users reach the same actions with Shift+F10.", "spec": [ - "`{#each rows as row (row)}` renders `` per row", + "`{#each rows as row (row)}` renders `` per row", "The `menu` snippet starts with `{row}` and has Open and Send reminder items", "Items set `last` to the action and the row name (Open or Send plus the invoice), shown under the list" ], @@ -16902,6 +16902,84 @@ "interactive": false, "demo": "https://ui.codeatlas.com.br/docs/layout/kpi-card#sparkline", "needs": "@codeatlasdev/ui@4.5.0 + echarts" + }, + { + "id": "986", + "group": "layout", + "page": "preview-stage", + "variant": "Inline", + "slug": "inline-content", + "title": "Inline content", + "description": "A component at phone width with a light and dark toggle in the toolbar.", + "summary": "A button pair inside a PreviewStage at 390 px, with a toolbar button that flips the content between light and dark.", + "use": "Showcasing a component at phone, tablet and desktop width inside a docs page or a design review.", + "spec": [ + "``", + "`{#snippet toolbar()}` adds a Dark / Light toggle next to the width control", + "The content is themed in its own island: the page around the stage keeps its scheme" + ], + "tags": [ + "preview-stage", + "device-frame", + "responsive", + "width", + "showcase" + ], + "shape": "wide", + "components": [ + "Button", + "PreviewStage" + ], + "imports": [ + { + "from": "@codeatlasdev/ui", + "names": [ + "Button", + "PreviewStage" + ] + } + ], + "interactive": true, + "demo": "https://ui.codeatlas.com.br/docs/layout/preview-stage#inline-content", + "needs": "@codeatlasdev/ui@4.5.0" + }, + { + "id": "987", + "group": "layout", + "page": "preview-stage", + "variant": "Page", + "slug": "a-whole-page", + "title": "A whole page", + "description": "A template in an isolated iframe; switch the width to see its media queries.", + "summary": "A whole page (the sign-in template) in a PreviewStage iframe, laid out at the chosen width with its own media queries.", + "use": "Showing a full page or a template at real phone, tablet and desktop widths with isolated styles.", + "spec": [ + "`` wraps the region content and takes the items in a `{#snippet menu()}` +- `` wraps the region content and takes the items in a `{#snippet menu()}` - The menu holds `DropdownMenuItem`, a ``, a `DropdownMenuSub` with `DropdownMenuSubTrigger`, a `DropdownMenuSeparator` and a destructive Delete - Each action item calls `function pick(action: string)`, which sets `last` and calls `announce` - Pinned (yes or no) and the last action are shown below the area @@ -32,7 +32,8 @@ Live demo: https://ui.codeatlas.com.br/docs/navigation/context-menu#file-menu } - +
+ quarterly-report.pdf. Right-click, long-press, or focus it and press Shift+F10. {#snippet menu()} pick('Rename')}>Rename @@ -46,11 +47,13 @@ Live demo: https://ui.codeatlas.com.br/docs/navigation/context-menu#file-menu pick('Delete')}>Delete {/snippet} +
Pinned: {pinned ? 'yes' : 'no'}{last ? `. Last action: ${last}` : ''} diff --git a/site/public/api/v1/en/e/986.txt b/site/public/api/v1/en/e/986.txt new file mode 100644 index 00000000..839d8a8f --- /dev/null +++ b/site/public/api/v1/en/e/986.txt @@ -0,0 +1,29 @@ + +Element: #986 Inline content (layout/preview-stage) +What it shows: A button pair inside a PreviewStage at 390 px, with a toolbar button that flips the content between light and dark. +When to use: Showcasing a component at phone, tablet and desktop width inside a docs page or a design review. +Spec: +- `` +- `{#snippet toolbar()}` adds a Dark / Light toggle next to the width control +- The content is themed in its own island: the page around the stage keeps its scheme +Tags: preview-stage, device-frame, responsive, width, showcase +Needs: @codeatlasdev/ui@4.5.0 +Components: Button, PreviewStage +Live demo: https://ui.codeatlas.com.br/docs/layout/preview-stage#inline-content + + + + + + + {#snippet toolbar()} + + {/snippet} + diff --git a/site/public/api/v1/en/e/987.txt b/site/public/api/v1/en/e/987.txt new file mode 100644 index 00000000..cd3476a2 --- /dev/null +++ b/site/public/api/v1/en/e/987.txt @@ -0,0 +1,18 @@ + +Element: #987 A whole page (layout/preview-stage) +What it shows: A whole page (the sign-in template) in a PreviewStage iframe, laid out at the chosen width with its own media queries. +When to use: Showing a full page or a template at real phone, tablet and desktop widths with isolated styles. +Spec: +- ` + import { PreviewStage } from '@codeatlasdev/ui'; + + + diff --git a/site/public/api/v1/en/p/942.txt b/site/public/api/v1/en/p/942.txt index b92708ab..6b7428ee 100644 --- a/site/public/api/v1/en/p/942.txt +++ b/site/public/api/v1/en/p/942.txt @@ -1,7 +1,7 @@ Use @codeatlasdev/ui demo #942 "File menu" (navigation/context-menu). What it shows: A dashed file area that opens a Rename, Pinned, Move to and Delete menu at the pointer on right-click, at the finger on a long touch press, or from the keyboard with Shift+F10. When to use: Context menus on rows, files or canvases that reuse the DropdownMenu items, including checkbox items and submenus, without wiring any event handler. Spec: -- `` wraps the region content and takes the items in a `{#snippet menu()}` +- `` wraps the region content and takes the items in a `{#snippet menu()}` - The menu holds `DropdownMenuItem`, a ``, a `DropdownMenuSub` with `DropdownMenuSubTrigger`, a `DropdownMenuSeparator` and a destructive Delete - Each action item calls `function pick(action: string)`, which sets `last` and calls `announce` - Pinned (yes or no) and the last action are shown below the area diff --git a/site/public/api/v1/en/p/943.txt b/site/public/api/v1/en/p/943.txt index a5392479..926209a9 100644 --- a/site/public/api/v1/en/p/943.txt +++ b/site/public/api/v1/en/p/943.txt @@ -1,7 +1,7 @@ Use @codeatlasdev/ui demo #943 "One menu per row" (navigation/context-menu). What it shows: Three invoice rows, each its own context menu with a label, Open and Send reminder, and a 400 ms long-press delay for touch. When to use: A menu per list row: each row is a focusable region, so keyboard users reach the same actions with Shift+F10. Spec: -- `{#each rows as row (row)}` renders `` per row +- `{#each rows as row (row)}` renders `` per row - The `menu` snippet starts with `{row}` and has Open and Send reminder items - Items set `last` to the action and the row name (Open or Send plus the invoice), shown under the list Check first: Svelte 5 + @codeatlasdev/ui installed? theme in src/lib/theme.ts? Kit 2 or 3? diff --git a/site/public/api/v1/en/p/986.txt b/site/public/api/v1/en/p/986.txt new file mode 100644 index 00000000..80e52eb1 --- /dev/null +++ b/site/public/api/v1/en/p/986.txt @@ -0,0 +1,12 @@ +Use @codeatlasdev/ui demo #986 "Inline content" (layout/preview-stage). +What it shows: A button pair inside a PreviewStage at 390 px, with a toolbar button that flips the content between light and dark. When to use: Showcasing a component at phone, tablet and desktop width inside a docs page or a design review. +Spec: +- `` +- `{#snippet toolbar()}` adds a Dark / Light toggle next to the width control +- The content is themed in its own island: the page around the stage keeps its scheme +Check first: Svelte 5 + @codeatlasdev/ui installed? theme in src/lib/theme.ts? Kit 2 or 3? +Apply: import only from "@codeatlasdev/ui[/charts|/map|/table|/code|/forms]"; keep component names, props and + variants of the reference as they are; no layout CSS, --ca-* tokens only (SKILL.md rules); + replace sample data/copy with the project's; keep the first comment line. +Report: #986 Inline content → file, what changed, https://ui.codeatlas.com.br/docs/layout/preview-stage#inline-content; what you could not check (light/dark, 375px, keyboard, reduced motion). +Reference: https://ui.codeatlas.com.br/api/v1/en/e/986.txt Components API: ca-ui-mcp get_component(Button) diff --git a/site/public/api/v1/en/p/987.txt b/site/public/api/v1/en/p/987.txt new file mode 100644 index 00000000..62c3c4ec --- /dev/null +++ b/site/public/api/v1/en/p/987.txt @@ -0,0 +1,12 @@ +Use @codeatlasdev/ui demo #987 "A whole page" (layout/preview-stage). +What it shows: A whole page (the sign-in template) in a PreviewStage iframe, laid out at the chosen width with its own media queries. When to use: Showing a full page or a template at real phone, tablet and desktop widths with isolated styles. +Spec: +- ` e.kind === "page").length); let query = $state(""); let group = $state("all"); let picks = $state([]); let callback = $state(null); + let scheme = $state<"light" | "dark">("dark"); const known = $derived(new Set(elements.map((e) => e.id))); const byId = $derived(new Map(elements.map((e) => [e.id, e]))); @@ -86,7 +81,10 @@ const params = new URLSearchParams(location.search); picks = parsePicks(params.get("pick"), known); callback = safeCallback(params.get("cb")); - const hash = location.hash.match(/^#(\d{3})$/)?.[1]; + // Thumbnails of the admin pages render signed in with the public demo account. + ensureDemoSession(); + scheme = document.documentElement.classList.contains("dark") ? "dark" : "light"; + const hash = location.hash.match(/^#(\d{3}|page-[a-z0-9-]+)$/)?.[1]; if (hash) document.getElementById(hash)?.scrollIntoView({ block: "center" }); }); @@ -94,14 +92,14 @@