handzon-core 0.6.1 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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"
@@ -46,6 +46,8 @@
46
46
  "@fontsource-variable/geist": "^5.2.9",
47
47
  "@fontsource-variable/geist-mono": "^5.2.8",
48
48
  "@radix-ui/react-dialog": "^1.1.15",
49
+ "@radix-ui/react-popover": "^1.1.15",
50
+ "@radix-ui/react-select": "^2.1.6",
49
51
  "auth-astro": "^4.2.0",
50
52
  "cookie": "^1.0.2",
51
53
  "diff": "^9.0.0",
@@ -121,6 +121,11 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
121
121
  estimatedDuration: z.string().optional(),
122
122
  prerequisites: z.array(z.string()).default([]),
123
123
  nextTutorial: z.string().optional(),
124
+ // TODO(handzon): `cover` and `icon` are accepted by the schema for
125
+ // forward compatibility, but no page currently renders them
126
+ // (Home cards, TutorialLanding, OG meta all ignore them). Wire them
127
+ // up in TutorialCard and BaseLayout's OG tags before promoting
128
+ // cover art in author-facing docs and skills.
124
129
  cover: image().optional(),
125
130
  icon: z.union([z.string(), image()]).optional(),
126
131
  steps: z.array(z.string()).optional(),
@@ -1,7 +1,7 @@
1
1
  import { Sparkles } from "lucide-react";
2
2
  import { useState } from "react";
3
- import type { AiConfig } from "../../types/ai";
4
3
  import type { AssistantContext } from "../../lib/ai/context";
4
+ import type { AiConfig } from "../../types/ai";
5
5
  import ChatPanel from "./ChatPanel";
6
6
 
7
7
  interface Props {
@@ -3,9 +3,9 @@ import { KeyRound, Send, Settings, Sparkles, Trash2, X } from "lucide-react";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import ReactMarkdown from "react-markdown";
5
5
  import remarkGfm from "remark-gfm";
6
- import type { AiConfig } from "../../types/ai";
7
6
  import { type ChatMessage, clearLearnerKey, loadLearnerKey, streamChat } from "../../lib/ai/client";
8
7
  import type { AssistantContext } from "../../lib/ai/context";
8
+ import type { AiConfig } from "../../types/ai";
9
9
  import ByokSetup from "./ByokSetup";
10
10
 
11
11
  interface Props {
@@ -45,6 +45,11 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
45
45
 
46
46
  // Keep the latest message in view as chunks stream in (and on every
47
47
  // send / clear). Without this, long responses scroll out of frame.
48
+ // The deps aren't read inside the effect — they're triggers, so the
49
+ // effect re-runs when a new chunk arrives or streaming flips. Biome's
50
+ // exhaustive-deps lint would have us remove them; that would break
51
+ // the autoscroll. Keep the suppression scoped to this single effect.
52
+ // biome-ignore lint/correctness/useExhaustiveDependencies: messages + streaming are intentional triggers
48
53
  useEffect(() => {
49
54
  listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
50
55
  }, [messages, streaming]);
@@ -162,8 +167,8 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
162
167
  <KeyRound size={22} aria-hidden="true" />
163
168
  <h3>API key required</h3>
164
169
  <p>
165
- {config.name} needs an API key to answer questions. Add one to get started —
166
- it's stored in this browser only.
170
+ {config.name} needs an API key to answer questions. Add one to get started — it's
171
+ stored in this browser only.
167
172
  </p>
168
173
  <button type="button" onClick={() => setByokOpen(true)}>
169
174
  Set up key
@@ -197,7 +202,7 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
197
202
  <div className="chat-msg chat-msg-assistant">
198
203
  <span className="chat-role">{config.name}</span>
199
204
  <div className="chat-content">
200
- <span className="chat-thinking" aria-label="Thinking">
205
+ <span className="chat-thinking" role="status" aria-label="Thinking">
201
206
  <span /> <span /> <span />
202
207
  </span>
203
208
  </div>
@@ -53,7 +53,7 @@ export default function UserMenu() {
53
53
  }
54
54
  const sess = (await sessRes.json()) as Session | null;
55
55
  const csrf = (await csrfRes.json()) as { csrfToken?: string } | null;
56
- setSession(sess && sess.user ? sess : null);
56
+ setSession(sess?.user ? sess : null);
57
57
  setCsrfToken(csrf?.csrfToken ?? null);
58
58
  } catch {
59
59
  if (!cancelled) {
@@ -73,22 +73,31 @@ export default function UserMenu() {
73
73
  const user = session?.user;
74
74
  const callbackUrl = typeof window !== "undefined" ? window.location.href : "/";
75
75
 
76
+ // Compact label for the topbar: first word of `name`, falling back
77
+ // to the local part of `email`, falling back to a generic. Full name
78
+ // / email stays in the `alt` text and `title` for accessibility +
79
+ // long-form context.
80
+ const fullLabel = user?.name ?? user?.email ?? "Signed in";
81
+ const displayName = user
82
+ ? ((user.name ? user.name.trim().split(/\s+/)[0] : null) ??
83
+ (user.email ? user.email.split("@")[0] : null) ??
84
+ "Signed in")
85
+ : "";
86
+
76
87
  return (
77
88
  <div className="user-menu">
78
89
  {user ? (
79
90
  <>
80
91
  {user.image ? (
81
- <img
82
- className="um-avatar"
83
- src={user.image}
84
- alt={user.name ?? user.email ?? "Signed in"}
85
- />
92
+ <img className="um-avatar" src={user.image} alt={fullLabel} />
86
93
  ) : (
87
94
  <span className="um-avatar um-avatar-fallback" aria-hidden="true">
88
- {(user.name ?? user.email ?? "?").trim().charAt(0).toUpperCase()}
95
+ {fullLabel.trim().charAt(0).toUpperCase()}
89
96
  </span>
90
97
  )}
91
- <span className="um-name">{user.name ?? user.email ?? "Signed in"}</span>
98
+ <span className="um-name" title={fullLabel}>
99
+ {displayName}
100
+ </span>
92
101
  <form method="post" action="/api/auth/signout">
93
102
  <input type="hidden" name="csrfToken" value={csrfToken} />
94
103
  <input type="hidden" name="callbackUrl" value={callbackUrl} />
@@ -0,0 +1,88 @@
1
+ import { X } from "lucide-react";
2
+ import type { KeyboardEvent } from "react";
3
+
4
+ interface Props {
5
+ q: string;
6
+ levels: Set<string>;
7
+ tags: Set<string>;
8
+ onClearQ: () => void;
9
+ onRemoveLevel: (level: string) => void;
10
+ onRemoveTag: (tag: string) => void;
11
+ onClearAll: () => void;
12
+ }
13
+
14
+ /**
15
+ * Active-filter chip row. Pure presentation — the parent FilterBar
16
+ * owns state mutations and URL writes. Shown only when the parent
17
+ * has determined at least one filter is active.
18
+ *
19
+ * Each chip is a button. Clicking removes the facet value.
20
+ * Backspace/Delete on a focused chip removes it via keyboard.
21
+ *
22
+ * The trailing "Clear all" sits at the end and resets everything
23
+ * (search + levels + tags). This is the only "clear" affordance now;
24
+ * the in-toolbar Clear button was removed.
25
+ */
26
+ export default function ActiveFilterChips({
27
+ q,
28
+ levels,
29
+ tags,
30
+ onClearQ,
31
+ onRemoveLevel,
32
+ onRemoveTag,
33
+ onClearAll,
34
+ }: Props) {
35
+ function chipKey(e: KeyboardEvent<HTMLButtonElement>, remove: () => void) {
36
+ if (e.key === "Backspace" || e.key === "Delete") {
37
+ e.preventDefault();
38
+ remove();
39
+ }
40
+ }
41
+
42
+ return (
43
+ // biome-ignore lint/a11y/useSemanticElements: <fieldset> requires <legend> and carries form-control semantics; this row groups filter-removal buttons.
44
+ <div className="active-filters" role="group" aria-label="Active filters">
45
+ {q && (
46
+ <button
47
+ type="button"
48
+ className="active-filter-chip"
49
+ aria-label={`Remove search: ${q}`}
50
+ onClick={onClearQ}
51
+ onKeyDown={(e) => chipKey(e, onClearQ)}
52
+ >
53
+ <span className="afc-label">search: {q}</span>
54
+ <X size={12} aria-hidden="true" />
55
+ </button>
56
+ )}
57
+ {[...levels].sort().map((level) => (
58
+ <button
59
+ key={`l:${level}`}
60
+ type="button"
61
+ className="active-filter-chip"
62
+ aria-label={`Remove level: ${level}`}
63
+ onClick={() => onRemoveLevel(level)}
64
+ onKeyDown={(e) => chipKey(e, () => onRemoveLevel(level))}
65
+ >
66
+ <span className="afc-label">{level}</span>
67
+ <X size={12} aria-hidden="true" />
68
+ </button>
69
+ ))}
70
+ {[...tags].sort().map((tag) => (
71
+ <button
72
+ key={`t:${tag}`}
73
+ type="button"
74
+ className="active-filter-chip"
75
+ aria-label={`Remove topic: ${tag}`}
76
+ onClick={() => onRemoveTag(tag)}
77
+ onKeyDown={(e) => chipKey(e, () => onRemoveTag(tag))}
78
+ >
79
+ <span className="afc-label">#{tag}</span>
80
+ <X size={12} aria-hidden="true" />
81
+ </button>
82
+ ))}
83
+ <button type="button" className="active-filter-clear" onClick={onClearAll}>
84
+ Clear all
85
+ </button>
86
+ </div>
87
+ );
88
+ }
@@ -1,27 +1,48 @@
1
- import { Flame, Search, X } from "lucide-react";
1
+ import { GraduationCap, Hash, Search } from "lucide-react";
2
2
  import { useEffect, useState } from "react";
3
+ import MultiSelect, { type MultiSelectOption } from "../ui/MultiSelect.tsx";
4
+ import ActiveFilterChips from "./ActiveFilterChips.tsx";
5
+ import SortBar from "./SortBar.tsx";
3
6
 
4
7
  interface Props {
5
8
  difficulties: string[];
6
9
  tags: string[];
10
+ /** Hit counts per difficulty value, server-computed. */
11
+ difficultyCounts: Record<string, number>;
12
+ /** Hit counts per tag, server-computed. */
13
+ tagCounts: Record<string, number>;
7
14
  }
8
15
 
9
16
  interface FilterState {
10
17
  q: string;
11
- difficulty: string;
12
- tag: string;
13
- /** "" = curated (numeric-prefix) order; "popular" = by data-popularity desc. */
14
- sort: string;
18
+ levels: Set<string>;
19
+ tags: Set<string>;
20
+ }
21
+
22
+ function parseCsv(value: string | null): Set<string> {
23
+ if (!value) return new Set();
24
+ return new Set(
25
+ value
26
+ .split(",")
27
+ .map((s) => s.trim())
28
+ .filter(Boolean),
29
+ );
15
30
  }
16
31
 
17
32
  function readUrlState(): FilterState {
18
- if (typeof window === "undefined") return { q: "", difficulty: "", tag: "", sort: "" };
33
+ if (typeof window === "undefined") {
34
+ return { q: "", levels: new Set(), tags: new Set() };
35
+ }
19
36
  const url = new URL(window.location.href);
37
+ const levels = url.searchParams.get("level")
38
+ ? parseCsv(url.searchParams.get("level"))
39
+ : // Legacy single-value shape; honor on read so shared links keep
40
+ // working. The next interaction rewrites to ?level=.
41
+ parseCsv(url.searchParams.get("difficulty"));
20
42
  return {
21
43
  q: url.searchParams.get("q") ?? "",
22
- difficulty: url.searchParams.get("difficulty") ?? "",
23
- tag: url.searchParams.get("tag") ?? "",
24
- sort: url.searchParams.get("sort") ?? "",
44
+ levels,
45
+ tags: parseCsv(url.searchParams.get("tag")),
25
46
  };
26
47
  }
27
48
 
@@ -29,12 +50,13 @@ function writeUrlState(state: FilterState) {
29
50
  const url = new URL(window.location.href);
30
51
  if (state.q) url.searchParams.set("q", state.q);
31
52
  else url.searchParams.delete("q");
32
- if (state.difficulty) url.searchParams.set("difficulty", state.difficulty);
33
- else url.searchParams.delete("difficulty");
34
- if (state.tag) url.searchParams.set("tag", state.tag);
53
+ if (state.levels.size > 0) url.searchParams.set("level", [...state.levels].join(","));
54
+ else url.searchParams.delete("level");
55
+ if (state.tags.size > 0) url.searchParams.set("tag", [...state.tags].join(","));
35
56
  else url.searchParams.delete("tag");
36
- if (state.sort) url.searchParams.set("sort", state.sort);
37
- else url.searchParams.delete("sort");
57
+ // Always strip the legacy key so an upgraded URL doesn't carry both
58
+ // shapes. First user interaction "migrates" the link.
59
+ url.searchParams.delete("difficulty");
38
60
  window.history.replaceState({}, "", url.toString());
39
61
  }
40
62
 
@@ -44,9 +66,10 @@ function applyFilters(state: FilterState) {
44
66
  let visible = 0;
45
67
  cards.forEach((card) => {
46
68
  const matchesQ = !q || card.dataset.search!.includes(q);
47
- const matchesDiff = !state.difficulty || card.dataset.difficulty === state.difficulty;
48
- const matchesTag = !state.tag || (card.dataset.tags ?? "").split(",").includes(state.tag);
49
- const show = matchesQ && matchesDiff && matchesTag;
69
+ const matchesLevel = state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
70
+ const cardTags = (card.dataset.tags ?? "").split(",");
71
+ const matchesTag = state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
72
+ const show = matchesQ && matchesLevel && matchesTag;
50
73
  if (show) {
51
74
  card.removeAttribute("data-filter-hidden");
52
75
  visible += 1;
@@ -54,98 +77,124 @@ function applyFilters(state: FilterState) {
54
77
  card.setAttribute("data-filter-hidden", "");
55
78
  }
56
79
  });
80
+ // Empty state element controls its own visibility; we just set it.
81
+ // The Home.astro inline empty-state is shown when visible === 0.
57
82
  const empty = document.querySelector<HTMLElement>("[data-empty-state]");
58
83
  if (empty) empty.style.display = visible === 0 ? "" : "none";
59
- // Notify Pagination (and any other listeners) that the filtered set
60
- // changed so they can reset their slice.
84
+ // Results status line. Read total from rendered card count (server
85
+ // SSR'd them; localStorage / pagination don't change cards.length).
86
+ const status = document.querySelector<HTMLElement>("[data-results-status]");
87
+ if (status) {
88
+ const total = cards.length;
89
+ if (total === 0) {
90
+ status.textContent = "";
91
+ } else if (visible === total) {
92
+ status.textContent = `Showing all ${total} tutorial${total === 1 ? "" : "s"}`;
93
+ } else {
94
+ status.textContent = `Showing ${visible} of ${total} tutorial${total === 1 ? "" : "s"}`;
95
+ }
96
+ }
97
+ // Fire once per state transition so Pagination resets to page 1.
61
98
  window.dispatchEvent(new CustomEvent("hz:filter-changed"));
62
99
  }
63
100
 
64
- export default function FilterBar({ difficulties, tags }: Props) {
101
+ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCounts }: Props) {
65
102
  const [state, setState] = useState<FilterState>(readUrlState);
66
103
 
67
104
  useEffect(() => {
68
105
  applyFilters(state);
69
106
  writeUrlState(state);
70
- // Sort lives in a separate inline script on Home.astro because it
71
- // doesn't depend on React state — it just reads data-popularity.
72
- // Fire an event so it can re-run.
73
- window.dispatchEvent(
74
- new CustomEvent("hz:sort-changed", { detail: { sort: state.sort } }),
75
- );
76
107
  }, [state]);
77
108
 
78
- function set<K extends keyof FilterState>(key: K, value: FilterState[K]) {
79
- setState((prev) => ({ ...prev, [key]: value }));
109
+ function setQ(q: string) {
110
+ setState((prev) => ({ ...prev, q }));
80
111
  }
81
-
82
- function clear() {
83
- setState({ q: "", difficulty: "", tag: "", sort: "" });
112
+ function setLevels(levels: Set<string>) {
113
+ setState((prev) => ({ ...prev, levels }));
114
+ }
115
+ function setTags(next: Set<string>) {
116
+ setState((prev) => ({ ...prev, tags: next }));
84
117
  }
118
+ function removeLevel(level: string) {
119
+ setState((prev) => {
120
+ const next = new Set(prev.levels);
121
+ next.delete(level);
122
+ return { ...prev, levels: next };
123
+ });
124
+ }
125
+ function removeTag(tag: string) {
126
+ setState((prev) => {
127
+ const next = new Set(prev.tags);
128
+ next.delete(tag);
129
+ return { ...prev, tags: next };
130
+ });
131
+ }
132
+ function clearAll() {
133
+ setState({ q: "", levels: new Set(), tags: new Set() });
134
+ }
135
+
136
+ const levelOpts: MultiSelectOption[] = difficulties.map((d) => ({
137
+ value: d,
138
+ label: d,
139
+ count: difficultyCounts[d] ?? 0,
140
+ }));
141
+ const tagOpts: MultiSelectOption[] = tags.map((t) => ({
142
+ value: t,
143
+ label: `#${t}`,
144
+ count: tagCounts[t] ?? 0,
145
+ }));
85
146
 
86
- const hasFilters = state.q || state.difficulty || state.tag || state.sort;
147
+ const hasActive = state.q.length > 0 || state.levels.size > 0 || state.tags.size > 0;
87
148
 
88
149
  return (
89
150
  <div className="filterbar">
90
- {/* Row 1: search takes the lion's share + level + clear */}
91
- <div className="fb-row fb-row-primary">
151
+ <div className="fb-toolbar">
92
152
  <label className="search">
93
153
  <Search size={16} aria-hidden="true" />
94
154
  <input
95
155
  type="search"
96
156
  placeholder="Search tutorials…"
97
157
  value={state.q}
98
- onChange={(e) => set("q", e.target.value)}
158
+ onChange={(e) => setQ(e.target.value)}
99
159
  aria-label="Search tutorials"
100
160
  />
101
161
  </label>
102
162
 
103
- <div className="pills" role="group" aria-label="Difficulty">
104
- {difficulties.map((d) => (
105
- <button
106
- key={d}
107
- type="button"
108
- className={`pill ${state.difficulty === d ? "is-active" : ""}`}
109
- onClick={() => set("difficulty", state.difficulty === d ? "" : d)}
110
- >
111
- {d}
112
- </button>
113
- ))}
114
- </div>
163
+ <MultiSelect
164
+ id="hz-level"
165
+ label="Level"
166
+ values={state.levels}
167
+ onChange={setLevels}
168
+ options={levelOpts}
169
+ triggerIcon={<GraduationCap size={14} aria-hidden="true" />}
170
+ />
115
171
 
116
- <div className="pills" role="group" aria-label="Sort">
117
- <button
118
- type="button"
119
- className={`pill ${state.sort === "popular" ? "is-active" : ""}`}
120
- onClick={() => set("sort", state.sort === "popular" ? "" : "popular")}
121
- aria-pressed={state.sort === "popular"}
122
- title="Sort by cross-learner popularity"
123
- >
124
- <Flame size={12} aria-hidden="true" /> popular
125
- </button>
126
- </div>
172
+ <MultiSelect
173
+ id="hz-topics"
174
+ label="Topics"
175
+ values={state.tags}
176
+ onChange={setTags}
177
+ options={tagOpts}
178
+ searchable
179
+ triggerIcon={<Hash size={14} aria-hidden="true" />}
180
+ />
127
181
 
128
- {hasFilters && (
129
- <button type="button" className="clear" onClick={clear}>
130
- <X size={14} aria-hidden="true" /> Clear
131
- </button>
132
- )}
182
+ <span className="fb-divider" aria-hidden="true" />
183
+ <div className="fb-sort-slot">
184
+ <SortBar />
185
+ </div>
133
186
  </div>
134
187
 
135
- {/* Row 2: tags wrap freely under the primary row */}
136
- {tags.length > 0 && (
137
- <div className="pills pills-tags" role="group" aria-label="Tags">
138
- {tags.map((t) => (
139
- <button
140
- key={t}
141
- type="button"
142
- className={`pill ${state.tag === t ? "is-active" : ""}`}
143
- onClick={() => set("tag", state.tag === t ? "" : t)}
144
- >
145
- #{t}
146
- </button>
147
- ))}
148
- </div>
188
+ {hasActive && (
189
+ <ActiveFilterChips
190
+ q={state.q}
191
+ levels={state.levels}
192
+ tags={state.tags}
193
+ onClearQ={() => setQ("")}
194
+ onRemoveLevel={removeLevel}
195
+ onRemoveTag={removeTag}
196
+ onClearAll={clearAll}
197
+ />
149
198
  )}
150
199
  </div>
151
200
  );
@@ -19,9 +19,7 @@ interface Props {
19
19
  */
20
20
  function applyPagination(page: number, pageSize: number): number {
21
21
  const visibleByFilter = Array.from(
22
- document.querySelectorAll<HTMLElement>(
23
- "[data-search]:not([data-filter-hidden])",
24
- ),
22
+ document.querySelectorAll<HTMLElement>("[data-search]:not([data-filter-hidden])"),
25
23
  );
26
24
  const start = (page - 1) * pageSize;
27
25
  const end = start + pageSize;
@@ -44,7 +44,9 @@ export default function ResumeRail({ tutorials }: Props) {
44
44
  <span className="rr-prefix">Continue</span>
45
45
  <span className="rr-title">{mostRecent.title}</span>
46
46
  <span className="rr-step">/ {mostRecent.step}</span>
47
- <span className="rr-arrow" aria-hidden="true">→</span>
47
+ <span className="rr-arrow" aria-hidden="true">
48
+
49
+ </span>
48
50
  </a>
49
51
  );
50
52
  }
@@ -0,0 +1,65 @@
1
+ import { ArrowUpDown } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import Dropdown, { type DropdownOption } from "../ui/Dropdown.tsx";
4
+
5
+ /**
6
+ * Sort dropdown for the homepage tutorial grid. Lives above the grid
7
+ * (not inside the filterbar) so the UI affordance signals "order"
8
+ * rather than "filter".
9
+ *
10
+ * URL-driven via `?sort=`. Internally we use the sentinel "default"
11
+ * because Radix Select forbids "" as an item value; the URL still
12
+ * uses an absent param to mean "curated default order".
13
+ * Emits `hz:sort-changed` so the inline script on Home.astro that
14
+ * rewrites CSS `order` on each card re-runs without React coupling.
15
+ */
16
+ type SortValue = "default" | "popular";
17
+
18
+ const OPTIONS: DropdownOption<SortValue>[] = [
19
+ { value: "default", label: "Default" },
20
+ { value: "popular", label: "Most popular" },
21
+ ];
22
+
23
+ function readSortFromUrl(): SortValue {
24
+ if (typeof window === "undefined") return "default";
25
+ const v = new URL(window.location.href).searchParams.get("sort") ?? "";
26
+ return v === "popular" ? "popular" : "default";
27
+ }
28
+
29
+ function writeSortToUrl(value: SortValue) {
30
+ const url = new URL(window.location.href);
31
+ if (value === "popular") url.searchParams.set("sort", "popular");
32
+ else url.searchParams.delete("sort");
33
+ window.history.replaceState({}, "", url.toString());
34
+ }
35
+
36
+ export default function SortBar() {
37
+ const [sort, setSort] = useState<SortValue>("default");
38
+
39
+ // Read once on mount (client-only) so SSR doesn't try to touch
40
+ // `window`. After the initial sync, the dropdown owns the URL.
41
+ useEffect(() => {
42
+ setSort(readSortFromUrl());
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ writeSortToUrl(sort);
47
+ // Translate the internal sentinel back to "" on the wire so the
48
+ // existing Home.astro inline reorder script (which checks for the
49
+ // "popular" string) keeps working without changes.
50
+ const wire = sort === "popular" ? "popular" : "";
51
+ window.dispatchEvent(new CustomEvent("hz:sort-changed", { detail: { sort: wire } }));
52
+ }, [sort]);
53
+
54
+ return (
55
+ <Dropdown<SortValue>
56
+ id="hz-sort"
57
+ value={sort}
58
+ onChange={setSort}
59
+ options={OPTIONS}
60
+ label="Sort"
61
+ triggerIcon={<ArrowUpDown size={14} aria-hidden="true" />}
62
+ ariaLabel="Sort tutorials"
63
+ />
64
+ );
65
+ }