From fb2a57e12bf0450ee144ff9506653b2d161686e0 Mon Sep 17 00:00:00 2001 From: Vijay Janapa Reddi Date: Fri, 24 Apr 2026 16:10:06 -0400 Subject: [PATCH] feat(staffml): optional visual field + QuestionVisual component + build-side asset mirroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the plumbing for questions to attach an optional SVG diagram without touching any existing question data. The feature is strictly opt-in — every question without a visual: block renders exactly as before. Schema (backend): - Pydantic: new Visual model — kind (svg | mermaid), path (bare filename, path-traversal rejected), alt (required, ≤400 chars, accessibility contract), optional caption (≤120 chars). Question gains optional visual: Visual field after scenario, before details. - LinkML question_schema.yaml: matching Visual class + visual slot on the Question definition. Pattern-validated for kind and path. - legacy_export._adapt: emits visual metadata (kind/path/alt/caption) to both full corpus.json and the summary bundle. The metadata is small (≤600 chars/question) so shipping it synchronously avoids a lazy-hydration regression on first render. - legacy_export.copy_visual_assets: new bundle-build step that mirrors interviews/vault/visuals//*.svg to interviews/staffml/public/question-visuals//. Overwrites on content change, prunes files whose source is gone. Wired into vault build --legacy-json after emit_legacy_corpus. Frontend: - Question TS interface: visual?: { kind, path, alt, caption? }. - QuestionVisual component: renders an from /question-visuals// with responsive sizing (max 420px tall), a
for the caption, and a graceful error state that surfaces the alt text if the SVG fails to load. Uses rather than inline SVG so the browser handles caching and sanitization — the vault's SVG linting already runs as a pre-commit hook. Build hygiene: - staffml/.gitignore: ignore public/question-visuals/ — it's a build output mirroring the vault. Source of truth is interviews/vault/visuals/. --- interviews/staffml/.gitignore | 5 ++ .../staffml/src/components/QuestionVisual.tsx | 73 +++++++++++++++++++ interviews/staffml/src/lib/corpus.ts | 12 +++ .../vault-cli/src/vault_cli/commands/build.py | 15 +++- .../vault-cli/src/vault_cli/legacy_export.py | 63 +++++++++++++++- interviews/vault/schema.py | 69 ++++++++++++++++++ interviews/vault/schema/question_schema.yaml | 37 ++++++++++ 7 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 interviews/staffml/src/components/QuestionVisual.tsx diff --git a/interviews/staffml/.gitignore b/interviews/staffml/.gitignore index e8caf33af..1fd686f9b 100644 --- a/interviews/staffml/.gitignore +++ b/interviews/staffml/.gitignore @@ -9,3 +9,8 @@ node_modules/ worker/.wrangler/ worker/wrangler.toml.bak tsconfig.tsbuildinfo + +# Build output — `vault build --legacy-json` mirrors SVG assets from +# interviews/vault/visuals/ into public/question-visuals/. The vault +# is the source of truth; don't commit the copy. +public/question-visuals/ diff --git a/interviews/staffml/src/components/QuestionVisual.tsx b/interviews/staffml/src/components/QuestionVisual.tsx new file mode 100644 index 000000000..c1b24feba --- /dev/null +++ b/interviews/staffml/src/components/QuestionVisual.tsx @@ -0,0 +1,73 @@ +"use client"; + +/** + * Renders the optional diagram attached to a question. + * + * Architecture: visuals live as static SVG assets under + * `interviews/vault/visuals//` and are mirrored to + * `interviews/staffml/public/question-visuals//` at bundle + * build time (`vault build --legacy-json`). Here we just load them + * as `` elements — browser caching, no inline-SVG sanitization, + * and the HTTP request is a single static asset hit. + * + * The component is deliberately minimal. The SVG itself carries the + * semantic content (see `.claude/rules/svg-style.md` for the book's + * SVG style system, which authors should follow). Styling of the + * frame, caption, and responsive sizing lives here. + */ + +import { useState } from "react"; +import { ImageOff } from "lucide-react"; + +export interface QuestionVisualProps { + track: string; + visual: { + kind: "svg" | "mermaid"; + path: string; + alt: string; + caption?: string; + }; +} + +export default function QuestionVisual({ track, visual }: QuestionVisualProps) { + const [failed, setFailed] = useState(false); + + // Mermaid kind is reserved for a future inline-text path — MVP only + // supports svg file references. Render nothing rather than throw so + // a forward-compat YAML on an old frontend degrades gracefully. + if (visual.kind !== "svg") return null; + + const src = `/question-visuals/${track}/${visual.path}`; + + if (failed) { + return ( +
+ +
+ Diagram failed to load.{" "} + {visual.alt} +
+
+ ); + } + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {visual.alt} setFailed(true)} + loading="lazy" + /> +
+ {visual.caption && ( +
+ {visual.caption} +
+ )} +
+ ); +} diff --git a/interviews/staffml/src/lib/corpus.ts b/interviews/staffml/src/lib/corpus.ts index b04259357..6e18f3f83 100644 --- a/interviews/staffml/src/lib/corpus.ts +++ b/interviews/staffml/src/lib/corpus.ts @@ -27,6 +27,18 @@ export interface Question { * back to a zone-based inferred-task label. */ question?: string; + /** + * Optional diagram attached to the question. The SVG file lives at + * `/question-visuals//` — copied from the vault + * by the build step. Rendered between the scenario and the Your + * task callout so the reading order is context → diagram → ask. + */ + visual?: { + kind: "svg" | "mermaid"; + path: string; // bare filename, resolves under /question-visuals// + alt: string; // a11y-required description + caption?: string; + }; topic: string; // one of 87 curated topic IDs zone: string; // one of 11 ikigai zones competency_area: string; // one of 13 canonical areas diff --git a/interviews/vault-cli/src/vault_cli/commands/build.py b/interviews/vault-cli/src/vault_cli/commands/build.py index 3ebf27091..684fc8226 100644 --- a/interviews/vault-cli/src/vault_cli/commands/build.py +++ b/interviews/vault-cli/src/vault_cli/commands/build.py @@ -11,7 +11,7 @@ from rich.table import Table from vault_cli.compiler import build as compile_build from vault_cli.exit_codes import ExitCode -from vault_cli.legacy_export import emit_legacy_corpus +from vault_cli.legacy_export import copy_visual_assets, emit_legacy_corpus from vault_cli.loader import load_all console = Console() @@ -72,6 +72,19 @@ def register(app: typer.Typer) -> None: f"[dim]legacy corpus.json: {legacy_result['count']} questions → " f"{legacy_result['output']}[/dim]" ) + # Mirror visual assets alongside the JSON. The frontend + # references /question-visuals//.svg directly + # from Next.js's public/ tree — no hydration or worker + # round-trip, just static assets cached at the edge. + visuals_out = Path("interviews/staffml/public") + visuals_result = copy_visual_assets(vault_dir, visuals_out) + result["visual_assets"] = visuals_result + console.print( + f"[dim]visual assets: {visuals_result.get('total_assets', 0)} total, " + f"{visuals_result.get('copied', 0)} copied, " + f"{visuals_result.get('deleted', 0)} pruned → " + f"{visuals_out}/question-visuals/[/dim]" + ) if as_json: print(json.dumps({ diff --git a/interviews/vault-cli/src/vault_cli/legacy_export.py b/interviews/vault-cli/src/vault_cli/legacy_export.py index 476591613..3f676ff31 100644 --- a/interviews/vault-cli/src/vault_cli/legacy_export.py +++ b/interviews/vault-cli/src/vault_cli/legacy_export.py @@ -42,6 +42,21 @@ def _adapt(lq: LoadedQuestion) -> dict[str, Any]: # renders it synchronously, so lazy-hydration would be a regression. if q.question: legacy["question"] = q.question + # Visual metadata. The SVG file itself lives under + # interviews/vault/visuals// and is copied to + # interviews/staffml/public/question-visuals/ by `copy_visual_assets` + # below; this JSON only carries the metadata the frontend needs + # (kind + filename + alt + optional caption) to build the asset + # URL at /question-visuals//. + if q.visual is not None: + visual_out: dict[str, Any] = { + "kind": q.visual.kind, + "path": q.visual.path, + "alt": q.visual.alt, + } + if q.visual.caption: + visual_out["caption"] = q.visual.caption + legacy["visual"] = visual_out # Chain — legacy shape: chain_ids (list) + chain_positions (dict). # v1.0 schema already carries multi-chain membership natively. @@ -159,4 +174,50 @@ def _to_summary(item: dict[str, Any]) -> dict[str, Any]: return summary -__all__ = ["emit_legacy_corpus"] +def copy_visual_assets(vault_dir: Path, staffml_public_dir: Path) -> dict[str, Any]: + """Copy `interviews/vault/visuals//*.svg` → `/public/question-visuals//`. + + The Next.js frontend serves static assets from ``public/`` at + ``/question-visuals//.svg``. This function mirrors + the track-sharded directory layout so the same relative filename + used in the YAML's ``visual.path`` field resolves without + transformation. + + Overwrites destination files on every run — the vault is the + source of truth. Removes destination files whose source no longer + exists so renames/deletions propagate. Returns counts for the + build summary. + """ + import shutil + + source = vault_dir / "visuals" + dest = staffml_public_dir / "question-visuals" + copied = 0 + deleted = 0 + + if not source.exists(): + return {"copied": 0, "deleted": 0, "note": "no visuals directory"} + + # Mirror from source into dest. + dest.mkdir(parents=True, exist_ok=True) + source_files: set[Path] = set() + for svg in source.rglob("*.svg"): + rel = svg.relative_to(source) + source_files.add(rel) + target = dest / rel + target.parent.mkdir(parents=True, exist_ok=True) + if not target.exists() or target.read_bytes() != svg.read_bytes(): + shutil.copy2(svg, target) + copied += 1 + + # Prune destination files that no longer have a source. + for existing in dest.rglob("*.svg"): + rel = existing.relative_to(dest) + if rel not in source_files: + existing.unlink() + deleted += 1 + + return {"copied": copied, "deleted": deleted, "total_assets": len(source_files)} + + +__all__ = ["emit_legacy_corpus", "copy_visual_assets"] diff --git a/interviews/vault/schema.py b/interviews/vault/schema.py index bf5056305..cd9816034 100644 --- a/interviews/vault/schema.py +++ b/interviews/vault/schema.py @@ -61,6 +61,74 @@ class Resource(BaseModel): return v +class Visual(BaseModel): + """Optional diagram/figure attached to a question. + + Visuals live as separate asset files under + ``interviews/vault/visuals//`` so the SVG text does + not contaminate YAML diffs and existing SVG tooling (Inkscape, + formatters, linters) works unchanged. The bundle-build step copies + these into the Next.js ``public/question-visuals/`` tree. The + practice page renders them between the scenario and the + ``question`` callout — context → diagram → ask, mirroring how an + interviewer would flow the question in person. + """ + + kind: str = "svg" + """Renderer kind. MVP supports `svg` only. Future: `mermaid` + (inline text), `roofline` (parameterized React component), etc. + The renderer dispatches on this field.""" + + path: str + """Asset filename relative to ``interviews/vault/visuals//``. + Must end in ``.svg`` for ``kind=svg``. No path traversal.""" + + alt: str + """Accessibility description for screen readers and fallback when + the SVG fails to load. Required — a visual with no alt is an + accessibility regression, not an optional add-on.""" + + caption: Optional[str] = None + """Author-facing caption rendered below the figure. Short — max + 120 chars. Optional; the alt text handles the semantic payload.""" + + @field_validator("kind") + @classmethod + def valid_kind(cls, v: str) -> str: + if v not in {"svg", "mermaid"}: + raise ValueError(f"Visual.kind must be 'svg' or 'mermaid' (got {v!r})") + return v + + @field_validator("path") + @classmethod + def safe_path(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Visual.path must be non-empty") + if "/" in v or "\\" in v or ".." in v: + raise ValueError( + f"Visual.path must be a bare filename, no traversal (got {v!r})" + ) + if len(v) > 120: + raise ValueError(f"Visual.path too long ({len(v)} chars, max 120)") + return v + + @field_validator("alt") + @classmethod + def alt_non_empty(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Visual.alt must be non-empty (accessibility requirement)") + if len(v) > 400: + raise ValueError(f"Visual.alt too long ({len(v)} chars, max 400)") + return v + + @field_validator("caption") + @classmethod + def caption_length(cls, v: Optional[str]) -> Optional[str]: + if v is not None and len(v) > 120: + raise ValueError(f"Visual.caption too long ({len(v)} chars, max 120)") + return v + + class ChainRef(BaseModel): """Structured chain reference with position (plural chains list item).""" @@ -140,6 +208,7 @@ class Question(BaseModel): # uniformly (without it, 71% of questions had no explicit ask — the # scenario just set context and the reader had to guess). question: Optional[str] = None + visual: Optional[Visual] = None details: QuestionDetails # Workflow diff --git a/interviews/vault/schema/question_schema.yaml b/interviews/vault/schema/question_schema.yaml index c3dd4e4f9..6fc56b069 100644 --- a/interviews/vault/schema/question_schema.yaml +++ b/interviews/vault/schema/question_schema.yaml @@ -157,6 +157,33 @@ classes: range: string description: Free-form reviewer notes. + Visual: + description: | + Optional diagram attached to a question. Stored as a separate + asset file under interviews/vault/visuals//; the YAML + only carries metadata (kind, filename, alt text, caption). This + keeps SVG markup out of YAML diffs and lets authors use real + SVG editors instead of YAML multiline scalars. + attributes: + kind: + range: string + required: true + pattern: "^(svg|mermaid)$" + description: Renderer kind. MVP ships `svg` only; `mermaid` reserved for future inline-text diagrams. + path: + range: string + required: true + pattern: "^[A-Za-z0-9_.-]+\\.(svg|mmd)$" + description: Bare filename under visuals//. No path traversal. ≤120 chars. + alt: + range: string + required: true + description: Accessibility description for screen readers and SVG-load failure. ≤400 chars. Required — a visual with no alt is an a11y regression. + caption: + range: string + required: false + description: Short author-facing caption rendered below the figure. ≤120 chars. Alt text handles the semantic payload; caption is for labelling only. + Details: description: Rich-text body of the question. attributes: @@ -238,6 +265,16 @@ classes: 50 GB/s chip interconnect and a 33 ms frame deadline; the question field reads "Which parallelism strategy (tensor vs. pipeline) would you choose and why?" + visual: + range: Visual + required: false + description: >- + Optional diagram attached to the question. Stored as a + separate file under interviews/vault/visuals// + so the SVG text does not contaminate YAML diffs and existing + SVG tooling works unchanged. Rendered between the scenario + and the question callout on the practice page — context → + diagram → ask. details: range: Details required: true