handzon-core 0.6.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.
Files changed (89) hide show
  1. package/package.json +74 -0
  2. package/src/collections.ts +150 -0
  3. package/src/components/Footer.astro +85 -0
  4. package/src/components/Navbar.astro +74 -0
  5. package/src/components/Progress.tsx +36 -0
  6. package/src/components/Sidebar.astro +162 -0
  7. package/src/components/StepNav.astro +107 -0
  8. package/src/components/ai/ByokSetup.tsx +90 -0
  9. package/src/components/ai/ChatButton.tsx +30 -0
  10. package/src/components/ai/ChatPanel.tsx +244 -0
  11. package/src/components/auth/SignInButton.astro +41 -0
  12. package/src/components/auth/UserMenu.astro +79 -0
  13. package/src/components/auth/UserMenu.tsx +136 -0
  14. package/src/components/home/FilterBar.tsx +152 -0
  15. package/src/components/home/Hero.astro +60 -0
  16. package/src/components/home/Pagination.tsx +89 -0
  17. package/src/components/home/ResumeRail.tsx +50 -0
  18. package/src/components/home/TutorialCard.astro +185 -0
  19. package/src/components/mdx/Callout.astro +77 -0
  20. package/src/components/mdx/Checkpoint.astro +14 -0
  21. package/src/components/mdx/Checkpoint.tsx +49 -0
  22. package/src/components/mdx/Diff.astro +6 -0
  23. package/src/components/mdx/Diff.tsx +100 -0
  24. package/src/components/mdx/Download.astro +37 -0
  25. package/src/components/mdx/Embed.astro +56 -0
  26. package/src/components/mdx/File.astro +28 -0
  27. package/src/components/mdx/FileTree.astro +6 -0
  28. package/src/components/mdx/FileTree.tsx +71 -0
  29. package/src/components/mdx/Hint.astro +51 -0
  30. package/src/components/mdx/Mermaid.astro +6 -0
  31. package/src/components/mdx/Mermaid.tsx +47 -0
  32. package/src/components/mdx/Playground.astro +6 -0
  33. package/src/components/mdx/Playground.tsx +34 -0
  34. package/src/components/mdx/Quiz.astro +6 -0
  35. package/src/components/mdx/Quiz.tsx +102 -0
  36. package/src/components/mdx/Recap.astro +65 -0
  37. package/src/components/mdx/Reveal.astro +7 -0
  38. package/src/components/mdx/Reveal.tsx +25 -0
  39. package/src/components/mdx/Step.astro +12 -0
  40. package/src/components/mdx/Steps.astro +40 -0
  41. package/src/components/mdx/Tab.astro +22 -0
  42. package/src/components/mdx/Tabs.astro +67 -0
  43. package/src/components/mdx/Terminal.astro +6 -0
  44. package/src/components/mdx/Terminal.tsx +47 -0
  45. package/src/index.ts +55 -0
  46. package/src/layouts/BaseLayout.astro +112 -0
  47. package/src/layouts/TutorialLayout.astro +218 -0
  48. package/src/lib/ai/client.ts +92 -0
  49. package/src/lib/ai/context.ts +97 -0
  50. package/src/lib/content.ts +73 -0
  51. package/src/lib/mdx-components.ts +47 -0
  52. package/src/lib/progress/local.ts +89 -0
  53. package/src/lib/progress/remote.ts +199 -0
  54. package/src/lib/progress/types.ts +63 -0
  55. package/src/lib/progress/useProgress.ts +117 -0
  56. package/src/lib/rehype-mermaid-passthrough.ts +31 -0
  57. package/src/pages/Home.astro +408 -0
  58. package/src/pages/TutorialLanding.astro +324 -0
  59. package/src/pages/TutorialStep.astro +67 -0
  60. package/src/pages/paths.ts +36 -0
  61. package/src/server/auth/config.ts +102 -0
  62. package/src/server/auth/schema.ts +66 -0
  63. package/src/server/auth/session.ts +27 -0
  64. package/src/server/auth.ts +127 -0
  65. package/src/server/db/client.ts +14 -0
  66. package/src/server/db/migrate.ts +29 -0
  67. package/src/server/db/schema.ts +65 -0
  68. package/src/server/handlers/healthz.ts +6 -0
  69. package/src/server/handlers/progress.ts +90 -0
  70. package/src/server/handlers/tutorialStats.ts +67 -0
  71. package/src/server/http.ts +33 -0
  72. package/src/types/ai.ts +17 -0
  73. package/styles/base.css +127 -0
  74. package/styles/components/a11y.css +12 -0
  75. package/styles/components/byok.css +50 -0
  76. package/styles/components/chat.css +304 -0
  77. package/styles/components/checkpoint.css +49 -0
  78. package/styles/components/diff.css +44 -0
  79. package/styles/components/expressive-code.css +61 -0
  80. package/styles/components/filetree.css +68 -0
  81. package/styles/components/mermaid.css +19 -0
  82. package/styles/components/modal.css +25 -0
  83. package/styles/components/progress.css +19 -0
  84. package/styles/components/quiz.css +101 -0
  85. package/styles/components/reveal.css +25 -0
  86. package/styles/components/tabs.css +60 -0
  87. package/styles/components/terminal.css +55 -0
  88. package/styles/components.css +28 -0
  89. package/styles/global.css +15 -0
@@ -0,0 +1,100 @@
1
+ import { diffLines } from "diff";
2
+ import { useMemo, useState } from "react";
3
+
4
+ interface Props {
5
+ before: string;
6
+ after: string;
7
+ lang?: string;
8
+ layout?: "side-by-side" | "unified";
9
+ beforeLabel?: string;
10
+ afterLabel?: string;
11
+ }
12
+
13
+ export default function Diff({
14
+ before,
15
+ after,
16
+ layout: initialLayout = "side-by-side",
17
+ beforeLabel = "Before",
18
+ afterLabel = "After",
19
+ }: Props) {
20
+ const [layout, setLayout] = useState(initialLayout);
21
+ const parts = useMemo(() => diffLines(before, after), [before, after]);
22
+
23
+ if (layout === "unified") {
24
+ return (
25
+ <div className="diff">
26
+ <div className="diff-bar">
27
+ <button type="button" onClick={() => setLayout("side-by-side")}>
28
+ Side-by-side
29
+ </button>
30
+ </div>
31
+ <pre className="diff-body">
32
+ {parts.map((part, i) => (
33
+ <span
34
+ key={i}
35
+ className={part.added ? "diff-add" : part.removed ? "diff-del" : "diff-ctx"}
36
+ >
37
+ {part.added ? "+ " : part.removed ? "- " : " "}
38
+ {part.value}
39
+ </span>
40
+ ))}
41
+ </pre>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ const leftLines: string[] = [];
47
+ const rightLines: string[] = [];
48
+ for (const p of parts) {
49
+ const lines = p.value.split("\n");
50
+ if (lines[lines.length - 1] === "") lines.pop();
51
+ if (p.added) {
52
+ for (const l of lines) {
53
+ leftLines.push("");
54
+ rightLines.push(`+ ${l}`);
55
+ }
56
+ } else if (p.removed) {
57
+ for (const l of lines) {
58
+ leftLines.push(`- ${l}`);
59
+ rightLines.push("");
60
+ }
61
+ } else {
62
+ for (const l of lines) {
63
+ leftLines.push(` ${l}`);
64
+ rightLines.push(` ${l}`);
65
+ }
66
+ }
67
+ }
68
+
69
+ return (
70
+ <div className="diff">
71
+ <div className="diff-bar">
72
+ <button type="button" onClick={() => setLayout("unified")}>
73
+ Unified
74
+ </button>
75
+ </div>
76
+ <div className="diff-grid">
77
+ <div className="diff-col">
78
+ <div className="diff-label">{beforeLabel}</div>
79
+ <pre>
80
+ {leftLines.map((l, i) => (
81
+ <div key={i} className={l.startsWith("- ") ? "diff-del" : "diff-ctx"}>
82
+ {l || " "}
83
+ </div>
84
+ ))}
85
+ </pre>
86
+ </div>
87
+ <div className="diff-col">
88
+ <div className="diff-label">{afterLabel}</div>
89
+ <pre>
90
+ {rightLines.map((l, i) => (
91
+ <div key={i} className={l.startsWith("+ ") ? "diff-add" : "diff-ctx"}>
92
+ {l || " "}
93
+ </div>
94
+ ))}
95
+ </pre>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,37 @@
1
+ ---
2
+ interface Props {
3
+ href: string;
4
+ label?: string;
5
+ size?: string;
6
+ }
7
+ const { href, label, size } = Astro.props;
8
+ const filename = label ?? href.split("/").pop() ?? "download";
9
+ ---
10
+ <a class="download" href={href} download>
11
+ <span class="download-icon" aria-hidden="true">⬇</span>
12
+ <span class="download-name">{filename}</span>
13
+ {size && <span class="download-size">{size}</span>}
14
+ </a>
15
+
16
+ <style>
17
+ .download {
18
+ display: inline-flex;
19
+ align-items: center;
20
+ gap: 0.5rem;
21
+ padding: 0.5rem 0.9rem;
22
+ border: var(--border-default, 2px) solid var(--color-fg);
23
+ background: var(--color-bg);
24
+ color: var(--color-fg);
25
+ font-family: var(--font-mono);
26
+ font-size: 0.9em;
27
+ text-decoration: none;
28
+ transition: transform 0.08s ease, box-shadow 0.08s ease;
29
+ }
30
+ .download:hover {
31
+ transform: translate(-2px, -2px);
32
+ box-shadow: var(--shadow-raised);
33
+ color: var(--color-fg);
34
+ }
35
+ .download-icon { color: var(--color-accent); }
36
+ .download-size { color: var(--color-muted); font-size: 0.85em; margin-left: 0.5rem; }
37
+ </style>
@@ -0,0 +1,56 @@
1
+ ---
2
+ interface Props {
3
+ src: string;
4
+ title?: string;
5
+ aspect?: string;
6
+ type?: "iframe" | "video";
7
+ }
8
+ const { src, title = "Embedded content", aspect = "16/9", type = "iframe" } = Astro.props;
9
+
10
+ function normalize(url: string): string {
11
+ try {
12
+ const u = new URL(url, "https://example.com");
13
+ if (u.hostname.endsWith("youtube.com")) {
14
+ u.hostname = "www.youtube-nocookie.com";
15
+ return u.toString();
16
+ }
17
+ return url;
18
+ } catch {
19
+ return url;
20
+ }
21
+ }
22
+
23
+ const finalSrc = type === "iframe" ? normalize(src) : src;
24
+ ---
25
+ <div class="embed" style={`aspect-ratio: ${aspect};`}>
26
+ {type === "iframe" ? (
27
+ <iframe
28
+ src={finalSrc}
29
+ title={title}
30
+ loading="lazy"
31
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
32
+ referrerpolicy="strict-origin-when-cross-origin"
33
+ allowfullscreen
34
+ />
35
+ ) : (
36
+ <video controls preload="metadata">
37
+ <source src={finalSrc} />
38
+ </video>
39
+ )}
40
+ </div>
41
+
42
+ <style>
43
+ .embed {
44
+ margin: 1.5rem 0;
45
+ border: var(--border-default, 2px) solid var(--color-border);
46
+ background: var(--color-surface);
47
+ overflow: hidden;
48
+ }
49
+ .embed iframe,
50
+ .embed video {
51
+ width: 100%;
52
+ height: 100%;
53
+ border: 0;
54
+ display: block;
55
+ }
56
+ </style>
@@ -0,0 +1,28 @@
1
+ ---
2
+ interface Props {
3
+ name: string;
4
+ }
5
+ const { name } = Astro.props;
6
+ ---
7
+ <div class="file-label">
8
+ <span class="file-icon" aria-hidden="true">▸</span>
9
+ <span class="file-name">{name}</span>
10
+ </div>
11
+ <div class="file-body"><slot /></div>
12
+
13
+ <style>
14
+ .file-label {
15
+ display: inline-flex;
16
+ align-items: center;
17
+ gap: 0.4rem;
18
+ background: var(--color-surface);
19
+ border: var(--border-default, 2px) solid var(--color-border);
20
+ border-bottom: none;
21
+ padding: 0.3rem 0.6rem;
22
+ font-family: var(--font-mono);
23
+ font-size: 0.85em;
24
+ color: var(--color-muted);
25
+ }
26
+ .file-icon { color: var(--color-accent); }
27
+ .file-body :global(pre) { margin-top: 0 !important; }
28
+ </style>
@@ -0,0 +1,6 @@
1
+ ---
2
+ import FileTreeIsland from "./FileTree.tsx";
3
+ type Props = Parameters<typeof FileTreeIsland>[0];
4
+ const props = Astro.props as Props;
5
+ ---
6
+ <FileTreeIsland client:visible {...props} />
@@ -0,0 +1,71 @@
1
+ import { File as FileIcon, Folder, FolderOpen } from "lucide-react";
2
+ import { useState } from "react";
3
+
4
+ type Node = { name: string; children?: Node[] };
5
+
6
+ interface Props {
7
+ paths?: string[];
8
+ tree?: Node[];
9
+ }
10
+
11
+ function pathsToTree(paths: string[]): Node[] {
12
+ const root: Node = { name: "", children: [] };
13
+ for (const path of paths) {
14
+ const parts = path.split("/").filter(Boolean);
15
+ let cursor = root;
16
+ for (const part of parts) {
17
+ cursor.children ??= [];
18
+ let child = cursor.children.find((c) => c.name === part);
19
+ if (!child) {
20
+ child = { name: part };
21
+ cursor.children.push(child);
22
+ }
23
+ cursor = child;
24
+ }
25
+ }
26
+ return root.children ?? [];
27
+ }
28
+
29
+ function isFolder(node: Node): boolean {
30
+ return Array.isArray(node.children) && node.children.length > 0;
31
+ }
32
+
33
+ function NodeRow({ node }: { node: Node }) {
34
+ const [open, setOpen] = useState(true);
35
+ const folder = isFolder(node);
36
+ return (
37
+ <li className="ft-row">
38
+ <button
39
+ type="button"
40
+ className="ft-btn"
41
+ onClick={() => folder && setOpen((o) => !o)}
42
+ aria-expanded={folder ? open : undefined}
43
+ >
44
+ {folder ? open ? <FolderOpen size={13} /> : <Folder size={13} /> : <FileIcon size={13} />}
45
+ <span className="ft-name">{node.name}</span>
46
+ </button>
47
+ {folder && open && node.children && (
48
+ <ul className="ft-list">
49
+ {node.children.map((child) => (
50
+ // Indentation comes from .ft-list CSS (padding-left + guide
51
+ // line), not an inline marginLeft. Compounding the inline
52
+ // offset with the browser-default UL padding produced the
53
+ // huge stair-step in the screenshot.
54
+ <NodeRow key={child.name} node={child} />
55
+ ))}
56
+ </ul>
57
+ )}
58
+ </li>
59
+ );
60
+ }
61
+
62
+ export default function FileTree({ paths, tree }: Props) {
63
+ const resolved = tree ?? (paths ? pathsToTree(paths) : []);
64
+ return (
65
+ <ul className="ft-root">
66
+ {resolved.map((node) => (
67
+ <NodeRow key={node.name} node={node} />
68
+ ))}
69
+ </ul>
70
+ );
71
+ }
@@ -0,0 +1,51 @@
1
+ ---
2
+ interface Props {
3
+ title?: string;
4
+ }
5
+ const { title = "Show hint" } = Astro.props;
6
+ ---
7
+
8
+ <details class="hint">
9
+ <summary>
10
+ <svg class="hint-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
11
+ <path d="M9 18h6"></path>
12
+ <path d="M10 22h4"></path>
13
+ <path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"></path>
14
+ </svg>
15
+ <span>{title}</span>
16
+ </summary>
17
+ <div class="hint-body"><slot /></div>
18
+ </details>
19
+
20
+ <style>
21
+ .hint {
22
+ margin: 1rem 0;
23
+ background: var(--color-surface);
24
+ border: var(--border-default) solid var(--color-border);
25
+ border-radius: 0;
26
+ overflow: hidden;
27
+ }
28
+ .hint > summary {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 0.5rem;
32
+ padding: 0.65rem 0.9rem;
33
+ cursor: pointer;
34
+ font-weight: 500;
35
+ color: var(--color-accent);
36
+ list-style: none;
37
+ user-select: none;
38
+ }
39
+ .hint > summary::-webkit-details-marker { display: none; }
40
+ .hint-icon {
41
+ color: var(--color-accent);
42
+ transition: transform 0.15s ease;
43
+ flex-shrink: 0;
44
+ }
45
+ .hint[open] .hint-icon { transform: rotate(15deg); }
46
+ .hint-body {
47
+ padding: 0.6rem 0.85rem;
48
+ background: var(--color-bg);
49
+ margin: 0 0.5rem 0.5rem;
50
+ }
51
+ </style>
@@ -0,0 +1,6 @@
1
+ ---
2
+ import MermaidIsland from "./Mermaid.tsx";
3
+ type Props = Parameters<typeof MermaidIsland>[0];
4
+ const props = Astro.props as Props;
5
+ ---
6
+ <MermaidIsland client:visible {...props} />
@@ -0,0 +1,47 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ interface Props {
4
+ chart: string;
5
+ id?: string;
6
+ }
7
+
8
+ /**
9
+ * Interactive mermaid island. The default authoring path is fenced
10
+ * ```mermaid blocks, which rehype-mermaid renders at build time. Use
11
+ * this island only when the diagram needs to respond to runtime state.
12
+ */
13
+ export default function Mermaid({ chart, id }: Props) {
14
+ const ref = useRef<HTMLDivElement>(null);
15
+ const [error, setError] = useState<string | null>(null);
16
+
17
+ useEffect(() => {
18
+ let cancelled = false;
19
+ (async () => {
20
+ try {
21
+ const mermaid = (await import("mermaid")).default;
22
+ mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
23
+ const { svg } = await mermaid.render(
24
+ id ?? `mermaid-${Math.random().toString(36).slice(2)}`,
25
+ chart,
26
+ );
27
+ if (!cancelled && ref.current) ref.current.innerHTML = svg;
28
+ } catch (e) {
29
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e));
30
+ }
31
+ })();
32
+ return () => {
33
+ cancelled = true;
34
+ };
35
+ }, [chart, id]);
36
+
37
+ if (error) {
38
+ return (
39
+ <pre className="mermaid-error">
40
+ Mermaid render failed: {error}
41
+ {"\n"}
42
+ {chart}
43
+ </pre>
44
+ );
45
+ }
46
+ return <div ref={ref} className="mermaid-wrap" />;
47
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import PlaygroundIsland from "./Playground.tsx";
3
+ type Props = Parameters<typeof PlaygroundIsland>[0];
4
+ const props = Astro.props as Props;
5
+ ---
6
+ <PlaygroundIsland client:load {...props} />
@@ -0,0 +1,34 @@
1
+ import { Sandpack, type SandpackPredefinedTemplate } from "@codesandbox/sandpack-react";
2
+
3
+ interface Props {
4
+ template?: SandpackPredefinedTemplate;
5
+ files?: Record<string, string | { code: string; hidden?: boolean }>;
6
+ dependencies?: Record<string, string>;
7
+ height?: number;
8
+ showConsole?: boolean;
9
+ }
10
+
11
+ export default function Playground({
12
+ template = "react-ts",
13
+ files,
14
+ dependencies,
15
+ height = 480,
16
+ showConsole = true,
17
+ }: Props) {
18
+ return (
19
+ <div className="playground" style={{ height }}>
20
+ <Sandpack
21
+ template={template}
22
+ files={files}
23
+ customSetup={dependencies ? { dependencies } : undefined}
24
+ theme="dark"
25
+ options={{
26
+ showConsole,
27
+ showConsoleButton: true,
28
+ editorHeight: height,
29
+ editorWidthPercentage: 50,
30
+ }}
31
+ />
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import QuizIsland from "./Quiz.tsx";
3
+ type Props = Parameters<typeof QuizIsland>[0];
4
+ const props = Astro.props as Props;
5
+ ---
6
+ <QuizIsland client:load {...props} />
@@ -0,0 +1,102 @@
1
+ import { Check, X } from "lucide-react";
2
+ import { useId, useState } from "react";
3
+ import { useProgress } from "../../lib/progress/useProgress";
4
+
5
+ interface Props {
6
+ question: string;
7
+ options: string[];
8
+ answer: number | number[];
9
+ explanation?: string;
10
+ id?: string;
11
+ multi?: boolean;
12
+ }
13
+
14
+ export default function Quiz({ question, options, answer, explanation, id, multi }: Props) {
15
+ const reactId = useId();
16
+ const questionId = id ?? `quiz:${reactId}:${question.slice(0, 40)}`;
17
+ const { state, recordQuiz } = useProgress();
18
+
19
+ const previous = state.quizzes[questionId];
20
+ const [chosen, setChosen] = useState<number[]>(previous?.chosen ?? []);
21
+ const [submitted, setSubmitted] = useState<boolean>(!!previous);
22
+
23
+ const correctSet = new Set(Array.isArray(answer) ? answer : [answer]);
24
+ const expectMulti = multi ?? Array.isArray(answer);
25
+
26
+ function toggle(i: number) {
27
+ if (submitted) return;
28
+ if (expectMulti) {
29
+ setChosen((c) => (c.includes(i) ? c.filter((x) => x !== i) : [...c, i]));
30
+ } else {
31
+ setChosen([i]);
32
+ }
33
+ }
34
+
35
+ function submit() {
36
+ const sorted = [...chosen].sort((a, b) => a - b);
37
+ const correct = sorted.length === correctSet.size && sorted.every((v) => correctSet.has(v));
38
+ recordQuiz(questionId, sorted, correct);
39
+ setSubmitted(true);
40
+ }
41
+
42
+ function reset() {
43
+ setChosen([]);
44
+ setSubmitted(false);
45
+ }
46
+
47
+ return (
48
+ <fieldset className="quiz" disabled={submitted}>
49
+ <legend className="quiz-q">{question}</legend>
50
+ <div className="quiz-options">
51
+ {options.map((opt, i) => {
52
+ const isChosen = chosen.includes(i);
53
+ const isCorrect = submitted && correctSet.has(i);
54
+ const wasWrong = submitted && isChosen && !correctSet.has(i);
55
+ return (
56
+ <label
57
+ key={i}
58
+ className={[
59
+ "quiz-opt",
60
+ isChosen ? "is-chosen" : "",
61
+ isCorrect ? "is-correct" : "",
62
+ wasWrong ? "is-wrong" : "",
63
+ ].join(" ")}
64
+ >
65
+ <input
66
+ type={expectMulti ? "checkbox" : "radio"}
67
+ name={questionId}
68
+ checked={isChosen}
69
+ onChange={() => toggle(i)}
70
+ />
71
+ <span
72
+ className={`quiz-opt-toggle quiz-opt-toggle--${expectMulti ? "multi" : "single"}`}
73
+ aria-hidden="true"
74
+ >
75
+ {isCorrect ? <Check size={12} /> : wasWrong ? <X size={12} /> : null}
76
+ </span>
77
+ <span>{opt}</span>
78
+ </label>
79
+ );
80
+ })}
81
+ </div>
82
+ <div className="quiz-actions">
83
+ {!submitted && (
84
+ <button type="button" onClick={submit} disabled={chosen.length === 0}>
85
+ Check
86
+ </button>
87
+ )}
88
+ {submitted && (
89
+ <button type="button" onClick={reset}>
90
+ Try again
91
+ </button>
92
+ )}
93
+ {submitted && (
94
+ <span className={previous?.correct ? "quiz-msg ok" : "quiz-msg no"}>
95
+ {previous?.correct ? "Correct" : "Not quite"}
96
+ </span>
97
+ )}
98
+ </div>
99
+ {submitted && explanation && <p className="quiz-exp">{explanation}</p>}
100
+ </fieldset>
101
+ );
102
+ }
@@ -0,0 +1,65 @@
1
+ ---
2
+ interface Props {
3
+ title?: string;
4
+ items: string[];
5
+ }
6
+ const { title = "What you learned", items } = Astro.props;
7
+ ---
8
+ <section class="recap">
9
+ <h3 class="recap-title">
10
+ <svg class="recap-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
11
+ <polyline points="20 6 9 17 4 12"></polyline>
12
+ </svg>
13
+ <span>{title}</span>
14
+ </h3>
15
+ <ul class="recap-items">
16
+ {items.map((item) => (
17
+ <li>
18
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
19
+ <polyline points="20 6 9 17 4 12"></polyline>
20
+ </svg>
21
+ <span>{item}</span>
22
+ </li>
23
+ ))}
24
+ </ul>
25
+ </section>
26
+
27
+ <style>
28
+ .recap {
29
+ margin: 2rem 0;
30
+ padding: 1rem 1.25rem;
31
+ background: var(--color-surface);
32
+ border: var(--border-default) solid var(--color-border);
33
+ border-left: var(--border-thick) solid var(--color-accent);
34
+ border-radius: 0;
35
+ }
36
+ .recap-title {
37
+ margin: 0 0 0.6rem;
38
+ font-size: 0.78rem;
39
+ text-transform: uppercase;
40
+ letter-spacing: 0.08em;
41
+ color: var(--color-accent);
42
+ display: inline-flex;
43
+ align-items: center;
44
+ gap: 0.4rem;
45
+ }
46
+ .recap-icon { color: var(--color-accent); flex-shrink: 0; }
47
+ .recap-items {
48
+ list-style: none;
49
+ margin: 0;
50
+ padding: 0;
51
+ display: grid;
52
+ gap: 0.3rem;
53
+ }
54
+ .recap-items li {
55
+ display: grid;
56
+ grid-template-columns: 1.1rem 1fr;
57
+ gap: 0.55rem;
58
+ align-items: start;
59
+ margin: 0;
60
+ }
61
+ .recap-items li :global(svg) {
62
+ color: var(--color-accent);
63
+ margin-top: 0.25em;
64
+ }
65
+ </style>
@@ -0,0 +1,7 @@
1
+ ---
2
+ import RevealIsland from "./Reveal.tsx";
3
+ const { label } = Astro.props;
4
+ ---
5
+ <RevealIsland client:visible label={label}>
6
+ <slot />
7
+ </RevealIsland>
@@ -0,0 +1,25 @@
1
+ import { Eye, EyeOff } from "lucide-react";
2
+ import { type ReactNode, useState } from "react";
3
+
4
+ interface Props {
5
+ label?: string;
6
+ children: ReactNode;
7
+ }
8
+
9
+ export default function Reveal({ label = "Show solution", children }: Props) {
10
+ const [open, setOpen] = useState(false);
11
+ return (
12
+ <div className="reveal">
13
+ <button
14
+ type="button"
15
+ onClick={() => setOpen((o) => !o)}
16
+ className="reveal-btn"
17
+ aria-expanded={open}
18
+ >
19
+ {open ? <EyeOff size={16} /> : <Eye size={16} />}
20
+ <span>{open ? "Hide" : label}</span>
21
+ </button>
22
+ {open && <div className="reveal-content">{children}</div>}
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,12 @@
1
+ ---
2
+ interface Props {
3
+ title?: string;
4
+ }
5
+ const { title } = Astro.props;
6
+ ---
7
+ <li class="step">
8
+ <div>
9
+ {title && <strong>{title}</strong>}
10
+ <slot />
11
+ </div>
12
+ </li>