mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-05-07 02:03:55 -05:00
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:
5
interviews/staffml/.gitignore
vendored
5
interviews/staffml/.gitignore
vendored
@@ -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/
|
||||
|
||||
73
interviews/staffml/src/components/QuestionVisual.tsx
Normal file
73
interviews/staffml/src/components/QuestionVisual.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user