diff --git a/docs/challenge-explainers.md b/docs/challenge-explainers.md new file mode 100644 index 0000000..c3c55ee --- /dev/null +++ b/docs/challenge-explainers.md @@ -0,0 +1,61 @@ +# Challenge explainer direction + +The 13 current challenges already have diagrams. A signature explainer should +make one consequential transition legible, not add a decorative second figure. +Public statements and harness contracts remain the source of truth. These +artworks are conceptual examples, never a claim about an official run or score. + +## Visual grammar + +- Ink `#282334` grounds mechanisms; paper `#f2f0ed` carries reading text. +- Orange `#ff784f` means an incoming operation or interruption. Lilac + `#b9a1ff` marks resident state, mint `#a9e8cb` preserved state, cyan + `#53d9ff` transit, and lime `#b7ff45` a confirmed outcome. These meanings + are reinforced with words and shape, not color alone. +- Bricolage Grotesque is for the one-line concept headline, Inter for the + explanation, and JetBrains Mono for keys, sequence numbers, bytes, and ticks. +- One large mechanism occupies the figure. No graph-paper wallpaper, generic + three-card sequence, looping sparkle, fake terminal output, or fictitious + performance result. + +```text +concept headline operation / current state + + [ one large mechanism ] + +one sentence explaining the current consequence Replay +``` + +Keep the artwork left-aligned with the statement. Start its finite sequence +when it enters view, settle on a meaningful frame, and allow replay. A reduced- +motion preference shows the final explanatory state without motion. The full +sequence must be understandable through a static accessible description. If a +later explainer needs ongoing motion, add pause/stop controls rather than an +uncontrolled loop. A pre-rendered SVG/video is welcome when it conveys the +concept more clearly; it must still have a lightweight poster and transcript. + +## One visual question per challenge + +| Challenge | Moment to explain | +| --- | --- | +| Cache Pressure, easy | One read changes recency, so a different key is evicted. | +| Cache Pressure, medium | A cold scan displaces a hot set under pure LRU; admission protects reuse. | +| Chunk Store | A short read touches one independently framed block. | +| Deadline Scheduler | A running non-preemptive job delays a newly arrived urgent job. | +| Durable Work Queue | A lease expires and the same job becomes available again. | +| Fair Gate | Tokens refill; an expensive request waits for the exact retry tick. | +| Journal Recovery | A crash leaves a durable prefix and an incomplete tail. | +| Lossy Link | A lost ACK triggers retry; duplicate data is acknowledged, not delivered twice. | +| LSM Compactor | An L0 choice closes over L1 overlaps before tombstone-aware merging. | +| Stream Windows | A watermark finalizes one window while the next remains open. | +| Compaction | Moving one live cell extends the contiguous free suffix. | +| Victim Selection | Choosing a block trades reclaimed space for live-page migration. | +| Wear Levelling | Spreading erases trades extra migration for lower wear concentration. | + +## Review gate + +For each artwork, compare every shown state with the public contract/example; +check the before/after story at phone width and desktop width; inspect a real +screenshot rather than relying on a build; test keyboard replay and reduced +motion; and keep the surrounding editorial to one observation, one invariant, +and one learner decision. The first pilots are Cache Pressure and Lossy Link. diff --git a/src/app/(workspace)/layout.tsx b/src/app/(workspace)/layout.tsx index c504577..bb27318 100644 --- a/src/app/(workspace)/layout.tsx +++ b/src/app/(workspace)/layout.tsx @@ -1,3 +1,11 @@ +import { Bricolage_Grotesque } from "next/font/google"; + +const editorial = Bricolage_Grotesque({ + subsets: ["latin"], + display: "swap", + variable: "--font-editorial", +}); + /** * Chrome for the arena: none. * @@ -14,7 +22,7 @@ export default function WorkspaceLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( -
+
{children}
); diff --git a/src/components/blocks/block-renderer.tsx b/src/components/blocks/block-renderer.tsx index 6446441..2bbf057 100644 --- a/src/components/blocks/block-renderer.tsx +++ b/src/components/blocks/block-renderer.tsx @@ -70,7 +70,7 @@ function renderCore(block: CoreBlock, context: BlockContext) { case "figure": return ( -
+
); case "example": @@ -143,10 +143,12 @@ function Figure({ illustration, caption, label, + presentation, }: { illustration: IllustrationSpec; caption?: string; label?: string; + presentation?: "immersive"; }) { const drawing = getIllustration(illustration.id); if (!drawing) { @@ -155,7 +157,11 @@ function Figure({ return (
-
+
{createElement(drawing, illustration.props ?? {})}
{(label || caption) && ( diff --git a/src/lib/arena/statement.ts b/src/lib/arena/statement.ts index 8d3cde2..ce1c89f 100644 --- a/src/lib/arena/statement.ts +++ b/src/lib/arena/statement.ts @@ -72,6 +72,8 @@ const conceptFigureProps = { "systems.frames": frameProps, "systems.ranges": rangeProps, "systems.exchange": exchangeProps, + "systems.cache-story": z.object({ mode: z.enum(["lru", "scan"]) }).strict(), + "systems.lost-ack-story": z.object({}).strict(), } as const; const figureBlock = z.object({ @@ -81,11 +83,13 @@ const figureBlock = z.object({ "systems.flow", "systems.state", "systems.timeline", "systems.snapshot", "systems.budget", "systems.frames", "systems.ranges", "systems.exchange", + "systems.cache-story", "systems.lost-ack-story", ]), props: z.record(z.string(), z.unknown()).optional(), }).strict(), caption: z.string().trim().min(1).max(1_000).optional(), label: z.string().trim().min(1).max(40).optional(), + presentation: z.literal("immersive").optional(), }).strict().superRefine((figure, ctx) => { const schema = conceptFigureProps[figure.illustration.id as keyof typeof conceptFigureProps]; if (schema && !schema.safeParse(figure.illustration.props).success) { diff --git a/src/lib/blocks/types.ts b/src/lib/blocks/types.ts index 7ee1b6a..1993c48 100644 --- a/src/lib/blocks/types.ts +++ b/src/lib/blocks/types.ts @@ -46,6 +46,8 @@ export type CoreBlock = readonly caption?: string; /** Short label such as "FIG 1.2". */ readonly label?: string; + /** Lets an authored illustration own its full artwork surface. */ + readonly presentation?: "immersive"; } | { readonly kind: "example"; diff --git a/src/packs/systems/index.ts b/src/packs/systems/index.ts index da2ae82..003a443 100644 --- a/src/packs/systems/index.ts +++ b/src/packs/systems/index.ts @@ -3,6 +3,7 @@ import { registerIllustration } from "../registry"; import { BudgetDiagram, ExchangeDiagram, FrameDiagram, RangeDiagram, SnapshotDiagram } from "./concept-illustrations"; import { FlowDiagram, StateDiagram, TimelineDiagram } from "./illustrations"; +import { CacheStory, LostAckStory } from "./story-illustrations"; registerIllustration("systems.flow", FlowDiagram); registerIllustration("systems.state", StateDiagram); @@ -12,3 +13,5 @@ registerIllustration("systems.budget", BudgetDiagram); registerIllustration("systems.frames", FrameDiagram); registerIllustration("systems.ranges", RangeDiagram); registerIllustration("systems.exchange", ExchangeDiagram); +registerIllustration("systems.cache-story", CacheStory); +registerIllustration("systems.lost-ack-story", LostAckStory); diff --git a/src/packs/systems/story-illustrations.tsx b/src/packs/systems/story-illustrations.tsx new file mode 100644 index 0000000..4304712 --- /dev/null +++ b/src/packs/systems/story-illustrations.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +function useVisibleStory(lastStage: number, intervalMs: number) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + const [reducedMotion, setReducedMotion] = useState(false); + const [stage, setStage] = useState(0); + + useEffect(() => { + const preference = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReducedMotion(preference.matches); + update(); + preference.addEventListener("change", update); + return () => preference.removeEventListener("change", update); + }, []); + + useEffect(() => { + const element = ref.current; + if (!element) return; + if (typeof IntersectionObserver === "undefined") { + const frame = window.requestAnimationFrame(() => setVisible(true)); + return () => window.cancelAnimationFrame(frame); + } + const observer = new IntersectionObserver( + ([entry]) => setVisible(Boolean(entry?.isIntersecting)), + { threshold: 0.35 }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!visible || reducedMotion || stage >= lastStage) return; + const timer = window.setTimeout(() => setStage((current) => current + 1), intervalMs); + return () => window.clearTimeout(timer); + }, [visible, reducedMotion, stage, lastStage, intervalMs]); + + return { + ref, + stage: reducedMotion ? lastStage : stage, + reducedMotion, + replay: () => setStage(0), + }; +} + +function Replay({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +const EASY_STEPS = [ + "A is oldest. B is newest. The 66-byte budget is full.", + "Read A. A hit makes it newest without another backing read.", + "Insert C. B leaves because one read changed the order.", +] as const; + +const SCAN_STEPS = [ + "The same three hot keys keep returning.", + "A one-time scan fills pure LRU with cold keys.", + "When hot keys return, pure LRU misses. A protected set still hits.", +] as const; + +function CacheTile({ + name, + charge, + left, + visible, + color, + still, +}: { + name: string; + charge: string; + left: string; + visible: boolean; + color: string; + still: boolean; +}) { + return ( + + ); +} + +function CacheEvictionStory() { + const { ref, stage, reducedMotion, replay } = useVisibleStory(2, 1300); + return ( +
+
+
+

+ One read. New victim. +

+ + {stage === 0 ? "Put A, B" : stage === 1 ? "Get A" : "Put C"} + +
+
+
+
+
+
+ + + +
+ oldestnewest +
+
+
+ capacity 66 Bused 66 B +
+
+
+

{EASY_STEPS[stage]}

+ {stage === 2 && !reducedMotion && } +
+
+ ); +} + +function CacheScanStory() { + const { ref, stage, reducedMotion, replay } = useVisibleStory(2, 1450); + const scanned = stage >= 1; + return ( +
+
+
+

+ Cold scan. Hot misses. +

+ + {stage === 0 ? "hot set" : stage === 1 ? "cold scan" : "return"} + +
+
+ {[ + { title: "Pure LRU", protected: false }, + { title: "Protected hot set", protected: true }, + ].map((row) => ( +
+ {row.title} +
+ {[1, 2, 3].map((number) => { + const cold = scanned && !row.protected; + return ( +
+ {cold ? `S${number}` : `H${number}`} +
+ ); + })} +
+
+ ))} +
+
+ {stage === 2 ? "LRU: 3 misses" : "scan: S1, S2, S3"} + {stage === 2 ? "protected: 3 hits" : "same capacity"} +
+
+
+

{SCAN_STEPS[stage]}

+ {stage === 2 && !reducedMotion && } +
+
+ ); +} + +export function CacheStory({ mode }: { mode?: "lru" | "scan" }) { + return mode === "scan" ? : ; +} + +const LINK_STEPS = [ + "DATA 0 carries abc toward the receiver.", + "The receiver delivers abc once and sends ACK 0.", + "ACK 0 vanishes. The sender cannot know delivery succeeded.", + "The timer fires, so the sender retransmits DATA 0.", + "The receiver discards the duplicate and repeats ACK 0.", +] as const; + +export function LostAckStory() { + const { ref, stage, reducedMotion, replay } = useVisibleStory(4, 1120); + const packet = stage === 2 || stage === 4 ? "ACK 0" : "DATA 0"; + const left = ["3%", "73%", "47%", "50%", "3%"][stage]; + return ( +
+
+
+

+ Delivered once. ACKed twice. +

+ + seq 0 + +
+
+ senderreceiver +
+
+
+
+
+
+ {packet} +
+ {stage === 2 && ( +
ACK lost ×
+ )} + {stage === 3 && ( +
timeout → retry
+ )} + {stage === 4 && ( +
duplicate discarded
+ )} +
+
+ source: abcdelivered: {stage === 0 ? "0" : "1"} copy +
+
+
+

{LINK_STEPS[stage]}

+ {stage === 4 && !reducedMotion && } +
+
+ ); +} diff --git a/tests/e2e/arena-submit.spec.ts b/tests/e2e/arena-submit.spec.ts index 22f6b68..6b0016c 100644 --- a/tests/e2e/arena-submit.spec.ts +++ b/tests/e2e/arena-submit.spec.ts @@ -80,7 +80,7 @@ test.describe("arena submission", () => { await expect(page.getByRole("tab", { name: "Submissions" })).toBeVisible(); await expect(page.getByRole("button", { name: "Submit" })).toBeVisible(); await expect(page.getByLabel("Run pipeline: compile, exercise, measure")).toBeVisible(); - await expect(page.getByRole("img", { name: /Oldest on the left · newest on the right/ })).toBeVisible(); + await expect(page.getByRole("img", { name: /Oldest on the left|Reading A makes B the least recently used/ })).toBeVisible(); await expect(page.getByText("The invariant", { exact: true })).toBeVisible(); await expect(page.getByText("What you implement", { exact: true })).toBeVisible(); await expect(page.getByText("A good way in", { exact: true })).toHaveCount(0); diff --git a/tests/e2e/challenge-explainers.spec.ts b/tests/e2e/challenge-explainers.spec.ts new file mode 100644 index 0000000..8f3d974 --- /dev/null +++ b/tests/e2e/challenge-explainers.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + +test.describe("challenge explainers", () => { + test.skip(!process.env.BYTEARENA_ARENA_ROOTS, "External challenge checkout is not configured"); + + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + }); + + test("cache tiers show different final mechanisms", async ({ page }) => { + await page.goto("/challenges/cache-pressure-easy"); + await expect(page.getByRole("img", { name: /Inserting C evicts B/ })).toBeVisible(); + await expect(page.getByText("Insert C. B leaves because one read changed the order.")).toBeVisible(); + + await page.goto("/challenges/cache-pressure-medium"); + await expect(page.getByRole("img", { name: /one-time scan of S1, S2, S3/ })).toBeVisible(); + await expect(page.getByText("LRU: 3 misses")).toBeVisible(); + await expect(page.getByText("protected: 3 hits")).toBeVisible(); + }); + + test("a lost acknowledgement never delivers the same payload twice", async ({ page }) => { + await page.goto("/challenges/lossy-link-easy"); + await expect(page.getByRole("img", { name: /Receiver recognizes the duplicate/ })).toBeVisible(); + await expect(page.getByText("delivered: 1 copy")).toBeVisible(); + await expect(page.getByText("duplicate discarded")).toBeVisible(); + }); + + test("the finite story settles and can be replayed", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/challenges/cache-pressure-easy"); + await expect(page.getByText("Insert C. B leaves because one read changed the order.")).toBeVisible({ timeout: 10_000 }); + await page.getByRole("button", { name: "Replay" }).click(); + await expect(page.getByText("A is oldest. B is newest. The 66-byte budget is full.")).toBeVisible(); + }); +}); diff --git a/tests/unit/lib/arena/statement.test.ts b/tests/unit/lib/arena/statement.test.ts index a1ff7af..aec6e4c 100644 --- a/tests/unit/lib/arena/statement.test.ts +++ b/tests/unit/lib/arena/statement.test.ts @@ -55,6 +55,19 @@ describe("arena educational statements", () => { }))).toThrow(); }); + it("accepts authored story figures but rejects unknown modes and surfaces", () => { + const figure = (id: string, props: unknown, presentation: string = "immersive") => JSON.stringify({ + version: 1, + blocks: [{ kind: "figure", illustration: { id, props }, presentation }], + }); + + expect(parseArenaStatement(figure("systems.cache-story", { mode: "lru" }))).toHaveLength(1); + expect(parseArenaStatement(figure("systems.cache-story", { mode: "scan" }))).toHaveLength(1); + expect(parseArenaStatement(figure("systems.lost-ack-story", {}))).toHaveLength(1); + expect(() => parseArenaStatement(figure("systems.cache-story", { mode: "invented" }))).toThrow(); + expect(() => parseArenaStatement(figure("systems.lost-ack-story", {}, "flashing"))).toThrow(); + }); + it("falls back only when statement.json is absent", async () => { const root = await mkdtemp(path.join(tmpdir(), "bytearena-statement-")); await expect(loadArenaStatement(root)).resolves.toBeNull();