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 +1 -1
- package/src/components/StepNav.astro +7 -4
- package/src/components/mdx/Checkpoint.tsx +10 -23
- package/src/components/mdx/Mermaid.tsx +2 -1
- package/src/components/mdx/Quiz.tsx +6 -2
- package/src/layouts/BaseLayout.astro +27 -5
- package/src/layouts/TutorialLayout.astro +34 -0
- package/src/lib/mermaid-theme.ts +130 -0
- package/src/lib/progress/stepCompletion.ts +20 -0
- package/src/pages/TutorialStep.astro +2 -0
- package/styles/components/mermaid.css +21 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.
|
|
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 } =
|
|
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 &&
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
pre.mermaid
|
|
13
|
-
|
|
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;
|