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.
@@ -4,8 +4,14 @@ import type { TutorialEntry } from "../../lib/content";
4
4
  interface Props {
5
5
  tutorial: TutorialEntry;
6
6
  duration?: string;
7
+ /** Total number of steps in this tutorial. Used as the denominator
8
+ * for the per-card progress bar — has to come from the server-side
9
+ * content collection because localStorage only holds states for the
10
+ * steps the user has visited (so a fresh learner who completed 1 of
11
+ * 4 steps would otherwise see 1/1). */
12
+ stepCount: number;
7
13
  }
8
- const { tutorial, duration } = Astro.props;
14
+ const { tutorial, duration, stepCount } = Astro.props;
9
15
  const data = tutorial.data;
10
16
  const slug = tutorial.id;
11
17
  ---
@@ -42,6 +48,11 @@ const slug = tutorial.id;
42
48
  {duration}
43
49
  </span>
44
50
  )}
51
+ <span
52
+ class="card-progress"
53
+ data-tutorial-slug={slug}
54
+ data-step-count={stepCount}
55
+ ></span>
45
56
  </div>
46
57
  {data.tags.length > 0 && (
47
58
  <div class="tags">
@@ -53,7 +64,6 @@ const slug = tutorial.id;
53
64
  data-tutorial-stats-slug={slug}
54
65
  aria-hidden="true"
55
66
  ></div>
56
- <div class="card-progress" data-tutorial-slug={slug}></div>
57
67
  </div>
58
68
  </a>
59
69
  </article>
@@ -83,14 +93,16 @@ const slug = tutorial.id;
83
93
  flex: 1;
84
94
  }
85
95
 
86
- /* Metadata row: difficulty + duration, sitting *below* the
87
- * description (was above). Keeps the title as the lead element and
88
- * groups all the meta badges, tags, progress in the lower half
89
- * of the card. */
96
+ /* Metadata row: difficulty + duration with a divider beneath it.
97
+ * `margin-top: auto` pins this row (and everything below it tags,
98
+ * stats, progress) to the bottom of the card so all cards in the
99
+ * grid share the same bottom alignment regardless of how short the
100
+ * description is. */
90
101
  .badges {
91
102
  display: flex;
92
103
  gap: 0.4rem;
93
- margin-top: 0.85rem;
104
+ margin-top: auto;
105
+ padding-top: 0.85rem;
94
106
  padding-bottom: 0.85rem;
95
107
  border-bottom: 1px solid var(--color-border);
96
108
  flex-wrap: wrap;
@@ -159,7 +171,44 @@ const slug = tutorial.id;
159
171
  color: var(--color-muted);
160
172
  }
161
173
 
162
- .card-progress:not(:empty) { padding-top: 0.5rem; }
174
+ /* Per-card completion ring, hydrated by the home-page script.
175
+ * Sits inside the `.badges` row, pushed to the right edge so it
176
+ * lines up with the level + duration chips on the same baseline. */
177
+ .card-progress {
178
+ margin-left: auto;
179
+ display: inline-flex;
180
+ align-items: center;
181
+ gap: 0.4rem;
182
+ font-family: var(--font-mono);
183
+ font-size: 0.68em;
184
+ color: var(--color-muted);
185
+ text-transform: uppercase;
186
+ letter-spacing: 0.08em;
187
+ }
188
+ .card-progress:empty { display: none; }
189
+ /* The ring + label live inside `.card-progress` but are injected
190
+ * via innerHTML at runtime, so Astro's scope hash doesn't apply to
191
+ * them. Use :global() so the rules still match. */
192
+ .card-progress :global(.ring) {
193
+ /* Start the fill at 12 o'clock. */
194
+ transform: rotate(-90deg);
195
+ flex-shrink: 0;
196
+ }
197
+ .card-progress :global(.ring-track) {
198
+ fill: none;
199
+ stroke: var(--color-border);
200
+ stroke-width: 2.5;
201
+ }
202
+ .card-progress :global(.ring-fill) {
203
+ fill: none;
204
+ stroke: var(--color-accent);
205
+ stroke-width: 2.5;
206
+ stroke-linecap: round;
207
+ transition: stroke-dashoffset 0.3s ease;
208
+ }
209
+ .card-progress :global(.ring-label) {
210
+ color: var(--color-accent);
211
+ }
163
212
 
164
213
  /* Cross-learner popularity, hydrated from /api/tutorials/stats.
165
214
  * Hidden until the script populates content so the card height
@@ -27,12 +27,17 @@ function useRoute() {
27
27
  export default function Checkpoint({ label, id }: Props) {
28
28
  const reactId = useId();
29
29
  const checkpointId = id ?? `checkpoint:${reactId}:${label.slice(0, 40)}`;
30
- const { state, recordCheckpoint, markStepComplete } = useProgress();
30
+ const { state, recordCheckpoint, removeCheckpoint, markStepComplete, markStepIncomplete } =
31
+ useProgress();
31
32
  const route = useRoute();
32
33
  const done = !!state.checkpoints[checkpointId];
33
34
 
34
35
  function onToggle() {
35
- if (done) return;
36
+ if (done) {
37
+ removeCheckpoint(checkpointId);
38
+ if (route) markStepIncomplete(route.tutorial, route.step);
39
+ return;
40
+ }
36
41
  recordCheckpoint(checkpointId);
37
42
  if (route) markStepComplete(route.tutorial, route.step);
38
43
  }
@@ -0,0 +1,91 @@
1
+ import * as Select from "@radix-ui/react-select";
2
+ import { Check, ChevronDown, ChevronUp } from "lucide-react";
3
+ import type { ReactNode } from "react";
4
+
5
+ export interface DropdownOption<V extends string = string> {
6
+ value: V;
7
+ label: string;
8
+ /** Optional leading glyph rendered before the label inside the item. */
9
+ icon?: ReactNode;
10
+ }
11
+
12
+ export interface DropdownProps<V extends string = string> {
13
+ value: V;
14
+ onChange: (value: V) => void;
15
+ options: DropdownOption<V>[];
16
+ /** Visible label glued to the left of the trigger (uppercase mono chip). */
17
+ label?: string;
18
+ /** Optional leading icon rendered inside the trigger before the label. */
19
+ triggerIcon?: ReactNode;
20
+ /** aria-label for the trigger when no `label` is shown. */
21
+ ariaLabel?: string;
22
+ /** Placeholder shown when `value` doesn't match any option. */
23
+ placeholder?: string;
24
+ /** Extra class on the outer wrapper for layout tweaks. */
25
+ className?: string;
26
+ id?: string;
27
+ }
28
+
29
+ /**
30
+ * Project-wide dropdown. Wraps Radix Select so we never ship the native
31
+ * browser dropdown UI — those look out-of-place against the brutalist
32
+ * mono/hard-edge palette and vary wildly across OSes. Every new select
33
+ * in handzon-core should use this; do not introduce raw `<select>`.
34
+ */
35
+ export default function Dropdown<V extends string = string>({
36
+ value,
37
+ onChange,
38
+ options,
39
+ label,
40
+ triggerIcon,
41
+ ariaLabel,
42
+ placeholder,
43
+ className,
44
+ id,
45
+ }: DropdownProps<V>) {
46
+ return (
47
+ <div className={`hz-dd${className ? ` ${className}` : ""}`}>
48
+ {label && (
49
+ <span className="hz-dd-label" id={id ? `${id}-label` : undefined}>
50
+ {label}
51
+ </span>
52
+ )}
53
+ <Select.Root value={value} onValueChange={(v) => onChange(v as V)}>
54
+ <Select.Trigger
55
+ id={id}
56
+ className="hz-dd-trigger"
57
+ aria-label={ariaLabel ?? label}
58
+ aria-labelledby={label && id ? `${id}-label` : undefined}
59
+ >
60
+ {triggerIcon && <span className="hz-dd-tricon">{triggerIcon}</span>}
61
+ <Select.Value placeholder={placeholder ?? "Select…"} />
62
+ <Select.Icon className="hz-dd-caret">
63
+ <ChevronDown size={14} aria-hidden="true" />
64
+ </Select.Icon>
65
+ </Select.Trigger>
66
+
67
+ <Select.Portal>
68
+ <Select.Content className="hz-dd-content" position="popper" sideOffset={6}>
69
+ <Select.ScrollUpButton className="hz-dd-scroll">
70
+ <ChevronUp size={14} aria-hidden="true" />
71
+ </Select.ScrollUpButton>
72
+ <Select.Viewport className="hz-dd-viewport">
73
+ {options.map((opt) => (
74
+ <Select.Item key={opt.value} value={opt.value} className="hz-dd-item">
75
+ {opt.icon && <span className="hz-dd-icon">{opt.icon}</span>}
76
+ <Select.ItemText>{opt.label}</Select.ItemText>
77
+ <Select.ItemIndicator className="hz-dd-check">
78
+ <Check size={14} aria-hidden="true" />
79
+ </Select.ItemIndicator>
80
+ </Select.Item>
81
+ ))}
82
+ </Select.Viewport>
83
+ <Select.ScrollDownButton className="hz-dd-scroll">
84
+ <ChevronDown size={14} aria-hidden="true" />
85
+ </Select.ScrollDownButton>
86
+ </Select.Content>
87
+ </Select.Portal>
88
+ </Select.Root>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,205 @@
1
+ import * as Popover from "@radix-ui/react-popover";
2
+ import { Check, ChevronDown, Search, X } from "lucide-react";
3
+ import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
4
+
5
+ export interface MultiSelectOption {
6
+ value: string;
7
+ label: string;
8
+ count: number;
9
+ }
10
+
11
+ export interface MultiSelectProps {
12
+ values: Set<string>;
13
+ onChange: (next: Set<string>) => void;
14
+ options: MultiSelectOption[];
15
+ /** Label chip glued to the left of the trigger (uppercase mono). */
16
+ label: string;
17
+ /** Optional icon rendered inside the trigger. */
18
+ triggerIcon?: ReactNode;
19
+ /** Adds a "Filter values…" input inside the popover. Use for the
20
+ * Topics popover (long lists); skip for Level (only 3 options). */
21
+ searchable?: boolean;
22
+ /** Trigger aria-label override (falls back to `label`). */
23
+ ariaLabel?: string;
24
+ id?: string;
25
+ }
26
+
27
+ /**
28
+ * Project-wide multi-select dropdown. Wraps Radix Popover so we never
29
+ * ship the browser-native UI and so every dropdown across the app
30
+ * shares the same visual chrome (mono label chip + hard-edge trigger
31
+ * + accent-on-focus). Pairs with Dropdown.tsx — that one handles
32
+ * single-select, this one handles multi.
33
+ *
34
+ * Layout: trigger summarises selection count; popover holds the full
35
+ * list with optional in-popover search, count badges per option, and
36
+ * a footer "Clear" that empties this facet (doesn't close).
37
+ *
38
+ * Keyboard: arrow keys move focus across options (roving tabIndex),
39
+ * Space toggles, Esc closes. Radix Popover doesn't provide list-nav,
40
+ * so we implement it explicitly on each row.
41
+ */
42
+ export default function MultiSelect({
43
+ values,
44
+ onChange,
45
+ options,
46
+ label,
47
+ triggerIcon,
48
+ searchable = false,
49
+ ariaLabel,
50
+ id,
51
+ }: MultiSelectProps) {
52
+ const [open, setOpen] = useState(false);
53
+ const [query, setQuery] = useState("");
54
+ const optionRefs = useRef<Array<HTMLDivElement | null>>([]);
55
+ const [focusIdx, setFocusIdx] = useState(0);
56
+
57
+ // Sort by count desc so "weighty" facets bubble up. Stable
58
+ // alphabetical secondary sort keeps neighbours predictable.
59
+ const sorted = useMemo(
60
+ () => [...options].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)),
61
+ [options],
62
+ );
63
+
64
+ const filtered = useMemo(() => {
65
+ if (!query.trim()) return sorted;
66
+ const q = query.trim().toLowerCase();
67
+ return sorted.filter(
68
+ (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
69
+ );
70
+ }, [sorted, query]);
71
+
72
+ // Reset the search field every time the popover closes so the next
73
+ // open starts clean. Keep focus index in range as `filtered` shrinks.
74
+ useEffect(() => {
75
+ if (!open) {
76
+ setQuery("");
77
+ setFocusIdx(0);
78
+ }
79
+ }, [open]);
80
+ useEffect(() => {
81
+ if (focusIdx >= filtered.length) setFocusIdx(Math.max(0, filtered.length - 1));
82
+ }, [filtered.length, focusIdx]);
83
+
84
+ function toggle(value: string) {
85
+ const next = new Set(values);
86
+ if (next.has(value)) next.delete(value);
87
+ else next.add(value);
88
+ onChange(next);
89
+ }
90
+
91
+ function clear() {
92
+ if (values.size === 0) return;
93
+ onChange(new Set());
94
+ }
95
+
96
+ function summary(): string {
97
+ // The label chip on the left already names the facet ("LEVEL",
98
+ // "TOPICS"), so the trigger body only carries the *value* — no
99
+ // repeat. Empty state reads "Any" (meaning "no filter applied").
100
+ if (values.size === 0) return "Any";
101
+ if (values.size === 1) {
102
+ const only = [...values][0];
103
+ const match = options.find((o) => o.value === only);
104
+ return match?.label ?? only;
105
+ }
106
+ return `${values.size} selected`;
107
+ }
108
+
109
+ function onOptionKey(e: KeyboardEvent<HTMLDivElement>, idx: number) {
110
+ if (e.key === "ArrowDown") {
111
+ e.preventDefault();
112
+ const next = Math.min(idx + 1, filtered.length - 1);
113
+ setFocusIdx(next);
114
+ optionRefs.current[next]?.focus();
115
+ } else if (e.key === "ArrowUp") {
116
+ e.preventDefault();
117
+ const next = Math.max(idx - 1, 0);
118
+ setFocusIdx(next);
119
+ optionRefs.current[next]?.focus();
120
+ } else if (e.key === " " || e.key === "Enter") {
121
+ e.preventDefault();
122
+ toggle(filtered[idx].value);
123
+ }
124
+ }
125
+
126
+ return (
127
+ <Popover.Root open={open} onOpenChange={setOpen}>
128
+ <Popover.Trigger asChild>
129
+ <button
130
+ id={id}
131
+ type="button"
132
+ className="hz-dd hz-ms-trigger"
133
+ aria-label={ariaLabel ?? label}
134
+ data-active={values.size > 0 || undefined}
135
+ >
136
+ <span className="hz-dd-label hz-ms-label">
137
+ {triggerIcon && <span className="hz-ms-label-icon">{triggerIcon}</span>}
138
+ <span>{label}</span>
139
+ </span>
140
+ <span className="hz-dd-trigger hz-ms-trigger-body">
141
+ <span className="hz-ms-value" data-empty={values.size === 0 || undefined}>
142
+ {summary()}
143
+ </span>
144
+ <span className="hz-dd-caret">
145
+ <ChevronDown size={14} aria-hidden="true" />
146
+ </span>
147
+ </span>
148
+ </button>
149
+ </Popover.Trigger>
150
+ <Popover.Portal>
151
+ <Popover.Content className="hz-ms-content" align="start" sideOffset={6}>
152
+ {searchable && (
153
+ <label className="hz-ms-search">
154
+ <Search size={14} aria-hidden="true" />
155
+ <input
156
+ type="search"
157
+ placeholder={`Filter ${label.toLowerCase()}…`}
158
+ value={query}
159
+ onChange={(e) => setQuery(e.target.value)}
160
+ aria-label={`Filter ${label.toLowerCase()}`}
161
+ />
162
+ </label>
163
+ )}
164
+ <div className="hz-ms-viewport" role="listbox" aria-multiselectable="true">
165
+ {filtered.length === 0 ? (
166
+ <div className="hz-ms-empty">No matches.</div>
167
+ ) : (
168
+ filtered.map((opt, idx) => {
169
+ const checked = values.has(opt.value);
170
+ return (
171
+ <div
172
+ key={opt.value}
173
+ ref={(el) => {
174
+ optionRefs.current[idx] = el;
175
+ }}
176
+ role="option"
177
+ aria-selected={checked}
178
+ tabIndex={idx === focusIdx ? 0 : -1}
179
+ className="hz-ms-option"
180
+ data-checked={checked || undefined}
181
+ onClick={() => toggle(opt.value)}
182
+ onKeyDown={(e) => onOptionKey(e, idx)}
183
+ >
184
+ <span className="hz-ms-check" aria-hidden="true">
185
+ {checked && <Check size={12} />}
186
+ </span>
187
+ <span className="hz-ms-option-label">{opt.label}</span>
188
+ <span className="hz-ms-count">{opt.count}</span>
189
+ </div>
190
+ );
191
+ })
192
+ )}
193
+ </div>
194
+ {values.size > 0 && (
195
+ <div className="hz-ms-footer">
196
+ <button type="button" className="hz-ms-clear" onClick={clear}>
197
+ <X size={12} aria-hidden="true" /> Clear {label.toLowerCase()}
198
+ </button>
199
+ </div>
200
+ )}
201
+ </Popover.Content>
202
+ </Popover.Portal>
203
+ </Popover.Root>
204
+ );
205
+ }
package/src/index.ts CHANGED
@@ -8,48 +8,43 @@
8
8
  * and types.
9
9
  */
10
10
 
11
+ // AI client (browser-side BYOK + streaming chat to handzon-ai).
12
+ export {
13
+ type ChatMessage,
14
+ clearLearnerKey,
15
+ loadLearnerKey,
16
+ saveLearnerKey,
17
+ streamChat,
18
+ } from "./lib/ai/client.ts";
19
+ export { type AssistantContext, buildContext } from "./lib/ai/context.ts";
11
20
  // Content collection helpers (built on top of astro:content).
12
21
  export {
13
- parseStepId,
14
- getTutorials,
15
- getTutorialBySlug,
16
- getStepsForTutorial,
17
22
  getStep,
23
+ getStepsForTutorial,
24
+ getTutorialBySlug,
25
+ getTutorials,
26
+ parseStepId,
27
+ type StepEntry,
18
28
  sumDurations,
19
29
  type TutorialEntry,
20
- type StepEntry,
21
30
  } from "./lib/content.ts";
22
-
23
31
  // MDX component map used by .astro pages rendering tutorial content.
24
32
  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
33
  // Progress store (localStorage + optional server sync).
41
34
  export { getStore } from "./lib/progress/local.ts";
42
- export {
43
- useProgress,
44
- useProgressAfterMount,
45
- } from "./lib/progress/useProgress.ts";
46
35
  export {
47
36
  emptyState,
48
- type ProgressState,
49
- type StepKey,
50
37
  type LastVisitedEntry,
38
+ type ProgressState,
51
39
  type ProgressStore,
40
+ type StepKey,
52
41
  } from "./lib/progress/types.ts";
42
+ export {
43
+ useProgress,
44
+ useProgressAfterMount,
45
+ } from "./lib/progress/useProgress.ts";
46
+ // Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
47
+ export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
53
48
 
54
49
  // AI config type (consumers provide concrete values; framework consumes shape).
55
50
  export type { AiConfig } from "./types/ai.ts";
@@ -85,7 +85,11 @@ const desc = description ?? tagline;
85
85
  <UserMenu />
86
86
  </div>
87
87
  )}
88
- <slot />
88
+ {/* Wrap the slot in a flex-growing region so the footer hugs the
89
+ bottom of the viewport even when the page content is short. */}
90
+ <div class="hz-page">
91
+ <slot />
92
+ </div>
89
93
  {showFooter && <Footer repoUrl={repoUrl} />}
90
94
  <script>
91
95
  // Render any <pre class="mermaid"> blocks emitted by rehype-mermaid.
@@ -98,6 +102,25 @@ const desc = description ?? tagline;
98
102
  </script>
99
103
 
100
104
  <style is:global>
105
+ /* Sticky-footer layout: body is a flex column at viewport height
106
+ * minimum; the page-content region grows to fill, pushing the
107
+ * footer to the bottom when content is short. */
108
+ html, body {
109
+ min-height: 100dvh;
110
+ }
111
+ body {
112
+ display: flex;
113
+ flex-direction: column;
114
+ }
115
+ .hz-page {
116
+ flex: 1 0 auto;
117
+ display: flex;
118
+ flex-direction: column;
119
+ }
120
+ .hz-page > * {
121
+ flex: 1 0 auto;
122
+ }
123
+
101
124
  .hz-topbar {
102
125
  position: fixed;
103
126
  top: 0.75rem;
@@ -65,6 +65,13 @@ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
65
65
  out.push({ kind: "checkpoint", scope: "global", key: id, value });
66
66
  }
67
67
  }
68
+ // Emit deletions so the server tombstones unchecked checkpoints; without
69
+ // this the next snapshot fetch would resurrect them from the DB.
70
+ for (const id of Object.keys(prev.checkpoints)) {
71
+ if (!next.checkpoints[id]) {
72
+ out.push({ kind: "checkpoint", scope: "global", key: id, value: null });
73
+ }
74
+ }
68
75
  for (const [k, value] of Object.entries(next.prefs)) {
69
76
  if ((prev.prefs as Record<string, unknown>)[k] !== value) {
70
77
  out.push({ kind: "pref", scope: "global", key: k, value });
@@ -150,6 +157,7 @@ export function createRemoteStore(): ProgressStore {
150
157
  } else if (e.kind === "quiz") {
151
158
  merged.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
152
159
  } else if (e.kind === "checkpoint") {
160
+ if (e.value == null) continue;
153
161
  merged.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
154
162
  } else if (e.kind === "pref") {
155
163
  (merged.prefs as Record<string, unknown>)[e.key] = e.value;
@@ -8,6 +8,7 @@ interface ProgressApi {
8
8
  markStepIncomplete: (tutorial: string, step: string) => void;
9
9
  recordQuiz: (questionId: string, chosen: number[], correct: boolean) => void;
10
10
  recordCheckpoint: (checkpointId: string) => void;
11
+ removeCheckpoint: (checkpointId: string) => void;
11
12
  setPref: <K extends keyof ProgressState["prefs"]>(
12
13
  key: K,
13
14
  value: ProgressState["prefs"][K],
@@ -56,6 +57,13 @@ export function useProgress(): ProgressApi {
56
57
  ...s,
57
58
  checkpoints: { ...s.checkpoints, [checkpointId]: { ts: Date.now() } },
58
59
  })),
60
+ removeCheckpoint: (checkpointId: string) =>
61
+ store.set((s) => {
62
+ if (!s.checkpoints[checkpointId]) return s;
63
+ const next = { ...s.checkpoints };
64
+ delete next[checkpointId];
65
+ return { ...s, checkpoints: next };
66
+ }),
59
67
  setPref: <K extends keyof ProgressState["prefs"]>(key: K, value: ProgressState["prefs"][K]) =>
60
68
  store.set((s) => ({ ...s, prefs: { ...s.prefs, [key]: value } })),
61
69
  setLastVisited: (tutorial: string, step: string) =>