handzon-core 0.12.2 → 0.13.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.
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { Image } from "astro:assets";
2
3
  import BaseLayout from "../layouts/BaseLayout.astro";
3
4
  import { withBase } from "../lib/base.ts";
4
5
  import { getStepsForTutorial, parseStepId, sumDurations } from "../lib/content.ts";
@@ -35,10 +36,14 @@ const {
35
36
  const steps = await getStepsForTutorial(tutorial.id);
36
37
  const duration = tutorial.data.estimatedDuration ?? sumDurations(steps) ?? "";
37
38
  const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
39
+ const iconText = typeof tutorial.data.icon === "string" ? tutorial.data.icon : undefined;
40
+ const iconImage = tutorial.data.icon && typeof tutorial.data.icon !== "string" ? tutorial.data.icon : undefined;
41
+ const coverUrl = tutorial.data.cover?.src;
38
42
  ---
39
43
  <BaseLayout
40
44
  title={tutorial.data.title}
41
45
  description={tutorial.data.description}
46
+ ogImageUrl={coverUrl}
42
47
  siteName={siteName}
43
48
  tagline={tagline}
44
49
  logoUrl={logoUrl}
@@ -54,38 +59,76 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
54
59
  <div class="landing">
55
60
  <a class="back" href={withBase("/")}>← All tutorials</a>
56
61
 
57
- <header class="hero">
58
- <div class="hero-meta">
59
- <span class={`pill pill-${tutorial.data.difficulty}`}>{tutorial.data.difficulty}</span>
60
- {duration && <span class="pill pill-neutral">⏱ {duration}</span>}
61
- <span class="pill pill-neutral">{steps.length} {steps.length === 1 ? "step" : "steps"}</span>
62
- </div>
62
+ <header class:list={["hero", tutorial.data.cover && "hero-with-cover"]}>
63
+ <div class="hero-copy">
64
+ <div class="hero-meta">
65
+ <span class={`pill pill-${tutorial.data.difficulty}`}>{tutorial.data.difficulty}</span>
66
+ {duration && <span class="pill pill-neutral">⏱ {duration}</span>}
67
+ <span class="pill pill-neutral">{steps.length} {steps.length === 1 ? "step" : "steps"}</span>
68
+ </div>
63
69
 
64
- <h1>{tutorial.data.title}</h1>
65
- <p class="desc">{tutorial.data.description}</p>
70
+ <div class="hero-title-row">
71
+ {iconImage && (
72
+ <Image
73
+ src={iconImage}
74
+ alt=""
75
+ class="hero-icon hero-icon-image"
76
+ width={48}
77
+ height={48}
78
+ />
79
+ )}
80
+ {iconText && <span class="hero-icon hero-icon-text" aria-hidden="true">{iconText}</span>}
81
+ <h1>{tutorial.data.title}</h1>
82
+ </div>
83
+ <p class="desc">{tutorial.data.description}</p>
66
84
 
67
- <div class="hero-actions">
68
- {firstStepSlug && (
69
- <a class="cta" href={withBase(`/${tutorial.id}/${firstStepSlug}`)} data-tutorial-slug={tutorial.id}>
70
- <span class="cta-label">Start tutorial</span>
71
- <span class="cta-arrow" aria-hidden="true">→</span>
72
- </a>
73
- )}
74
- {tutorial.data.author && (
75
- <div class="author">
76
- By <strong>{tutorial.data.author.name}</strong>
77
- {tutorial.data.publishedAt && (
78
- <> · {new Date(tutorial.data.publishedAt).toLocaleDateString()}</>
79
- )}
85
+ <div class="hero-actions">
86
+ {firstStepSlug && (
87
+ <a class="cta" href={withBase(`/${tutorial.id}/${firstStepSlug}`)} data-tutorial-slug={tutorial.id}>
88
+ <span class="cta-label">Start tutorial</span>
89
+ <span class="cta-arrow" aria-hidden="true">→</span>
90
+ </a>
91
+ )}
92
+ {tutorial.data.author && (
93
+ <div class="author">
94
+ {tutorial.data.author.avatar && (
95
+ <Image
96
+ src={tutorial.data.author.avatar}
97
+ alt=""
98
+ class="author-avatar"
99
+ width={32}
100
+ height={32}
101
+ loading="lazy"
102
+ />
103
+ )}
104
+ <span>
105
+ By <strong>{tutorial.data.author.name}</strong>
106
+ {tutorial.data.publishedAt && (
107
+ <> · {new Date(tutorial.data.publishedAt).toLocaleDateString()}</>
108
+ )}
109
+ </span>
110
+ </div>
111
+ )}
112
+ </div>
113
+
114
+ {tutorial.data.tags.length > 0 && (
115
+ <div class="hero-tags" aria-label="Topics">
116
+ {tutorial.data.tags.map((tag: string) => (
117
+ <a class="hero-tag" href={withBase(`/?tag=${encodeURIComponent(tag)}`)}>#{tag}</a>
118
+ ))}
80
119
  </div>
81
120
  )}
82
121
  </div>
83
122
 
84
- {tutorial.data.tags.length > 0 && (
85
- <div class="hero-tags" aria-label="Topics">
86
- {tutorial.data.tags.map((tag: string) => (
87
- <a class="hero-tag" href={withBase(`/?tag=${encodeURIComponent(tag)}`)}>#{tag}</a>
88
- ))}
123
+ {tutorial.data.cover && (
124
+ <div class="hero-media">
125
+ <Image
126
+ src={tutorial.data.cover}
127
+ alt=""
128
+ class="hero-cover"
129
+ widths={[520, 760, 1040]}
130
+ sizes="(min-width: 900px) 38vw, 100vw"
131
+ />
89
132
  </div>
90
133
  )}
91
134
  </header>
@@ -167,6 +210,53 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
167
210
  border-left-color: var(--color-accent);
168
211
  margin-bottom: 3rem;
169
212
  }
213
+ .hero-with-cover {
214
+ display: grid;
215
+ grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.9fr);
216
+ gap: clamp(1.5rem, 4vw, 3rem);
217
+ align-items: stretch;
218
+ }
219
+ .hero-copy {
220
+ min-width: 0;
221
+ }
222
+ .hero-title-row {
223
+ display: flex;
224
+ align-items: flex-start;
225
+ gap: 0.85rem;
226
+ }
227
+ .hero-icon {
228
+ flex-shrink: 0;
229
+ width: 3rem;
230
+ height: 3rem;
231
+ border: var(--border-default, 2px) solid var(--color-border);
232
+ background: var(--color-bg);
233
+ margin-top: 0.25rem;
234
+ }
235
+ .hero-icon-image {
236
+ object-fit: cover;
237
+ display: block;
238
+ }
239
+ .hero-icon-text {
240
+ display: inline-grid;
241
+ place-items: center;
242
+ font-size: 1.45rem;
243
+ line-height: 1;
244
+ }
245
+ .hero-media {
246
+ min-height: 100%;
247
+ border: var(--border-default, 2px) solid var(--color-border);
248
+ background: var(--color-surface);
249
+ overflow: hidden;
250
+ box-shadow: 0.45rem 0.45rem 0 color-mix(in oklab, var(--color-accent) 35%, transparent);
251
+ }
252
+ .hero-cover {
253
+ width: 100%;
254
+ height: 100%;
255
+ min-height: 18rem;
256
+ object-fit: cover;
257
+ display: block;
258
+ filter: saturate(0.95) contrast(1.06);
259
+ }
170
260
  .hero-meta {
171
261
  display: flex;
172
262
  flex-wrap: wrap;
@@ -234,7 +324,19 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
234
324
  align-items: center;
235
325
  gap: 1.25rem;
236
326
  }
237
- .author { color: var(--color-muted); font-size: 0.85em; }
327
+ .author {
328
+ color: var(--color-muted);
329
+ font-size: 0.85em;
330
+ display: inline-flex;
331
+ align-items: center;
332
+ gap: 0.55rem;
333
+ }
334
+ .author-avatar {
335
+ width: 2rem;
336
+ height: 2rem;
337
+ border: 1px solid var(--color-border);
338
+ object-fit: cover;
339
+ }
238
340
  .cta {
239
341
  display: inline-flex;
240
342
  align-items: center;
@@ -345,6 +447,9 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
345
447
  }
346
448
  @media (max-width: 640px) {
347
449
  .hero { padding: 1.5rem 1.25rem 1.75rem; }
450
+ .hero-with-cover { grid-template-columns: 1fr; }
451
+ .hero-media { order: -1; }
452
+ .hero-cover { min-height: 12rem; }
348
453
  .step-list a { grid-template-columns: auto 1fr auto; padding: 0.75rem; }
349
454
  .step-chevron { display: none; }
350
455
  }