handzon-core 0.14.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.14.1",
3
+ "version": "0.15.1",
4
4
  "description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -2,6 +2,7 @@
2
2
  import { withBase } from "../lib/base";
3
3
  import type { TutorialEntry, StepEntry } from "../lib/content";
4
4
  import { parseStepId } from "../lib/content";
5
+ import { STORAGE_KEY } from "../lib/progress/types";
5
6
  import { fallbackTextForTrack, iconForTrack } from "../lib/track-icons";
6
7
  import Progress from "./Progress.tsx";
7
8
  import TrackSelector from "./TrackSelector.tsx";
@@ -87,6 +88,40 @@ const slug = tutorial.id;
87
88
  </ol>
88
89
  </aside>
89
90
 
91
+ {/* Pre-paint: mirror the persisted progress state into the SSR sidebar
92
+ before hydrated modules run, avoiding a step-change flash from
93
+ 0 / unchecked to the learner's real progress. */}
94
+ <script is:inline define:vars={{ storageKey: STORAGE_KEY, tutorialSlug: slug, totalSteps: steps.length }}>
95
+ (function () {
96
+ try {
97
+ var raw = window.localStorage.getItem(storageKey);
98
+ var state = raw ? JSON.parse(raw) : null;
99
+ var steps = (state && state.steps) || {};
100
+ var completed = 0;
101
+
102
+ document.querySelectorAll("[data-step-key]").forEach(function (el) {
103
+ var key = el.getAttribute("data-step-key");
104
+ var done = key && steps[key] === "complete";
105
+ el.setAttribute("data-done", done ? "true" : "false");
106
+ if (done && key.indexOf(tutorialSlug + "/") === 0) completed += 1;
107
+ });
108
+
109
+ var progress = document.querySelector(".sidebar .progress");
110
+ if (!progress) return;
111
+ var bounded = Math.min(completed, totalSteps);
112
+ var pct = totalSteps > 0 ? Math.round((bounded / totalSteps) * 100) : 0;
113
+ progress.setAttribute("aria-label", bounded + " of " + totalSteps + " steps complete");
114
+ progress.setAttribute("aria-valuenow", String(bounded));
115
+ var fill = progress.querySelector(".progress-fill");
116
+ if (fill) fill.style.width = pct + "%";
117
+ var label = progress.querySelector(".progress-label");
118
+ if (label) label.textContent = bounded + " / " + totalSteps + " steps";
119
+ } catch (e) {
120
+ /* ignore — hydrated progress will correct the sidebar */
121
+ }
122
+ })();
123
+ </script>
124
+
90
125
  <script>
91
126
  // Hydrate per-step check marks from localStorage without an island.
92
127
  import { getStore } from "../lib/progress/local";
@@ -18,6 +18,14 @@ type StepHeroMedia =
18
18
  aspect?: string;
19
19
  type?: "iframe" | "video";
20
20
  caption?: string;
21
+ }
22
+ | {
23
+ kind: "slides";
24
+ src: string;
25
+ title: string;
26
+ aspect?: string;
27
+ slide?: string | number;
28
+ caption?: string;
21
29
  };
22
30
 
23
31
  interface Props {
@@ -35,6 +43,14 @@ const { media } = Astro.props;
35
43
  aspect={media.aspect}
36
44
  type={media.type}
37
45
  />
46
+ ) : media.kind === "slides" ? (
47
+ <Embed
48
+ src={media.src}
49
+ title={media.title}
50
+ aspect={media.aspect}
51
+ type="slides"
52
+ slide={media.slide}
53
+ />
38
54
  ) : typeof media.src !== "string" ? (
39
55
  <Image
40
56
  src={media.src}
@@ -1,7 +1,9 @@
1
1
  ---
2
+ import TutorialCompletion from "./TutorialCompletion.tsx";
2
3
  import { withBase } from "../lib/base";
3
4
  import type { StepEntry } from "../lib/content";
4
5
  import { parseStepId } from "../lib/content";
6
+ import type { TutorialSummary } from "../lib/tutorialSummary";
5
7
 
6
8
  interface Props {
7
9
  tutorialSlug: string;
@@ -9,16 +11,17 @@ interface Props {
9
11
  currentStepSlug: string;
10
12
  gated: boolean;
11
13
  hasCheckpoint: boolean;
14
+ nextTutorial?: TutorialSummary;
12
15
  }
13
16
 
14
- const { tutorialSlug, steps, currentStepSlug, gated, hasCheckpoint } = Astro.props;
17
+ const { tutorialSlug, steps, currentStepSlug, gated, hasCheckpoint, nextTutorial } = Astro.props;
15
18
  const idx = steps.findIndex((s) => parseStepId(s.id).stepSlug === currentStepSlug);
16
19
  const prev = idx > 0 ? steps[idx - 1] : null;
17
20
  const next = idx >= 0 && idx < steps.length - 1 ? steps[idx + 1] : null;
18
21
  const prevSlug = prev ? parseStepId(prev.id).stepSlug : null;
19
22
  const nextSlug = next ? parseStepId(next.id).stepSlug : null;
20
23
  ---
21
- <nav class="step-nav" data-gated={gated && hasCheckpoint ? "true" : "false"} data-step-key={`${tutorialSlug}/${currentStepSlug}`}>
24
+ <nav class:list={["step-nav", !next && "step-nav-final"]} data-gated={gated && hasCheckpoint ? "true" : "false"} data-step-key={`${tutorialSlug}/${currentStepSlug}`}>
22
25
  <div>
23
26
  {prev && (
24
27
  <a class="sn-prev" href={withBase(`/${tutorialSlug}/${prevSlug}`)}>
@@ -26,15 +29,18 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
26
29
  </a>
27
30
  )}
28
31
  </div>
29
- <div>
32
+ <div class="sn-slot">
30
33
  {next ? (
31
34
  <a class="sn-next" href={withBase(`/${tutorialSlug}/${nextSlug}`)} data-next-link="true">
32
35
  {hasCheckpoint ? "Continue" : "Next"}: {next.data.title} →
33
36
  </a>
34
37
  ) : (
35
- <a class="sn-next sn-done" href={withBase(`/${tutorialSlug}`)}>
36
- Finish tutorial →
37
- </a>
38
+ <TutorialCompletion
39
+ client:load
40
+ tutorialSlug={tutorialSlug}
41
+ totalSteps={steps.length}
42
+ nextTutorial={nextTutorial}
43
+ />
38
44
  )}
39
45
  </div>
40
46
  </nav>
@@ -83,11 +89,20 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
83
89
  .step-nav {
84
90
  display: flex;
85
91
  justify-content: space-between;
92
+ align-items: flex-start;
86
93
  gap: 1rem;
87
94
  margin-top: 3rem;
88
95
  padding-top: 1.5rem;
89
96
  border-top: var(--border-default, 2px) solid var(--color-border);
90
97
  }
98
+ .step-nav-final {
99
+ display: grid;
100
+ grid-template-columns: minmax(0, auto) minmax(18rem, 1fr);
101
+ }
102
+ .sn-slot {
103
+ display: flex;
104
+ justify-content: flex-end;
105
+ }
91
106
  .sn-prev, .sn-next {
92
107
  display: inline-block;
93
108
  padding: 0.7rem 1rem;
@@ -105,4 +120,78 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
105
120
  .sn-next { background: var(--color-accent); color: var(--color-accent-fg); border-color: var(--color-accent); }
106
121
  .sn-next:hover:not(.is-disabled) { color: var(--color-accent-fg); }
107
122
  .sn-next.is-disabled { opacity: 0.4; cursor: not-allowed; }
123
+ :global(.tutorial-completion) {
124
+ width: min(100%, 42rem);
125
+ display: grid;
126
+ gap: 0.9rem;
127
+ }
128
+ :global(.completion-status),
129
+ :global(.completion-card) {
130
+ border: var(--border-default, 2px) solid var(--color-border);
131
+ background: var(--color-surface);
132
+ padding: 1rem;
133
+ }
134
+ :global(.tutorial-completion.is-complete .completion-status) {
135
+ border-color: var(--color-success, var(--color-accent));
136
+ box-shadow: var(--shadow-raised);
137
+ }
138
+ :global(.completion-kicker),
139
+ :global(.completion-card-label),
140
+ :global(.completion-meta) {
141
+ display: block;
142
+ font-family: var(--font-mono);
143
+ font-size: 0.75rem;
144
+ text-transform: uppercase;
145
+ letter-spacing: 0.06em;
146
+ color: var(--color-muted);
147
+ }
148
+ :global(.completion-status h2) {
149
+ margin: 0.3rem 0 0.25rem;
150
+ font-size: 1.25rem;
151
+ line-height: var(--leading-heading, 1.2);
152
+ }
153
+ :global(.completion-status p),
154
+ :global(.completion-card span) {
155
+ margin: 0;
156
+ color: var(--color-muted);
157
+ }
158
+ :global(.completion-actions) {
159
+ display: grid;
160
+ gap: 0.75rem;
161
+ }
162
+ :global(.completion-card) {
163
+ display: grid;
164
+ gap: 0.4rem;
165
+ color: var(--color-fg);
166
+ text-decoration: none;
167
+ }
168
+ :global(a.completion-card:hover) {
169
+ border-color: var(--color-accent);
170
+ transform: translate(-2px, -2px);
171
+ box-shadow: var(--shadow-raised);
172
+ }
173
+ :global(.completion-card-primary) {
174
+ border-color: var(--color-accent);
175
+ }
176
+ :global(.completion-card strong) {
177
+ font-size: 1.05rem;
178
+ }
179
+ :global(.tutorial-completion.is-locked .completion-status) {
180
+ opacity: 0.48;
181
+ filter: grayscale(1);
182
+ cursor: not-allowed;
183
+ }
184
+ @media (max-width: 760px) {
185
+ .step-nav,
186
+ .step-nav-final {
187
+ display: grid;
188
+ grid-template-columns: 1fr;
189
+ }
190
+ .sn-slot {
191
+ justify-content: stretch;
192
+ }
193
+ :global(.tutorial-completion) {
194
+ width: 100%;
195
+ }
196
+ }
108
197
  </style>
@@ -0,0 +1,69 @@
1
+ import { withBase } from "../lib/base";
2
+ import { useProgress } from "../lib/progress/useProgress";
3
+ import type { TutorialSummary } from "../lib/tutorialSummary";
4
+
5
+ interface Props {
6
+ tutorialSlug: string;
7
+ totalSteps: number;
8
+ nextTutorial?: TutorialSummary;
9
+ }
10
+
11
+ function countCompletedSteps(steps: Record<string, unknown>, tutorialSlug: string): number {
12
+ return Object.entries(steps).filter(
13
+ ([key, value]) => key.startsWith(`${tutorialSlug}/`) && value === "complete",
14
+ ).length;
15
+ }
16
+
17
+ export default function TutorialCompletion({ tutorialSlug, totalSteps, nextTutorial }: Props) {
18
+ const { state } = useProgress();
19
+ const completedSteps = countCompletedSteps(state.steps, tutorialSlug);
20
+ const isComplete = totalSteps > 0 && completedSteps >= totalSteps;
21
+ const progressLabel = `${Math.min(completedSteps, totalSteps)} / ${totalSteps} steps complete`;
22
+
23
+ if (!isComplete) {
24
+ return (
25
+ <section className="tutorial-completion is-locked" aria-label="Tutorial completion">
26
+ <div className="completion-status" aria-disabled="true">
27
+ <span className="completion-kicker">Almost done</span>
28
+ <h2>Complete the remaining checkpoints to finish.</h2>
29
+ <p>
30
+ {progressLabel}
31
+ {nextTutorial ? ` Then you can continue to ${nextTutorial.title}.` : ""}
32
+ </p>
33
+ </div>
34
+ </section>
35
+ );
36
+ }
37
+
38
+ return (
39
+ <section className="tutorial-completion is-complete" aria-label="Tutorial completion">
40
+ <div className="completion-status">
41
+ <span className="completion-kicker">Tutorial complete</span>
42
+ <h2>You completed every step.</h2>
43
+ <p>{progressLabel}</p>
44
+ </div>
45
+ <div className="completion-actions">
46
+ {nextTutorial ? (
47
+ <a
48
+ className="completion-card completion-card-primary"
49
+ href={withBase(`/${nextTutorial.slug}`)}
50
+ >
51
+ <span className="completion-card-label">Continue learning</span>
52
+ <strong>{nextTutorial.title}</strong>
53
+ <span>{nextTutorial.description}</span>
54
+ <span className="completion-meta">
55
+ {nextTutorial.difficulty}
56
+ {nextTutorial.duration ? ` | ${nextTutorial.duration}` : ""}
57
+ </span>
58
+ </a>
59
+ ) : (
60
+ <a className="completion-card completion-card-primary" href={withBase("/")}>
61
+ <span className="completion-card-label">Browse tutorials</span>
62
+ <strong>Pick your next tutorial</strong>
63
+ <span>Browse the catalog and choose what to build next.</span>
64
+ </a>
65
+ )}
66
+ </div>
67
+ </section>
68
+ );
69
+ }
@@ -6,30 +6,126 @@
6
6
  * `Astro.request.headers` access at render time, no warning when a
7
7
  * prerendered route happens to include BaseLayout.
8
8
  */
9
+ import { withBase } from "../../lib/base";
9
10
  import UserMenuIsland from "./UserMenu.tsx";
10
11
 
12
+ // Shared with UserMenu.tsx's readAuthSnapshot/writeAuthSnapshot — the
13
+ // inline script below reads this key before paint to swap the static
14
+ // fallback to the cached signed-in state, removing the nav reload flash.
15
+ const AUTH_SNAPSHOT_KEY = "hz-auth-snapshot";
16
+ const TOKENS_HREF = withBase("/settings/tokens");
17
+
11
18
  const GITHUB_ICON_PATH =
12
19
  "M12 .5C5.65.5.5 5.65 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-2.02c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.27-5.24-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.03 0 0 .96-.31 3.15 1.17a10.94 10.94 0 0 1 5.76 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.58.23 2.74.11 3.03.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.37-5.25 5.65.41.35.78 1.04.78 2.11v3.13c0 .3.21.66.79.55C20.71 21.39 24 17.08 24 12 24 5.65 18.85.5 12 .5z";
13
20
  ---
14
21
  <div class="user-menu-shell">
15
22
  <div class="user-menu um-fallback" data-user-menu-fallback aria-hidden="true">
16
- <button type="button" class="um-btn" disabled tabindex="-1">
17
- <svg
18
- class="um-gh"
19
- viewBox="0 0 24 24"
20
- width="14"
21
- height="14"
22
- fill="currentColor"
23
- aria-hidden="true"
24
- >
25
- <path d={GITHUB_ICON_PATH} />
26
- </svg>
27
- <span>Sign in with GitHub</span>
28
- </button>
23
+ {/* Signed-out placeholder. Shown by default; the inline script below
24
+ hides it when a cached signed-in snapshot exists. */}
25
+ <span class="um-fallback-variant" data-um-fallback-signedout>
26
+ <button type="button" class="um-btn" disabled tabindex="-1">
27
+ <svg
28
+ class="um-gh"
29
+ viewBox="0 0 24 24"
30
+ width="14"
31
+ height="14"
32
+ fill="currentColor"
33
+ aria-hidden="true"
34
+ >
35
+ <path d={GITHUB_ICON_PATH} />
36
+ </svg>
37
+ <span>Sign in with GitHub</span>
38
+ </button>
39
+ </span>
40
+ {/* Signed-in placeholder. Hidden until the inline script fills in the
41
+ cached avatar + name and reveals it. Mirrors the island's
42
+ signed-in layout so the swap on hydration is invisible. */}
43
+ <span class="um-fallback-variant" data-um-fallback-signedin hidden>
44
+ <img class="um-avatar" alt="" data-um-avatar hidden />
45
+ <span class="um-avatar um-avatar-fallback" aria-hidden="true" data-um-initial hidden></span>
46
+ <span class="um-name" data-um-name></span>
47
+ <a class="um-btn um-mcp-btn" href={TOKENS_HREF} tabindex="-1">
48
+ <svg
49
+ viewBox="0 0 24 24"
50
+ width="14"
51
+ height="14"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ stroke-width="2"
55
+ stroke-linecap="round"
56
+ stroke-linejoin="round"
57
+ aria-hidden="true"
58
+ >
59
+ <circle cx="7.5" cy="15.5" r="4.5" />
60
+ <path d="M11 12L20 3l1.5 1.5L20 6l1.5 1.5L19 9l-1.5-1.5L16 9" />
61
+ </svg>
62
+ <span>MCP setup</span>
63
+ </a>
64
+ <button type="button" class="um-btn um-btn-icon" disabled tabindex="-1">
65
+ <svg
66
+ viewBox="0 0 24 24"
67
+ width="14"
68
+ height="14"
69
+ fill="none"
70
+ stroke="currentColor"
71
+ stroke-width="2"
72
+ stroke-linecap="round"
73
+ stroke-linejoin="round"
74
+ aria-hidden="true"
75
+ >
76
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
77
+ <polyline points="16 17 21 12 16 7" />
78
+ <line x1="21" y1="12" x2="9" y2="12" />
79
+ </svg>
80
+ <span class="sr-only">Sign out</span>
81
+ </button>
82
+ </span>
29
83
  </div>
30
84
  <UserMenuIsland client:only="react" />
31
85
  </div>
32
86
 
87
+ {/* Pre-paint: read the cached session snapshot and, if signed in, swap
88
+ the static fallback to an avatar + name placeholder before the first
89
+ frame. Runs inline (blocking) so there's no flash of "Sign in with
90
+ GitHub" for returning signed-in users. */}
91
+ <script is:inline define:vars={{ snapshotKey: AUTH_SNAPSHOT_KEY }}>
92
+ (function () {
93
+ try {
94
+ var raw = window.localStorage.getItem(snapshotKey);
95
+ if (!raw) return;
96
+ var user = (JSON.parse(raw) || {}).user;
97
+ if (!user) return; // signed out → keep the default placeholder
98
+ var root = document.querySelector("[data-user-menu-fallback]");
99
+ if (!root) return;
100
+ var signedOut = root.querySelector("[data-um-fallback-signedout]");
101
+ var signedIn = root.querySelector("[data-um-fallback-signedin]");
102
+ if (!signedOut || !signedIn) return;
103
+
104
+ var label = user.name || user.email || "Signed in";
105
+ var first = String(label).trim().split(/\s+/)[0] || "Signed in";
106
+ var nameEl = signedIn.querySelector("[data-um-name]");
107
+ if (nameEl) {
108
+ nameEl.textContent = first;
109
+ nameEl.setAttribute("title", label);
110
+ }
111
+ var avatar = signedIn.querySelector("[data-um-avatar]");
112
+ var initial = signedIn.querySelector("[data-um-initial]");
113
+ if (user.image && avatar) {
114
+ avatar.src = user.image;
115
+ avatar.alt = label;
116
+ avatar.hidden = false;
117
+ } else if (initial) {
118
+ initial.textContent = label.trim().charAt(0).toUpperCase();
119
+ initial.hidden = false;
120
+ }
121
+ signedOut.hidden = true;
122
+ signedIn.hidden = false;
123
+ } catch (e) {
124
+ /* ignore — fall back to the default signed-out placeholder */
125
+ }
126
+ })();
127
+ </script>
128
+
33
129
  <style is:global>
34
130
  .user-menu-shell {
35
131
  display: grid;
@@ -76,6 +172,14 @@ const GITHUB_ICON_PATH =
76
172
  .um-fallback[hidden] {
77
173
  display: none;
78
174
  }
175
+ /* Variants flow their children directly into the .user-menu flex row
176
+ (so the parent's gap applies), and collapse fully when hidden. */
177
+ .um-fallback-variant {
178
+ display: contents;
179
+ }
180
+ .um-fallback-variant[hidden] {
181
+ display: none;
182
+ }
79
183
  .um-btn {
80
184
  display: inline-flex;
81
185
  align-items: center;
@@ -27,15 +27,72 @@ interface Session {
27
27
  user?: SessionUser;
28
28
  }
29
29
 
30
+ // Key for the client-side session snapshot. Shared with the inline
31
+ // pre-paint script in UserMenu.astro — keep both in sync.
32
+ const AUTH_SNAPSHOT_KEY = "hz-auth-snapshot";
33
+
34
+ // Read the last-known session synchronously so the island can paint the
35
+ // correct menu on its very first render instead of waiting a network
36
+ // round-trip (the source of the nav "reload flash"). Returns:
37
+ // - `undefined` → never resolved on this device; render nothing yet
38
+ // - `null` → last known to be signed out
39
+ // - Session → last known signed-in user
40
+ function readAuthSnapshot(): Session | null | undefined {
41
+ if (typeof window === "undefined") return undefined;
42
+ try {
43
+ const raw = window.localStorage.getItem(AUTH_SNAPSHOT_KEY);
44
+ if (!raw) return undefined;
45
+ const parsed = JSON.parse(raw) as { user?: SessionUser | null };
46
+ return parsed?.user ? { user: parsed.user } : null;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ // Persist a minimal, non-sensitive snapshot (name/email/image) so the
53
+ // next page load can render optimistically. Stores `{ user: null }` when
54
+ // signed out so a returning signed-out user also skips the loading gap.
55
+ function writeAuthSnapshot(session: Session | null) {
56
+ if (typeof window === "undefined") return;
57
+ try {
58
+ const user = session?.user
59
+ ? {
60
+ name: session.user.name ?? null,
61
+ email: session.user.email ?? null,
62
+ image: session.user.image ?? null,
63
+ }
64
+ : null;
65
+ window.localStorage.setItem(AUTH_SNAPSHOT_KEY, JSON.stringify({ user }));
66
+ } catch {
67
+ /* storage unavailable (private mode, quota) — degrade to no cache */
68
+ }
69
+ }
70
+
71
+ function clearAuthSnapshot() {
72
+ if (typeof window === "undefined") return;
73
+ try {
74
+ window.localStorage.removeItem(AUTH_SNAPSHOT_KEY);
75
+ } catch {
76
+ /* storage unavailable (private mode, quota) — degrade to no cache */
77
+ }
78
+ }
79
+
30
80
  const GITHUB_ICON_PATH =
31
81
  "M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.55v-2.02c-3.2.7-3.88-1.36-3.88-1.36-.52-1.33-1.28-1.68-1.28-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.27-5.24-5.66 0-1.25.45-2.27 1.18-3.07-.12-.29-.51-1.45.11-3.03 0 0 .96-.31 3.15 1.17a10.94 10.94 0 0 1 5.76 0c2.19-1.48 3.15-1.17 3.15-1.17.62 1.58.23 2.74.11 3.03.74.8 1.18 1.82 1.18 3.07 0 4.4-2.69 5.37-5.25 5.65.41.35.78 1.04.78 2.11v3.13c0 .3.21.66.79.55C20.71 21.39 24 17.08 24 12 24 5.65 18.85.5 12 .5z";
32
82
 
33
83
  export default function UserMenu() {
34
84
  // `undefined` = not yet loaded; `null` = no auth or signed out;
35
- // object = signed in. The tri-state avoids flashing the sign-in
85
+ // object = signed in. Seeded from the last-known snapshot so a
86
+ // returning user paints the right menu immediately, then revalidated
87
+ // by the fetch below. The tri-state avoids flashing the sign-in
36
88
  // button while the session fetch is in flight.
37
- const [session, setSession] = useState<Session | null | undefined>(undefined);
89
+ const [session, setSession] = useState<Session | null | undefined>(readAuthSnapshot);
38
90
  const [csrfToken, setCsrfToken] = useState<string | null>(null);
91
+ // Flips true once the network fetch settles (success, 404, or error).
92
+ // Lets us tell a *cached* signed-out state (keep the static fallback
93
+ // visible until we know more) apart from a *resolved* not-wired state
94
+ // (hide the fallback so Tier-1 scaffolds show no dead UI).
95
+ const [resolved, setResolved] = useState(false);
39
96
 
40
97
  useEffect(() => {
41
98
  let cancelled = false;
@@ -50,16 +107,23 @@ export default function UserMenu() {
50
107
  if (!sessRes.ok || !csrfRes.ok) {
51
108
  setSession(null);
52
109
  setCsrfToken(null);
110
+ setResolved(true);
111
+ clearAuthSnapshot();
53
112
  return;
54
113
  }
55
114
  const sess = (await sessRes.json()) as Session | null;
56
115
  const csrf = (await csrfRes.json()) as { csrfToken?: string } | null;
57
- setSession(sess?.user ? sess : null);
116
+ const nextSession = sess?.user ? sess : null;
117
+ setSession(nextSession);
58
118
  setCsrfToken(csrf?.csrfToken ?? null);
119
+ setResolved(true);
120
+ writeAuthSnapshot(nextSession);
59
121
  } catch {
60
122
  if (!cancelled) {
61
123
  setSession(null);
62
124
  setCsrfToken(null);
125
+ setResolved(true);
126
+ clearAuthSnapshot();
63
127
  }
64
128
  }
65
129
  })();
@@ -69,16 +133,28 @@ export default function UserMenu() {
69
133
  }, []);
70
134
 
71
135
  useEffect(() => {
72
- if (session === undefined) return;
136
+ // Only retire the static fallback once the island has something to
137
+ // show in its place (a known user or the wired sign-in form) or the
138
+ // fetch has resolved with nothing to show (Tier-1). Until then, keep
139
+ // the server-rendered fallback so the nav never goes blank.
140
+ const islandHasContent = Boolean(session?.user) || Boolean(csrfToken);
141
+ if (!islandHasContent && !resolved) return;
73
142
  document.querySelectorAll<HTMLElement>("[data-user-menu-fallback]").forEach((el) => {
74
143
  el.hidden = true;
75
144
  });
76
- }, [session]);
77
-
78
- // Loading or auth not wired → render nothing.
79
- if (session === undefined || !csrfToken) return null;
145
+ }, [session, csrfToken, resolved]);
80
146
 
81
147
  const user = session?.user;
148
+
149
+ // Render nothing until we either know a signed-in user (from the cache
150
+ // or the fetch) or have confirmed auth is wired (csrf token present).
151
+ // A cached user paints immediately — that's what kills the reload
152
+ // flash. Withholding the signed-out form until csrf loads keeps
153
+ // Tier-1 scaffolds — where the auth endpoints 404, so csrf stays
154
+ // null — from surfacing a dead "Sign in with GitHub" button, and
155
+ // avoids a tokenless form before the round-trip completes.
156
+ if (!user && !csrfToken) return null;
157
+
82
158
  const callbackUrl = typeof window !== "undefined" ? window.location.href : withBase("/");
83
159
 
84
160
  // Compact label for the topbar: first word of `name`, falling back
@@ -127,10 +203,19 @@ export default function UserMenu() {
127
203
  </svg>
128
204
  <span>MCP setup</span>
129
205
  </a>
130
- <form method="post" action={withBase("/api/auth/signout")}>
131
- <input type="hidden" name="csrfToken" value={csrfToken} />
206
+ <form
207
+ method="post"
208
+ action={withBase("/api/auth/signout")}
209
+ onSubmit={() => writeAuthSnapshot(null)}
210
+ >
211
+ <input type="hidden" name="csrfToken" value={csrfToken ?? ""} />
132
212
  <input type="hidden" name="callbackUrl" value={callbackUrl} />
133
- <button type="submit" className="um-btn um-btn-icon" title="Sign out">
213
+ <button
214
+ type="submit"
215
+ className="um-btn um-btn-icon"
216
+ title="Sign out"
217
+ disabled={!csrfToken}
218
+ >
134
219
  <svg
135
220
  viewBox="0 0 24 24"
136
221
  width="14"
@@ -152,9 +237,9 @@ export default function UserMenu() {
152
237
  </>
153
238
  ) : (
154
239
  <form method="post" action={withBase("/api/auth/signin/github")}>
155
- <input type="hidden" name="csrfToken" value={csrfToken} />
240
+ <input type="hidden" name="csrfToken" value={csrfToken ?? ""} />
156
241
  <input type="hidden" name="callbackUrl" value={callbackUrl} />
157
- <button type="submit" className="um-btn">
242
+ <button type="submit" className="um-btn" disabled={!csrfToken}>
158
243
  <svg
159
244
  className="um-gh"
160
245
  viewBox="0 0 24 24"
@@ -3,11 +3,18 @@ interface Props {
3
3
  src: string;
4
4
  title?: string;
5
5
  aspect?: string;
6
- type?: "iframe" | "video";
6
+ type?: "iframe" | "video" | "slides";
7
+ slide?: string | number;
7
8
  }
8
- const { src, title = "Embedded content", aspect = "16/9", type = "iframe" } = Astro.props;
9
+ const {
10
+ src,
11
+ title = "Embedded content",
12
+ aspect = "16/9",
13
+ type = "iframe",
14
+ slide,
15
+ } = Astro.props;
9
16
 
10
- function normalize(url: string): string {
17
+ function normalizeIframe(url: string): string {
11
18
  try {
12
19
  const u = new URL(url, "https://example.com");
13
20
  if (u.hostname.endsWith("youtube.com")) {
@@ -20,10 +27,53 @@ function normalize(url: string): string {
20
27
  }
21
28
  }
22
29
 
23
- const finalSrc = type === "iframe" ? normalize(src) : src;
30
+ // Google Slides jumps to a slide via `slide=id.p<n>` for a numeric position, or
31
+ // a raw object id (`id.g123abc`) passed through as-is.
32
+ function googleSlideParam(value: string | number): string {
33
+ return typeof value === "number" ? `id.p${value}` : value;
34
+ }
35
+
36
+ function normalizeSlides(url: string, start?: string | number): string {
37
+ let u: URL;
38
+ try {
39
+ u = new URL(url);
40
+ } catch {
41
+ return url;
42
+ }
43
+ const isGoogleSlides =
44
+ u.hostname.endsWith("docs.google.com") && u.pathname.includes("/presentation/");
45
+ if (isGoogleSlides) {
46
+ u.pathname = u.pathname.replace(/\/(edit|pub|present|preview|view)$/, "/embed");
47
+ if (!u.pathname.endsWith("/embed")) {
48
+ u.pathname = `${u.pathname.replace(/\/$/, "")}/embed`;
49
+ }
50
+ // The editor URL carries the slide in a `#slide=...` fragment; the embed
51
+ // player reads it from the query string instead.
52
+ const fragmentSlide = u.hash.match(/slide=([^&]+)/);
53
+ if (fragmentSlide && !u.searchParams.has("slide")) {
54
+ u.searchParams.set("slide", fragmentSlide[1]);
55
+ }
56
+ u.hash = "";
57
+ }
58
+ if (start !== undefined) {
59
+ u.searchParams.set("slide", isGoogleSlides ? googleSlideParam(start) : String(start));
60
+ }
61
+ return u.toString();
62
+ }
63
+
64
+ const isVideo = type === "video";
65
+ const finalSrc = isVideo
66
+ ? src
67
+ : type === "slides"
68
+ ? normalizeSlides(src, slide)
69
+ : normalizeIframe(src);
24
70
  ---
25
71
  <div class="embed" style={`aspect-ratio: ${aspect};`}>
26
- {type === "iframe" ? (
72
+ {isVideo ? (
73
+ <video controls preload="metadata">
74
+ <source src={finalSrc} />
75
+ </video>
76
+ ) : (
27
77
  <iframe
28
78
  src={finalSrc}
29
79
  title={title}
@@ -32,10 +82,6 @@ const finalSrc = type === "iframe" ? normalize(src) : src;
32
82
  referrerpolicy="strict-origin-when-cross-origin"
33
83
  allowfullscreen
34
84
  />
35
- ) : (
36
- <video controls preload="metadata">
37
- <source src={finalSrc} />
38
- </video>
39
85
  )}
40
86
  </div>
41
87
 
package/src/index.ts CHANGED
@@ -51,6 +51,12 @@ export {
51
51
  } from "./lib/progress/useProgress.ts";
52
52
  // Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
53
53
  export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
54
+ export {
55
+ createTutorialSummary,
56
+ type TutorialDifficulty,
57
+ type TutorialSummary,
58
+ type TutorialSummaryInput,
59
+ } from "./lib/tutorialSummary.ts";
54
60
 
55
61
  // AI config type (consumers provide concrete values; framework consumes shape).
56
62
  export type { AiConfig } from "./types/ai.ts";
@@ -5,6 +5,7 @@ import StepHeroMedia from "../components/StepHeroMedia.astro";
5
5
  import StepNav from "../components/StepNav.astro";
6
6
  import { parseStepId } from "../lib/content.ts";
7
7
  import type { TutorialEntry, StepEntry } from "../lib/content";
8
+ import type { TutorialSummary } from "../lib/tutorialSummary";
8
9
 
9
10
  interface Props {
10
11
  tutorial: TutorialEntry;
@@ -21,6 +22,7 @@ interface Props {
21
22
  repoUrl?: string;
22
23
  siteUrl?: string;
23
24
  siteCreditLabel?: string;
25
+ nextTutorial?: TutorialSummary;
24
26
  /** Set false to drop the built-in footer and supply your own. */
25
27
  showFooter?: boolean;
26
28
  }
@@ -40,6 +42,7 @@ const {
40
42
  repoUrl,
41
43
  siteUrl,
42
44
  siteCreditLabel,
45
+ nextTutorial,
43
46
  showFooter = true,
44
47
  } = Astro.props;
45
48
 
@@ -117,6 +120,7 @@ const trackBootstrap =
117
120
  currentStepSlug={currentStepSlug}
118
121
  gated={tutorial.data.gated}
119
122
  hasCheckpoint={hasCheckpoint}
123
+ nextTutorial={nextTutorial}
120
124
  />
121
125
 
122
126
  {tutorial.data.feedbackUrl && (
@@ -30,6 +30,16 @@ export function createHeroMediaSchema(
30
30
  caption: schema.string().min(1).optional(),
31
31
  })
32
32
  .strict(),
33
+ schema
34
+ .object({
35
+ kind: schema.literal("slides"),
36
+ src: schema.string().min(1),
37
+ title: schema.string().min(1),
38
+ aspect: schema.string().min(1).default("16/9"),
39
+ slide: schema.union([schema.string().min(1), schema.number()]).optional(),
40
+ caption: schema.string().min(1).optional(),
41
+ })
42
+ .strict(),
33
43
  ]);
34
44
  }
35
45
 
@@ -0,0 +1,32 @@
1
+ export type TutorialDifficulty = "beginner" | "intermediate" | "advanced";
2
+
3
+ export interface TutorialSummaryInput {
4
+ id: string;
5
+ data: {
6
+ title: string;
7
+ description: string;
8
+ difficulty: TutorialDifficulty;
9
+ estimatedDuration?: string;
10
+ };
11
+ }
12
+
13
+ export interface TutorialSummary {
14
+ slug: string;
15
+ title: string;
16
+ description: string;
17
+ difficulty: TutorialDifficulty;
18
+ duration?: string;
19
+ }
20
+
21
+ export function createTutorialSummary(
22
+ tutorial: TutorialSummaryInput,
23
+ summedDuration?: string,
24
+ ): TutorialSummary {
25
+ return {
26
+ slug: tutorial.id,
27
+ title: tutorial.data.title,
28
+ description: tutorial.data.description,
29
+ difficulty: tutorial.data.difficulty,
30
+ duration: tutorial.data.estimatedDuration ?? summedDuration,
31
+ };
32
+ }
@@ -5,11 +5,12 @@ import ChatButton from "../components/ai/ChatButton.tsx";
5
5
  import OpenInAgent from "../components/ai/OpenInAgent.tsx";
6
6
  import SelectionAsk from "../components/ai/SelectionAsk.tsx";
7
7
  import StepHelp from "../components/ai/StepHelp.tsx";
8
- import { parseStepId } from "../lib/content.ts";
8
+ import { getStepsForTutorial, getTutorialBySlug, parseStepId, sumDurations } from "../lib/content.ts";
9
9
  import type { StepEntry, TutorialEntry } from "../lib/content.ts";
10
10
  import { mdxComponents } from "../lib/mdx-components.ts";
11
11
  import { buildContext } from "../lib/ai/context.ts";
12
12
  import { emptyState } from "../lib/progress/types.ts";
13
+ import { createTutorialSummary } from "../lib/tutorialSummary.ts";
13
14
  import type { AiConfig } from "../types/ai.ts";
14
15
 
15
16
  interface Props {
@@ -51,6 +52,13 @@ const { Content } = await render(currentStep);
51
52
 
52
53
  const components = mdxComponents();
53
54
  const hasCheckpoint = currentStep.body?.includes("<Checkpoint") ?? false;
55
+ const nextTutorial = tutorial.data.nextTutorial
56
+ ? await getTutorialBySlug(tutorial.data.nextTutorial)
57
+ : undefined;
58
+ const nextTutorialSteps = nextTutorial ? await getStepsForTutorial(nextTutorial.id) : [];
59
+ const nextTutorialSummary = nextTutorial
60
+ ? createTutorialSummary(nextTutorial, sumDurations(nextTutorialSteps))
61
+ : undefined;
54
62
 
55
63
  const aiConfig: AiConfig = { ...aiDefaults, ...(tutorial.data.ai ?? {}) };
56
64
  const initialContext = buildContext({
@@ -77,6 +85,7 @@ const initialContext = buildContext({
77
85
  repoUrl={repoUrl}
78
86
  siteUrl={siteUrl}
79
87
  siteCreditLabel={siteCreditLabel}
88
+ nextTutorial={nextTutorialSummary}
80
89
  showFooter={showFooter}
81
90
  >
82
91
  <slot name="head" slot="head" />
@@ -12,41 +12,27 @@
12
12
  display: inline-flex;
13
13
  align-items: center;
14
14
  gap: 0.5rem;
15
- padding: 0.65rem 1rem;
16
- background: linear-gradient(
17
- 135deg,
18
- color-mix(in oklab, var(--color-accent) 92%, white),
19
- var(--color-accent) 50%,
20
- color-mix(in oklab, var(--color-accent) 80%, var(--color-fg))
21
- );
22
- color: var(--color-accent-fg);
23
- border: 0;
15
+ padding: 0.55rem 0.9rem;
16
+ /* Ghost / outlined style: transparent surface so the button reads as a
17
+ * quiet affordance rather than a primary CTA. */
18
+ background: color-mix(in oklab, var(--color-surface) 70%, transparent);
19
+ color: var(--color-fg);
20
+ border: var(--border-default) solid color-mix(in oklab, var(--color-border) 80%, var(--color-accent));
24
21
  font-weight: 600;
25
22
  cursor: pointer;
26
- /* Inset top-light for depth + a soft accent glow that lifts the
27
- * button off the page bg without rounding the corners. */
28
- box-shadow:
29
- inset 0 1px 0 color-mix(in srgb, white 25%, transparent),
30
- 0 0 0 1px color-mix(in oklab, var(--color-accent) 60%, var(--color-fg)),
31
- 0 6px 20px color-mix(in oklab, var(--color-accent) 35%, transparent);
32
- transition: transform 0.12s ease, box-shadow 0.12s ease, background-position 0.3s ease;
33
- background-size: 140% 140%;
34
- background-position: 0% 0%;
23
+ backdrop-filter: blur(6px);
24
+ transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
35
25
  }
36
26
  .chat-fab:hover {
37
- transform: translateY(-1px);
38
- background-position: 100% 100%;
39
- box-shadow:
40
- inset 0 1px 0 color-mix(in srgb, white 30%, transparent),
41
- 0 0 0 1px color-mix(in oklab, var(--color-accent) 70%, var(--color-fg)),
42
- 0 10px 26px color-mix(in oklab, var(--color-accent) 50%, transparent);
27
+ color: var(--color-accent);
28
+ border-color: var(--color-accent);
29
+ background: color-mix(in oklab, var(--color-accent) 10%, var(--color-surface));
43
30
  }
44
31
  .chat-fab:active {
45
- transform: translateY(0);
32
+ background: color-mix(in oklab, var(--color-accent) 16%, var(--color-surface));
46
33
  }
47
34
  .chat-fab :is(svg) {
48
- /* Tiny shadow under the Sparkles so it reads as an icon, not paint. */
49
- filter: drop-shadow(0 1px 0 color-mix(in srgb, black 30%, transparent));
35
+ color: var(--color-accent);
50
36
  }
51
37
 
52
38
  .chat-panel {