handzon-core 0.14.1 → 0.15.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.14.1",
3
+ "version": "0.15.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"
@@ -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
+ }
@@ -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 {