handzon-core 0.15.3 → 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.3",
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");
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useLayoutEffect, useState } from "react";
2
2
  import { withBase } from "../../lib/base";
3
+ import { GITHUB_ICON_PATH } from "../../lib/icons";
3
4
 
4
5
  /**
5
6
  * Client-only auth menu. Fetches `/api/auth/session` + `/api/auth/csrf`
@@ -77,9 +78,6 @@ function clearAuthSnapshot() {
77
78
  }
78
79
  }
79
80
 
80
- const GITHUB_ICON_PATH =
81
- "M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-2.02c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.27-5.24-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.03 0 0 .96-.31 3.15 1.17a10.94 10.94 0 0 1 5.76 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.58.23 2.74.11 3.03.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.37-5.25 5.65.41.35.78 1.04.78 2.11v3.13c0 .3.21.66.79.55C20.71 21.39 24 17.08 24 12 24 5.65 18.85.5 12 .5z";
82
-
83
81
  export default function UserMenu() {
84
82
  // `undefined` = not yet loaded; `null` = no auth or signed out;
85
83
  // object = signed in. Seeded from the last-known snapshot so a
@@ -5,9 +5,11 @@ interface Props {
5
5
  q: string;
6
6
  levels: Set<string>;
7
7
  tags: Set<string>;
8
+ favoritesOnly: boolean;
8
9
  onClearQ: () => void;
9
10
  onRemoveLevel: (level: string) => void;
10
11
  onRemoveTag: (tag: string) => void;
12
+ onClearFavorites: () => void;
11
13
  onClearAll: () => void;
12
14
  }
13
15
 
@@ -27,9 +29,11 @@ export default function ActiveFilterChips({
27
29
  q,
28
30
  levels,
29
31
  tags,
32
+ favoritesOnly,
30
33
  onClearQ,
31
34
  onRemoveLevel,
32
35
  onRemoveTag,
36
+ onClearFavorites,
33
37
  onClearAll,
34
38
  }: Props) {
35
39
  function chipKey(e: KeyboardEvent<HTMLButtonElement>, remove: () => void) {
@@ -80,6 +84,18 @@ export default function ActiveFilterChips({
80
84
  <X size={12} aria-hidden="true" />
81
85
  </button>
82
86
  ))}
87
+ {favoritesOnly && (
88
+ <button
89
+ type="button"
90
+ className="active-filter-chip"
91
+ aria-label="Remove favorites filter"
92
+ onClick={onClearFavorites}
93
+ onKeyDown={(e) => chipKey(e, onClearFavorites)}
94
+ >
95
+ <span className="afc-label">favorites</span>
96
+ <X size={12} aria-hidden="true" />
97
+ </button>
98
+ )}
83
99
  <button type="button" className="active-filter-clear" onClick={onClearAll}>
84
100
  Clear all
85
101
  </button>
@@ -1,5 +1,5 @@
1
- import { GraduationCap, Hash, Search } from "lucide-react";
2
- import { useEffect, useState } from "react";
1
+ import { GraduationCap, Hash, Search, Star } from "lucide-react";
2
+ import { useEffect, useRef, useState } from "react";
3
3
  import MultiSelect, { type MultiSelectOption } from "../ui/MultiSelect.tsx";
4
4
  import ActiveFilterChips from "./ActiveFilterChips.tsx";
5
5
  import SortBar from "./SortBar.tsx";
@@ -17,6 +17,7 @@ interface FilterState {
17
17
  q: string;
18
18
  levels: Set<string>;
19
19
  tags: Set<string>;
20
+ favoritesOnly: boolean;
20
21
  }
21
22
 
22
23
  function parseCsv(value: string | null): Set<string> {
@@ -31,7 +32,7 @@ function parseCsv(value: string | null): Set<string> {
31
32
 
32
33
  function readUrlState(): FilterState {
33
34
  if (typeof window === "undefined") {
34
- return { q: "", levels: new Set(), tags: new Set() };
35
+ return { q: "", levels: new Set(), tags: new Set(), favoritesOnly: false };
35
36
  }
36
37
  const url = new URL(window.location.href);
37
38
  const levels = url.searchParams.get("level")
@@ -43,6 +44,7 @@ function readUrlState(): FilterState {
43
44
  q: url.searchParams.get("q") ?? "",
44
45
  levels,
45
46
  tags: parseCsv(url.searchParams.get("tag")),
47
+ favoritesOnly: url.searchParams.get("favorites") === "1",
46
48
  };
47
49
  }
48
50
 
@@ -54,6 +56,8 @@ function writeUrlState(state: FilterState) {
54
56
  else url.searchParams.delete("level");
55
57
  if (state.tags.size > 0) url.searchParams.set("tag", [...state.tags].join(","));
56
58
  else url.searchParams.delete("tag");
59
+ if (state.favoritesOnly) url.searchParams.set("favorites", "1");
60
+ else url.searchParams.delete("favorites");
57
61
  // Always strip the legacy key so an upgraded URL doesn't carry both
58
62
  // shapes. First user interaction "migrates" the link.
59
63
  url.searchParams.delete("difficulty");
@@ -69,7 +73,8 @@ function applyFilters(state: FilterState) {
69
73
  const matchesLevel = state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
70
74
  const cardTags = (card.dataset.tags ?? "").split(",");
71
75
  const matchesTag = state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
72
- const show = matchesQ && matchesLevel && matchesTag;
76
+ const matchesFavorite = !state.favoritesOnly || card.hasAttribute("data-favorited");
77
+ const show = matchesQ && matchesLevel && matchesTag && matchesFavorite;
73
78
  if (show) {
74
79
  card.removeAttribute("data-filter-hidden");
75
80
  visible += 1;
@@ -100,12 +105,22 @@ function applyFilters(state: FilterState) {
100
105
 
101
106
  export default function FilterBar({ difficulties, tags, difficultyCounts, tagCounts }: Props) {
102
107
  const [state, setState] = useState<FilterState>(readUrlState);
108
+ const stateRef = useRef(state);
103
109
 
104
110
  useEffect(() => {
111
+ stateRef.current = state;
105
112
  applyFilters(state);
106
113
  writeUrlState(state);
107
114
  }, [state]);
108
115
 
116
+ useEffect(() => {
117
+ function onFavoritesChanged() {
118
+ applyFilters(stateRef.current);
119
+ }
120
+ window.addEventListener("hz:favorites-changed", onFavoritesChanged);
121
+ return () => window.removeEventListener("hz:favorites-changed", onFavoritesChanged);
122
+ }, []);
123
+
109
124
  function setQ(q: string) {
110
125
  setState((prev) => ({ ...prev, q }));
111
126
  }
@@ -115,6 +130,9 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
115
130
  function setTags(next: Set<string>) {
116
131
  setState((prev) => ({ ...prev, tags: next }));
117
132
  }
133
+ function setFavoritesOnly(favoritesOnly: boolean) {
134
+ setState((prev) => ({ ...prev, favoritesOnly }));
135
+ }
118
136
  function removeLevel(level: string) {
119
137
  setState((prev) => {
120
138
  const next = new Set(prev.levels);
@@ -130,7 +148,7 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
130
148
  });
131
149
  }
132
150
  function clearAll() {
133
- setState({ q: "", levels: new Set(), tags: new Set() });
151
+ setState({ q: "", levels: new Set(), tags: new Set(), favoritesOnly: false });
134
152
  }
135
153
 
136
154
  const levelOpts: MultiSelectOption[] = difficulties.map((d) => ({
@@ -144,7 +162,8 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
144
162
  count: tagCounts[t] ?? 0,
145
163
  }));
146
164
 
147
- const hasActive = state.q.length > 0 || state.levels.size > 0 || state.tags.size > 0;
165
+ const hasActive =
166
+ state.q.length > 0 || state.levels.size > 0 || state.tags.size > 0 || state.favoritesOnly;
148
167
 
149
168
  return (
150
169
  <div className="filterbar">
@@ -179,6 +198,16 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
179
198
  triggerIcon={<Hash size={14} aria-hidden="true" />}
180
199
  />
181
200
 
201
+ <button
202
+ type="button"
203
+ className="favorites-filter"
204
+ aria-pressed={state.favoritesOnly}
205
+ onClick={() => setFavoritesOnly(!state.favoritesOnly)}
206
+ >
207
+ <Star size={14} aria-hidden="true" />
208
+ Favorites
209
+ </button>
210
+
182
211
  <span className="fb-divider" aria-hidden="true" />
183
212
  <div className="fb-sort-slot">
184
213
  <SortBar />
@@ -190,9 +219,11 @@ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCou
190
219
  q={state.q}
191
220
  levels={state.levels}
192
221
  tags={state.tags}
222
+ favoritesOnly={state.favoritesOnly}
193
223
  onClearQ={() => setQ("")}
194
224
  onRemoveLevel={removeLevel}
195
225
  onRemoveTag={removeTag}
226
+ onClearFavorites={() => setFavoritesOnly(false)}
196
227
  onClearAll={clearAll}
197
228
  />
198
229
  )}
@@ -37,6 +37,30 @@ const defaultCover = getDefaultCoverMeta({
37
37
  data-card-slug={slug}
38
38
  data-popularity="0"
39
39
  >
40
+ <button
41
+ type="button"
42
+ class="card-fav"
43
+ data-fav-slug={slug}
44
+ aria-pressed="false"
45
+ aria-label={`Add ${data.title} to favorites`}
46
+ >
47
+ <svg
48
+ viewBox="0 0 24 24"
49
+ width="18"
50
+ height="18"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ stroke-width="2"
54
+ stroke-linecap="round"
55
+ stroke-linejoin="round"
56
+ aria-hidden="true"
57
+ >
58
+ <path
59
+ d="M11.48 3.5a.59.59 0 0 1 1.04 0l2.38 4.82a.59.59 0 0 0 .44.32l5.32.77a.59.59 0 0 1 .33 1l-3.85 3.75a.59.59 0 0 0-.17.52l.91 5.3a.59.59 0 0 1-.86.62l-4.76-2.5a.59.59 0 0 0-.55 0l-4.76 2.5a.59.59 0 0 1-.86-.62l.91-5.3a.59.59 0 0 0-.17-.52L3.01 10.4a.59.59 0 0 1 .33-1l5.32-.77a.59.59 0 0 0 .44-.32l2.38-4.82Z"
60
+ />
61
+ </svg>
62
+ <span class="sr-only">Favorite</span>
63
+ </button>
40
64
  <a href={withBase(`/${slug}`)} class="card-link">
41
65
  <div class="card-media">
42
66
  {
@@ -151,6 +175,7 @@ const defaultCover = getDefaultCoverMeta({
151
175
  border: 1px solid var(--color-border);
152
176
  background: var(--color-bg);
153
177
  display: flex;
178
+ position: relative;
154
179
  transition:
155
180
  border-color 0.12s ease,
156
181
  transform 0.12s ease;
@@ -166,6 +191,36 @@ const defaultCover = getDefaultCoverMeta({
166
191
  flex-direction: column;
167
192
  width: 100%;
168
193
  }
194
+ .card-fav {
195
+ position: absolute;
196
+ top: 0.35rem;
197
+ right: 0.35rem;
198
+ z-index: 5;
199
+ display: inline-grid;
200
+ place-items: center;
201
+ width: 2rem;
202
+ height: 2rem;
203
+ padding: 0;
204
+ border: 0;
205
+ background: transparent;
206
+ color: var(--color-muted);
207
+ cursor: pointer;
208
+ transition:
209
+ color 0.12s ease,
210
+ transform 0.12s ease;
211
+ }
212
+ .card-fav:hover,
213
+ .card-fav:focus-visible {
214
+ color: var(--color-fg);
215
+ outline: none;
216
+ transform: scale(1.04);
217
+ }
218
+ .card-fav[aria-pressed="true"] {
219
+ color: #fff;
220
+ }
221
+ .card-fav[aria-pressed="true"] svg {
222
+ fill: currentColor;
223
+ }
169
224
  .card-media {
170
225
  aspect-ratio: 16 / 9;
171
226
  border-bottom: 1px solid var(--color-border);
@@ -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,3 @@
1
+ import { siGithub } from "simple-icons";
2
+
3
+ export const GITHUB_ICON_PATH = siGithub.path;
@@ -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
+ }
@@ -35,6 +35,11 @@ export type ProgressState = {
35
35
  steps: Record<StepKey, "incomplete" | "complete">;
36
36
  quizzes: Record<string, { chosen: number[]; correct: boolean; ts: number }>;
37
37
  checkpoints: Record<string, { ts: number }>;
38
+ /**
39
+ * Per-learner favorite tutorials. Keyed by tutorial slug; the value is
40
+ * the timestamp when the learner last favorited it.
41
+ */
42
+ favorites: Record<string, number>;
38
43
  /**
39
44
  * Latest verification verdict per checkpoint id. `pass: true`
40
45
  * entries hang around as evidence; the Family D UI only renders
@@ -73,6 +78,7 @@ export const emptyState = (): ProgressState => ({
73
78
  steps: {},
74
79
  quizzes: {},
75
80
  checkpoints: {},
81
+ favorites: {},
76
82
  verificationFeedback: {},
77
83
  prefs: {},
78
84
  lastVisited: {},
@@ -16,7 +16,9 @@ interface ProgressApi {
16
16
  setLastVisited: (tutorial: string, step: string) => void;
17
17
  markTutorialStarted: (tutorial: string) => void;
18
18
  markTutorialCompleted: (tutorial: string) => void;
19
+ toggleFavorite: (tutorial: string) => void;
19
20
  isStepComplete: (tutorial: string, step: string) => boolean;
21
+ isFavorite: (tutorial: string) => boolean;
20
22
  }
21
23
 
22
24
  /**
@@ -100,6 +102,16 @@ export function useProgress(): ProgressApi {
100
102
  },
101
103
  };
102
104
  }),
105
+ toggleFavorite: (tutorial: string) =>
106
+ store.set((s) => {
107
+ const nextFavorites = { ...s.favorites };
108
+ if (nextFavorites[tutorial]) {
109
+ delete nextFavorites[tutorial];
110
+ } else {
111
+ nextFavorites[tutorial] = Date.now();
112
+ }
113
+ return { ...s, favorites: nextFavorites };
114
+ }),
103
115
  };
104
116
  }, [store]);
105
117
 
@@ -108,6 +120,7 @@ export function useProgress(): ProgressApi {
108
120
  ...actions,
109
121
  isStepComplete: (tutorial, step) =>
110
122
  state.steps[`${tutorial}/${step}` as StepKey] === "complete",
123
+ isFavorite: (tutorial) => !!state.favorites[tutorial],
111
124
  };
112
125
  }
113
126
 
@@ -168,6 +168,64 @@ for (const t of tutorials) {
168
168
  getStore().subscribe(refresh);
169
169
  </script>
170
170
 
171
+ <script>
172
+ // Hydrate per-card favorites from the same local progress store used
173
+ // by steps and preferences. Cards expose `data-favorited` so the
174
+ // FilterBar can compose favorites with search, facets, and pagination.
175
+ import { getStore } from "../lib/progress/local.ts";
176
+ const store = getStore();
177
+ let lastFavoritesSignature: string | null = null;
178
+
179
+ function favoriteSignature(favorites: Record<string, number>): string {
180
+ return Object.keys(favorites).sort().join("\0");
181
+ }
182
+
183
+ function paintFavorites() {
184
+ const favorites = store.get().favorites ?? {};
185
+ const favoriteSlugs = new Set(Object.keys(favorites));
186
+ const signature = favoriteSignature(favorites);
187
+ document.querySelectorAll<HTMLButtonElement>("[data-fav-slug]").forEach((button) => {
188
+ const slug = button.dataset.favSlug!;
189
+ const card = button.closest<HTMLElement>("[data-card-slug]");
190
+ const title = card?.querySelector("h3")?.textContent?.trim() ?? slug;
191
+ const isFavorite = favoriteSlugs.has(slug);
192
+ button.setAttribute("aria-pressed", String(isFavorite));
193
+ button.setAttribute(
194
+ "aria-label",
195
+ isFavorite ? `Remove ${title} from favorites` : `Add ${title} to favorites`,
196
+ );
197
+ if (isFavorite) {
198
+ card?.setAttribute("data-favorited", "");
199
+ } else {
200
+ card?.removeAttribute("data-favorited");
201
+ }
202
+ });
203
+ if (signature !== lastFavoritesSignature) {
204
+ lastFavoritesSignature = signature;
205
+ window.dispatchEvent(new CustomEvent("hz:favorites-changed"));
206
+ }
207
+ }
208
+
209
+ document.querySelectorAll<HTMLButtonElement>("[data-fav-slug]").forEach((button) => {
210
+ button.addEventListener("click", () => {
211
+ const slug = button.dataset.favSlug;
212
+ if (!slug) return;
213
+ store.set((state) => {
214
+ const favorites = { ...state.favorites };
215
+ if (favorites[slug]) {
216
+ delete favorites[slug];
217
+ } else {
218
+ favorites[slug] = Date.now();
219
+ }
220
+ return { ...state, favorites };
221
+ });
222
+ });
223
+ });
224
+
225
+ paintFavorites();
226
+ store.subscribe(paintFavorites);
227
+ </script>
228
+
171
229
  <script>
172
230
  // Hydrate cross-learner popularity numbers and the `data-popularity`
173
231
  // attribute used by the FilterBar's "Popular" sort. Single fetch on
@@ -217,22 +275,36 @@ for (const t of tutorials) {
217
275
  </script>
218
276
 
219
277
  <script>
220
- // Popularity sort: re-orders cards in place by writing CSS `order`
221
- // from `data-popularity`. Composes with the existing filter/page
222
- // hide attributes because order is a flex/grid layout concern, not
223
- // a visibility concern.
278
+ // Home-page ordering: tutorials this learner already started come
279
+ // first. Within that group, either keep the curated server order or
280
+ // apply the selected popularity sort.
281
+ import { getStore } from "../lib/progress/local.ts";
282
+ const store = getStore();
283
+
284
+ function hasStarted(card: HTMLElement): boolean {
285
+ const slug = card.dataset.cardSlug;
286
+ if (!slug) return false;
287
+ return !!store.get().tutorials[slug]?.started;
288
+ }
289
+
224
290
  function applySort(mode: string) {
225
- const cards = document.querySelectorAll<HTMLElement>("[data-card-slug]");
291
+ const cards = Array.from(document.querySelectorAll<HTMLElement>("[data-card-slug]"));
292
+ const originalOrder = new Map(cards.map((card, idx) => [card, idx]));
226
293
  if (mode === "popular") {
227
- const sorted = Array.from(cards).sort((a, b) => {
228
- return Number(b.dataset.popularity ?? 0) - Number(a.dataset.popularity ?? 0);
294
+ const sorted = cards.sort((a, b) => {
295
+ const startedDelta = Number(hasStarted(b)) - Number(hasStarted(a));
296
+ if (startedDelta !== 0) return startedDelta;
297
+ const popularityDelta =
298
+ Number(b.dataset.popularity ?? 0) - Number(a.dataset.popularity ?? 0);
299
+ if (popularityDelta !== 0) return popularityDelta;
300
+ return (originalOrder.get(a) ?? 0) - (originalOrder.get(b) ?? 0);
229
301
  });
230
302
  sorted.forEach((card, idx) => {
231
303
  card.style.order = String(idx);
232
304
  });
233
305
  } else {
234
- cards.forEach((card) => {
235
- card.style.removeProperty("order");
306
+ cards.forEach((card, idx) => {
307
+ card.style.order = String(hasStarted(card) ? idx : idx + cards.length);
236
308
  });
237
309
  }
238
310
  }
@@ -245,6 +317,7 @@ for (const t of tutorials) {
245
317
  applySort((e as CustomEvent<{ sort: string }>).detail.sort);
246
318
  });
247
319
  window.addEventListener("hz:stats-loaded", syncFromUrl);
320
+ store.subscribe(syncFromUrl);
248
321
  </script>
249
322
  </BaseLayout>
250
323
 
@@ -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}
@@ -65,6 +65,35 @@
65
65
  flex-shrink: 0;
66
66
  }
67
67
 
68
+ .favorites-filter {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: 0.45rem;
72
+ min-height: 2.5rem;
73
+ padding: 0.4rem 0.65rem;
74
+ background: transparent;
75
+ border: 1px solid var(--color-border);
76
+ color: var(--color-fg);
77
+ font-family: var(--font-mono);
78
+ font-size: 0.78em;
79
+ cursor: pointer;
80
+ transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
81
+ }
82
+ .favorites-filter:hover,
83
+ .favorites-filter:focus-visible {
84
+ border-color: var(--color-accent);
85
+ color: var(--color-accent);
86
+ outline: none;
87
+ }
88
+ .favorites-filter[aria-pressed="true"] {
89
+ border-color: var(--color-accent);
90
+ color: var(--color-accent);
91
+ background: color-mix(in oklab, var(--color-accent) 8%, transparent);
92
+ }
93
+ .favorites-filter[aria-pressed="true"] svg {
94
+ fill: currentColor;
95
+ }
96
+
68
97
  /* On narrow viewports the toolbar wraps; sort drops to its own line.
69
98
  * Keep the divider visible only when the row hasn't wrapped — when it
70
99
  * has, the layout already reads as distinct rows. Cheap heuristic:
@@ -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;