From b30a341fd4478fe2a4ace4539114dac7abe6a4e8 Mon Sep 17 00:00:00 2001 From: aeei <18022843+aeei@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:02:09 +0900 Subject: [PATCH 1/7] docs: design modal media viewer --- .../specs/2026-07-22-media-viewer-design.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-media-viewer-design.md diff --git a/docs/superpowers/specs/2026-07-22-media-viewer-design.md b/docs/superpowers/specs/2026-07-22-media-viewer-design.md new file mode 100644 index 000000000..14494bf62 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-media-viewer-design.md @@ -0,0 +1,172 @@ +# Media Viewer Design + +## Goal + +Markdown images and Mermaid diagrams in every consumer manual can open in a larger modal viewer. The interaction is discoverable through a CodeBlock-style action button and convenient through direct media click. + +## Scope + +Included: + +- Images rendered through the global MDX `img` mapping +- Mermaid diagrams rendered through the global MDX `mermaid` mapping +- Mouse, touch, keyboard, screen-reader, light/dark, desktop/mobile behavior +- App, LinkPie, DeskPie, and docs-starter validation through the shared theme + +Excluded from the first release: + +- Wheel zoom, pinch zoom, zoom percentage controls, rotation, download, or pan controls +- Video, iframe, chart, table, and CodeBlock viewing +- Per-document opt-in syntax +- Viewer behavior for linked images; linked images keep normal link navigation and do not expose viewer controls + +## Constraints + +- Use the pinned Base Nova `Button`, `Dialog`, and `Tooltip` registry sources unchanged. +- Do not add consumer CSS, `[data-slot]` overrides, duplicate primitives, or service-specific implementations. +- Do not change official component typography, color, border, radius, shadow, animation, or state styles. +- The approved visual exception is limited to semantic media-viewer layout: trigger placement, dialog viewport size, media containment, and overflow. Record this exception in `AGENTS.md` before implementation. +- Keep the existing CodeBlock action unchanged. The media action uses official `Button variant="ghost" size="icon-sm"` directly and must not import, copy, or reuse `CodeBlockButton` visual classes. + +## Chosen Approach + +Use one shared semantic `MediaViewer` adapter composed from official Base Nova primitives. + +Alternatives rejected: + +1. Bottom Sheet: supports full width without a dialog size exception, but the drawer conceptual model does not match inspecting an isolated image. +2. New browser tab: requires no modal layout, but loses context and creates inconsistent navigation. +3. Third-party image zoom package: introduces a second dialog/interaction system and violates the shared Base Nova component SSOT. + +## Architecture + +### `MediaViewer` + +A behavior/layout adapter. It owns open state and receives: + +- accessible title +- inline preview node +- expanded media node +- media kind: image or diagram + +It renders: + +- a semantic relative media root +- the inline media +- one official `Button` using `variant="ghost"` and `size="icon-sm"`; only its top-right placement follows the learned CodeBlock action mapping +- an official `Tooltip` for the expand action +- an official `Dialog` containing the expanded media +- an `sr-only` official `DialogHeader`, `DialogTitle`, and `DialogDescription` + +The adapter may define only structural classes needed for relative/absolute placement, viewport bounds, containment, and overflow. It does not style primitive surfaces or states. + +### `ZoomableImage` + +The global MDX image adapter renders the existing Docusaurus `MDXImg` for both preview and expanded content. The preview preserves every authored prop, including `loading`, `decoding`, and `className`. The expanded copy preserves media and accessibility props such as `src`, `srcSet`, `sizes`, and `alt`, sets `loading="eager"`, and does not carry preview-only authored layout classes that could constrain the modal copy. + +The image `alt` value becomes the dialog title when present. Missing alt remains an authoring/accessibility defect; the dialog uses the generic localized title `Image preview` without inventing filename-derived alt text. Consumer validation inventories missing alt text before release. + +### `ZoomableMermaid` + +The global MDX Mermaid adapter renders the existing Docusaurus Mermaid component from the same source text in preview and dialog contexts. Each render keeps Docusaurus error handling and Mermaid interaction binding. The implementation must verify that generated SVG IDs remain unique while both copies exist. + +### MDX integration + +`MDXComponents/index.tsx` maps: + +- `img` → `ZoomableImage` +- `mermaid` → `ZoomableMermaid` + +The existing MDX link adapter additionally identifies a direct image child and marks it non-viewable before rendering the official link. This prevents nested interactive controls: linked images preserve link navigation and do not open the viewer. + +No document callsite changes are required. + +## Interaction + +### Open + +- Pointer/touch click anywhere on a viewable image or Mermaid diagram opens the viewer. +- The visible expand action opens the same viewer. +- Keyboard users open it through the action button with Enter or Space. +- The action uses a Lucide expand icon and the localized accessible label/tooltip `View larger`. + +The inline media click is a pointer convenience, not a second keyboard stop. The official action button is the canonical accessible trigger. + +### Dialog + +- The official Dialog traps focus, makes background content inert, and provides overlay/transition behavior. +- Content uses the approved media viewport layout up to the browser viewport minus the official outer inset. This viewport sizing is the sole Dialog metric excluded from official parity; Dialog chrome and every primitive state remain unchanged. +- Images preserve aspect ratio with `object-fit: contain`. +- Mermaid SVGs scale into the available viewport; oversized content remains scrollable rather than clipped. +- No upscale beyond intrinsic raster resolution is required. + +### Close + +- Escape, backdrop click, or the official close button closes the viewer. +- Focus returns to the source media's expand action, including when pointer click on the media opened the dialog. +- Route state and URL do not change. + +## Responsive Behavior + +- Desktop: centered Dialog with a large bounded media viewport. +- Mobile: Dialog keeps the official outer inset and uses the remaining viewport. +- Portrait and landscape content preserve aspect ratio. +- No horizontal page overflow is introduced while the dialog is closed. + +## Failure Behavior + +- Image loading and failure use native browser/Docusaurus behavior; the viewer does not invent a second error surface. +- Mermaid render failures continue through the existing Docusaurus Mermaid error boundary. +- If expanded rendering fails, closing the dialog always remains available. +- Server rendering emits a stable wrapper, inline preview, and action button. The expanded media portal mounts only after the dialog opens in the browser, avoiding duplicate initial media and hydration divergence. + +## Testing + +### Unit and contract tests + +- MDX image and Mermaid mappings use the shared viewer adapters. +- Viewer uses official Base Nova Button, Dialog, Tooltip, and Lucide imports. +- No consumer CSS or `[data-slot]` visual override is added. +- Dialog title/description and action accessible name exist. +- Clicking media or action opens; Escape/backdrop/close closes; focus returns. +- Mermaid preview and expanded SVG IDs do not conflict. +- Linked images render as links without nested viewer controls. +- Consumer image inventory contains no missing non-decorative alt text before release. + +### Browser tests + +Use actual docs-starter routes and authored fixtures for the full interaction matrix: + +- one landscape screenshot +- one portrait screenshot +- one linked image +- one Mermaid diagram +- desktop/mobile × light/dark +- pointer media click +- keyboard action open/close +- focus trap and focus restoration +- dialog geometry larger than constrained inline media +- no page overflow +- image/SVG readable without clipping + +Then run one App, LinkPie, and DeskPie actual-route smoke each: open through pointer and keyboard, close, verify focus restoration, confirm no overflow, and save screenshot evidence. + +### Visual acceptance + +Compare official Base Nova Button, Tooltip, and Dialog states against `ui.shadcn.com` at identical viewport/state. Primitive chrome and state metrics must remain identical; the explicitly approved media viewport dimensions are excluded. Manually review original-resolution screenshots before implementation commit or push. + +## Delivery + +### Upstream implementation acceptance + +1. Implement and validate in the upstream theme worktree. +2. Show local docs-starter plus App, LinkPie, and DeskPie screenshots and interaction evidence. +3. Wait for explicit visual approval. +4. After approval, commit/push the implementation. + +### Downstream release acceptance + +1. Build a new theme tar archive after upstream approval. +2. Replace the Deck vendored artifact and regenerate all three lockfiles. +3. Rebuild all three manuals and the DeskPie Spring artifact. +4. Show downstream comparison evidence and wait for release approval before Deck commit, push, or deployment. From b2c873d1c447dd57bfc897c71e2766b51522a66b Mon Sep 17 00:00:00 2001 From: aeei <18022843+aeei@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:27:57 +0900 Subject: [PATCH 2/7] docs: plan modal media viewer implementation --- .../plans/2026-07-22-media-viewer.md | 682 ++++++++++++++++++ 1 file changed, 682 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-media-viewer.md diff --git a/docs/superpowers/plans/2026-07-22-media-viewer.md b/docs/superpowers/plans/2026-07-22-media-viewer.md new file mode 100644 index 000000000..1a84e9ead --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-media-viewer.md @@ -0,0 +1,682 @@ +# Media Viewer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a shared Base Nova modal viewer for Markdown images and Mermaid diagrams, opened by direct media click or an official expand action. + +**Architecture:** Add one controlled `MediaViewer` adapter composed from the existing official Button, Tooltip, and Dialog primitives. Add MDX image, Mermaid, and link adapters that preserve Docusaurus rendering while routing viewable media through the shared adapter. Keep all visual primitive sources unchanged; only semantic placement, viewport, containment, and overflow layout are new. + +**Tech Stack:** React 19, TypeScript 6, Docusaurus 3.10, Base UI 1.6, shadcn 4.12 Base Nova, Lucide, Jest source contracts, Playwright. + +## Global Constraints + +- Pinned visual oracle: shadcn 4.12 Base Nova, Base UI, Neutral, Lucide. +- Do not modify official `Button`, `Dialog`, or `Tooltip` source or visual state classes. +- Do not add consumer CSS, `[data-slot]` overrides, duplicate primitives, or service-specific viewer implementations. +- Approved layout exception only: media action placement, Dialog media viewport dimensions, media containment, and overflow. +- Use `Button variant="ghost" size="icon-sm"`; do not reuse `CodeBlockButton` custom classes. +- Viewer targets global Markdown images and Mermaid diagrams. Linked images retain link behavior and receive no viewer controls. +- No zoom, pan, rotate, download, video, iframe, chart, table, or per-document opt-in support. +- TDD: run each focused test red before implementation and green afterward. +- Do not commit/push implementation before local screenshot evidence and explicit user visual approval. + +--- + +## File Map + +- Modify: `AGENTS.md` — record the approved media-viewer layout exception. +- Create: `packages/docusaurus-theme/src/theme/components/media-viewer/index.tsx` — controlled Dialog behavior, action, tooltip, focus restoration, pointer handling. +- Create: `packages/docusaurus-theme/src/theme/components/media-viewer/mdx-media.tsx` — image, Mermaid, and link adapters plus linked-media context. +- Modify: `packages/docusaurus-theme/src/theme/MDXComponents/index.tsx` — global adapter registration. +- Modify: `packages/docusaurus-theme/src/theme/base.scss` — semantic placement, containment, and overflow only. +- Create: `packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts` — source/ownership/no-override contracts. +- Modify: `examples/docs-starter/docs/guides/markdown-gfm.md` — linked-image fixture. +- Modify: `examples/docs-starter/docs/showcase/mermaid.md` — reuse existing Mermaid as viewer fixture; add stable audit heading only if selectors require it. +- Create: `tests/media-viewer.spec.ts` — actual browser interaction, accessibility, geometry, responsive, light/dark tests. + +--- + +### Task 1: Lock the ownership and no-override contract + +**Files:** +- Modify: `AGENTS.md` +- Create: `packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts` + +**Interfaces:** +- Consumes: project Base Nova parity contract. +- Produces: source contracts for `MediaViewer`, `ZoomableImage`, `ZoomableMermaid`, and `MediaAwareLink`. + +- [ ] **Step 1: Add the approved exception to `AGENTS.md`** + +Append under user-approved visual exceptions: + +```markdown +- Media viewer는 official Base Nova Button, Tooltip, Dialog source와 state style을 그대로 사용한다. Semantic adapter는 action 위치, Dialog media viewport 크기, image/SVG containment, overflow만 소유한다. Consumer CSS와 `[data-slot]` override는 금지한다. +``` + +- [ ] **Step 2: Write the failing source contract** + +Create `packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts`: + +```ts +import fs from "node:fs"; +import path from "node:path"; + +const root = __dirname; +const read = (file: string) => fs.readFileSync(path.join(root, file), "utf8"); + +describe("Media viewer ownership contract", () => { + it("maps global MDX images, links, and Mermaid through shared adapters", () => { + const mdx = read("MDXComponents/index.tsx"); + expect(mdx).toContain("ZoomableImage"); + expect(mdx).toContain("ZoomableMermaid"); + expect(mdx).toContain("MediaAwareLink"); + expect(mdx).toContain("img: ZoomableImage"); + expect(mdx).toContain("mermaid: ZoomableMermaid"); + expect(mdx).toContain("a: MediaAwareLink"); + }); + + it("composes only official Base Nova actions and dialog", () => { + const viewer = read("components/media-viewer/index.tsx"); + expect(viewer).toContain('variant="ghost"'); + expect(viewer).toContain('size="icon-sm"'); + expect(viewer).toContain(" { + const adapters = read("components/media-viewer/mdx-media.tsx"); + expect(adapters).toContain("LinkedMediaContext.Provider"); + expect(adapters).toContain("useContext(LinkedMediaContext)"); + expect(adapters).toContain("return "); + }); + + it("limits CSS to semantic viewer layout selectors", () => { + const css = read("base.scss"); + expect(css).toContain(".theme-media-viewer"); + expect(css).toContain(".theme-media-viewer__viewport"); + expect(css).not.toMatch(/\[data-slot=[^\]]+\][^{]*theme-media-viewer/); + }); +}); +``` + +- [ ] **Step 3: Run the focused contract and confirm red** + +Run: + +```bash +yarn jest packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts --runInBand +``` + +Expected: FAIL because viewer files and MDX mappings do not exist. + +--- + +### Task 2: Implement the shared viewer and MDX adapters + +**Files:** +- Create: `packages/docusaurus-theme/src/theme/components/media-viewer/index.tsx` +- Create: `packages/docusaurus-theme/src/theme/components/media-viewer/mdx-media.tsx` +- Modify: `packages/docusaurus-theme/src/theme/MDXComponents/index.tsx` +- Modify: `packages/docusaurus-theme/src/theme/base.scss` +- Test: `packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts` + +**Interfaces:** +- Produces: + - `MediaViewer(props: MediaViewerProps): ReactNode` + - `ZoomableImage(props: ImgProps): ReactNode` + - `ZoomableMermaid(props: MermaidProps): ReactNode` + - `MediaAwareLink(props: ComponentProps): ReactNode` +- `MediaViewerProps`: + +```ts +type MediaViewerProps = { + as: "span" | "div"; + kind: "image" | "diagram"; + title: string; + preview: ReactNode; + expanded: ReactNode; +}; +``` + +- [ ] **Step 1: Implement the controlled `MediaViewer`** + +Create `packages/docusaurus-theme/src/theme/components/media-viewer/index.tsx` with these behaviors: + +```tsx +import React, { + type MouseEvent, + type ReactNode, + useCallback, + useRef, + useState, +} from "react"; + +import { translate } from "@docusaurus/Translate"; +import { Maximize2 } from "lucide-react"; + +import { Button } from "@theme/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@theme/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@theme/components/ui/tooltip"; + +export type MediaViewerProps = { + as: "span" | "div"; + kind: "image" | "diagram"; + title: string; + preview: ReactNode; + expanded: ReactNode; +}; + +export default function MediaViewer({ + as, + kind, + title, + preview, + expanded, +}: MediaViewerProps): ReactNode { + const [open, setOpen] = useState(false); + const actionRef = useRef(null); + const Root = as; + const viewLabel = translate({ + id: "theme.mediaViewer.viewLarger", + message: "View larger", + }); + const description = translate({ + id: "theme.mediaViewer.description", + message: "Expanded media preview. Press Escape to close.", + }); + + const changeOpen = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) requestAnimationFrame(() => actionRef.current?.focus()); + }, []); + + const openFromMedia = useCallback((event: MouseEvent) => { + const target = event.target as Element; + if (target.closest("a,button")) return; + setOpen(true); + }, []); + + return ( + + + {preview} + + setOpen(true)} + > + + + + + {title} + {description} + +
+ {expanded} +
+
+
+ ); +} +``` + +- [ ] **Step 2: Implement linked-media, image, and Mermaid adapters** + +Create `packages/docusaurus-theme/src/theme/components/media-viewer/mdx-media.tsx`: + +```tsx +import React, { + type ComponentProps, + createContext, + type ReactNode, + useContext, +} from "react"; + +import { translate } from "@docusaurus/Translate"; +import MDXA from "@theme/MDXComponents/A"; +import MDXImg from "@theme/MDXComponents/Img"; +import Mermaid from "@theme/Mermaid"; +import type { Props as ImgProps } from "@theme/MDXComponents/Img"; +import type { Props as MermaidProps } from "@theme/Mermaid"; + +import MediaViewer from "@theme/components/media-viewer"; + +const LinkedMediaContext = createContext(false); + +export function MediaAwareLink( + props: ComponentProps +): ReactNode { + return ( + + + + ); +} + +export function ZoomableImage(props: ImgProps): ReactNode { + const linked = useContext(LinkedMediaContext); + if (linked) return ; + + const title = + props.alt?.trim() || + translate({ + id: "theme.mediaViewer.imageTitle", + message: "Image preview", + }); + const { + className: _className, + style: _style, + loading: _loading, + decoding: _decoding, + ...expandedProps + } = props; + + return ( + } + expanded={ + + } + /> + ); +} + +export function ZoomableMermaid(props: MermaidProps): ReactNode { + const title = translate({ + id: "theme.mediaViewer.diagramTitle", + message: "Diagram preview", + }); + return ( + } + expanded={} + /> + ); +} +``` + +- [ ] **Step 3: Register adapters globally** + +Modify `packages/docusaurus-theme/src/theme/MDXComponents/index.tsx`: + +```tsx +import { + MediaAwareLink, + ZoomableImage, + ZoomableMermaid, +} from "@theme/components/media-viewer/mdx-media"; +``` + +Replace only these mappings: + +```tsx +a: MediaAwareLink, +img: ZoomableImage, +mermaid: ZoomableMermaid, +``` + +Keep the existing original `MDXA`, `MDXImg`, and `Mermaid` ownership inside `mdx-media.tsx`; remove now-unused imports from `MDXComponents/index.tsx`. + +- [ ] **Step 4: Add semantic layout CSS** + +Append to `packages/docusaurus-theme/src/theme/base.scss`: + +```scss +.theme-media-viewer { + position: relative; +} + +span.theme-media-viewer { + display: block; +} + +.theme-media-viewer__action { + position: absolute; + z-index: 1; + inset-block-start: calc(var(--spacing) * 2); + inset-inline-end: calc(var(--spacing) * 2); +} + +.theme-media-viewer__viewport { + display: grid; + min-width: 0; + min-height: 0; + max-height: calc(100dvh - 4rem); + place-items: center; + overflow: auto; +} + +.theme-media-viewer__expanded-image { + display: block; + width: auto; + max-width: 100%; + height: auto; + max-height: calc(100dvh - 4rem); + object-fit: contain; +} + +.theme-media-viewer__viewport .docusaurus-mermaid-container { + max-width: 100%; + margin: 0; +} + +.theme-media-viewer__viewport .docusaurus-mermaid-container > svg { + width: auto; + max-width: 100%; + height: auto; + max-height: calc(100dvh - 4rem); +} +``` + +Do not add color, background, border, radius, shadow, typography, opacity, hover, focus, active, or `[data-slot]` rules. + +- [ ] **Step 5: Run focused contract and TypeScript diagnostics** + +Run: + +```bash +yarn jest packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts --runInBand +yarn workspace @aeei/docusaurus-theme build +``` + +Expected: focused test PASS and package build PASS. Fix only type/API mismatches while preserving the defined interfaces and constraints. + +--- + +### Task 3: Add actual browser fixtures and interaction tests + +**Files:** +- Modify: `examples/docs-starter/docs/guides/markdown-gfm.md` +- Create: `tests/media-viewer.spec.ts` + +**Interfaces:** +- Consumes: `data-media-kind`, `.theme-media-viewer__action`, `.theme-media-viewer__viewport`, official `[data-slot="dialog-content"]`. +- Produces: reproducible pointer/keyboard/focus/geometry/light-dark/mobile evidence. + +- [ ] **Step 1: Add a linked-image fixture** + +Under the existing image fixtures in `examples/docs-starter/docs/guides/markdown-gfm.md`, add: + +```md +- Linked image keeps navigation and receives no viewer control: + +[![Linked hero art](/img/docs-hero.svg)](../showcase/mdx-playground) +``` + +- [ ] **Step 2: Write failing Playwright tests** + +Create `tests/media-viewer.spec.ts` with tests that: + +```ts +import { expect, test } from "playwright/test"; + +const markdownRoute = "guides/markdown-gfm"; +const mermaidRoute = "showcase/mermaid"; + +for (const colorScheme of ["light", "dark"] as const) { + test.describe(colorScheme, () => { + test.use({ colorScheme }); + + for (const viewport of [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 375, height: 812 }, + ]) { + test(`${viewport.name} image opens and closes an accessible viewer`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await page.goto(markdownRoute); + const media = page.locator('[data-media-kind="image"]').first(); + const action = media.getByRole("button", { name: "View larger" }); + await expect(action).toBeVisible(); + + const inlineBox = await media.locator("img").boundingBox(); + await action.focus(); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Hero art"); + const expandedBox = await dialog.locator("img").boundingBox(); + if (viewport.name === "desktop") { + expect(expandedBox!.width).toBeGreaterThan(inlineBox!.width); + } else { + const dialogBox = await dialog.boundingBox(); + expect(dialogBox!.width).toBeGreaterThanOrEqual(viewport.width - 32); + } + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(action).toBeFocused(); + expect( + await page.evaluate(() => document.documentElement.scrollWidth) + ).toBeLessThanOrEqual(viewport.width); + }); + } + }); +} + +test("media pointer click opens while linked image keeps navigation", async ({ + page, +}) => { + await page.goto(markdownRoute); + await page.locator('[data-media-kind="image"]').first().locator("img").click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + + const linkedImage = page.getByRole("img", { name: "Linked hero art" }); + await expect(linkedImage.locator("xpath=ancestor::a")).toHaveAttribute( + "href", + /mdx-playground/ + ); + await expect( + linkedImage.locator("xpath=ancestor::a").getByRole("button") + ).toHaveCount(0); +}); + +test("Mermaid viewer renders unique readable SVG copies", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto(mermaidRoute); + const media = page.locator('[data-media-kind="diagram"]').first(); + await media.getByRole("button", { name: "View larger" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.locator("svg")).toBeVisible(); + const duplicateIds = await page.evaluate(() => { + const ids = [...document.querySelectorAll("[id]")].map((node) => node.id); + return ids.filter((id, index) => ids.indexOf(id) !== index); + }); + expect(duplicateIds).toEqual([]); +}); +``` + +Adjust selectors only for actual accessible output; do not weaken outcomes to mere element existence. + +- [ ] **Step 3: Run Playwright red, then green after Task 2** + +Run: + +```bash +yarn playwright test tests/media-viewer.spec.ts +``` + +Expected before Task 2: FAIL because viewer controls are absent. Expected after Task 2: all new tests PASS. + +- [ ] **Step 4: Run complete upstream validation** + +Run: + +```bash +yarn prettier --check \ + packages/docusaurus-theme/src/theme/components/media-viewer/index.tsx \ + packages/docusaurus-theme/src/theme/components/media-viewer/mdx-media.tsx \ + packages/docusaurus-theme/src/theme/MDXComponents/index.tsx \ + packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts \ + tests/media-viewer.spec.ts \ + examples/docs-starter/docs/guides/markdown-gfm.md +yarn test +yarn build +yarn workspace @aeei/docs-starter build +yarn playwright test +git diff --check +``` + +Expected: all commands PASS. Use LSP diagnostics on changed TS/TSX files and require zero errors. + +--- + +### Task 4: Build a local tar and validate actual Deck routes + +**Files:** +- No upstream source changes. +- Local-only downstream preview worktree: `/Users/kelly/w/deck-worktrees/260722-media-viewer-preview` +- Local-only archive replacement: `docs/manual/vendor/docusaurus-theme.tgz` + +**Interfaces:** +- Consumes: built upstream package with media viewer. +- Produces: local App, LinkPie, DeskPie route evidence without committing downstream files. + +- [ ] **Step 1: Pack the upstream theme** + +Run: + +```bash +cd /Users/kelly/w/docs-worktrees/260722-media-viewer/packages/docusaurus-theme +npm pack --pack-destination /tmp/media-viewer-theme +``` + +Expected: one `.tgz` containing `components/media-viewer`, compiled CSS, license files, and the current package version. + +- [ ] **Step 2: Create a clean Deck preview worktree** + +Run: + +```bash +cd /Users/kelly/w/deck +git fetch origin develop +git worktree add -b 260722-media-viewer-preview \ + /Users/kelly/w/deck-worktrees/260722-media-viewer-preview origin/develop +cp /tmp/media-viewer-theme/*.tgz \ + /Users/kelly/w/deck-worktrees/260722-media-viewer-preview/docs/manual/vendor/docusaurus-theme.tgz +``` + +- [ ] **Step 3: Regenerate local lockfiles and build all manuals** + +Run: + +```bash +base=/Users/kelly/w/deck-worktrees/260722-media-viewer-preview/docs/manual +for site in app linkpie deskpie; do + pnpm --dir "$base/$site" install --lockfile-only + pnpm --dir "$base/$site" install --frozen-lockfile + pnpm --dir "$base/$site" typecheck +done +pnpm --dir "$base" test +pnpm --dir "$base" build:github-pages +``` + +Expected: all tests/typechecks/builds PASS using the same local tar integrity. + +- [ ] **Step 4: Serve and audit actual routes** + +Serve: + +```bash +python3 -m http.server 3422 --directory "$base/_site" +``` + +Use Playwright against: + +- `http://127.0.0.1:3422/app/docs/developer/organization-oauth-client/` — Mermaid +- `http://127.0.0.1:3422/linkpie/docs/developer/aip-gateway-org-path-routing/` — Mermaid +- `http://127.0.0.1:3422/deskpie/docs/user/license-requests/` — screenshot image + +For each route: + +- open by pointer and keyboard +- verify dialog role/title, Escape close, focus restoration +- verify no viewport overflow +- verify light/dark at desktop 1440×900 and mobile 375×812 +- save original-resolution screenshots under `/tmp/manual-media-viewer/` + +- [ ] **Step 5: Present evidence and stop at approval gate** + +Present: + +- docs-starter screenshots +- App/LinkPie/DeskPie screenshots +- test/build commands and results +- primitive parity report +- known residual risks + +Do not commit, push, replace the reviewed Deck artifact permanently, or deploy until the user explicitly approves the screenshots. + +--- + +### Task 5: Commit only after visual approval + +**Files:** all upstream implementation and test files from Tasks 1–3. + +- [ ] **Step 1: Confirm explicit visual approval in chat** + +Expected: user explicitly approves the shown local screenshots. Automated checks alone are insufficient. + +- [ ] **Step 2: Commit the upstream implementation** + +Run: + +```bash +cd /Users/kelly/w/docs-worktrees/260722-media-viewer +git add \ + AGENTS.md \ + packages/docusaurus-theme/src/theme/components/media-viewer \ + packages/docusaurus-theme/src/theme/MDXComponents/index.tsx \ + packages/docusaurus-theme/src/theme/base.scss \ + packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts \ + examples/docs-starter/docs/guides/markdown-gfm.md \ + tests/media-viewer.spec.ts +git commit -m "feat(theme): add modal media viewer" +``` + +Expected: clean committed upstream implementation. Push/PR and downstream Deck artifact replacement remain a separate release step requiring the project's normal approval and validation gates. From 9481dbec47c95323402f50e769632ad528515a90 Mon Sep 17 00:00:00 2001 From: aeei <18022843+aeei@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:06:55 +0900 Subject: [PATCH 3/7] feat(theme): add shared media viewer and docs controls --- AGENTS.md | 4 + .../plans/2026-07-22-media-viewer.md | 34 ++-- .../specs/2026-07-22-media-viewer-design.md | 38 ++-- .../docs-starter/docs/guides/markdown-gfm.md | 4 + package.json | 2 +- .../src/theme/DocItem/TOC/Desktop/index.tsx | 11 +- .../src/theme/MDXComponents/index.tsx | 15 +- packages/docusaurus-theme/src/theme/base.scss | 72 +++++++- .../theme/components/local-search/index.tsx | 13 +- .../theme/components/media-viewer/index.tsx | 92 ++++++++++ .../components/media-viewer/mdx-media.tsx | 77 ++++++++ .../src/theme/local-search-contract.test.ts | 23 +++ .../src/theme/media-viewer-contract.test.ts | 59 ++++++ .../src/theme/toc-contract.test.ts | 9 +- .../src/theme/vendor/search-nova.css | 2 +- tests/local-search.spec.ts | 48 +++++ tests/media-viewer.spec.ts | 169 ++++++++++++++++++ tests/theme-parity.spec.ts | 15 +- 18 files changed, 639 insertions(+), 48 deletions(-) create mode 100644 packages/docusaurus-theme/src/theme/components/media-viewer/index.tsx create mode 100644 packages/docusaurus-theme/src/theme/components/media-viewer/mdx-media.tsx create mode 100644 packages/docusaurus-theme/src/theme/local-search-contract.test.ts create mode 100644 packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts create mode 100644 tests/local-search.spec.ts create mode 100644 tests/media-viewer.spec.ts diff --git a/AGENTS.md b/AGENTS.md index daf704f02..396439bc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,10 @@ yarn watch:demo See `CONTRIBUTING.md` for the full guide. Use clear commit messages so reviewers can understand what each commit does. +## Approved Base Nova Visual Exceptions + +- The shared media-viewer adapter may manage prose flow spacing, responsive action visibility and placement, Dialog viewport bounds, media containment, and overflow. It must reuse the existing `CodeBlockButton` and official Dialog primitive without changing their visual metrics or states. + ## Required Checks Run the following before committing any code changes (except documentation or comment-only updates): diff --git a/docs/superpowers/plans/2026-07-22-media-viewer.md b/docs/superpowers/plans/2026-07-22-media-viewer.md index 1a84e9ead..4508dfe6e 100644 --- a/docs/superpowers/plans/2026-07-22-media-viewer.md +++ b/docs/superpowers/plans/2026-07-22-media-viewer.md @@ -2,19 +2,23 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add a shared Base Nova modal viewer for Markdown images and Mermaid diagrams, opened by direct media click or an official expand action. +**Goal:** Add a shared Base Nova modal viewer for Markdown images and Mermaid diagrams, opened through media click/tap or the existing CodeBlock action button. -**Architecture:** Add one controlled `MediaViewer` adapter composed from the existing official Button, Tooltip, and Dialog primitives. Add MDX image, Mermaid, and link adapters that preserve Docusaurus rendering while routing viewable media through the shared adapter. Keep all visual primitive sources unchanged; only semantic placement, viewport, containment, and overflow layout are new. +**Architecture:** Add one controlled `MediaViewer` adapter composed from the existing `CodeBlockButton` SSOT and official Dialog primitive. Add MDX image, Mermaid, and link adapters that preserve Docusaurus rendering while routing viewable media through the shared adapter. Keep all visual primitive sources unchanged; only semantic prose flow spacing, responsive action visibility, viewport, containment, and overflow layout are new. + +> **User-approved revision 1 (2026-07-22):** No Tooltip is rendered, the media action reuses `CodeBlockButton` exactly, and Dialog width is capped by `--theme-shell-max-width`. +> +> **User-approved revision 2 (2026-07-22):** Media click/tap opens the viewer. Fine-pointer desktop reveals the action only on hover/focus. Coarse touch keeps the visual action hidden while retaining the Button in the accessibility tree. This revision supersedes conflicting initial snippets below. **Tech Stack:** React 19, TypeScript 6, Docusaurus 3.10, Base UI 1.6, shadcn 4.12 Base Nova, Lucide, Jest source contracts, Playwright. ## Global Constraints - Pinned visual oracle: shadcn 4.12 Base Nova, Base UI, Neutral, Lucide. -- Do not modify official `Button`, `Dialog`, or `Tooltip` source or visual state classes. +- Do not modify official `Button` or `Dialog` source or visual state classes. - Do not add consumer CSS, `[data-slot]` overrides, duplicate primitives, or service-specific viewer implementations. -- Approved layout exception only: media action placement, Dialog media viewport dimensions, media containment, and overflow. -- Use `Button variant="ghost" size="icon-sm"`; do not reuse `CodeBlockButton` custom classes. +- Approved layout exception only: media block rhythm through `--typeset-flow`, responsive media action visibility, Dialog media viewport dimensions, media containment, and overflow. +- Reuse `CodeBlockButton` itself; do not copy its visual classes. - Viewer targets global Markdown images and Mermaid diagrams. Linked images retain link behavior and receive no viewer controls. - No zoom, pan, rotate, download, video, iframe, chart, table, or per-document opt-in support. - TDD: run each focused test red before implementation and green afterward. @@ -25,7 +29,7 @@ ## File Map - Modify: `AGENTS.md` — record the approved media-viewer layout exception. -- Create: `packages/docusaurus-theme/src/theme/components/media-viewer/index.tsx` — controlled Dialog behavior, action, tooltip, focus restoration, pointer handling. +- Create: `packages/docusaurus-theme/src/theme/components/media-viewer/index.tsx` — controlled Dialog behavior, shared action, and focus restoration. - Create: `packages/docusaurus-theme/src/theme/components/media-viewer/mdx-media.tsx` — image, Mermaid, and link adapters plus linked-media context. - Modify: `packages/docusaurus-theme/src/theme/MDXComponents/index.tsx` — global adapter registration. - Modify: `packages/docusaurus-theme/src/theme/base.scss` — semantic placement, containment, and overflow only. @@ -39,10 +43,12 @@ ### Task 1: Lock the ownership and no-override contract **Files:** + - Modify: `AGENTS.md` - Create: `packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts` **Interfaces:** + - Consumes: project Base Nova parity contract. - Produces: source contracts for `MediaViewer`, `ZoomableImage`, `ZoomableMermaid`, and `MediaAwareLink`. @@ -118,6 +124,7 @@ Expected: FAIL because viewer files and MDX mappings do not exist. ### Task 2: Implement the shared viewer and MDX adapters **Files:** + - Create: `packages/docusaurus-theme/src/theme/components/media-viewer/index.tsx` - Create: `packages/docusaurus-theme/src/theme/components/media-viewer/mdx-media.tsx` - Modify: `packages/docusaurus-theme/src/theme/MDXComponents/index.tsx` @@ -125,6 +132,7 @@ Expected: FAIL because viewer files and MDX mappings do not exist. - Test: `packages/docusaurus-theme/src/theme/media-viewer-contract.test.ts` **Interfaces:** + - Produces: - `MediaViewer(props: MediaViewerProps): ReactNode` - `ZoomableImage(props: ImgProps): ReactNode` @@ -274,9 +282,7 @@ import MediaViewer from "@theme/components/media-viewer"; const LinkedMediaContext = createContext(false); -export function MediaAwareLink( - props: ComponentProps -): ReactNode { +export function MediaAwareLink(props: ComponentProps): ReactNode { return ( @@ -428,10 +434,12 @@ Expected: focused test PASS and package build PASS. Fix only type/API mismatches ### Task 3: Add actual browser fixtures and interaction tests **Files:** + - Modify: `examples/docs-starter/docs/guides/markdown-gfm.md` - Create: `tests/media-viewer.spec.ts` **Interfaces:** + - Consumes: `data-media-kind`, `.theme-media-viewer__action`, `.theme-media-viewer__viewport`, official `[data-slot="dialog-content"]`. - Produces: reproducible pointer/keyboard/focus/geometry/light-dark/mobile evidence. @@ -501,7 +509,11 @@ test("media pointer click opens while linked image keeps navigation", async ({ page, }) => { await page.goto(markdownRoute); - await page.locator('[data-media-kind="image"]').first().locator("img").click(); + await page + .locator('[data-media-kind="image"]') + .first() + .locator("img") + .click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape"); @@ -569,11 +581,13 @@ Expected: all commands PASS. Use LSP diagnostics on changed TS/TSX files and req ### Task 4: Build a local tar and validate actual Deck routes **Files:** + - No upstream source changes. - Local-only downstream preview worktree: `/Users/kelly/w/deck-worktrees/260722-media-viewer-preview` - Local-only archive replacement: `docs/manual/vendor/docusaurus-theme.tgz` **Interfaces:** + - Consumes: built upstream package with media viewer. - Produces: local App, LinkPie, DeskPie route evidence without committing downstream files. diff --git a/docs/superpowers/specs/2026-07-22-media-viewer-design.md b/docs/superpowers/specs/2026-07-22-media-viewer-design.md index 14494bf62..1fcf5c907 100644 --- a/docs/superpowers/specs/2026-07-22-media-viewer-design.md +++ b/docs/superpowers/specs/2026-07-22-media-viewer-design.md @@ -2,7 +2,7 @@ ## Goal -Markdown images and Mermaid diagrams in every consumer manual can open in a larger modal viewer. The interaction is discoverable through a CodeBlock-style action button and convenient through direct media click. +Markdown images and Mermaid diagrams in every consumer manual can open in a larger modal viewer through media click/tap or the same action button used by CodeBlock. ## Scope @@ -22,11 +22,11 @@ Excluded from the first release: ## Constraints -- Use the pinned Base Nova `Button`, `Dialog`, and `Tooltip` registry sources unchanged. +- Use the pinned Base Nova `Button` and `Dialog` registry sources unchanged. - Do not add consumer CSS, `[data-slot]` overrides, duplicate primitives, or service-specific implementations. - Do not change official component typography, color, border, radius, shadow, animation, or state styles. -- The approved visual exception is limited to semantic media-viewer layout: trigger placement, dialog viewport size, media containment, and overflow. Record this exception in `AGENTS.md` before implementation. -- Keep the existing CodeBlock action unchanged. The media action uses official `Button variant="ghost" size="icon-sm"` directly and must not import, copy, or reuse `CodeBlockButton` visual classes. +- The approved visual exception is limited to semantic media-viewer layout: prose flow spacing, responsive action visibility and placement, dialog viewport size, media containment, and overflow. Keep this exception recorded in `AGENTS.md`. +- Keep the existing CodeBlock action unchanged. The media action reuses `CodeBlockButton` itself so action visuals remain one SSOT without copied classes. ## Chosen Approach @@ -53,12 +53,11 @@ It renders: - a semantic relative media root - the inline media -- one official `Button` using `variant="ghost"` and `size="icon-sm"`; only its top-right placement follows the learned CodeBlock action mapping -- an official `Tooltip` for the expand action +- the existing `CodeBlockButton`, including its official callsite placement and state style - an official `Dialog` containing the expanded media - an `sr-only` official `DialogHeader`, `DialogTitle`, and `DialogDescription` -The adapter may define only structural classes needed for relative/absolute placement, viewport bounds, containment, and overflow. It does not style primitive surfaces or states. +The adapter may define only structural classes needed for prose flow spacing, relative/absolute placement, responsive action visibility, viewport bounds, containment, and overflow. Media block rhythm uses the existing `--typeset-flow` token. It does not change primitive surfaces or visual state metrics. ### `ZoomableImage` @@ -85,17 +84,17 @@ No document callsite changes are required. ### Open -- Pointer/touch click anywhere on a viewable image or Mermaid diagram opens the viewer. -- The visible expand action opens the same viewer. -- Keyboard users open it through the action button with Enter or Space. -- The action uses a Lucide expand icon and the localized accessible label/tooltip `View larger`. - -The inline media click is a pointer convenience, not a second keyboard stop. The official action button is the canonical accessible trigger. +- Clicking or tapping a viewable image or diagram opens the viewer. +- On fine-pointer desktop, the expand action is hidden at rest and appears when the media is hovered or the action receives keyboard focus. +- On coarse touch, the action remains visually hidden so it does not cover media; tapping the media opens the viewer. +- The action stays in the accessibility tree and is the canonical keyboard trigger with Enter or Space. +- The action uses a Lucide expand icon and the localized accessible label `View larger`. +- No Tooltip is rendered. ### Dialog - The official Dialog traps focus, makes background content inert, and provides overlay/transition behavior. -- Content uses the approved media viewport layout up to the browser viewport minus the official outer inset. This viewport sizing is the sole Dialog metric excluded from official parity; Dialog chrome and every primitive state remain unchanged. +- Content uses the approved media viewport layout up to the browser viewport minus the official outer inset, capped by the shared `--theme-shell-max-width` token. This viewport sizing is the sole Dialog metric excluded from official parity; Dialog chrome and every primitive state remain unchanged. - Images preserve aspect ratio with `object-fit: contain`. - Mermaid SVGs scale into the available viewport; oversized content remains scrollable rather than clipped. - No upscale beyond intrinsic raster resolution is required. @@ -103,7 +102,7 @@ The inline media click is a pointer convenience, not a second keyboard stop. The ### Close - Escape, backdrop click, or the official close button closes the viewer. -- Focus returns to the source media's expand action, including when pointer click on the media opened the dialog. +- Focus returns to the source media's expand action. - Route state and URL do not change. ## Responsive Behavior @@ -125,10 +124,10 @@ The inline media click is a pointer convenience, not a second keyboard stop. The ### Unit and contract tests - MDX image and Mermaid mappings use the shared viewer adapters. -- Viewer uses official Base Nova Button, Dialog, Tooltip, and Lucide imports. +- Viewer reuses the existing CodeBlock action component and official Base Nova Dialog. - No consumer CSS or `[data-slot]` visual override is added. - Dialog title/description and action accessible name exist. -- Clicking media or action opens; Escape/backdrop/close closes; focus returns. +- Clicking/tapping media or activating the action opens; Escape/backdrop/close closes; focus returns. - Mermaid preview and expanded SVG IDs do not conflict. - Linked images render as links without nested viewer controls. - Consumer image inventory contains no missing non-decorative alt text before release. @@ -142,7 +141,8 @@ Use actual docs-starter routes and authored fixtures for the full interaction ma - one linked image - one Mermaid diagram - desktop/mobile × light/dark -- pointer media click +- desktop pointer media click and hover-revealed action +- touch media tap with visually hidden action - keyboard action open/close - focus trap and focus restoration - dialog geometry larger than constrained inline media @@ -153,7 +153,7 @@ Then run one App, LinkPie, and DeskPie actual-route smoke each: open through poi ### Visual acceptance -Compare official Base Nova Button, Tooltip, and Dialog states against `ui.shadcn.com` at identical viewport/state. Primitive chrome and state metrics must remain identical; the explicitly approved media viewport dimensions are excluded. Manually review original-resolution screenshots before implementation commit or push. +Compare official Base Nova Button and Dialog states against `ui.shadcn.com` at identical viewport/state. Primitive chrome and state metrics must remain identical; the explicitly approved media viewport dimensions are excluded. Manually review original-resolution screenshots before implementation commit or push. ## Delivery diff --git a/examples/docs-starter/docs/guides/markdown-gfm.md b/examples/docs-starter/docs/guides/markdown-gfm.md index dec3aa272..8bd88f10d 100644 --- a/examples/docs-starter/docs/guides/markdown-gfm.md +++ b/examples/docs-starter/docs/guides/markdown-gfm.md @@ -26,6 +26,10 @@ Jump to [paragraph flow](#paragraph-flow-and-emphasis), [lists](#lists-and-tasks ![Hero art](/img/docs-hero.svg) +- Linked image keeps navigation and receives no viewer control: + +[![Linked hero art](/img/docs-hero.svg)](../showcase/mdx-playground) +
![Figure fixture](/img/docs-hero.svg) diff --git a/package.json b/package.json index 40eecc7b4..250b77ff8 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "format": "prettier . --check --ignore-unknown --ignore-path .prettierignore", "lint": "eslint . --ignore-path .gitignore --ext .ts,.tsx,.js", "test": "NODE_OPTIONS=--experimental-vm-modules jest", - "test:e2e:theme": "playwright test tests/theme-parity.spec.ts", + "test:e2e:theme": "playwright test", "test:cypress:dev": "start-server-and-test watch:demo http://localhost:3000 cy:open", "test:cypress": "start-server-and-test serve http://localhost:3000 cy:run", "release:changelog": "scripts/changelog.ts", diff --git a/packages/docusaurus-theme/src/theme/DocItem/TOC/Desktop/index.tsx b/packages/docusaurus-theme/src/theme/DocItem/TOC/Desktop/index.tsx index 790cd9802..a922054d3 100644 --- a/packages/docusaurus-theme/src/theme/DocItem/TOC/Desktop/index.tsx +++ b/packages/docusaurus-theme/src/theme/DocItem/TOC/Desktop/index.tsx @@ -1,6 +1,7 @@ import React, { type ReactNode } from "react"; import { useDoc } from "@docusaurus/plugin-content-docs/client"; +import Translate from "@docusaurus/Translate"; import TOCItems from "@theme/TOCItems"; const LINK_CLASS_NAME = "table-of-contents__link toc-highlight"; @@ -14,7 +15,15 @@ export default function DocItemTOCDesktop(): ReactNode {