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
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "handzon-core",
3
+ "version": "0.6.0",
4
+ "description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "MIT",
9
+ "author": "r4ph_t",
10
+ "homepage": "https://github.com/R4ph-t/handzon#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/R4ph-t/handzon/issues"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/R4ph-t/handzon.git",
17
+ "directory": "packages/ui"
18
+ },
19
+ "type": "module",
20
+ "files": [
21
+ "src",
22
+ "styles",
23
+ "README.md"
24
+ ],
25
+ "exports": {
26
+ ".": "./src/index.ts",
27
+ "./collections.ts": "./src/collections.ts",
28
+ "./components/*": "./src/components/*",
29
+ "./components/auth/*": "./src/components/auth/*",
30
+ "./layouts/*": "./src/layouts/*",
31
+ "./lib/*": "./src/lib/*",
32
+ "./pages/*": "./src/pages/*",
33
+ "./server/*": "./src/server/*",
34
+ "./server/auth/*": "./src/server/auth/*",
35
+ "./styles/*": "./styles/*"
36
+ },
37
+ "peerDependencies": {
38
+ "astro": "^6.0.0",
39
+ "react": "^19.0.0",
40
+ "react-dom": "^19.0.0"
41
+ },
42
+ "dependencies": {
43
+ "@auth/core": "^0.37.3",
44
+ "@auth/drizzle-adapter": "^1.11.2",
45
+ "@codesandbox/sandpack-react": "^2.20.0",
46
+ "@fontsource-variable/geist": "^5.2.9",
47
+ "@fontsource-variable/geist-mono": "^5.2.8",
48
+ "@radix-ui/react-dialog": "^1.1.15",
49
+ "auth-astro": "^4.2.0",
50
+ "cookie": "^1.0.2",
51
+ "diff": "^9.0.0",
52
+ "drizzle-orm": "^0.45.2",
53
+ "lucide-react": "^1.16.0",
54
+ "mermaid": "^11.15.0",
55
+ "postgres": "^3.4.9",
56
+ "react-markdown": "^9.0.1",
57
+ "remark-gfm": "^4.0.0",
58
+ "unist-util-visit": "^5.0.0",
59
+ "zod": "^3.23.8"
60
+ },
61
+ "devDependencies": {
62
+ "@types/hast": "^3.0.4",
63
+ "@types/react": "^19.2.15",
64
+ "@types/react-dom": "^19.2.3",
65
+ "astro": "^6.3.5",
66
+ "typescript": "^6.0.3"
67
+ },
68
+ "engines": {
69
+ "node": ">=22.0.0"
70
+ },
71
+ "scripts": {
72
+ "build": "echo 'handzon-core ships source — Astro type-checks at consume time'"
73
+ }
74
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Content schemas + loaders for Handzon tutorials.
3
+ *
4
+ * Scaffolds wire these into their `src/content.config.ts`:
5
+ *
6
+ * import { defineCollection } from "astro:content";
7
+ * import { tutorialsLoader, tutorialsSchema, stepsLoader, stepsSchema } from "handzon-core/collections.ts";
8
+ *
9
+ * export const collections = {
10
+ * tutorials: defineCollection({ loader: tutorialsLoader(), schema: tutorialsSchema }),
11
+ * steps: defineCollection({ loader: stepsLoader(), schema: stepsSchema }),
12
+ * };
13
+ *
14
+ * The loader path is resolved against cwd, which is the scaffold root at
15
+ * Astro build/dev time — no need to thread the path through.
16
+ */
17
+ import { z } from "astro:content";
18
+ import { readdir, readFile } from "node:fs/promises";
19
+ import { join, relative, resolve } from "node:path";
20
+ import type { Loader } from "astro/loaders";
21
+ import { glob } from "astro/loaders";
22
+
23
+ const TUTORIALS_REL = "src/content/tutorials";
24
+ const INDEX_FILE = "_index.json";
25
+
26
+ async function readIndexOrder(dir: string): Promise<string[]> {
27
+ try {
28
+ const raw = await readFile(join(dir, INDEX_FILE), "utf8");
29
+ const parsed = JSON.parse(raw);
30
+ return Array.isArray(parsed?.order)
31
+ ? parsed.order.filter((s: unknown) => typeof s === "string")
32
+ : [];
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Custom loader that scans tutorials/<slug>/_meta.json for each tutorial.
40
+ * Tutorial order is driven by `_index.json` at the tutorials root; folder
41
+ * names are used verbatim as slugs (no numeric prefix expected).
42
+ */
43
+ export function tutorialsLoader(): Loader {
44
+ return {
45
+ name: "tutorials-meta",
46
+ load: async ({ store, parseData, watcher }) => {
47
+ const dir = resolve(TUTORIALS_REL);
48
+ store.clear();
49
+ let folders: string[] = [];
50
+ try {
51
+ const dirents = await readdir(dir, { withFileTypes: true });
52
+ folders = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
53
+ } catch {
54
+ return;
55
+ }
56
+ const indexOrder = await readIndexOrder(dir);
57
+ const listed = new Set(indexOrder);
58
+ const unlisted = folders.filter((f) => !listed.has(f)).sort();
59
+ const orderOf = (slug: string): number => {
60
+ const i = indexOrder.indexOf(slug);
61
+ if (i >= 0) return i;
62
+ const u = unlisted.indexOf(slug);
63
+ return u >= 0 ? indexOrder.length + u : indexOrder.length + unlisted.length;
64
+ };
65
+ for (const folder of folders) {
66
+ const metaPath = join(dir, folder, "_meta.json");
67
+ let raw: string;
68
+ try {
69
+ raw = await readFile(metaPath, "utf8");
70
+ } catch {
71
+ continue;
72
+ }
73
+ const parsed = JSON.parse(raw);
74
+ const validated = await parseData({ id: folder, data: parsed });
75
+ store.set({
76
+ id: folder,
77
+ data: { ...validated, order: orderOf(folder) },
78
+ filePath: relative(process.cwd(), metaPath),
79
+ });
80
+ }
81
+ if (watcher) {
82
+ watcher.add(`${dir}/**/_meta.json`);
83
+ watcher.add(join(dir, INDEX_FILE));
84
+ }
85
+ },
86
+ };
87
+ }
88
+
89
+ /** Glob loader for tutorial step `.mdx`/`.md` files. */
90
+ export function stepsLoader() {
91
+ return glob({
92
+ pattern: "**/[0-9]*-*.{mdx,md}",
93
+ base: `./${TUTORIALS_REL}`,
94
+ });
95
+ }
96
+
97
+ /** Schema for tutorial step entries. */
98
+ export const stepsSchema = z.object({
99
+ title: z.string(),
100
+ duration: z.string().optional(),
101
+ summary: z.string().optional(),
102
+ ai: z.boolean().optional(),
103
+ });
104
+
105
+ /** Schema for tutorial entries. Pass through Astro's image() helper. */
106
+ export function tutorialsSchema({ image }: { image: () => import("astro/zod").ZodType }) {
107
+ return z.object({
108
+ title: z.string(),
109
+ description: z.string(),
110
+ author: z
111
+ .object({
112
+ name: z.string(),
113
+ url: z.string().url().optional(),
114
+ avatar: image().optional(),
115
+ })
116
+ .optional(),
117
+ publishedAt: z.coerce.date().optional(),
118
+ updatedAt: z.coerce.date().optional(),
119
+ tags: z.array(z.string()).default([]),
120
+ difficulty: z.enum(["beginner", "intermediate", "advanced"]).default("beginner"),
121
+ estimatedDuration: z.string().optional(),
122
+ prerequisites: z.array(z.string()).default([]),
123
+ nextTutorial: z.string().optional(),
124
+ cover: image().optional(),
125
+ icon: z.union([z.string(), image()]).optional(),
126
+ steps: z.array(z.string()).optional(),
127
+ gated: z.boolean().default(false),
128
+ showProgress: z.boolean().default(true),
129
+ feedbackUrl: z.string().url().optional(),
130
+ ai: z
131
+ .object({
132
+ enabled: z.boolean().optional(),
133
+ name: z.string().optional(),
134
+ tagline: z.string().optional(),
135
+ greeting: z.string().optional(),
136
+ avatar: image().optional(),
137
+ persona: z.string().optional(),
138
+ tone: z.enum(["socratic", "direct", "encouraging"]).optional(),
139
+ provider: z.string().optional(),
140
+ model: z.string().optional(),
141
+ byok: z.enum(["required", "optional", "disabled"]).optional(),
142
+ references: z.array(z.string()).default([]),
143
+ allowedDomains: z.array(z.string()).default([]),
144
+ disabledSkills: z.array(z.string()).default([]),
145
+ enableSuggestPlaygroundEdit: z.boolean().default(false),
146
+ includeFutureSteps: z.boolean().optional(),
147
+ })
148
+ .optional(),
149
+ });
150
+ }
@@ -0,0 +1,85 @@
1
+ ---
2
+ /**
3
+ * Site footer. A single quiet line at the bottom of every page,
4
+ * crediting the framework + linking to the Handzon repo. Sized to
5
+ * match the rest of the brutalist surface treatment.
6
+ */
7
+ interface Props {
8
+ /** Public URL to the framework repo. Override to point elsewhere. */
9
+ repoUrl?: string;
10
+ /** Year displayed in the credit line; defaults to the current year. */
11
+ year?: number;
12
+ }
13
+
14
+ const {
15
+ repoUrl = "https://github.com/R4ph-t/handzon",
16
+ year = new Date().getFullYear(),
17
+ } = Astro.props;
18
+ ---
19
+ <footer class="hz-footer">
20
+ <div class="hz-footer-inner">
21
+ <span class="hz-footer-credit">
22
+ © {year} · Built with
23
+ <a class="hz-footer-link" href={repoUrl} target="_blank" rel="noopener">
24
+ Handzon
25
+ <svg
26
+ viewBox="0 0 24 24"
27
+ width="11"
28
+ height="11"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ stroke-width="2"
32
+ stroke-linecap="round"
33
+ stroke-linejoin="round"
34
+ aria-hidden="true"
35
+ >
36
+ <path d="M7 17 17 7" />
37
+ <path d="M7 7h10v10" />
38
+ </svg>
39
+ </a>
40
+ </span>
41
+ </div>
42
+ </footer>
43
+
44
+ <style is:global>
45
+ /* No margin-top here: the footer's border sits flush against the
46
+ * preceding block. Pages that need breathing room above the footer
47
+ * (home grid, tutorial landing) carry their own padding-bottom on
48
+ * their content column; the tutorial step page wants the sidebar's
49
+ * right border to meet the footer's top border without a gap, which
50
+ * only works when this margin is zero. */
51
+ .hz-footer {
52
+ border-top: 1px solid var(--color-border);
53
+ }
54
+ .hz-footer-inner {
55
+ /* Same alignment trick as the navbar — match the active page's
56
+ * content column so the footer sits flush with what's above. */
57
+ max-width: var(--hz-page-max-width, 80rem);
58
+ margin: 0 auto;
59
+ padding: 1.25rem var(--hz-page-padding-x, clamp(1rem, 4vw, 2rem));
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ color: var(--color-muted);
64
+ font-family: var(--font-mono);
65
+ font-size: 0.75em;
66
+ }
67
+ .hz-footer-credit {
68
+ display: inline-flex;
69
+ align-items: center;
70
+ gap: 0.45rem;
71
+ }
72
+ .hz-footer-link {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ gap: 0.25rem;
76
+ color: var(--color-fg);
77
+ text-decoration: none;
78
+ border-bottom: 1px solid transparent;
79
+ transition: color 0.12s ease, border-color 0.12s ease;
80
+ }
81
+ .hz-footer-link:hover {
82
+ color: var(--color-accent);
83
+ border-bottom-color: var(--color-accent);
84
+ }
85
+ </style>
@@ -0,0 +1,74 @@
1
+ ---
2
+ /**
3
+ * Site-wide top bar. Rendered by BaseLayout on every page except the
4
+ * homepage (the homepage's Hero contains the brand mark inline).
5
+ *
6
+ * Layout: logo left, UserMenu right. The logo links back to the
7
+ * homepage. Sticky so it stays available as users scroll long
8
+ * tutorial steps.
9
+ */
10
+ import UserMenu from "./auth/UserMenu.astro";
11
+
12
+ interface Props {
13
+ logoUrl?: string;
14
+ siteName?: string;
15
+ }
16
+
17
+ const { logoUrl = "/logo.svg", siteName = "Handzon" } = Astro.props;
18
+ ---
19
+ <header class="hz-nav">
20
+ <div class="hz-nav-inner">
21
+ {logoUrl && (
22
+ <a href="/" class="hz-nav-brand" aria-label={`${siteName} home`}>
23
+ <img src={logoUrl} alt={siteName} class="hz-nav-logo" />
24
+ </a>
25
+ )}
26
+ <div class="hz-nav-actions">
27
+ <UserMenu />
28
+ </div>
29
+ </div>
30
+ </header>
31
+
32
+ <style is:global>
33
+ .hz-nav {
34
+ position: sticky;
35
+ top: 0;
36
+ z-index: 40;
37
+ background: color-mix(in oklab, var(--color-bg) 92%, transparent);
38
+ backdrop-filter: blur(8px);
39
+ -webkit-backdrop-filter: blur(8px);
40
+ border-bottom: 1px solid var(--color-border);
41
+ }
42
+ .hz-nav-inner {
43
+ /* Align with the current page's content column. Each page sets
44
+ * --hz-page-max-width + --hz-page-padding-x via BaseLayout so the
45
+ * logo lines up with whatever's directly below it: home grid
46
+ * (80rem, 2rem), tutorial landing (56rem, 3rem), tutorial step
47
+ * (full width, 1rem to match the sidebar). */
48
+ max-width: var(--hz-page-max-width, 80rem);
49
+ margin: 0 auto;
50
+ padding: 0.75rem var(--hz-page-padding-x, clamp(1rem, 4vw, 2rem));
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: space-between;
54
+ gap: 1rem;
55
+ }
56
+ .hz-nav-brand {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ line-height: 0;
60
+ opacity: 0.92;
61
+ transition: opacity 0.12s ease;
62
+ }
63
+ .hz-nav-brand:hover { opacity: 1; }
64
+ .hz-nav-logo {
65
+ display: block;
66
+ height: 1.4rem;
67
+ width: auto;
68
+ }
69
+ .hz-nav-actions {
70
+ display: flex;
71
+ align-items: center;
72
+ margin-left: auto;
73
+ }
74
+ </style>
@@ -0,0 +1,36 @@
1
+ import { useMemo } from "react";
2
+ import { useProgress } from "../lib/progress/useProgress";
3
+
4
+ interface Props {
5
+ tutorialSlug: string;
6
+ totalSteps: number;
7
+ }
8
+
9
+ export default function Progress({ tutorialSlug, totalSteps }: Props) {
10
+ const { state } = useProgress();
11
+ const completed = useMemo(() => {
12
+ return Object.entries(state.steps).filter(
13
+ ([k, v]) => k.startsWith(`${tutorialSlug}/`) && v === "complete",
14
+ ).length;
15
+ }, [state.steps, tutorialSlug]);
16
+
17
+ const pct = totalSteps > 0 ? Math.round((completed / totalSteps) * 100) : 0;
18
+
19
+ return (
20
+ <div
21
+ className="progress"
22
+ role="progressbar"
23
+ aria-label={`${completed} of ${totalSteps} steps complete`}
24
+ aria-valuenow={completed}
25
+ aria-valuemin={0}
26
+ aria-valuemax={totalSteps}
27
+ >
28
+ <div className="progress-bar">
29
+ <div className="progress-fill" style={{ width: `${pct}%` }} />
30
+ </div>
31
+ <div className="progress-label">
32
+ {completed} / {totalSteps} steps
33
+ </div>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,162 @@
1
+ ---
2
+ import type { TutorialEntry, StepEntry } from "../lib/content";
3
+ import { parseStepId } from "../lib/content";
4
+ import Progress from "./Progress.tsx";
5
+
6
+ interface Props {
7
+ tutorial: TutorialEntry;
8
+ steps: StepEntry[];
9
+ currentStepSlug: string;
10
+ }
11
+
12
+ const { tutorial, steps, currentStepSlug } = Astro.props;
13
+ const slug = tutorial.id;
14
+ ---
15
+ <aside class="sidebar" aria-label="Tutorial steps">
16
+ {/* Top block: back link, title, progress. Stays fixed while the
17
+ * step list below scrolls when there are many steps. */}
18
+ <div class="sb-top">
19
+ <header class="sb-header">
20
+ <a class="sb-back" href="/">← All tutorials</a>
21
+ <h2 class="sb-title">
22
+ <a href={`/${slug}`}>{tutorial.data.title}</a>
23
+ </h2>
24
+ {tutorial.data.estimatedDuration && (
25
+ <div class="sb-meta">{tutorial.data.estimatedDuration}</div>
26
+ )}
27
+ </header>
28
+
29
+ {tutorial.data.showProgress && (
30
+ <Progress tutorialSlug={slug} totalSteps={steps.length} client:load />
31
+ )}
32
+ </div>
33
+
34
+ <ol class="sb-steps">
35
+ {steps.map((step) => {
36
+ const { stepSlug } = parseStepId(step.id);
37
+ const isCurrent = stepSlug === currentStepSlug;
38
+ return (
39
+ <li class={isCurrent ? "is-current" : ""} data-step-slug={stepSlug}>
40
+ <a href={`/${slug}/${stepSlug}`}>
41
+ <span class="sb-check" data-step-key={`${slug}/${stepSlug}`}></span>
42
+ <span class="sb-name">{step.data.title}</span>
43
+ {step.data.duration && <span class="sb-dur">{step.data.duration}</span>}
44
+ </a>
45
+ </li>
46
+ );
47
+ })}
48
+ </ol>
49
+ </aside>
50
+
51
+ <script>
52
+ // Hydrate per-step check marks from localStorage without an island.
53
+ import { getStore } from "../lib/progress/local";
54
+ function refresh() {
55
+ const store = getStore();
56
+ const state = store.get();
57
+ document.querySelectorAll<HTMLElement>("[data-step-key]").forEach((el) => {
58
+ const key = el.dataset.stepKey as `${string}/${string}`;
59
+ const done = state.steps[key] === "complete";
60
+ el.dataset.done = done ? "true" : "false";
61
+ });
62
+ }
63
+ refresh();
64
+ getStore().subscribe(refresh);
65
+ </script>
66
+
67
+ <style>
68
+ /* Two-zone layout: `.sb-top` (back link + title + progress) stays
69
+ * at the top; `.sb-steps` scrolls underneath when the list
70
+ * overflows. The wrapping `.sidebar-wrap` handles sticky
71
+ * positioning below the navbar; the sidebar fills it 100%. The
72
+ * column divider against `.main` is drawn by `.layout`'s
73
+ * background gradient, not a border here. */
74
+ .sidebar {
75
+ height: 100%;
76
+ background: var(--color-bg);
77
+ display: flex;
78
+ flex-direction: column;
79
+ overflow: hidden;
80
+ }
81
+ .sb-top {
82
+ padding: 1rem 1rem 0.75rem;
83
+ flex-shrink: 0;
84
+ }
85
+ .sb-header { margin-bottom: 1rem; }
86
+ .sb-back {
87
+ font-family: var(--font-mono);
88
+ font-size: 0.75em;
89
+ color: var(--color-muted);
90
+ text-decoration: none;
91
+ text-transform: uppercase;
92
+ letter-spacing: 0.06em;
93
+ }
94
+ .sb-back:hover { color: var(--color-accent); }
95
+ .sb-title {
96
+ font-size: 1.15rem;
97
+ font-weight: 700;
98
+ letter-spacing: -0.015em;
99
+ margin: 0.5rem 0 0.25rem;
100
+ line-height: 1.2;
101
+ }
102
+ .sb-title a { color: var(--color-fg); text-decoration: none; }
103
+ .sb-meta {
104
+ color: var(--color-muted);
105
+ font-size: 0.72em;
106
+ font-family: var(--font-mono);
107
+ letter-spacing: 0.04em;
108
+ }
109
+ .sb-steps {
110
+ list-style: none;
111
+ padding: 0 1rem 2rem;
112
+ margin: 0.75rem 0 0;
113
+ flex: 1;
114
+ overflow-y: auto;
115
+ border-top: var(--border-default) solid var(--color-border);
116
+ }
117
+ .sb-steps li + li {
118
+ border-top: var(--border-default) solid var(--color-border);
119
+ }
120
+ .sb-steps a {
121
+ display: grid;
122
+ grid-template-columns: 18px 1fr auto;
123
+ align-items: center;
124
+ gap: 0.6rem;
125
+ padding: 0.6rem 0.7rem;
126
+ color: var(--color-fg);
127
+ text-decoration: none;
128
+ font-size: 0.92em;
129
+ font-weight: 500;
130
+ letter-spacing: -0.005em;
131
+ }
132
+ .sb-steps a:hover {
133
+ background: var(--color-surface);
134
+ color: var(--color-fg);
135
+ }
136
+ .sb-steps li.is-current a {
137
+ background: color-mix(in oklab, var(--color-accent) 40%, var(--color-bg));
138
+ color: var(--color-fg);
139
+ font-weight: 500;
140
+ }
141
+ .sb-steps li.is-current a:hover {
142
+ background: color-mix(in oklab, var(--color-accent) 48%, var(--color-bg));
143
+ }
144
+ .sb-check {
145
+ width: 14px;
146
+ height: 14px;
147
+ border: var(--border-default) solid var(--color-border-strong);
148
+ display: inline-block;
149
+ }
150
+ .sb-check[data-done="true"],
151
+ .sb-steps [data-step-key][data-done="true"] {
152
+ background: var(--color-accent);
153
+ border-color: var(--color-accent);
154
+ }
155
+ .sb-dur {
156
+ color: var(--color-muted);
157
+ font-family: var(--font-mono);
158
+ font-size: 0.72em;
159
+ font-weight: 400;
160
+ letter-spacing: 0.02em;
161
+ }
162
+ </style>
@@ -0,0 +1,107 @@
1
+ ---
2
+ import type { StepEntry } from "../lib/content";
3
+ import { parseStepId } from "../lib/content";
4
+
5
+ interface Props {
6
+ tutorialSlug: string;
7
+ steps: StepEntry[];
8
+ currentStepSlug: string;
9
+ gated: boolean;
10
+ hasCheckpoint: boolean;
11
+ }
12
+
13
+ const { tutorialSlug, steps, currentStepSlug, gated, hasCheckpoint } = Astro.props;
14
+ const idx = steps.findIndex((s) => parseStepId(s.id).stepSlug === currentStepSlug);
15
+ const prev = idx > 0 ? steps[idx - 1] : null;
16
+ const next = idx >= 0 && idx < steps.length - 1 ? steps[idx + 1] : null;
17
+ const prevSlug = prev ? parseStepId(prev.id).stepSlug : null;
18
+ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
19
+ ---
20
+ <nav class="step-nav" data-gated={gated && hasCheckpoint ? "true" : "false"} data-step-key={`${tutorialSlug}/${currentStepSlug}`}>
21
+ <div>
22
+ {prev && (
23
+ <a class="sn-prev" href={`/${tutorialSlug}/${prevSlug}`}>
24
+ ← {prev.data.title}
25
+ </a>
26
+ )}
27
+ </div>
28
+ <div>
29
+ {next ? (
30
+ <a class="sn-next" href={`/${tutorialSlug}/${nextSlug}`} data-next-link="true">
31
+ {hasCheckpoint ? "Continue" : "Next"}: {next.data.title} →
32
+ </a>
33
+ ) : (
34
+ <a class="sn-next sn-done" href={`/${tutorialSlug}`}>
35
+ Finish tutorial →
36
+ </a>
37
+ )}
38
+ </div>
39
+ </nav>
40
+
41
+ <script>
42
+ // Gating: if the host step contains a Checkpoint AND tutorial is gated,
43
+ // disable Next until the step is marked complete.
44
+ import { getStore } from "../lib/progress/local";
45
+ const nav = document.querySelector<HTMLElement>(".step-nav");
46
+ if (nav && nav.dataset.gated === "true") {
47
+ const next = nav.querySelector<HTMLAnchorElement>("[data-next-link]");
48
+ if (next) {
49
+ const key = nav.dataset.stepKey as `${string}/${string}`;
50
+ const refresh = () => {
51
+ const done = getStore().get().steps[key] === "complete";
52
+ if (!done) {
53
+ next.classList.add("is-disabled");
54
+ next.setAttribute("aria-disabled", "true");
55
+ next.addEventListener("click", blockClick);
56
+ } else {
57
+ next.classList.remove("is-disabled");
58
+ next.removeAttribute("aria-disabled");
59
+ next.removeEventListener("click", blockClick);
60
+ }
61
+ };
62
+ function blockClick(e: Event) { e.preventDefault(); }
63
+ refresh();
64
+ getStore().subscribe(refresh);
65
+ }
66
+ }
67
+
68
+ // Keyboard nav: ← prev, → next.
69
+ document.addEventListener("keydown", (e) => {
70
+ if (e.target instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
71
+ if (e.key === "ArrowLeft") {
72
+ const prev = document.querySelector<HTMLAnchorElement>(".sn-prev");
73
+ if (prev) window.location.href = prev.href;
74
+ } else if (e.key === "ArrowRight") {
75
+ const next = document.querySelector<HTMLAnchorElement>(".sn-next:not(.is-disabled)");
76
+ if (next) window.location.href = next.href;
77
+ }
78
+ });
79
+ </script>
80
+
81
+ <style>
82
+ .step-nav {
83
+ display: flex;
84
+ justify-content: space-between;
85
+ gap: 1rem;
86
+ margin-top: 3rem;
87
+ padding-top: 1.5rem;
88
+ border-top: var(--border-default, 2px) solid var(--color-border);
89
+ }
90
+ .sn-prev, .sn-next {
91
+ display: inline-block;
92
+ padding: 0.7rem 1rem;
93
+ border: var(--border-default, 2px) solid var(--color-fg);
94
+ text-decoration: none;
95
+ color: var(--color-fg);
96
+ font-weight: 700;
97
+ transition: transform 0.08s ease, box-shadow 0.08s ease;
98
+ }
99
+ .sn-prev:hover, .sn-next:hover:not(.is-disabled) {
100
+ transform: translate(-2px, -2px);
101
+ box-shadow: var(--shadow-raised);
102
+ color: var(--color-fg);
103
+ }
104
+ .sn-next { background: var(--color-accent); color: var(--color-accent-fg); border-color: var(--color-accent); }
105
+ .sn-next:hover:not(.is-disabled) { color: var(--color-accent-fg); }
106
+ .sn-next.is-disabled { opacity: 0.4; cursor: not-allowed; }
107
+ </style>