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,152 @@
1
+ import { Flame, Search, X } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+
4
+ interface Props {
5
+ difficulties: string[];
6
+ tags: string[];
7
+ }
8
+
9
+ interface FilterState {
10
+ q: string;
11
+ difficulty: string;
12
+ tag: string;
13
+ /** "" = curated (numeric-prefix) order; "popular" = by data-popularity desc. */
14
+ sort: string;
15
+ }
16
+
17
+ function readUrlState(): FilterState {
18
+ if (typeof window === "undefined") return { q: "", difficulty: "", tag: "", sort: "" };
19
+ const url = new URL(window.location.href);
20
+ return {
21
+ q: url.searchParams.get("q") ?? "",
22
+ difficulty: url.searchParams.get("difficulty") ?? "",
23
+ tag: url.searchParams.get("tag") ?? "",
24
+ sort: url.searchParams.get("sort") ?? "",
25
+ };
26
+ }
27
+
28
+ function writeUrlState(state: FilterState) {
29
+ const url = new URL(window.location.href);
30
+ if (state.q) url.searchParams.set("q", state.q);
31
+ else url.searchParams.delete("q");
32
+ if (state.difficulty) url.searchParams.set("difficulty", state.difficulty);
33
+ else url.searchParams.delete("difficulty");
34
+ if (state.tag) url.searchParams.set("tag", state.tag);
35
+ else url.searchParams.delete("tag");
36
+ if (state.sort) url.searchParams.set("sort", state.sort);
37
+ else url.searchParams.delete("sort");
38
+ window.history.replaceState({}, "", url.toString());
39
+ }
40
+
41
+ function applyFilters(state: FilterState) {
42
+ const cards = document.querySelectorAll<HTMLElement>("[data-search]");
43
+ const q = state.q.trim().toLowerCase();
44
+ let visible = 0;
45
+ cards.forEach((card) => {
46
+ const matchesQ = !q || card.dataset.search!.includes(q);
47
+ const matchesDiff = !state.difficulty || card.dataset.difficulty === state.difficulty;
48
+ const matchesTag = !state.tag || (card.dataset.tags ?? "").split(",").includes(state.tag);
49
+ const show = matchesQ && matchesDiff && matchesTag;
50
+ if (show) {
51
+ card.removeAttribute("data-filter-hidden");
52
+ visible += 1;
53
+ } else {
54
+ card.setAttribute("data-filter-hidden", "");
55
+ }
56
+ });
57
+ const empty = document.querySelector<HTMLElement>("[data-empty-state]");
58
+ if (empty) empty.style.display = visible === 0 ? "" : "none";
59
+ // Notify Pagination (and any other listeners) that the filtered set
60
+ // changed so they can reset their slice.
61
+ window.dispatchEvent(new CustomEvent("hz:filter-changed"));
62
+ }
63
+
64
+ export default function FilterBar({ difficulties, tags }: Props) {
65
+ const [state, setState] = useState<FilterState>(readUrlState);
66
+
67
+ useEffect(() => {
68
+ applyFilters(state);
69
+ writeUrlState(state);
70
+ // Sort lives in a separate inline script on Home.astro because it
71
+ // doesn't depend on React state — it just reads data-popularity.
72
+ // Fire an event so it can re-run.
73
+ window.dispatchEvent(
74
+ new CustomEvent("hz:sort-changed", { detail: { sort: state.sort } }),
75
+ );
76
+ }, [state]);
77
+
78
+ function set<K extends keyof FilterState>(key: K, value: FilterState[K]) {
79
+ setState((prev) => ({ ...prev, [key]: value }));
80
+ }
81
+
82
+ function clear() {
83
+ setState({ q: "", difficulty: "", tag: "", sort: "" });
84
+ }
85
+
86
+ const hasFilters = state.q || state.difficulty || state.tag || state.sort;
87
+
88
+ return (
89
+ <div className="filterbar">
90
+ {/* Row 1: search takes the lion's share + level + clear */}
91
+ <div className="fb-row fb-row-primary">
92
+ <label className="search">
93
+ <Search size={16} aria-hidden="true" />
94
+ <input
95
+ type="search"
96
+ placeholder="Search tutorials…"
97
+ value={state.q}
98
+ onChange={(e) => set("q", e.target.value)}
99
+ aria-label="Search tutorials"
100
+ />
101
+ </label>
102
+
103
+ <div className="pills" role="group" aria-label="Difficulty">
104
+ {difficulties.map((d) => (
105
+ <button
106
+ key={d}
107
+ type="button"
108
+ className={`pill ${state.difficulty === d ? "is-active" : ""}`}
109
+ onClick={() => set("difficulty", state.difficulty === d ? "" : d)}
110
+ >
111
+ {d}
112
+ </button>
113
+ ))}
114
+ </div>
115
+
116
+ <div className="pills" role="group" aria-label="Sort">
117
+ <button
118
+ type="button"
119
+ className={`pill ${state.sort === "popular" ? "is-active" : ""}`}
120
+ onClick={() => set("sort", state.sort === "popular" ? "" : "popular")}
121
+ aria-pressed={state.sort === "popular"}
122
+ title="Sort by cross-learner popularity"
123
+ >
124
+ <Flame size={12} aria-hidden="true" /> popular
125
+ </button>
126
+ </div>
127
+
128
+ {hasFilters && (
129
+ <button type="button" className="clear" onClick={clear}>
130
+ <X size={14} aria-hidden="true" /> Clear
131
+ </button>
132
+ )}
133
+ </div>
134
+
135
+ {/* Row 2: tags wrap freely under the primary row */}
136
+ {tags.length > 0 && (
137
+ <div className="pills pills-tags" role="group" aria-label="Tags">
138
+ {tags.map((t) => (
139
+ <button
140
+ key={t}
141
+ type="button"
142
+ className={`pill ${state.tag === t ? "is-active" : ""}`}
143
+ onClick={() => set("tag", state.tag === t ? "" : t)}
144
+ >
145
+ #{t}
146
+ </button>
147
+ ))}
148
+ </div>
149
+ )}
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,60 @@
1
+ ---
2
+ interface Props {
3
+ title?: string;
4
+ subtitle?: string;
5
+ /**
6
+ * Brand mark shown left of the headline. Defaults to `/logo.svg`
7
+ * (the scaffold ships a starter at `public/logo.svg`). Pass `""`
8
+ * to hide the logo entirely.
9
+ */
10
+ logoUrl?: string;
11
+ }
12
+ const {
13
+ title = "Handzon.",
14
+ subtitle = "Step-by-step tutorials.",
15
+ logoUrl = "/logo.svg",
16
+ } = Astro.props;
17
+ ---
18
+ <header class="hero">
19
+ <h1 class="hero-headline">
20
+ {logoUrl && (
21
+ <img class="hero-logo" src={logoUrl} alt="" aria-hidden="true" />
22
+ )}
23
+ <span>{title}</span>
24
+ </h1>
25
+ <p>{subtitle}</p>
26
+ </header>
27
+
28
+ <style>
29
+ .hero {
30
+ padding: 4rem 0 2rem;
31
+ border-bottom: var(--border-default, 2px) solid var(--color-border);
32
+ }
33
+ .hero-headline {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: clamp(0.6rem, 1.2vw, 1rem);
37
+ margin: 0 0 0.75rem;
38
+ font-size: clamp(2.25rem, 5vw, 3.75rem);
39
+ font-weight: 700;
40
+ letter-spacing: -0.025em;
41
+ line-height: 1.05;
42
+ }
43
+ /* Logo height tracks the heading's cap-height (~0.72em of the
44
+ * font-size) so the brand mark visually matches the "H" glyph. The
45
+ * SVG's viewBox has been tightened to remove padding, so the box
46
+ * dimensions are the visible mark. */
47
+ .hero-logo {
48
+ height: 0.72em;
49
+ width: auto;
50
+ display: block;
51
+ flex-shrink: 0;
52
+ }
53
+ .hero p {
54
+ font-size: 1.125rem;
55
+ line-height: 1.55;
56
+ color: var(--color-muted);
57
+ max-width: 60ch;
58
+ margin: 0;
59
+ }
60
+ </style>
@@ -0,0 +1,89 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ interface Props {
4
+ /** How many cards to show per page. */
5
+ pageSize?: number;
6
+ }
7
+
8
+ /**
9
+ * Client-side pagination for the homepage tutorial grid.
10
+ *
11
+ * Works in concert with FilterBar:
12
+ * - FilterBar marks non-matching cards with `data-filter-hidden`.
13
+ * - Pagination marks beyond-page cards with `data-page-hidden`.
14
+ * - The CSS hides a card if either attribute is set.
15
+ *
16
+ * FilterBar fires a `hz:filter-changed` window event when the filter
17
+ * state changes; pagination listens and resets to page 1, then
18
+ * re-applies the page slice over the new filtered set.
19
+ */
20
+ function applyPagination(page: number, pageSize: number): number {
21
+ const visibleByFilter = Array.from(
22
+ document.querySelectorAll<HTMLElement>(
23
+ "[data-search]:not([data-filter-hidden])",
24
+ ),
25
+ );
26
+ const start = (page - 1) * pageSize;
27
+ const end = start + pageSize;
28
+ visibleByFilter.forEach((card, idx) => {
29
+ if (idx >= start && idx < end) {
30
+ card.removeAttribute("data-page-hidden");
31
+ } else {
32
+ card.setAttribute("data-page-hidden", "");
33
+ }
34
+ });
35
+ return visibleByFilter.length;
36
+ }
37
+
38
+ export default function Pagination({ pageSize = 9 }: Props) {
39
+ const [page, setPage] = useState(1);
40
+ const [total, setTotal] = useState(0);
41
+
42
+ useEffect(() => {
43
+ setTotal(applyPagination(page, pageSize));
44
+ }, [page, pageSize]);
45
+
46
+ useEffect(() => {
47
+ function onFilterChanged() {
48
+ setPage(1);
49
+ setTotal(applyPagination(1, pageSize));
50
+ }
51
+ window.addEventListener("hz:filter-changed", onFilterChanged);
52
+ return () => window.removeEventListener("hz:filter-changed", onFilterChanged);
53
+ }, [pageSize]);
54
+
55
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
56
+ const safePage = Math.min(page, totalPages);
57
+ // Keep state coherent if the filter shrinks below the current page.
58
+ useEffect(() => {
59
+ if (safePage !== page) setPage(safePage);
60
+ }, [safePage, page]);
61
+
62
+ if (total <= pageSize) return null;
63
+
64
+ return (
65
+ <nav className="pagination" aria-label="Tutorial pages">
66
+ <button
67
+ type="button"
68
+ className="pg-btn"
69
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
70
+ disabled={safePage <= 1}
71
+ aria-label="Previous page"
72
+ >
73
+ ← Prev
74
+ </button>
75
+ <span className="pg-status" aria-live="polite">
76
+ Page <strong>{safePage}</strong> of {totalPages}
77
+ </span>
78
+ <button
79
+ type="button"
80
+ className="pg-btn"
81
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
82
+ disabled={safePage >= totalPages}
83
+ aria-label="Next page"
84
+ >
85
+ Next →
86
+ </button>
87
+ </nav>
88
+ );
89
+ }
@@ -0,0 +1,50 @@
1
+ import { useMemo } from "react";
2
+ import { useProgressAfterMount } from "../../lib/progress/useProgress";
3
+
4
+ interface Tutorial {
5
+ slug: string;
6
+ title: string;
7
+ }
8
+
9
+ interface Props {
10
+ tutorials: Tutorial[];
11
+ }
12
+
13
+ /**
14
+ * "Continue [Tutorial] / step →" — a single bordered link that reads
15
+ * as one coherent action, replacing the previous three-color, three-
16
+ * size stack of label + title + step.
17
+ */
18
+ export default function ResumeRail({ tutorials }: Props) {
19
+ const state = useProgressAfterMount();
20
+
21
+ const mostRecent = useMemo(() => {
22
+ if (!state) return null;
23
+ let best: { slug: string; step: string; ts: number } | null = null;
24
+ for (const [slug, entry] of Object.entries(state.lastVisited)) {
25
+ // Legacy shape (older builds stored `step` as a bare string)
26
+ // shows up as `unknown` at runtime — coerce so we don't crash.
27
+ const raw = entry as unknown;
28
+ const next =
29
+ typeof raw === "string"
30
+ ? { step: raw, ts: 0 }
31
+ : { step: (raw as { step: string }).step, ts: (raw as { ts?: number }).ts ?? 0 };
32
+ if (!best || next.ts > best.ts) best = { slug, ...next };
33
+ }
34
+ if (!best) return null;
35
+ const tutorial = tutorials.find((t) => t.slug === best.slug);
36
+ if (!tutorial) return null;
37
+ return { slug: best.slug, title: tutorial.title, step: best.step };
38
+ }, [state, tutorials]);
39
+
40
+ if (!state || !mostRecent) return null;
41
+
42
+ return (
43
+ <a className="resume-rail" href={`/${mostRecent.slug}/${mostRecent.step}`}>
44
+ <span className="rr-prefix">Continue</span>
45
+ <span className="rr-title">{mostRecent.title}</span>
46
+ <span className="rr-step">/ {mostRecent.step}</span>
47
+ <span className="rr-arrow" aria-hidden="true">→</span>
48
+ </a>
49
+ );
50
+ }
@@ -0,0 +1,185 @@
1
+ ---
2
+ import type { TutorialEntry } from "../../lib/content";
3
+
4
+ interface Props {
5
+ tutorial: TutorialEntry;
6
+ duration?: string;
7
+ }
8
+ const { tutorial, duration } = Astro.props;
9
+ const data = tutorial.data;
10
+ const slug = tutorial.id;
11
+ ---
12
+ <article
13
+ class="card"
14
+ data-difficulty={data.difficulty}
15
+ data-tags={data.tags.join(",")}
16
+ data-search={`${data.title} ${data.description} ${data.tags.join(" ")}`.toLowerCase()}
17
+ data-card-slug={slug}
18
+ data-popularity="0"
19
+ >
20
+ <a href={`/${slug}`} class="card-link">
21
+ <div class="card-body">
22
+ <h3>{data.title}</h3>
23
+ <p>{data.description}</p>
24
+ <div class="badges">
25
+ <span class={`badge badge-${data.difficulty}`}>{data.difficulty}</span>
26
+ {duration && (
27
+ <span class="badge badge-neutral">
28
+ <svg
29
+ viewBox="0 0 24 24"
30
+ width="11"
31
+ height="11"
32
+ fill="none"
33
+ stroke="currentColor"
34
+ stroke-width="2"
35
+ stroke-linecap="round"
36
+ stroke-linejoin="round"
37
+ aria-hidden="true"
38
+ >
39
+ <circle cx="12" cy="12" r="10" />
40
+ <polyline points="12 7 12 12 15 14" />
41
+ </svg>
42
+ {duration}
43
+ </span>
44
+ )}
45
+ </div>
46
+ {data.tags.length > 0 && (
47
+ <div class="tags">
48
+ {data.tags.map((tag) => <span class="tag">#{tag}</span>)}
49
+ </div>
50
+ )}
51
+ <div
52
+ class="card-stats"
53
+ data-tutorial-stats-slug={slug}
54
+ aria-hidden="true"
55
+ ></div>
56
+ <div class="card-progress" data-tutorial-slug={slug}></div>
57
+ </div>
58
+ </a>
59
+ </article>
60
+
61
+ <style>
62
+ .card {
63
+ border: 1px solid var(--color-border);
64
+ background: var(--color-bg);
65
+ display: flex;
66
+ transition: border-color 0.12s ease, transform 0.12s ease;
67
+ }
68
+ .card:hover {
69
+ border-color: var(--color-accent);
70
+ transform: translateY(-2px);
71
+ }
72
+ .card-link {
73
+ color: var(--color-fg);
74
+ text-decoration: none;
75
+ display: flex;
76
+ flex-direction: column;
77
+ width: 100%;
78
+ }
79
+ .card-body {
80
+ padding: 1.1rem 1.2rem 1.2rem;
81
+ display: flex;
82
+ flex-direction: column;
83
+ flex: 1;
84
+ }
85
+
86
+ /* Metadata row: difficulty + duration, sitting *below* the
87
+ * description (was above). Keeps the title as the lead element and
88
+ * groups all the meta — badges, tags, progress — in the lower half
89
+ * of the card. */
90
+ .badges {
91
+ display: flex;
92
+ gap: 0.4rem;
93
+ margin-top: 0.85rem;
94
+ padding-bottom: 0.85rem;
95
+ border-bottom: 1px solid var(--color-border);
96
+ flex-wrap: wrap;
97
+ }
98
+ .badge svg {
99
+ flex-shrink: 0;
100
+ }
101
+ .badge {
102
+ display: inline-flex;
103
+ align-items: center;
104
+ gap: 0.4rem;
105
+ padding: 0.18rem 0.5rem;
106
+ font-family: var(--font-mono);
107
+ font-size: 0.68em;
108
+ text-transform: uppercase;
109
+ letter-spacing: 0.08em;
110
+ border: 1px solid var(--color-border);
111
+ color: var(--color-muted);
112
+ }
113
+ .badge-beginner::before,
114
+ .badge-intermediate::before,
115
+ .badge-advanced::before {
116
+ content: "";
117
+ width: 0.45rem;
118
+ height: 0.45rem;
119
+ border-radius: 50%;
120
+ background: currentColor;
121
+ flex-shrink: 0;
122
+ }
123
+ .badge-beginner { color: var(--color-success); }
124
+ .badge-intermediate { color: var(--color-warn); }
125
+ .badge-advanced { color: var(--color-danger); }
126
+
127
+ h3 {
128
+ font-size: 1.05rem;
129
+ font-weight: 700;
130
+ margin: 0 0 0.5rem;
131
+ letter-spacing: -0.015em;
132
+ line-height: 1.3;
133
+ }
134
+ p {
135
+ margin: 0;
136
+ color: var(--color-muted);
137
+ font-size: 0.92em;
138
+ line-height: 1.5;
139
+ /* Clamp to 3 lines so cards stay the same height regardless of
140
+ * description length. Keeps the grid visually consistent. */
141
+ display: -webkit-box;
142
+ -webkit-line-clamp: 3;
143
+ -webkit-box-orient: vertical;
144
+ overflow: hidden;
145
+ }
146
+
147
+ /* Tags — quiet, hash-prefixed. Pinned toward the bottom via
148
+ * margin-top: auto so cards in the grid stay the same height when
149
+ * descriptions vary. */
150
+ .tags {
151
+ display: flex;
152
+ gap: 0.5rem;
153
+ flex-wrap: wrap;
154
+ margin-top: 0.85rem;
155
+ }
156
+ .tag {
157
+ font-family: var(--font-mono);
158
+ font-size: 0.7em;
159
+ color: var(--color-muted);
160
+ }
161
+
162
+ .card-progress:not(:empty) { padding-top: 0.5rem; }
163
+
164
+ /* Cross-learner popularity, hydrated from /api/tutorials/stats.
165
+ * Hidden until the script populates content so the card height
166
+ * stays stable on first paint and gracefully degrades on Tier 1
167
+ * (no DB → no numbers → no row). */
168
+ .card-stats:empty { display: none; }
169
+ .card-stats {
170
+ margin-top: 0.6rem;
171
+ display: flex;
172
+ gap: 0.6rem;
173
+ font-family: var(--font-mono);
174
+ font-size: 0.7em;
175
+ color: var(--color-muted);
176
+ }
177
+ .card-stats .stat {
178
+ display: inline-flex;
179
+ align-items: center;
180
+ gap: 0.3rem;
181
+ }
182
+ .card-stats .stat-divider {
183
+ color: var(--color-border);
184
+ }
185
+ </style>
@@ -0,0 +1,77 @@
1
+ ---
2
+ type CalloutType = "info" | "tip" | "warn" | "danger";
3
+
4
+ interface Props {
5
+ type?: CalloutType;
6
+ title?: string;
7
+ }
8
+
9
+ const { type = "info", title } = Astro.props;
10
+
11
+ const colorVar = {
12
+ info: "var(--color-info)",
13
+ tip: "var(--color-tip)",
14
+ warn: "var(--color-warn)",
15
+ danger: "var(--color-danger)",
16
+ }[type];
17
+
18
+ const label = {
19
+ info: "Note",
20
+ tip: "Tip",
21
+ warn: "Warning",
22
+ danger: "Heads up",
23
+ }[type];
24
+
25
+ // Single-element SVG per type (Astro templates don't support React-style
26
+ // fragments inside expressions). Each is a Lucide-style outline icon.
27
+ const iconPaths = {
28
+ info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
29
+ tip: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5C17.7 10.2 18 9 18 8a6 6 0 0 0-12 0c0 1 .3 2.2 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
30
+ warn: '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
31
+ danger:
32
+ '<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>',
33
+ }[type];
34
+ ---
35
+
36
+ <aside class="callout" data-type={type} style={`--callout-color: ${colorVar};`}>
37
+ <div class="callout-bar"></div>
38
+ <div class="callout-body">
39
+ <div class="callout-label">
40
+ <svg class="callout-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" set:html={iconPaths} />
41
+ <span>{title ?? label}</span>
42
+ </div>
43
+ <div class="callout-content"><slot /></div>
44
+ </div>
45
+ </aside>
46
+
47
+ <style>
48
+ .callout {
49
+ display: flex;
50
+ gap: 0;
51
+ margin: 1.25rem 0;
52
+ background: color-mix(in oklab, var(--callout-color) 12%, var(--color-bg));
53
+ border: var(--border-default) solid color-mix(in oklab, var(--callout-color) 40%, var(--color-border));
54
+ border-left: 3px solid var(--callout-color);
55
+ border-radius: 0;
56
+ overflow: hidden;
57
+ }
58
+ .callout-bar { display: none; }
59
+ .callout-body {
60
+ padding: 0.7rem 1rem;
61
+ flex: 1;
62
+ }
63
+ .callout-label {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ gap: 0.4rem;
67
+ text-transform: uppercase;
68
+ font-size: 0.7rem;
69
+ letter-spacing: 0.08em;
70
+ color: var(--callout-color);
71
+ font-weight: 700;
72
+ margin-bottom: 0.3rem;
73
+ }
74
+ .callout-icon { color: var(--callout-color); flex-shrink: 0; }
75
+ .callout-content :global(p:first-child) { margin-top: 0; }
76
+ .callout-content :global(p:last-child) { margin-bottom: 0; }
77
+ </style>
@@ -0,0 +1,14 @@
1
+ ---
2
+ import CheckpointIsland from "./Checkpoint.tsx";
3
+
4
+ interface Props {
5
+ label: string;
6
+ id?: string;
7
+ }
8
+ const props = Astro.props as Props;
9
+
10
+ // The host step/tutorial come from the page-level route marker (set by
11
+ // TutorialLayout). Reading them client-side keeps the Astro wrapper simple
12
+ // and means authors never have to pass them by hand.
13
+ ---
14
+ <CheckpointIsland client:load {...props} />
@@ -0,0 +1,49 @@
1
+ import { Check } from "lucide-react";
2
+ import { useEffect, useId, useState } from "react";
3
+ import { useProgress } from "../../lib/progress/useProgress";
4
+
5
+ interface Props {
6
+ label: string;
7
+ id?: string;
8
+ }
9
+
10
+ /**
11
+ * Reads the host tutorial/step from the page-level route marker
12
+ * (<div id="tt-route" data-tutorial-slug=... data-step-slug=...>) that
13
+ * TutorialLayout emits. Looking it up here keeps the Astro wrapper trivial.
14
+ */
15
+ function useRoute() {
16
+ const [route, setRoute] = useState<{ tutorial: string; step: string } | null>(null);
17
+ useEffect(() => {
18
+ const el = document.getElementById("tt-route");
19
+ if (!el) return;
20
+ const tutorial = el.dataset.tutorialSlug;
21
+ const step = el.dataset.stepSlug;
22
+ if (tutorial && step) setRoute({ tutorial, step });
23
+ }, []);
24
+ return route;
25
+ }
26
+
27
+ export default function Checkpoint({ label, id }: Props) {
28
+ const reactId = useId();
29
+ const checkpointId = id ?? `checkpoint:${reactId}:${label.slice(0, 40)}`;
30
+ const { state, recordCheckpoint, markStepComplete } = useProgress();
31
+ const route = useRoute();
32
+ const done = !!state.checkpoints[checkpointId];
33
+
34
+ function onToggle() {
35
+ if (done) return;
36
+ recordCheckpoint(checkpointId);
37
+ if (route) markStepComplete(route.tutorial, route.step);
38
+ }
39
+
40
+ return (
41
+ <div className={done ? "checkpoint is-done" : "checkpoint"}>
42
+ <button type="button" onClick={onToggle} aria-pressed={done}>
43
+ <span className="checkpoint-box">{done && <Check size={16} />}</span>
44
+ <span>{label}</span>
45
+ </button>
46
+ {done && <span className="checkpoint-msg">Step complete</span>}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import DiffIsland from "./Diff.tsx";
3
+ type Props = Parameters<typeof DiffIsland>[0];
4
+ const props = Astro.props as Props;
5
+ ---
6
+ <DiffIsland client:visible {...props} />