From d67ff3db1ae9e3dea79eeea48e56a34c358335d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sat, 24 Oct 2026 23:03:02 -0300 Subject: [PATCH 1/5] chore: start #557 From a3f78661ffc4f9fcb379a79889a57e6ec265b42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sat, 24 Oct 2026 23:35:44 -0300 Subject: [PATCH 2/5] feat: gallery viewer, pages category and PreviewStage --- .agents/skills/codeatlas-ui/API.md | 22 ++ .agents/skills/codeatlas-ui/DEMOS.md | 2 + .changeset/preview-stage.md | 5 + docs/css-api.md | 6 + .../PreviewStage/PreviewStage.svelte | 328 ++++++++++++++++ .../PreviewStage/PreviewStage.svelte.test.ts | 84 ++++ .../PreviewStageHarness.test.svelte | 30 ++ packages/ui/src/lib/i18n/all.ts | 2 + .../ui/src/lib/i18n/groups/previewStage.ts | 18 + packages/ui/src/lib/index.ts | 1 + site/public/api/v1/catalog.json | 84 +++- site/public/api/v1/en/catalog.tsv | 2 + site/public/api/v1/en/e/986.txt | 29 ++ site/public/api/v1/en/e/987.txt | 18 + site/public/api/v1/en/p/986.txt | 12 + site/public/api/v1/en/p/987.txt | 12 + site/src/components/gallery/Gallery.svelte | 140 +++++-- .../components/gallery/GalleryPreview.svelte | 32 +- .../components/gallery/GalleryViewer.svelte | 364 ++++++++++++++++++ site/src/components/gallery/PageThumb.svelte | 84 ++++ site/src/components/gallery/StageTheme.svelte | 33 ++ site/src/components/gallery/demo-loader.ts | 13 + site/src/components/gallery/demo-session.ts | 10 + site/src/components/gallery/stage-protocol.ts | 43 +++ .../src/content/docs/layout/preview-stage.mdx | 48 +++ site/src/demos/catalog.ts | 2 + .../layout/preview-stage/Inline.meta.json | 11 + .../demos/layout/preview-stage/Inline.svelte | 16 + .../demos/layout/preview-stage/Page.meta.json | 11 + .../demos/layout/preview-stage/Page.svelte | 5 + site/src/layouts/TemplateLayout.astro | 3 + site/src/lib/gallery-items.ts | 337 ++++++++++++++++ site/src/pages/gallery.astro | 32 -- site/src/pages/gallery/[id].astro | 29 ++ site/src/pages/gallery/index.astro | 23 ++ site/src/pages/landing-embed/mobile.astro | 2 + site/tests/gallery-viewer.spec.ts | 69 ++++ site/tests/gallery.spec.ts | 28 ++ 38 files changed, 1907 insertions(+), 83 deletions(-) create mode 100644 .changeset/preview-stage.md create mode 100644 packages/ui/src/lib/components/PreviewStage/PreviewStage.svelte create mode 100644 packages/ui/src/lib/components/PreviewStage/PreviewStage.svelte.test.ts create mode 100644 packages/ui/src/lib/components/PreviewStage/PreviewStageHarness.test.svelte create mode 100644 packages/ui/src/lib/i18n/groups/previewStage.ts create mode 100644 site/public/api/v1/en/e/986.txt create mode 100644 site/public/api/v1/en/e/987.txt create mode 100644 site/public/api/v1/en/p/986.txt create mode 100644 site/public/api/v1/en/p/987.txt create mode 100644 site/src/components/gallery/GalleryViewer.svelte create mode 100644 site/src/components/gallery/PageThumb.svelte create mode 100644 site/src/components/gallery/StageTheme.svelte create mode 100644 site/src/components/gallery/demo-loader.ts create mode 100644 site/src/components/gallery/demo-session.ts create mode 100644 site/src/components/gallery/stage-protocol.ts create mode 100644 site/src/content/docs/layout/preview-stage.mdx create mode 100644 site/src/demos/layout/preview-stage/Inline.meta.json create mode 100644 site/src/demos/layout/preview-stage/Inline.svelte create mode 100644 site/src/demos/layout/preview-stage/Page.meta.json create mode 100644 site/src/demos/layout/preview-stage/Page.svelte create mode 100644 site/src/lib/gallery-items.ts delete mode 100644 site/src/pages/gallery.astro create mode 100644 site/src/pages/gallery/[id].astro create mode 100644 site/src/pages/gallery/index.astro create mode 100644 site/tests/gallery-viewer.spec.ts 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/site/public/api/v1/catalog.json b/site/public/api/v1/catalog.json index 6b29c526..dd54d1fd 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", @@ -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": [ + "` +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/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 @@