handzon-core 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -11,17 +11,20 @@ interface Props {
11
11
  currentStepSlug: string;
12
12
  gated: boolean;
13
13
  hasCheckpoint: boolean;
14
+ hasQuiz: boolean;
14
15
  nextTutorial?: TutorialSummary;
15
16
  }
16
17
 
17
- const { tutorialSlug, steps, currentStepSlug, gated, hasCheckpoint, nextTutorial } = Astro.props;
18
+ const { tutorialSlug, steps, currentStepSlug, gated, hasCheckpoint, hasQuiz, nextTutorial } =
19
+ Astro.props;
18
20
  const idx = steps.findIndex((s) => parseStepId(s.id).stepSlug === currentStepSlug);
19
21
  const prev = idx > 0 ? steps[idx - 1] : null;
20
22
  const next = idx >= 0 && idx < steps.length - 1 ? steps[idx + 1] : null;
21
23
  const prevSlug = prev ? parseStepId(prev.id).stepSlug : null;
22
24
  const nextSlug = next ? parseStepId(next.id).stepSlug : null;
25
+ const hasCompletionItem = hasCheckpoint || hasQuiz;
23
26
  ---
24
- <nav class:list={["step-nav", !next && "step-nav-final"]} data-gated={gated && hasCheckpoint ? "true" : "false"} data-step-key={`${tutorialSlug}/${currentStepSlug}`}>
27
+ <nav class:list={["step-nav", !next && "step-nav-final"]} data-gated={gated && hasCompletionItem ? "true" : "false"} data-step-key={`${tutorialSlug}/${currentStepSlug}`}>
25
28
  <div>
26
29
  {prev && (
27
30
  <a class="sn-prev" href={withBase(`/${tutorialSlug}/${prevSlug}`)}>
@@ -32,7 +35,7 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
32
35
  <div class="sn-slot">
33
36
  {next ? (
34
37
  <a class="sn-next" href={withBase(`/${tutorialSlug}/${nextSlug}`)} data-next-link="true">
35
- {hasCheckpoint ? "Continue" : "Next"}: {next.data.title} →
38
+ {hasCompletionItem ? "Continue" : "Next"}: {next.data.title} →
36
39
  </a>
37
40
  ) : (
38
41
  <TutorialCompletion
@@ -46,7 +49,7 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
46
49
  </nav>
47
50
 
48
51
  <script>
49
- // Gating: if the host step contains a Checkpoint AND tutorial is gated,
52
+ // Gating: if the host step contains a completion item AND tutorial is gated,
50
53
  // disable Next until the step is marked complete.
51
54
  import { getStore } from "../lib/progress/local";
52
55
  const nav = document.querySelector<HTMLElement>(".step-nav");
@@ -15,34 +15,19 @@ interface Props {
15
15
  id?: string;
16
16
  }
17
17
 
18
- /**
19
- * Reads the host tutorial/step from the page-level route marker
20
- * (<div id="tt-route" data-tutorial-slug=... data-step-slug=...>) that
21
- * TutorialLayout emits. Looking it up here keeps the Astro wrapper trivial.
22
- */
23
- function useRoute() {
24
- const [route, setRoute] = useState<{ tutorial: string; step: string } | null>(null);
25
- useEffect(() => {
26
- const el = document.getElementById("tt-route");
27
- if (!el) return;
28
- const tutorial = el.dataset.tutorialSlug;
29
- const step = el.dataset.stepSlug;
30
- if (tutorial && step) setRoute({ tutorial, step });
31
- }, []);
32
- return route;
33
- }
34
-
35
18
  export default function Checkpoint({ label, id }: Props) {
36
19
  const reactId = useId();
37
20
  const checkpointId = id ?? `checkpoint:${reactId}:${label.slice(0, 40)}`;
38
- const { state, recordCheckpoint, removeCheckpoint, markStepComplete, markStepIncomplete } =
39
- useProgress();
40
- const route = useRoute();
21
+ const { state, recordCheckpoint, removeCheckpoint } = useProgress();
41
22
  const done = !!state.checkpoints[checkpointId];
42
23
  const aiEnabled = useAiEnabled();
43
24
  const [stuck, setStuck] = useState(false);
44
25
  const rootRef = useRef<HTMLDivElement>(null);
45
26
 
27
+ useEffect(() => {
28
+ document.dispatchEvent(new CustomEvent("hz:step-item"));
29
+ }, []);
30
+
46
31
  // Show the "Stuck?" nudge after STUCK_DELAY_MS of an unchecked
47
32
  // checkpoint being on-screen. Resets the timer if the checkpoint
48
33
  // scrolls back off screen. Fires once — once shown, stays shown
@@ -75,11 +60,9 @@ export default function Checkpoint({ label, id }: Props) {
75
60
  function onToggle() {
76
61
  if (done) {
77
62
  removeCheckpoint(checkpointId);
78
- if (route) markStepIncomplete(route.tutorial, route.step);
79
63
  return;
80
64
  }
81
65
  recordCheckpoint(checkpointId);
82
- if (route) markStepComplete(route.tutorial, route.step);
83
66
  }
84
67
 
85
68
  // Family D: inline failure feedback from a submit_verification call.
@@ -91,7 +74,11 @@ export default function Checkpoint({ label, id }: Props) {
91
74
  const showFeedback = !done && feedback && !feedback.pass;
92
75
 
93
76
  return (
94
- <div ref={rootRef} className={done ? "checkpoint is-done" : "checkpoint"}>
77
+ <div
78
+ ref={rootRef}
79
+ className={done ? "checkpoint is-done" : "checkpoint"}
80
+ data-checkpoint-id={checkpointId}
81
+ >
95
82
  <button type="button" onClick={onToggle} aria-pressed={done}>
96
83
  <span className="checkpoint-box">{done && <Check size={16} />}</span>
97
84
  <span>{label}</span>
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { buildMermaidConfig } from "../../lib/mermaid-theme.ts";
2
3
 
3
4
  interface Props {
4
5
  chart: string;
@@ -19,7 +20,7 @@ export default function Mermaid({ chart, id }: Props) {
19
20
  (async () => {
20
21
  try {
21
22
  const mermaid = (await import("mermaid")).default;
22
- mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
23
+ mermaid.initialize(buildMermaidConfig());
23
24
  const { svg } = await mermaid.render(
24
25
  id ?? `mermaid-${Math.random().toString(36).slice(2)}`,
25
26
  chart,
@@ -1,5 +1,5 @@
1
1
  import { Check, X } from "lucide-react";
2
- import { useId, useState } from "react";
2
+ import { useEffect, useId, useState } from "react";
3
3
  import { dispatchAssist, useAiEnabled } from "../../lib/ai/assist";
4
4
  import { useProgress } from "../../lib/progress/useProgress";
5
5
 
@@ -25,6 +25,10 @@ export default function Quiz({ question, options, answer, explanation, id, multi
25
25
  const correctSet = new Set(Array.isArray(answer) ? answer : [answer]);
26
26
  const expectMulti = multi ?? Array.isArray(answer);
27
27
 
28
+ useEffect(() => {
29
+ document.dispatchEvent(new CustomEvent("hz:step-item"));
30
+ }, []);
31
+
28
32
  function toggle(i: number) {
29
33
  if (submitted) return;
30
34
  if (expectMulti) {
@@ -47,7 +51,7 @@ export default function Quiz({ question, options, answer, explanation, id, multi
47
51
  }
48
52
 
49
53
  return (
50
- <fieldset className="quiz" disabled={submitted}>
54
+ <fieldset className="quiz" data-quiz-id={questionId} disabled={submitted}>
51
55
  <legend className="quiz-q">{question}</legend>
52
56
  <div className="quiz-options">
53
57
  {options.map((opt, i) => {
@@ -140,11 +140,33 @@ const socialImageUrl = ogImageUrl ? withBase(ogImageUrl) : undefined;
140
140
  />
141
141
  )}
142
142
  <script>
143
- // Render any <pre class="mermaid"> blocks emitted by rehype-mermaid.
144
- if (document.querySelector("pre.mermaid")) {
145
- import("mermaid").then(({ default: mermaid }) => {
146
- mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
147
- mermaid.run({ querySelector: "pre.mermaid" });
143
+ // Render any <pre class="mermaid"> blocks emitted by rehype-mermaid,
144
+ // themed to match the active site palette (see lib/mermaid-theme).
145
+ //
146
+ // We capture each block's source synchronously (this module runs before
147
+ // the window `load` event) and render explicitly rather than calling
148
+ // mermaid.run(). mermaid auto-runs on `load` with its default theme, and
149
+ // on a fast load that race can win and mark nodes data-processed before
150
+ // our dynamic import resolves — leaving unthemed diagrams. Rendering each
151
+ // block ourselves from the captured source sidesteps that entirely.
152
+ const mermaidBlocks = Array.from(document.querySelectorAll("pre.mermaid"));
153
+ if (mermaidBlocks.length > 0) {
154
+ const sources = mermaidBlocks.map((el) => el.textContent ?? "");
155
+ Promise.all([
156
+ import("mermaid"),
157
+ import("handzon-core/lib/mermaid-theme.ts"),
158
+ ]).then(async ([{ default: mermaid }, { buildMermaidConfig }]) => {
159
+ mermaid.startOnLoad = false;
160
+ mermaid.initialize(buildMermaidConfig());
161
+ for (let i = 0; i < mermaidBlocks.length; i++) {
162
+ try {
163
+ const { svg } = await mermaid.render(`hz-mermaid-${i}`, sources[i]);
164
+ mermaidBlocks[i].innerHTML = svg;
165
+ mermaidBlocks[i].setAttribute("data-processed", "true");
166
+ } catch (err) {
167
+ console.error("Mermaid render failed", err);
168
+ }
169
+ }
148
170
  });
149
171
  }
150
172
  </script>
@@ -13,6 +13,7 @@ interface Props {
13
13
  currentStep: StepEntry;
14
14
  currentStepSlug: string;
15
15
  hasCheckpoint?: boolean;
16
+ hasQuiz?: boolean;
16
17
  siteName?: string;
17
18
  tagline?: string;
18
19
  logoUrl?: string;
@@ -33,6 +34,7 @@ const {
33
34
  currentStep,
34
35
  currentStepSlug,
35
36
  hasCheckpoint = false,
37
+ hasQuiz = false,
36
38
  siteName,
37
39
  tagline,
38
40
  logoUrl,
@@ -120,6 +122,7 @@ const trackBootstrap =
120
122
  currentStepSlug={currentStepSlug}
121
123
  gated={tutorial.data.gated}
122
124
  hasCheckpoint={hasCheckpoint}
125
+ hasQuiz={hasQuiz}
123
126
  nextTutorial={nextTutorial}
124
127
  />
125
128
 
@@ -166,10 +169,13 @@ const trackBootstrap =
166
169
  // dynamic import("~/...") bypasses Vite's resolver and the browser
167
170
  // can't load the module.
168
171
  import { getStore } from "../lib/progress/local";
172
+ import { deriveStepCompletion } from "../lib/progress/stepCompletion";
173
+ import type { StepKey } from "../lib/progress/types";
169
174
  const route = document.getElementById("tt-route");
170
175
  if (route) {
171
176
  const tutorialSlug = route.dataset.tutorialSlug!;
172
177
  const stepSlug = route.dataset.stepSlug!;
178
+ const stepKey = `${tutorialSlug}/${stepSlug}` as StepKey;
173
179
  const tutorialSteps = JSON.parse(
174
180
  route.dataset.tutorialSteps ?? "[]",
175
181
  ) as string[];
@@ -200,6 +206,34 @@ const trackBootstrap =
200
206
  };
201
207
  });
202
208
 
209
+ function readCompletionItemIds(selector: string, attr: string) {
210
+ const activeTrack = document.documentElement.dataset.track;
211
+ const ids = Array.from(document.querySelectorAll<HTMLElement>(selector))
212
+ .map((el) => {
213
+ const trackPanel = el.closest<HTMLElement>("[data-track-panel]");
214
+ if (activeTrack && trackPanel?.dataset.trackPanel !== activeTrack) return null;
215
+ return el.dataset[attr];
216
+ })
217
+ .filter((id): id is string => !!id);
218
+ return Array.from(new Set(ids));
219
+ }
220
+
221
+ function recomputeStepCompletion() {
222
+ const completion = deriveStepCompletion(store.get(), {
223
+ quizIds: readCompletionItemIds("[data-quiz-id]", "quizId"),
224
+ checkpointIds: readCompletionItemIds("[data-checkpoint-id]", "checkpointId"),
225
+ });
226
+ if (completion === null) return;
227
+ store.set((s) => {
228
+ if (s.steps[stepKey] === completion) return s;
229
+ return { ...s, steps: { ...s.steps, [stepKey]: completion } };
230
+ });
231
+ }
232
+
233
+ recomputeStepCompletion();
234
+ document.addEventListener("hz:step-item", recomputeStepCompletion);
235
+ store.subscribe(recomputeStepCompletion);
236
+
203
237
  // Watch for tutorial completion: once every step in the embedded
204
238
  // list flips to "complete", record the "completed" event exactly
205
239
  // once. Re-runs on store changes so finishing the last step on a
@@ -0,0 +1,130 @@
1
+ import type { MermaidConfig } from "mermaid";
2
+
3
+ /**
4
+ * Browser-only helpers that derive a Mermaid configuration from the active
5
+ * theme's CSS custom properties. Mermaid bakes colors into the rendered SVG
6
+ * and runs color math (via khroma) on its theme variables, so we cannot hand
7
+ * it raw `var(--token)` references or `oklch()` strings. Instead we read the
8
+ * computed token values and normalize each to a hex/rgb string the renderer
9
+ * can manipulate, then feed Mermaid's `base` theme so diagrams inherit the
10
+ * site palette, fonts, and light/dark mode rather than Mermaid's stock theme.
11
+ */
12
+
13
+ /**
14
+ * Normalize any CSS color string (including `oklch()`) to a `#rrggbb` string.
15
+ * We rasterize one pixel and read the bytes back rather than reading
16
+ * `ctx.fillStyle`, because Chromium re-serializes `oklch()` as `oklch()` and
17
+ * Mermaid's color library (khroma) only understands hex/rgb/hsl. Reading the
18
+ * pixel forces a concrete sRGB value the renderer can manipulate. Returns the
19
+ * fallback when the value is empty or the browser cannot parse it.
20
+ */
21
+ function resolveColor(value: string, fallback: string): string {
22
+ const input = value.trim();
23
+ if (!input) return fallback;
24
+ const ctx = document.createElement("canvas").getContext("2d", {
25
+ willReadFrequently: true,
26
+ });
27
+ if (!ctx) return fallback;
28
+ // Seed with a sentinel; if the input is rejected the pixel stays this value.
29
+ ctx.fillStyle = "#ff00ff";
30
+ ctx.fillRect(0, 0, 1, 1);
31
+ ctx.fillStyle = input;
32
+ if (ctx.fillStyle === "#ff00ff" && input.toLowerCase() !== "#ff00ff") {
33
+ return fallback;
34
+ }
35
+ ctx.fillRect(0, 0, 1, 1);
36
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
37
+ const hex = (n: number) => n.toString(16).padStart(2, "0");
38
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
39
+ }
40
+
41
+ /** Relative luminance (0–1) of a `#rrggbb` color, for dark-mode detection. */
42
+ function luminance(hex: string): number {
43
+ const m = /^#([0-9a-f]{6})$/i.exec(hex);
44
+ if (!m) return 0;
45
+ const int = parseInt(m[1], 16);
46
+ const channel = (c: number) => {
47
+ const s = c / 255;
48
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
49
+ };
50
+ const r = channel((int >> 16) & 0xff);
51
+ const g = channel((int >> 8) & 0xff);
52
+ const b = channel(int & 0xff);
53
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
54
+ }
55
+
56
+ /** Build a Mermaid config from the document's active theme tokens. */
57
+ export function buildMermaidConfig(): MermaidConfig {
58
+ const cs = getComputedStyle(document.documentElement);
59
+ const token = (name: string) => cs.getPropertyValue(name).trim();
60
+ const color = (name: string, fallback: string) => resolveColor(token(name), fallback);
61
+
62
+ const bg = color("--color-bg", "#0a0a0a");
63
+ const surface = color("--color-surface", "#16181d");
64
+ const surface2 = color("--color-surface-2", surface);
65
+ const fg = color("--color-fg", "#f5f5f5");
66
+ const muted = color("--color-muted", "#9ca3af");
67
+ const border = color("--color-border", "#3a3a3a");
68
+ const borderStrong = color("--color-border-strong", border);
69
+ const accent = color("--color-accent", "#8b5cf6");
70
+ const accentFg = color("--color-accent-fg", "#ffffff");
71
+
72
+ const fontFamily = token("--font-sans") || "ui-sans-serif, system-ui, sans-serif";
73
+ const darkMode = luminance(bg) < 0.5;
74
+
75
+ return {
76
+ startOnLoad: false,
77
+ securityLevel: "strict",
78
+ theme: "base",
79
+ fontFamily,
80
+ themeVariables: {
81
+ darkMode,
82
+ background: surface,
83
+ fontFamily,
84
+ // Nodes
85
+ primaryColor: surface2,
86
+ primaryTextColor: fg,
87
+ primaryBorderColor: accent,
88
+ secondaryColor: surface,
89
+ secondaryTextColor: fg,
90
+ secondaryBorderColor: border,
91
+ tertiaryColor: surface,
92
+ tertiaryTextColor: fg,
93
+ tertiaryBorderColor: border,
94
+ mainBkg: surface2,
95
+ nodeBorder: accent,
96
+ nodeTextColor: fg,
97
+ // Edges + general text
98
+ lineColor: borderStrong,
99
+ textColor: fg,
100
+ titleColor: fg,
101
+ edgeLabelBackground: surface,
102
+ // Clusters / subgraphs
103
+ clusterBkg: surface,
104
+ clusterBorder: border,
105
+ // Notes
106
+ noteBkgColor: surface2,
107
+ noteTextColor: fg,
108
+ noteBorderColor: accent,
109
+ // Sequence diagrams
110
+ actorBkg: surface2,
111
+ actorBorder: accent,
112
+ actorTextColor: fg,
113
+ actorLineColor: borderStrong,
114
+ signalColor: fg,
115
+ signalTextColor: fg,
116
+ labelBoxBkgColor: surface2,
117
+ labelBoxBorderColor: border,
118
+ labelTextColor: fg,
119
+ loopTextColor: fg,
120
+ activationBkgColor: accent,
121
+ activationBorderColor: accent,
122
+ // Accent emphasis
123
+ altBackground: surface,
124
+ errorBkgColor: surface2,
125
+ errorTextColor: muted,
126
+ // Keep the accent legible where Mermaid fills with it.
127
+ primaryColorText: accentFg,
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,20 @@
1
+ import type { ProgressState } from "./types";
2
+
3
+ export interface StepCompletionItems {
4
+ quizIds: string[];
5
+ checkpointIds: string[];
6
+ }
7
+
8
+ export type DerivedStepCompletion = "complete" | "incomplete" | null;
9
+
10
+ export function deriveStepCompletion(
11
+ state: ProgressState,
12
+ { quizIds, checkpointIds }: StepCompletionItems,
13
+ ): DerivedStepCompletion {
14
+ if (quizIds.length === 0 && checkpointIds.length === 0) return null;
15
+
16
+ const allQuizzesCorrect = quizIds.every((id) => state.quizzes[id]?.correct === true);
17
+ const allCheckpointsDone = checkpointIds.every((id) => !!state.checkpoints[id]);
18
+
19
+ return allQuizzesCorrect && allCheckpointsDone ? "complete" : "incomplete";
20
+ }
@@ -52,6 +52,7 @@ const { Content } = await render(currentStep);
52
52
 
53
53
  const components = mdxComponents();
54
54
  const hasCheckpoint = currentStep.body?.includes("<Checkpoint") ?? false;
55
+ const hasQuiz = currentStep.body?.includes("<Quiz") ?? false;
55
56
  const nextTutorial = tutorial.data.nextTutorial
56
57
  ? await getTutorialBySlug(tutorial.data.nextTutorial)
57
58
  : undefined;
@@ -76,6 +77,7 @@ const initialContext = buildContext({
76
77
  currentStep={currentStep}
77
78
  currentStepSlug={stepSlug}
78
79
  hasCheckpoint={hasCheckpoint}
80
+ hasQuiz={hasQuiz}
79
81
  siteName={siteName}
80
82
  tagline={tagline}
81
83
  logoUrl={logoUrl}
@@ -1,16 +1,31 @@
1
- /* Mermaid */
2
- .mermaid-wrap {
1
+ /* Mermaid — diagram colors come from JS (lib/mermaid-theme) so the SVG
2
+ * inherits the active palette; this only frames the rendered diagram so it
3
+ * reads as part of the page rather than a floating image. */
4
+ .mermaid-wrap,
5
+ pre.mermaid {
3
6
  margin: 1.25rem 0;
4
7
  padding: 1rem;
5
8
  background: var(--color-surface);
9
+ border: var(--border-default, 1px) solid var(--color-border);
10
+ border-radius: var(--radius-md, 0);
6
11
  display: grid;
7
12
  place-items: center;
8
- border-radius: 0;
9
13
  min-height: var(--mermaid-min-height, 14rem);
14
+ overflow-x: auto;
10
15
  }
11
- .mermaid-wrap :global(svg) { max-width: 100%; height: auto; }
12
- pre.mermaid {
13
- min-height: var(--mermaid-min-height, 14rem);
16
+ /* Before the client script runs, the raw fenced source sits inside
17
+ * pre.mermaid; keep it from flashing as monospace text. */
18
+ pre.mermaid:not([data-processed]) {
19
+ color: transparent;
20
+ }
21
+ .mermaid-wrap :global(svg),
22
+ pre.mermaid svg {
23
+ max-width: 100%;
24
+ height: auto;
25
+ }
26
+ .mermaid-wrap :global(svg) text,
27
+ pre.mermaid svg text {
28
+ font-family: var(--font-sans);
14
29
  }
15
30
  .mermaid-error {
16
31
  padding: 0.75rem;