handzon-core 0.15.0 → 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.15.0",
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";
@@ -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"