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,40 @@
1
+ ---
2
+ // Wraps an ordered list of <Step> items.
3
+ ---
4
+ <ol class="steps"><slot /></ol>
5
+
6
+ <style is:global>
7
+ .steps {
8
+ counter-reset: step;
9
+ list-style: none;
10
+ padding: 0;
11
+ margin: 1.5rem 0;
12
+ display: grid;
13
+ gap: 0.6rem;
14
+ }
15
+ .steps li.step {
16
+ counter-increment: step;
17
+ display: grid;
18
+ grid-template-columns: 2.25rem 1fr;
19
+ gap: 0.85rem;
20
+ align-items: baseline;
21
+ padding: 0.6rem 0.85rem;
22
+ border: var(--border-default, 2px) solid var(--color-border);
23
+ background: var(--color-surface);
24
+ }
25
+ .steps li.step::before {
26
+ content: counter(step, decimal-leading-zero);
27
+ font-family: var(--font-mono);
28
+ font-size: 0.85em;
29
+ font-weight: 700;
30
+ color: var(--color-accent);
31
+ letter-spacing: 0.04em;
32
+ }
33
+ .steps li.step > * {
34
+ margin: 0;
35
+ }
36
+ .steps li.step strong {
37
+ display: block;
38
+ margin-bottom: 0.15rem;
39
+ }
40
+ </style>
@@ -0,0 +1,22 @@
1
+ ---
2
+ interface Props {
3
+ value: string;
4
+ }
5
+ const { value } = Astro.props;
6
+ // The Tabs script toggles hidden based on data-tab-panel === active value.
7
+ // First panel renders visible by default (matches Tabs.astro's initial state).
8
+ ---
9
+ <div class="tut-tab-panel" data-tab-panel={value} hidden={false}>
10
+ <slot />
11
+ </div>
12
+
13
+ <script define:vars={{ value }}>
14
+ // First-load: hide non-initial panels. Tabs' script flips hidden on activation.
15
+ const panels = document.querySelectorAll(`[data-tab-panel="${value}"]`);
16
+ panels.forEach((panel) => {
17
+ const root = panel.closest("[data-tabs]");
18
+ if (root && root.dataset.active !== value) {
19
+ panel.hidden = true;
20
+ }
21
+ });
22
+ </script>
@@ -0,0 +1,67 @@
1
+ ---
2
+ interface TabItem {
3
+ label: string;
4
+ value: string;
5
+ }
6
+
7
+ interface Props {
8
+ items: TabItem[];
9
+ group?: string;
10
+ }
11
+
12
+ const { items, group } = Astro.props;
13
+ const groupAttr = group ?? `tabs-${Math.random().toString(36).slice(2, 8)}`;
14
+ const initial = items[0]?.value ?? "";
15
+ ---
16
+
17
+ <div class="tut-tabs" data-tabs data-tab-group={groupAttr} data-active={initial}>
18
+ <div class="tut-tabs-list" role="tablist">
19
+ {
20
+ items.map((item, i) => (
21
+ <button
22
+ type="button"
23
+ role="tab"
24
+ class="tut-tabs-trigger"
25
+ data-tab-value={item.value}
26
+ data-active={i === 0 ? "true" : "false"}
27
+ aria-selected={i === 0}
28
+ >
29
+ {item.label}
30
+ </button>
31
+ ))
32
+ }
33
+ </div>
34
+ <div class="tut-tabs-content">
35
+ <slot />
36
+ </div>
37
+ </div>
38
+
39
+ <script>
40
+ const STORAGE_PREFIX = "handzon:tabs:";
41
+
42
+ function activate(root: HTMLElement, value: string) {
43
+ root.dataset.active = value;
44
+ root.querySelectorAll<HTMLElement>("[data-tab-value]").forEach((btn) => {
45
+ const isActive = btn.dataset.tabValue === value;
46
+ btn.dataset.active = isActive ? "true" : "false";
47
+ btn.setAttribute("aria-selected", String(isActive));
48
+ });
49
+ root.querySelectorAll<HTMLElement>("[data-tab-panel]").forEach((panel) => {
50
+ panel.hidden = panel.dataset.tabPanel !== value;
51
+ });
52
+ }
53
+
54
+ document.querySelectorAll<HTMLElement>("[data-tabs]").forEach((root) => {
55
+ const group = root.dataset.tabGroup ?? "";
56
+ const storageKey = group ? `${STORAGE_PREFIX}${group}` : null;
57
+ const saved = storageKey ? window.localStorage.getItem(storageKey) : null;
58
+ if (saved) activate(root, saved);
59
+ root.querySelectorAll<HTMLButtonElement>("[data-tab-value]").forEach((btn) => {
60
+ btn.addEventListener("click", () => {
61
+ const v = btn.dataset.tabValue!;
62
+ activate(root, v);
63
+ if (storageKey) window.localStorage.setItem(storageKey, v);
64
+ });
65
+ });
66
+ });
67
+ </script>
@@ -0,0 +1,6 @@
1
+ ---
2
+ import TerminalIsland from "./Terminal.tsx";
3
+ type Props = Parameters<typeof TerminalIsland>[0];
4
+ const props = Astro.props as Props;
5
+ ---
6
+ <TerminalIsland client:visible {...props} />
@@ -0,0 +1,47 @@
1
+ import { useState } from "react";
2
+
3
+ type Entry =
4
+ | { command: string; output?: string; prompt?: string }
5
+ | { output: string; prompt?: never; command?: never };
6
+
7
+ interface Props {
8
+ entries: Entry[];
9
+ title?: string;
10
+ prompt?: string;
11
+ }
12
+
13
+ export default function Terminal({ entries, title = "Terminal", prompt = "$" }: Props) {
14
+ const [revealed, setRevealed] = useState(entries.length);
15
+
16
+ const visible = entries.slice(0, revealed);
17
+ return (
18
+ <div className="term">
19
+ <div className="term-bar">
20
+ <span className="term-dots" aria-hidden="true">
21
+ <span /> <span /> <span />
22
+ </span>
23
+ <span className="term-title">{title}</span>
24
+ </div>
25
+ <pre className="term-body">
26
+ {visible.map((entry, i) => (
27
+ <div key={i}>
28
+ {entry.command !== undefined && (
29
+ <div className="term-line">
30
+ <span className="term-prompt">{entry.prompt ?? prompt}</span>
31
+ <span>{entry.command}</span>
32
+ </div>
33
+ )}
34
+ {entry.output !== undefined && entry.output !== "" && (
35
+ <div className="term-out">{entry.output}</div>
36
+ )}
37
+ </div>
38
+ ))}
39
+ </pre>
40
+ {revealed < entries.length && (
41
+ <button type="button" className="term-run" onClick={() => setRevealed((r) => r + 1)}>
42
+ ▶ Next
43
+ </button>
44
+ )}
45
+ </div>
46
+ );
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Handzon framework — public API surface.
3
+ *
4
+ * .astro and .tsx components are NOT re-exported here; consumers import
5
+ * them by subpath (e.g. `import BaseLayout from "handzon-core/layouts/BaseLayout.astro"`).
6
+ * Astro can only resolve `.astro` files through the package's `exports`
7
+ * map, not through a barrel re-export, so this file ships only TS values
8
+ * and types.
9
+ */
10
+
11
+ // Content collection helpers (built on top of astro:content).
12
+ export {
13
+ parseStepId,
14
+ getTutorials,
15
+ getTutorialBySlug,
16
+ getStepsForTutorial,
17
+ getStep,
18
+ sumDurations,
19
+ type TutorialEntry,
20
+ type StepEntry,
21
+ } from "./lib/content.ts";
22
+
23
+ // MDX component map used by .astro pages rendering tutorial content.
24
+ export { mdxComponents } from "./lib/mdx-components.ts";
25
+
26
+ // Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
27
+ export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
28
+
29
+ // AI client (browser-side BYOK + streaming chat to handzon-ai).
30
+ export {
31
+ streamChat,
32
+ loadLearnerKey,
33
+ saveLearnerKey,
34
+ clearLearnerKey,
35
+ type ChatMessage,
36
+ } from "./lib/ai/client.ts";
37
+
38
+ export { buildContext, type AssistantContext } from "./lib/ai/context.ts";
39
+
40
+ // Progress store (localStorage + optional server sync).
41
+ export { getStore } from "./lib/progress/local.ts";
42
+ export {
43
+ useProgress,
44
+ useProgressAfterMount,
45
+ } from "./lib/progress/useProgress.ts";
46
+ export {
47
+ emptyState,
48
+ type ProgressState,
49
+ type StepKey,
50
+ type LastVisitedEntry,
51
+ type ProgressStore,
52
+ } from "./lib/progress/types.ts";
53
+
54
+ // AI config type (consumers provide concrete values; framework consumes shape).
55
+ export type { AiConfig } from "./types/ai.ts";
@@ -0,0 +1,112 @@
1
+ ---
2
+ import Footer from "../components/Footer.astro";
3
+ import Navbar from "../components/Navbar.astro";
4
+ import UserMenu from "../components/auth/UserMenu.astro";
5
+
6
+ interface Props {
7
+ title?: string;
8
+ description?: string;
9
+ siteName?: string;
10
+ tagline?: string;
11
+ /**
12
+ * Top navigation mode:
13
+ * - "full": Navbar with logo + UserMenu (default — every page).
14
+ * - "userMenu": floating UserMenu only, no logo. For the homepage
15
+ * where the Hero already shows the brand inline.
16
+ * - "none": no nav at all.
17
+ */
18
+ nav?: "full" | "userMenu" | "none";
19
+ /** Logo URL passed to Navbar (when nav === "full"). */
20
+ logoUrl?: string;
21
+ /** Favicon URL injected into <head>. */
22
+ faviconUrl?: string;
23
+ /** Show the site footer ("Built with Handzon" + repo link). */
24
+ showFooter?: boolean;
25
+ /** Footer link URL; defaults to the Handzon repo. */
26
+ repoUrl?: string;
27
+ /**
28
+ * Width of the page's content column. Drives the navbar + footer
29
+ * alignment via the --hz-page-max-width CSS custom property so the
30
+ * top/bottom bars sit flush with whatever the page renders. Default
31
+ * "80rem" matches the home grid; reader-style pages can pass a
32
+ * narrower value (e.g. "56rem"). Pass "none" for full-width layouts
33
+ * like the tutorial step (sidebar + main, no centered column).
34
+ */
35
+ pageMaxWidth?: string;
36
+ /**
37
+ * Horizontal padding inside the navbar + footer. Defaults to
38
+ * clamp(1rem, 4vw, 2rem). Use a smaller value (e.g. "1rem") when
39
+ * the page itself sits flush against the viewport edge — tutorial
40
+ * step pages do this to line up with the sidebar.
41
+ */
42
+ pagePaddingX?: string;
43
+ }
44
+ const {
45
+ title,
46
+ description,
47
+ siteName = "Handzon",
48
+ tagline = "Hands-on tutorials",
49
+ nav = "full",
50
+ logoUrl = "/logo.svg",
51
+ faviconUrl = "/favicon.svg",
52
+ showFooter = true,
53
+ repoUrl,
54
+ // Default to a full-width navbar + footer with a small consistent
55
+ // inset, matching the tutorial step page. Pages with a centred
56
+ // column (home grid, tutorial landing) keep their own content
57
+ // column; the bars stay as site-wide strips.
58
+ pageMaxWidth = "none",
59
+ pagePaddingX = "1rem",
60
+ } = Astro.props;
61
+ // pageMaxWidth: "none" means no constraint — bars span the full
62
+ // viewport width with just the padding.
63
+ const resolvedMaxWidth = pageMaxWidth === "none" ? "none" : pageMaxWidth;
64
+ const pageTitle = title ? `${title} — ${siteName}` : siteName;
65
+ const desc = description ?? tagline;
66
+ ---
67
+ <!doctype html>
68
+ <html lang="en">
69
+ <head>
70
+ <meta charset="utf-8" />
71
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
72
+ <link rel="icon" type="image/svg+xml" href={faviconUrl} />
73
+ <title>{pageTitle}</title>
74
+ <meta name="description" content={desc} />
75
+ <meta property="og:title" content={pageTitle} />
76
+ <meta property="og:description" content={desc} />
77
+ <meta property="og:type" content="website" />
78
+ </head>
79
+ <body style={`--hz-page-max-width: ${resolvedMaxWidth}; --hz-page-padding-x: ${pagePaddingX}; --hz-nav-height: 3rem;`}>
80
+ {nav === "full" && (
81
+ <Navbar logoUrl={logoUrl} siteName={siteName} />
82
+ )}
83
+ {nav === "userMenu" && (
84
+ <div class="hz-topbar">
85
+ <UserMenu />
86
+ </div>
87
+ )}
88
+ <slot />
89
+ {showFooter && <Footer repoUrl={repoUrl} />}
90
+ <script>
91
+ // Render any <pre class="mermaid"> blocks emitted by rehype-mermaid.
92
+ if (document.querySelector("pre.mermaid")) {
93
+ import("mermaid").then(({ default: mermaid }) => {
94
+ mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" });
95
+ mermaid.run({ querySelector: "pre.mermaid" });
96
+ });
97
+ }
98
+ </script>
99
+
100
+ <style is:global>
101
+ .hz-topbar {
102
+ position: fixed;
103
+ top: 0.75rem;
104
+ right: 0.9rem;
105
+ z-index: 50;
106
+ }
107
+ .hz-topbar:empty {
108
+ display: none;
109
+ }
110
+ </style>
111
+ </body>
112
+ </html>
@@ -0,0 +1,218 @@
1
+ ---
2
+ import BaseLayout from "./BaseLayout.astro";
3
+ import Sidebar from "../components/Sidebar.astro";
4
+ import StepNav from "../components/StepNav.astro";
5
+ import { parseStepId } from "../lib/content.ts";
6
+ import type { TutorialEntry, StepEntry } from "../lib/content";
7
+
8
+ interface Props {
9
+ tutorial: TutorialEntry;
10
+ steps: StepEntry[];
11
+ currentStep: StepEntry;
12
+ currentStepSlug: string;
13
+ hasCheckpoint?: boolean;
14
+ siteName?: string;
15
+ tagline?: string;
16
+ logoUrl?: string;
17
+ faviconUrl?: string;
18
+ repoUrl?: string;
19
+ }
20
+
21
+ const {
22
+ tutorial,
23
+ steps,
24
+ currentStep,
25
+ currentStepSlug,
26
+ hasCheckpoint = false,
27
+ siteName,
28
+ tagline,
29
+ logoUrl,
30
+ faviconUrl,
31
+ repoUrl,
32
+ } = Astro.props;
33
+
34
+ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
35
+ ---
36
+ <BaseLayout
37
+ title={`${currentStep.data.title} — ${tutorial.data.title}`}
38
+ description={currentStep.data.summary}
39
+ siteName={siteName}
40
+ tagline={tagline}
41
+ logoUrl={logoUrl}
42
+ faviconUrl={faviconUrl}
43
+ repoUrl={repoUrl}
44
+ >
45
+ <div class="layout">
46
+ <div class="sidebar-wrap">
47
+ <Sidebar tutorial={tutorial} steps={steps} currentStepSlug={currentStepSlug} />
48
+ </div>
49
+
50
+ <main class="main">
51
+ <header class="step-head">
52
+ <div class="crumb">{tutorial.data.title}</div>
53
+ <h1 class="step-title">{currentStep.data.title}</h1>
54
+ {currentStep.data.duration && (
55
+ <div class="step-dur">⏱ {currentStep.data.duration}</div>
56
+ )}
57
+ </header>
58
+
59
+ <article class="prose">
60
+ <slot />
61
+ </article>
62
+
63
+ <StepNav
64
+ tutorialSlug={tutorial.id}
65
+ steps={steps}
66
+ currentStepSlug={currentStepSlug}
67
+ gated={tutorial.data.gated}
68
+ hasCheckpoint={hasCheckpoint}
69
+ />
70
+
71
+ {tutorial.data.feedbackUrl && (
72
+ <footer class="step-foot">
73
+ <a href={tutorial.data.feedbackUrl} target="_blank" rel="noopener">
74
+ Report an issue with this tutorial →
75
+ </a>
76
+ </footer>
77
+ )}
78
+ </main>
79
+ </div>
80
+
81
+ <div
82
+ id="tt-route"
83
+ data-tutorial-slug={tutorial.id}
84
+ data-step-slug={currentStepSlug}
85
+ data-tutorial-steps={JSON.stringify(stepSlugs)}
86
+ hidden
87
+ ></div>
88
+ <script>
89
+ // Record last-visited + popularity markers from a data marker
90
+ // (set in the markup above). Using a normal <script> + relative
91
+ // import so Vite resolves the path alias correctly; define:vars +
92
+ // dynamic import("~/...") bypasses Vite's resolver and the browser
93
+ // can't load the module.
94
+ import { getStore } from "../lib/progress/local";
95
+ const route = document.getElementById("tt-route");
96
+ if (route) {
97
+ const tutorialSlug = route.dataset.tutorialSlug!;
98
+ const stepSlug = route.dataset.stepSlug!;
99
+ const tutorialSteps = JSON.parse(
100
+ route.dataset.tutorialSteps ?? "[]",
101
+ ) as string[];
102
+ const store = getStore();
103
+
104
+ // Mark last-visited + tutorial "started" on every mount. The
105
+ // started branch is idempotent: it only writes the timestamp the
106
+ // first time per learner, so /api/progress sees one POST per
107
+ // tutorial — not one per page view.
108
+ store.set((s) => {
109
+ const nextLastVisited = {
110
+ ...s.lastVisited,
111
+ [tutorialSlug]: { step: stepSlug, ts: Date.now() },
112
+ };
113
+ if (s.tutorials[tutorialSlug]?.started) {
114
+ return { ...s, lastVisited: nextLastVisited };
115
+ }
116
+ return {
117
+ ...s,
118
+ lastVisited: nextLastVisited,
119
+ tutorials: {
120
+ ...s.tutorials,
121
+ [tutorialSlug]: {
122
+ ...s.tutorials[tutorialSlug],
123
+ started: Date.now(),
124
+ },
125
+ },
126
+ };
127
+ });
128
+
129
+ // Watch for tutorial completion: once every step in the embedded
130
+ // list flips to "complete", record the "completed" event exactly
131
+ // once. Re-runs on store changes so finishing the last step on a
132
+ // mid-tutorial page (rare) still triggers it.
133
+ function checkCompletion() {
134
+ const s = store.get();
135
+ if (s.tutorials[tutorialSlug]?.completed) return;
136
+ if (tutorialSteps.length === 0) return;
137
+ const allDone = tutorialSteps.every(
138
+ (slug) => s.steps[`${tutorialSlug}/${slug}` as `${string}/${string}`] === "complete",
139
+ );
140
+ if (allDone) {
141
+ store.set((cur) => ({
142
+ ...cur,
143
+ tutorials: {
144
+ ...cur.tutorials,
145
+ [tutorialSlug]: {
146
+ ...cur.tutorials[tutorialSlug],
147
+ completed: Date.now(),
148
+ },
149
+ },
150
+ }));
151
+ }
152
+ }
153
+ checkCompletion();
154
+ store.subscribe(checkCompletion);
155
+ }
156
+ </script>
157
+ </BaseLayout>
158
+
159
+ <style>
160
+ /* Draw the sidebar/main divider as a background line on the grid
161
+ * itself: 1px-thin vertical strip at the 280px column boundary,
162
+ * from the top of the layout to the bottom. The grid auto-stretches
163
+ * to fit its content, so the line runs all the way down to the
164
+ * footer regardless of step length — without piggybacking on the
165
+ * (sticky, viewport-height) sidebar's own border. */
166
+ .layout {
167
+ display: grid;
168
+ grid-template-columns: 280px minmax(0, 1fr);
169
+ min-height: 100dvh;
170
+ background: linear-gradient(
171
+ to right,
172
+ transparent 280px,
173
+ var(--color-border) 280px,
174
+ var(--color-border) 281px,
175
+ transparent 281px
176
+ ) no-repeat;
177
+ }
178
+ .sidebar-wrap {
179
+ position: sticky;
180
+ top: var(--hz-nav-height, 3rem);
181
+ height: calc(100dvh - var(--hz-nav-height, 3rem));
182
+ }
183
+ .main {
184
+ padding: 2rem clamp(1rem, 4vw, 3rem);
185
+ max-width: 80ch;
186
+ }
187
+ .crumb {
188
+ font-family: var(--font-mono);
189
+ font-size: 0.75em;
190
+ text-transform: uppercase;
191
+ letter-spacing: 0.08em;
192
+ color: var(--color-muted);
193
+ }
194
+ .step-title {
195
+ font-size: clamp(1.75rem, 3vw, 2.25rem);
196
+ font-weight: 700;
197
+ margin: 0.3rem 0 0.5rem;
198
+ letter-spacing: -0.02em;
199
+ line-height: 1.15;
200
+ }
201
+ .step-dur {
202
+ font-family: var(--font-mono);
203
+ font-size: 0.85em;
204
+ color: var(--color-muted);
205
+ }
206
+ .step-head { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: var(--border-default, 2px) solid var(--color-border); }
207
+ .step-foot {
208
+ margin-top: 3rem;
209
+ padding-top: 1rem;
210
+ border-top: 1px solid var(--color-border);
211
+ font-size: 0.85em;
212
+ color: var(--color-muted);
213
+ }
214
+ @media (max-width: 900px) {
215
+ .layout { grid-template-columns: 1fr; }
216
+ .sidebar-wrap { display: none; }
217
+ }
218
+ </style>
@@ -0,0 +1,92 @@
1
+ import type { AiConfig } from "../../types/ai";
2
+ import type { AssistantContext } from "./context";
3
+
4
+ export interface ChatMessage {
5
+ role: "user" | "assistant";
6
+ content: string;
7
+ }
8
+
9
+ interface ChatRequestOptions {
10
+ messages: ChatMessage[];
11
+ config: AiConfig;
12
+ context: AssistantContext;
13
+ learnerKey?: string;
14
+ signal?: AbortSignal;
15
+ }
16
+
17
+ /**
18
+ * Render's `fromService.property: host` returns a bare private-network
19
+ * hostname (e.g. "myproject-ai-abc"). The browser needs a public URL.
20
+ * Treat the env value as a hostname unless it already includes a scheme
21
+ * — that lets local dev pass `http://localhost:4111` directly.
22
+ */
23
+ function resolveServiceUrl(raw: string): string {
24
+ const trimmed = raw.replace(/\/$/, "");
25
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
26
+ return `https://${trimmed}.onrender.com`;
27
+ }
28
+
29
+ /**
30
+ * POST to the Mastra service. Reads the service URL from
31
+ * import.meta.env.PUBLIC_AI_SERVICE_URL at build time. Streams plain text
32
+ * chunks via fetch's streaming response body.
33
+ */
34
+ export async function streamChat(opts: ChatRequestOptions): Promise<ReadableStream<string>> {
35
+ const rawServiceUrl = import.meta.env.PUBLIC_AI_SERVICE_URL;
36
+ if (!rawServiceUrl) throw new Error("PUBLIC_AI_SERVICE_URL is not set.");
37
+ const serviceUrl = resolveServiceUrl(rawServiceUrl);
38
+
39
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
40
+ if (opts.learnerKey) headers["X-Llm-Api-Key"] = opts.learnerKey;
41
+
42
+ const payload = {
43
+ tutorial: opts.context.tutorial,
44
+ outline: opts.context.outline,
45
+ currentStep: opts.context.currentStep,
46
+ priorSteps: opts.context.priorSteps,
47
+ progress: opts.context.progress,
48
+ references: opts.context.references,
49
+ allowedDomains: opts.config.allowedDomains ?? [],
50
+ };
51
+
52
+ const res = await fetch(`${serviceUrl}/chat`, {
53
+ method: "POST",
54
+ headers,
55
+ signal: opts.signal,
56
+ body: JSON.stringify({
57
+ messages: opts.messages,
58
+ config: {
59
+ provider: opts.config.provider,
60
+ model: opts.config.model,
61
+ name: opts.config.name,
62
+ tone: opts.config.tone,
63
+ persona: opts.config.persona,
64
+ disabledSkills: opts.config.disabledSkills,
65
+ },
66
+ payload,
67
+ }),
68
+ });
69
+
70
+ if (!res.ok) {
71
+ const text = await res.text();
72
+ throw new Error(`Chat service error ${res.status}: ${text}`);
73
+ }
74
+ if (!res.body) throw new Error("No response body from chat service.");
75
+
76
+ return res.body.pipeThrough(new TextDecoderStream());
77
+ }
78
+
79
+ const KEY_STORAGE_PREFIX = "handzon:byok:";
80
+
81
+ export function loadLearnerKey(provider: string): string | null {
82
+ if (typeof window === "undefined") return null;
83
+ return window.localStorage.getItem(`${KEY_STORAGE_PREFIX}${provider}`);
84
+ }
85
+
86
+ export function saveLearnerKey(provider: string, key: string): void {
87
+ window.localStorage.setItem(`${KEY_STORAGE_PREFIX}${provider}`, key);
88
+ }
89
+
90
+ export function clearLearnerKey(provider: string): void {
91
+ window.localStorage.removeItem(`${KEY_STORAGE_PREFIX}${provider}`);
92
+ }