handzon-core 0.12.2 → 0.13.2

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.
@@ -1,9 +1,12 @@
1
1
  import { Sparkles } from "lucide-react";
2
- import { useEffect, useState } from "react";
2
+ import { useEffect, useMemo, useState } from "react";
3
3
  import { ASSIST_EVENT, ASSIST_READY_EVENT, type AssistEventDetail } from "../../lib/ai/assist";
4
4
  import type { ChatMessage } from "../../lib/ai/client";
5
5
  import type { AssistantContext } from "../../lib/ai/context";
6
6
  import { buildAssistantPrompt } from "../../lib/ai/prompts";
7
+ import { useProgress } from "../../lib/progress/useProgress";
8
+ import { stripInactiveTrackBlocks } from "../../lib/track-source";
9
+ import { resolveActiveTrack } from "../../lib/tracks";
7
10
  import type { AiConfig } from "../../types/ai";
8
11
  import ChatPanel from "./ChatPanel";
9
12
 
@@ -13,12 +16,33 @@ interface Props {
13
16
  }
14
17
 
15
18
  export default function ChatButton({ config, context }: Props) {
19
+ const { state } = useProgress();
16
20
  const [open, setOpen] = useState(false);
17
21
  const [seed, setSeed] = useState<ChatMessage[] | undefined>(undefined);
18
22
  // Bumped on each new assist seed so ChatPanel remounts with the
19
23
  // new initialMessages even if the panel is already open from a
20
24
  // previous intent or FAB click.
21
25
  const [seedToken, setSeedToken] = useState(0);
26
+ const trackContext = useMemo<AssistantContext>(() => {
27
+ const activeTrack = resolveActiveTrack({
28
+ tracks: context.tutorial.tracks,
29
+ preferredTrack: state.prefs.track,
30
+ defaultTrack: context.tutorial.defaultTrack,
31
+ });
32
+ if (!activeTrack) return context;
33
+ return {
34
+ ...context,
35
+ tutorial: { ...context.tutorial, track: activeTrack },
36
+ currentStep: {
37
+ ...context.currentStep,
38
+ source: stripInactiveTrackBlocks(context.currentStep.source, activeTrack),
39
+ },
40
+ priorSteps: context.priorSteps.map((step) => ({
41
+ ...step,
42
+ source: stripInactiveTrackBlocks(step.source, activeTrack),
43
+ })),
44
+ };
45
+ }, [context, state.prefs.track]);
22
46
 
23
47
  // Tell Family A islands (HelpMe, Checkpoint nudge, …) that the tutor
24
48
  // is mounted on this page. Set the dataset flag for islands that
@@ -40,14 +64,14 @@ export default function ChatButton({ config, context }: Props) {
40
64
  function onAssist(e: Event) {
41
65
  const detail = (e as CustomEvent<AssistEventDetail>).detail;
42
66
  if (!detail?.intent) return;
43
- const { seedMessages } = buildAssistantPrompt(context, detail.intent);
67
+ const { seedMessages } = buildAssistantPrompt(trackContext, detail.intent);
44
68
  setSeed(seedMessages);
45
69
  setSeedToken((t) => t + 1);
46
70
  setOpen(true);
47
71
  }
48
72
  document.addEventListener(ASSIST_EVENT, onAssist);
49
73
  return () => document.removeEventListener(ASSIST_EVENT, onAssist);
50
- }, [context]);
74
+ }, [trackContext]);
51
75
 
52
76
  if (!config.enabled) return null;
53
77
 
@@ -70,7 +94,7 @@ export default function ChatButton({ config, context }: Props) {
70
94
  open={open}
71
95
  onOpenChange={setOpen}
72
96
  config={config}
73
- context={context}
97
+ context={trackContext}
74
98
  initialMessages={seed}
75
99
  />
76
100
  </>
@@ -224,6 +224,16 @@ export default function ChatPanel({ open, onOpenChange, config, context, initial
224
224
 
225
225
  <div className="chat-meta">
226
226
  On: <strong>{context.currentStep.title}</strong>
227
+ {context.tutorial.track && (
228
+ <>
229
+ {" "}
230
+ · Track:{" "}
231
+ <strong>
232
+ {context.tutorial.tracks.find((track) => track.id === context.tutorial.track)
233
+ ?.label ?? context.tutorial.track}
234
+ </strong>
235
+ </>
236
+ )}
227
237
  </div>
228
238
 
229
239
  {needsKey ? (
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { Image } from "astro:assets";
2
3
  import { withBase } from "../../lib/base";
3
4
  import type { TutorialEntry } from "../../lib/content";
4
5
 
@@ -15,6 +16,8 @@ interface Props {
15
16
  const { tutorial, duration, stepCount } = Astro.props;
16
17
  const data = tutorial.data;
17
18
  const slug = tutorial.id;
19
+ const iconText = typeof data.icon === "string" ? data.icon : undefined;
20
+ const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undefined;
18
21
  ---
19
22
  <article
20
23
  class="card"
@@ -25,8 +28,33 @@ const slug = tutorial.id;
25
28
  data-popularity="0"
26
29
  >
27
30
  <a href={withBase(`/${slug}`)} class="card-link">
31
+ {data.cover && (
32
+ <div class="card-media">
33
+ <Image
34
+ src={data.cover}
35
+ alt=""
36
+ class="card-cover"
37
+ loading="lazy"
38
+ widths={[360, 540, 720]}
39
+ sizes="(min-width: 960px) 33vw, (min-width: 640px) 50vw, 100vw"
40
+ />
41
+ </div>
42
+ )}
28
43
  <div class="card-body">
29
- <h3>{data.title}</h3>
44
+ <div class="card-title-row">
45
+ {iconImage && (
46
+ <Image
47
+ src={iconImage}
48
+ alt=""
49
+ class="card-icon card-icon-image"
50
+ loading="lazy"
51
+ width={32}
52
+ height={32}
53
+ />
54
+ )}
55
+ {iconText && <span class="card-icon card-icon-text" aria-hidden="true">{iconText}</span>}
56
+ <h3>{data.title}</h3>
57
+ </div>
30
58
  <p>{data.description}</p>
31
59
  <div class="badges">
32
60
  <span class={`badge badge-${data.difficulty}`}>{data.difficulty}</span>
@@ -57,7 +85,7 @@ const slug = tutorial.id;
57
85
  </div>
58
86
  {data.tags.length > 0 && (
59
87
  <div class="tags">
60
- {data.tags.map((tag) => <span class="tag">#{tag}</span>)}
88
+ {data.tags.map((tag: string) => <span class="tag">#{tag}</span>)}
61
89
  </div>
62
90
  )}
63
91
  <div
@@ -87,12 +115,58 @@ const slug = tutorial.id;
87
115
  flex-direction: column;
88
116
  width: 100%;
89
117
  }
118
+ .card-media {
119
+ aspect-ratio: 16 / 9;
120
+ border-bottom: 1px solid var(--color-border);
121
+ background:
122
+ linear-gradient(
123
+ 135deg,
124
+ color-mix(in oklab, var(--color-accent) 10%, transparent),
125
+ var(--color-surface)
126
+ );
127
+ overflow: hidden;
128
+ }
129
+ .card-cover {
130
+ width: 100%;
131
+ height: 100%;
132
+ object-fit: cover;
133
+ display: block;
134
+ filter: saturate(0.92) contrast(1.05);
135
+ transition: transform 0.18s ease, filter 0.18s ease;
136
+ }
137
+ .card:hover .card-cover {
138
+ transform: scale(1.025);
139
+ filter: saturate(1) contrast(1.08);
140
+ }
90
141
  .card-body {
91
142
  padding: 1.1rem 1.2rem 1.2rem;
92
143
  display: flex;
93
144
  flex-direction: column;
94
145
  flex: 1;
95
146
  }
147
+ .card-title-row {
148
+ display: flex;
149
+ align-items: flex-start;
150
+ gap: 0.65rem;
151
+ margin-bottom: 0.5rem;
152
+ }
153
+ .card-icon {
154
+ flex-shrink: 0;
155
+ width: 2rem;
156
+ height: 2rem;
157
+ border: 1px solid var(--color-border);
158
+ background: var(--color-surface);
159
+ }
160
+ .card-icon-image {
161
+ object-fit: cover;
162
+ display: block;
163
+ }
164
+ .card-icon-text {
165
+ display: inline-grid;
166
+ place-items: center;
167
+ font-size: 1rem;
168
+ line-height: 1;
169
+ }
96
170
 
97
171
  /* Metadata row: difficulty + duration with a divider beneath it.
98
172
  * `margin-top: auto` pins this row (and everything below it — tags,
@@ -140,7 +214,7 @@ const slug = tutorial.id;
140
214
  h3 {
141
215
  font-size: 1.05rem;
142
216
  font-weight: 700;
143
- margin: 0 0 0.5rem;
217
+ margin: 0;
144
218
  letter-spacing: -0.015em;
145
219
  line-height: 1.3;
146
220
  }
@@ -0,0 +1,11 @@
1
+ ---
2
+ interface Props {
3
+ id: string;
4
+ }
5
+
6
+ const { id } = Astro.props;
7
+ ---
8
+
9
+ <div class="track-panel" data-track-panel={id}>
10
+ <slot />
11
+ </div>
@@ -30,6 +30,8 @@ interface Props {
30
30
  logoHeight?: number;
31
31
  /** Favicon URL injected into <head>. */
32
32
  faviconUrl?: string;
33
+ /** Optional social preview image for Open Graph and Twitter cards. */
34
+ ogImageUrl?: string;
33
35
  /** Show the site footer ("Built with Handzon" + repo link). */
34
36
  showFooter?: boolean;
35
37
  /** Footer link URL; defaults to the Handzon repo. */
@@ -67,6 +69,7 @@ const {
67
69
  logoWidth = 76,
68
70
  logoHeight = 58,
69
71
  faviconUrl = "/favicon.svg",
72
+ ogImageUrl,
70
73
  showFooter = true,
71
74
  repoUrl,
72
75
  siteUrl,
@@ -83,6 +86,7 @@ const {
83
86
  const resolvedMaxWidth = pageMaxWidth === "none" ? "none" : pageMaxWidth;
84
87
  const pageTitle = title ? `${title} — ${siteName}` : siteName;
85
88
  const desc = description ?? tagline;
89
+ const socialImageUrl = ogImageUrl ? withBase(ogImageUrl) : undefined;
86
90
  ---
87
91
  <!doctype html>
88
92
  <html lang="en">
@@ -95,6 +99,9 @@ const desc = description ?? tagline;
95
99
  <meta property="og:title" content={pageTitle} />
96
100
  <meta property="og:description" content={desc} />
97
101
  <meta property="og:type" content="website" />
102
+ {socialImageUrl && <meta property="og:image" content={socialImageUrl} />}
103
+ {socialImageUrl && <meta name="twitter:card" content="summary_large_image" />}
104
+ {socialImageUrl && <meta name="twitter:image" content={socialImageUrl} />}
98
105
  {/* Optional consumer-side <head> injections. Forwarded by every
99
106
  * page wrapper (Home, TutorialLanding, TutorialStep / TutorialLayout)
100
107
  * so scaffolds can preload fonts, attach per-page OG images, add
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import BaseLayout from "./BaseLayout.astro";
3
3
  import Sidebar from "../components/Sidebar.astro";
4
+ import StepHeroMedia from "../components/StepHeroMedia.astro";
4
5
  import StepNav from "../components/StepNav.astro";
5
6
  import { parseStepId } from "../lib/content.ts";
6
7
  import type { TutorialEntry, StepEntry } from "../lib/content";
@@ -43,6 +44,34 @@ const {
43
44
  } = Astro.props;
44
45
 
45
46
  const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
47
+ const trackBootstrap =
48
+ tutorial.data.tracks.length > 0
49
+ ? `(() => {
50
+ try {
51
+ const tracks = ${JSON.stringify(tutorial.data.tracks).replace(/</g, "\\u003c")};
52
+ const defaultTrack = ${JSON.stringify(tutorial.data.defaultTrack ?? null)};
53
+ const ids = tracks.map((track) => track.id);
54
+ const raw = window.localStorage.getItem("handzon:v1");
55
+ const preferred = raw ? JSON.parse(raw)?.prefs?.track : null;
56
+ const active = ids.includes(preferred)
57
+ ? preferred
58
+ : defaultTrack && ids.includes(defaultTrack)
59
+ ? defaultTrack
60
+ : ids[0];
61
+ if (!active) return;
62
+ document.documentElement.dataset.track = active;
63
+ let style = document.getElementById("handzon-track-style");
64
+ if (!style) {
65
+ style = document.createElement("style");
66
+ style.id = "handzon-track-style";
67
+ document.head.appendChild(style);
68
+ }
69
+ style.textContent = '[data-track-panel]:not([data-track-panel="' + active + '"]) { display: none !important; }';
70
+ } catch {
71
+ // Track selection is progressive enhancement; leave all content visible on failure.
72
+ }
73
+ })();`
74
+ : null;
46
75
  ---
47
76
  <BaseLayout
48
77
  title={`${currentStep.data.title} — ${tutorial.data.title}`}
@@ -59,6 +88,7 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
59
88
  showFooter={showFooter}
60
89
  >
61
90
  <slot name="head" slot="head" />
91
+ {trackBootstrap && <script is:inline set:html={trackBootstrap} />}
62
92
  <div class="layout">
63
93
  <div class="sidebar-wrap">
64
94
  <Sidebar tutorial={tutorial} steps={steps} currentStepSlug={currentStepSlug} />
@@ -73,6 +103,10 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
73
103
  )}
74
104
  </header>
75
105
 
106
+ {currentStep.data.heroMedia && (
107
+ <StepHeroMedia media={currentStep.data.heroMedia} />
108
+ )}
109
+
76
110
  <article class="prose">
77
111
  <slot />
78
112
  </article>
@@ -1,6 +1,8 @@
1
1
  import type { StepEntry, TutorialEntry } from "../content";
2
2
  import { parseStepId } from "../content";
3
3
  import type { ProgressState } from "../progress/types";
4
+ import { stripInactiveTrackBlocks } from "../track-source";
5
+ import type { TrackOption } from "../tracks";
4
6
 
5
7
  export interface AssistantContext {
6
8
  tutorial: {
@@ -9,6 +11,9 @@ export interface AssistantContext {
9
11
  description: string;
10
12
  difficulty: string;
11
13
  tags: string[];
14
+ tracks: TrackOption[];
15
+ defaultTrack?: string;
16
+ track?: string | null;
12
17
  };
13
18
  outline: Array<{ slug: string; title: string; completed: boolean; current: boolean }>;
14
19
  currentStep: {
@@ -32,6 +37,7 @@ interface BuildOptions {
32
37
  progress: ProgressState;
33
38
  references?: Array<{ source: string; content: string }>;
34
39
  includeFutureSteps?: boolean;
40
+ activeTrack?: string;
35
41
  }
36
42
 
37
43
  /**
@@ -46,6 +52,7 @@ export function buildContext({
46
52
  progress,
47
53
  references = [],
48
54
  includeFutureSteps = false,
55
+ activeTrack,
49
56
  }: BuildOptions): AssistantContext {
50
57
  const slug = tutorial.id;
51
58
  const currentIdx = steps.findIndex((s) => s.id === currentStep.id);
@@ -67,7 +74,7 @@ export function buildContext({
67
74
  .map((s) => ({
68
75
  slug: parseStepId(s.id).stepSlug,
69
76
  title: s.data.title,
70
- source: s.body ?? "",
77
+ source: stripInactiveTrackBlocks(s.body ?? "", activeTrack),
71
78
  }));
72
79
 
73
80
  return {
@@ -77,12 +84,15 @@ export function buildContext({
77
84
  description: tutorial.data.description,
78
85
  difficulty: tutorial.data.difficulty,
79
86
  tags: tutorial.data.tags,
87
+ tracks: tutorial.data.tracks,
88
+ defaultTrack: tutorial.data.defaultTrack,
89
+ track: activeTrack ?? null,
80
90
  },
81
91
  outline,
82
92
  currentStep: {
83
93
  slug: currentSlug,
84
94
  title: currentStep.data.title,
85
- source: currentStep.body ?? "",
95
+ source: stripInactiveTrackBlocks(currentStep.body ?? "", activeTrack),
86
96
  },
87
97
  priorSteps,
88
98
  progress: {
@@ -30,7 +30,14 @@ export interface AssistantPrompt {
30
30
  }
31
31
 
32
32
  function header(ctx: AssistantContext): string {
33
- return [`Tutorial: ${ctx.tutorial.title}`, `Step: ${ctx.currentStep.title}`].join("\n");
33
+ const lines = [`Tutorial: ${ctx.tutorial.title}`, `Step: ${ctx.currentStep.title}`];
34
+ if (ctx.tutorial.track) {
35
+ const label =
36
+ ctx.tutorial.tracks.find((track) => track.id === ctx.tutorial.track)?.label ??
37
+ ctx.tutorial.track;
38
+ lines.push(`Track: ${label}`);
39
+ }
40
+ return lines.join("\n");
34
41
  }
35
42
 
36
43
  function renderIntent(ctx: AssistantContext, intent: AssistantIntent): string {
@@ -0,0 +1,37 @@
1
+ import { z as defaultZ } from "zod";
2
+
3
+ type SchemaBuilder = typeof defaultZ;
4
+ type ImageSchemaFactory = () => unknown;
5
+
6
+ export function createHeroMediaSchema(
7
+ schema: SchemaBuilder = defaultZ,
8
+ image?: ImageSchemaFactory,
9
+ ) {
10
+ const imageSrc = image
11
+ ? schema.union([image() as never, schema.string().min(1)])
12
+ : schema.string().min(1);
13
+
14
+ return schema.discriminatedUnion("kind", [
15
+ schema
16
+ .object({
17
+ kind: schema.literal("image"),
18
+ src: imageSrc,
19
+ alt: schema.string().min(1),
20
+ caption: schema.string().min(1).optional(),
21
+ })
22
+ .strict(),
23
+ schema
24
+ .object({
25
+ kind: schema.literal("video"),
26
+ src: schema.string().min(1),
27
+ title: schema.string().min(1),
28
+ aspect: schema.string().min(1).default("16/9"),
29
+ type: schema.enum(["iframe", "video"]).default("iframe"),
30
+ caption: schema.string().min(1).optional(),
31
+ })
32
+ .strict(),
33
+ ]);
34
+ }
35
+
36
+ export const heroMediaSchema = createHeroMediaSchema();
37
+ export type HeroMedia = defaultZ.infer<typeof heroMediaSchema>;
@@ -18,6 +18,7 @@ import StepsCmp from "../components/mdx/Steps.astro";
18
18
  import Tab from "../components/mdx/Tab.astro";
19
19
  import Tabs from "../components/mdx/Tabs.astro";
20
20
  import Terminal from "../components/mdx/Terminal.astro";
21
+ import Track from "../components/mdx/Track.astro";
21
22
 
22
23
  /**
23
24
  * The components map passed to <Content components={...} />. Every React
@@ -37,6 +38,7 @@ export function mdxComponents() {
37
38
  Download,
38
39
  Tabs,
39
40
  Tab,
41
+ Track,
40
42
  FileTree,
41
43
  Reveal,
42
44
  Terminal,
@@ -46,6 +46,7 @@ export type ProgressState = {
46
46
  packageManager?: "npm" | "pnpm" | "yarn" | "bun";
47
47
  os?: "macos" | "linux" | "windows";
48
48
  theme?: "light" | "dark";
49
+ track?: string;
49
50
  };
50
51
  /**
51
52
  * Per-tutorial "where was I last?" marker. Tracks `ts` so consumers
@@ -0,0 +1,24 @@
1
+ export type TrackMap<T> = Record<string, T>;
2
+ export type TrackScoped<T> = T | TrackMap<T>;
3
+
4
+ function isRecord(value: unknown): value is Record<string, unknown> {
5
+ return !!value && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+
8
+ export function isStarterSpec(value: unknown): value is { kind: string } {
9
+ return isRecord(value) && typeof value.kind === "string";
10
+ }
11
+
12
+ export function isVerifySpec(value: unknown): value is { id: string; checks: unknown[] } {
13
+ return isRecord(value) && typeof value.id === "string" && Array.isArray(value.checks);
14
+ }
15
+
16
+ export function resolveForTrack<T>(
17
+ value: TrackScoped<T> | undefined,
18
+ trackId: string | undefined,
19
+ isShared: (v: unknown) => v is T,
20
+ ): T | undefined {
21
+ if (!value) return undefined;
22
+ if (isShared(value)) return value;
23
+ return trackId ? value[trackId] : undefined;
24
+ }
@@ -0,0 +1,10 @@
1
+ const TRACK_BLOCK =
2
+ /<Track\b[^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})[^>]*>([\s\S]*?)<\/Track>/g;
3
+
4
+ export function stripInactiveTrackBlocks(source: string, activeTrack: string | undefined): string {
5
+ if (!activeTrack) return source;
6
+ return source.replace(TRACK_BLOCK, (_full, doubleId, singleId, templateId, body) => {
7
+ const id = doubleId ?? singleId ?? templateId;
8
+ return id === activeTrack ? String(body).trim() : "";
9
+ });
10
+ }
@@ -0,0 +1,32 @@
1
+ export interface TrackOption {
2
+ id: string;
3
+ label: string;
4
+ }
5
+
6
+ export interface ResolveTrackInput {
7
+ tracks?: TrackOption[];
8
+ explicitTrack?: string | null;
9
+ preferredTrack?: string | null;
10
+ defaultTrack?: string | null;
11
+ }
12
+
13
+ export function resolveActiveTrack({
14
+ tracks = [],
15
+ explicitTrack,
16
+ preferredTrack,
17
+ defaultTrack,
18
+ }: ResolveTrackInput): string | undefined {
19
+ if (tracks.length === 0) return undefined;
20
+ const ids = new Set(tracks.map((track) => track.id));
21
+ if (explicitTrack && ids.has(explicitTrack)) return explicitTrack;
22
+ if (preferredTrack && ids.has(preferredTrack)) return preferredTrack;
23
+ if (defaultTrack && ids.has(defaultTrack)) return defaultTrack;
24
+ return tracks[0]?.id;
25
+ }
26
+
27
+ export function trackStyleText(trackId: string | undefined): string {
28
+ if (!trackId) return "";
29
+ // Track ids are schema-limited to [a-z0-9_-], so a quoted attribute
30
+ // selector is safe without relying on CSS.escape in older browsers.
31
+ return `[data-track-panel]:not([data-track-panel="${trackId}"]) { display: none !important; }`;
32
+ }