feat(staffml): optional visual field + QuestionVisual component + build-side asset mirroring

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/<track>/*.svg to
  interviews/staffml/public/question-visuals/<track>/. 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 <img> from
  /question-visuals/<track>/<path> with responsive sizing (max
  420px tall), a <figcaption> for the caption, and a graceful
  error state that surfaces the alt text if the SVG fails to load.
  Uses <img> 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/.
This commit is contained in:
Vijay Janapa Reddi
2026-04-24 16:10:06 -04:00
parent f52c093023
commit fb2a57e12b
7 changed files with 272 additions and 2 deletions

View File

@@ -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/

View File

@@ -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/<track>/` and are mirrored to
* `interviews/staffml/public/question-visuals/<track>/` at bundle
* build time (`vault build --legacy-json`). Here we just load them
* as `<img>` 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 (
<figure className="mt-5 p-4 rounded-lg border border-dashed border-accentRed/30 bg-accentRed/5 flex items-start gap-3">
<ImageOff className="w-4 h-4 text-accentRed shrink-0 mt-0.5" />
<div className="text-sm text-textSecondary">
<span className="font-medium text-accentRed">Diagram failed to load.</span>{" "}
{visual.alt}
</div>
</figure>
);
}
return (
<figure className="mt-5">
<div className="rounded-lg border border-border bg-surface/40 p-3 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={visual.alt}
className="max-w-full max-h-[420px] h-auto w-auto"
onError={() => setFailed(true)}
loading="lazy"
/>
</div>
{visual.caption && (
<figcaption className="mt-2 text-[11px] font-mono text-textTertiary text-center">
{visual.caption}
</figcaption>
)}
</figure>
);
}

View File

@@ -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/<track>/<visual.path>` — 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/<track>/
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

View File

@@ -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/<track>/<file>.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({

View File

@@ -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/<track>/ 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/<track>/<path>.
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/<track>/*.svg` → `<staffml>/public/question-visuals/<track>/`.
The Next.js frontend serves static assets from ``public/`` at
``/question-visuals/<track>/<file>.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"]

View File

@@ -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/<track>/<path>`` 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/<track>/``.
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

View File

@@ -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/<track>/; 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/<track>/. 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/<track>/<path>
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