@half-built/astro 0.4.2 → 0.6.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.
Files changed (60) hide show
  1. package/README.md +50 -2
  2. package/package.json +11 -4
  3. package/src/components/CategoryCard.astro +9 -1
  4. package/src/components/CornerBadges.astro +14 -21
  5. package/src/components/Footer.astro +57 -29
  6. package/src/components/Icon.astro +16 -0
  7. package/src/components/LightboxLink.astro +19 -10
  8. package/src/components/LinkListWidget.astro +11 -9
  9. package/src/components/Pagination.astro +51 -28
  10. package/src/components/PostCard.astro +54 -22
  11. package/src/components/PostNavigation.astro +40 -29
  12. package/src/components/Shell.astro +26 -12
  13. package/src/components/SiteHeader.astro +139 -80
  14. package/src/components/SmartImage.astro +48 -17
  15. package/src/components/Subscribe.astro +60 -35
  16. package/src/components/ThemeToggle.astro +28 -15
  17. package/src/components/TwoColumn.astro +5 -5
  18. package/src/components/Widget.astro +10 -7
  19. package/src/components/content/BlogImage.astro +48 -13
  20. package/src/components/content/Button.astro +13 -13
  21. package/src/components/content/Callout.astro +17 -8
  22. package/src/components/content/CodeBlock.astro +6 -1
  23. package/src/components/content/EditorNote.astro +19 -21
  24. package/src/components/content/Gallery.astro +18 -5
  25. package/src/components/content/GalleryImage.astro +26 -7
  26. package/src/components/content/Group.astro +5 -6
  27. package/src/components/content/MediaText.astro +43 -9
  28. package/src/components/content/Palette.astro +37 -21
  29. package/src/components/content/PostLink.astro +15 -0
  30. package/src/components/content/Quote.astro +10 -9
  31. package/src/components/content/Spacer.astro +11 -2
  32. package/src/components/content/Step.astro +34 -15
  33. package/src/components/content/Walkthrough.astro +10 -6
  34. package/src/components/content/WhenPublished.astro +14 -0
  35. package/src/components/models.ts +2 -2
  36. package/src/lib/archive.ts +18 -4
  37. package/src/lib/drafts.ts +16 -5
  38. package/src/lib/format-date.ts +4 -1
  39. package/src/lib/ordering.ts +3 -1
  40. package/src/lib/paginate.ts +5 -1
  41. package/src/lib/slug.ts +30 -7
  42. package/src/scripts/code-island.ts +27 -4
  43. package/src/scripts/core/badges.ts +28 -0
  44. package/src/scripts/core/dom.ts +6 -1
  45. package/src/scripts/core/frame-loop.ts +4 -1
  46. package/src/scripts/core/icons.ts +92 -15
  47. package/src/scripts/ecosystem.ts +72 -12
  48. package/src/scripts/focus-mode.ts +14 -3
  49. package/src/scripts/lightbox.ts +227 -41
  50. package/src/scripts/link-tip.ts +55 -11
  51. package/src/scripts/path-player-math.ts +11 -2
  52. package/src/scripts/path-player-paint.ts +54 -8
  53. package/src/scripts/path-player.ts +131 -29
  54. package/src/scripts/plate-modal.ts +45 -9
  55. package/src/scripts/scroll-top.ts +6 -1
  56. package/src/scripts/site-header.ts +34 -6
  57. package/src/scripts/subscribe.ts +50 -9
  58. package/src/scripts/theme-toggle.ts +32 -5
  59. package/src/shiki/code-theme.mjs +25 -5
  60. package/src/shiki/code-vars.mjs +5 -1
@@ -1,27 +1,20 @@
1
1
  ---
2
- /* The real half-built-robots.com header, matching the live DOM structure:
3
- site-header-wrapper (bracket frame) containing three bands.
4
- - masthead-top: date box + social icons, pulled UP to straddle the top rule
5
- - masthead-brand: centered site branding (h1 on home, p elsewhere)
6
- - masthead-nav: nav boxes + search flyout, pulled DOWN to straddle the bottom rule
7
- No logo image in the header: the live site renders text branding only. */
8
2
  import ThemeToggle from "./ThemeToggle.astro";
3
+ import Icon from "./Icon.astro";
9
4
  import type { NavItem, SocialItem } from "./models";
5
+
10
6
  interface Props {
11
7
  siteName: string;
12
8
  tagline: string;
13
9
  nav: NavItem[];
14
10
  socials: SocialItem[];
15
- /* Preformatted date for the top band's box; the caller owns the format
16
- (the blog passes lib/header-date's live-theme format). The client
17
- refresh takes a formatDate option (step 11.2) defaulting to lib/header-date;
18
- only its package-time home remains a step 11.3 decision. */
19
11
  dateStr: string;
20
12
  isHome?: boolean;
21
13
  searchAction?: string;
22
14
  searchParam?: string;
23
15
  searchLabels?: { prompt?: string; placeholder?: string; submit?: string };
24
16
  }
17
+
25
18
  const {
26
19
  siteName,
27
20
  tagline,
@@ -33,107 +26,153 @@ const {
33
26
  searchParam = "s",
34
27
  searchLabels = {},
35
28
  } = Astro.props;
29
+
36
30
  const {
37
31
  prompt: searchPrompt = "Search for:",
38
- /* The static markup this replaces wrote the placeholder as the HTML
39
- entity `Search …`, which the compiler decodes to this same
40
- character at build time for static text. Written here as the
41
- decoded character (not the entity spelling) because a prop value
42
- goes through Astro's dynamic attribute path, which escapes a
43
- literal "&" instead of decoding it, and would otherwise double
44
- encode the entity into `…` in the built output. */
45
32
  placeholder: searchPlaceholder = "Search …",
46
33
  submit: searchSubmit = "Search",
47
34
  } = searchLabels;
48
35
 
49
36
  const current = Astro.url.pathname;
50
- /* Home stays the current item on paginated index pages, like WordPress,
51
- the component's own documented opinion (not a prop). */
37
+
52
38
  const isCurrent = (href: string) =>
53
- href === "/" ? current === "/" || current.startsWith("/page/") : current === href;
39
+ href === "/"
40
+ ? current === "/" || current.startsWith("/page/")
41
+ : current === href;
54
42
  ---
43
+
55
44
  <header id="masthead" class="site-header">
56
45
  <div class="shell">
57
46
  <div class="site-header-wrapper bracket-frame">
58
47
  <div class="masthead-top">
59
- <slot name="band-top"><div class="header-tools">
60
- <div class="date boxed-label" id="header-date">{dateStr}</div>
61
- <ThemeToggle />
62
- </div>
63
- <div class="social-icons">
64
- {/* Tips drop below and pin to the icon's right edge (scripts/link-tip.ts). */}
65
- <ul class="social-links" data-tip-place="below" data-tip-align="end">
66
- {socials.map((s) => (
67
- <li>
48
+ <slot name="band-top"
49
+ ><div class="header-tools">
50
+ <div class="date boxed-label" id="header-date">{dateStr}</div>
51
+ <ThemeToggle />
52
+ </div>
53
+ <div class="social-icons">
54
+ {/* Tips drop below and pin to the icon's right edge (scripts/link-tip.ts). */}
55
+ <ul
56
+ class="social-links"
57
+ data-tip-place="below"
58
+ data-tip-align="end"
59
+ >
60
+ {socials.map((s) => <li>
68
61
  <a class="icon-box" href={s.href}>
69
62
  <span class="screen-reader-text">{s.label}</span>
70
63
  <Fragment set:html={s.icon} />
71
64
  </a>
72
- </li>
73
- ))}
74
- </ul>
75
- </div></slot>
65
+ </li>)}
66
+ </ul>
67
+ </div></slot
68
+ >
76
69
  </div>
77
70
  <div class="masthead-brand">
78
- <slot name="band-brand"><div class="site-branding">
79
- <div class="site-identity">
80
- {isHome
81
- ? <h1 class="site-title"><a href="/" rel="home">{siteName}</a></h1>
82
- : <p class="site-title"><a href="/" rel="home">{siteName}</a></p>}
83
- <p class="site-description">{tagline}</p>
84
- </div>
85
- </div></slot>
71
+ <slot name="band-brand"
72
+ ><div class="site-branding">
73
+ <div class="site-identity">
74
+ {isHome ? (
75
+ <h1 class="site-title">
76
+ <a href="/" rel="home">{siteName}</a>
77
+ </h1>
78
+ ) : (
79
+ <p class="site-title"><a href="/" rel="home">{siteName}</a></p>
80
+ )}
81
+ <p class="site-description">{tagline}</p>
82
+ </div>
83
+ </div></slot
84
+ >
86
85
  </div>
87
86
  <div class="masthead-nav">
88
- <slot name="band-nav"><div class="masthead-nav-inner">
89
- <div class="header-nav-search">
90
- <div class="header-navigation">
91
- <nav id="site-navigation" class="main-navigation" aria-label="Primary">
92
- <button type="button" class="menu-toggle" aria-controls="primary-menu" aria-expanded="false">
93
- <span></span><span></span><span></span>
94
- <span class="screen-reader-text">Menu</span>
95
- </button>
96
- <div class="menu-container">
97
- <ul id="primary-menu" class="menu">
98
- {nav.map((item) => (
99
- <li class={`menu-item${isCurrent(item.href) ? " current-menu-item" : ""}`}>
100
- <a class:list={["press-box", isCurrent(item.href) && "tint-overlay"]} href={item.href} aria-current={isCurrent(item.href) ? "page" : undefined}>{item.label}</a>
101
- </li>
102
- ))}
103
- </ul>
104
- </div>
105
- </nav>
106
- </div>
107
- <div class="header-end">
108
- {/* Phones lose the top band, so the toggle sits beside the
87
+ <slot name="band-nav"
88
+ ><div class="masthead-nav-inner">
89
+ <div class="header-nav-search">
90
+ <div class="header-navigation">
91
+ <nav
92
+ id="site-navigation"
93
+ class="main-navigation"
94
+ aria-label="Primary"
95
+ >
96
+ <button
97
+ type="button"
98
+ class="menu-toggle"
99
+ aria-controls="primary-menu"
100
+ aria-expanded="false"
101
+ >
102
+ <span></span><span></span><span></span>
103
+ <span class="screen-reader-text">Menu</span>
104
+ </button>
105
+ <div class="menu-container">
106
+ <ul id="primary-menu" class="menu">
107
+ {nav.map((item) => <li
108
+ class={`menu-item${isCurrent(item.href) ? " current-menu-item" : ""}`}
109
+ >
110
+ <a
111
+ class:list={[
112
+ "press-box",
113
+ isCurrent(item.href) && "tint-overlay",
114
+ ]}
115
+ href={item.href}
116
+ aria-current={
117
+ isCurrent(item.href) ? "page" : undefined
118
+ }
119
+ >{item.label}</a
120
+ >
121
+ </li>)}
122
+ </ul>
123
+ </div>
124
+ </nav>
125
+ </div>
126
+ <div class="header-end">
127
+ {/* Phones lose the top band, so the toggle sits beside the
109
128
  search there (owner request 2026-08-26). */}
110
- <ThemeToggle placement="phone" />
111
- <div class="navigation-search">
112
- <div class="navigation-search-wrap">
113
- {/* A control that reveals UI is a button, not a link: a
129
+ <ThemeToggle placement="phone" />
130
+ <div class="navigation-search">
131
+ <div class="navigation-search-wrap">
132
+ {/* A control that reveals UI is a button, not a link: a
114
133
  bare-hash anchor navigates (scroll jump + history
115
134
  entry), audit finding A3. With the island mounted it
116
135
  is the flyout's toggle (site-header.ts); without
117
136
  JavaScript, focus-within still opens the flyout when
118
137
  the button takes focus. */}
119
- <button type="button" title="Search" class="navigation-search-icon icon-box" aria-expanded="false">
120
- <svg viewBox="0 0 512 512" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M505 442.7L405.3 343c28.4-34.9 45.7-79 45.7-127C451 96.5 354.5 0 235.5 0S20 96.5 20 215.5 116.5 431 235.5 431c48 0 92.1-17.3 127-45.7L462.3 485c5.9 5.9 15.4 5.9 21.3 0l21.4-21.4c5.9-5.9 5.9-15.4 0-20.9zM235.5 371c-85.9 0-155.5-69.6-155.5-155.5S149.6 60 235.5 60 391 129.6 391 215.5 321.4 371 235.5 371z"/></svg>
121
- <span class="screen-reader-text">Search</span>
122
- </button>
123
- <div class="navigation-search-form">
124
- <form role="search" class="search-form field-join" action={searchAction} method="get">
125
- <label>
126
- <span class="screen-reader-text">{searchPrompt}</span>
127
- <input type="search" class="search-field field-join-input" placeholder={searchPlaceholder} name={searchParam} />
128
- </label>
129
- <input type="submit" class="search-submit field-join-button press-box press-box-shaded" value={searchSubmit} />
130
- </form>
138
+ <button
139
+ type="button"
140
+ title="Search"
141
+ class="navigation-search-icon icon-box"
142
+ aria-expanded="false"
143
+ >
144
+ <Icon name="search" size={14} />
145
+ <span class="screen-reader-text">Search</span>
146
+ </button>
147
+ <div class="navigation-search-form">
148
+ <form
149
+ role="search"
150
+ class="search-form field-join"
151
+ action={searchAction}
152
+ method="get"
153
+ >
154
+ <label>
155
+ <span class="screen-reader-text">{searchPrompt}</span>
156
+ <input
157
+ type="search"
158
+ class="search-field field-join-input"
159
+ placeholder={searchPlaceholder}
160
+ name={searchParam}
161
+ />
162
+ </label>
163
+ <input
164
+ type="submit"
165
+ class="search-submit field-join-button press-box press-box-shaded"
166
+ value={searchSubmit}
167
+ />
168
+ </form>
169
+ </div>
131
170
  </div>
132
171
  </div>
133
172
  </div>
134
173
  </div>
135
- </div>
136
- </div></slot>
174
+ </div></slot
175
+ >
137
176
  </div>
138
177
  </div>
139
178
  </div>
@@ -153,18 +192,22 @@ const isCurrent = (href: string) =>
153
192
  justify-content: space-between;
154
193
  margin-top: calc(-30px + (-12px));
155
194
  }
195
+
156
196
  .masthead-top .date {
157
197
  padding: 4px 10px;
158
198
  display: grid;
159
199
  place-items: center;
160
200
  font-size: var(--font-size-sm);
161
201
  }
202
+
162
203
  /* Date box and the day/night toggle sit together on the left of the
163
204
  top band, spaced like the social icons on the right. */
164
205
  .header-tools { display: flex; align-items: center; gap: 10px; }
206
+
165
207
  /* Bottom band's right end: the phone toggle (hidden on desktop) beside
166
208
  the search icon, spaced like the top band's tools. */
167
209
  .header-end { display: flex; align-items: center; gap: 10px; }
210
+
168
211
  /* No background on the list itself: the header's top rule must stay
169
212
  visible in the gaps between the boxed icons. */
170
213
  ul.social-links {
@@ -174,6 +217,7 @@ const isCurrent = (href: string) =>
174
217
  display: flex;
175
218
  gap: 10px;
176
219
  }
220
+
177
221
  .masthead-brand {
178
222
  display: flex;
179
223
  align-items: center;
@@ -181,6 +225,7 @@ const isCurrent = (href: string) =>
181
225
  padding-block: 20px;
182
226
  }
183
227
  .site-branding { padding-block: 10px; text-align: center; width: 100%; }
228
+
184
229
  .site-title {
185
230
  font-size: var(--font-size-xl);
186
231
  line-height: 1.1;
@@ -188,6 +233,7 @@ const isCurrent = (href: string) =>
188
233
  margin: 0;
189
234
  }
190
235
  .site-title a { text-decoration: none; color: var(--ink); }
236
+
191
237
  /* The tagline wears the accent box (owner call 2026-09-03): the
192
238
  callout's combination, the same one the press box and the demo
193
239
  launcher wear. Accent line, the accent's legible ink for the text,
@@ -196,6 +242,7 @@ const isCurrent = (href: string) =>
196
242
  white at 1.9:1 (test/a11y.test.ts). Static: no hover, no press. */
197
243
  .site-description {
198
244
  display: inline-block;
245
+
199
246
  /* 14 above, 8 below (owner pick 2026-09-03, option B of three):
200
247
  the box sits as its own element between the title and the nav
201
248
  band rather than as a subtitle stuck to the title. */
@@ -211,6 +258,7 @@ const isCurrent = (href: string) =>
211
258
  position: relative;
212
259
  margin-bottom: calc(-30px + (-17px));
213
260
  }
261
+
214
262
  .header-nav-search {
215
263
  width: 100%;
216
264
  display: flex;
@@ -218,6 +266,7 @@ const isCurrent = (href: string) =>
218
266
  align-items: center;
219
267
  justify-content: space-between;
220
268
  }
269
+
221
270
  .main-navigation .menu {
222
271
  list-style: none;
223
272
  padding: 0;
@@ -227,6 +276,7 @@ const isCurrent = (href: string) =>
227
276
  flex-wrap: wrap;
228
277
  font-size: var(--font-size-sm);
229
278
  }
279
+
230
280
  /* Box, ink, and press feedback from .press-box; the nav's own size,
231
281
  case, and hover ring are here. */
232
282
  .main-navigation .menu a {
@@ -240,6 +290,7 @@ const isCurrent = (href: string) =>
240
290
  .main-navigation .menu a:hover { outline: var(--stroke) solid var(--rule); }
241
291
 
242
292
  .menu-toggle { display: none; }
293
+
243
294
  @media (--bp-sidebar) {
244
295
  .menu-toggle {
245
296
  display: block;
@@ -252,6 +303,7 @@ const isCurrent = (href: string) =>
252
303
  border: var(--stroke) solid var(--rule);
253
304
  background-color: var(--surface);
254
305
  }
306
+
255
307
  .menu-toggle span:not(.screen-reader-text) {
256
308
  display: block;
257
309
  position: absolute;
@@ -265,6 +317,7 @@ const isCurrent = (href: string) =>
265
317
  .menu-toggle span:nth-child(1) { top: 7px; }
266
318
  .menu-toggle span:nth-child(2) { top: 14px; }
267
319
  .menu-toggle span:nth-child(3) { top: 21px; }
320
+
268
321
  /* The open menu is a pop-out under the hamburger, floating over the
269
322
  page like the search flyout (same z tier), not a block that grows
270
323
  the header (owner request 2026-08-27, after launch). The header
@@ -272,6 +325,7 @@ const isCurrent = (href: string) =>
272
325
  bottom rule: hamburger, toggle, and magnifier hang off the rule
273
326
  instead of sitting inside the frame. */
274
327
  .main-navigation { position: relative; }
328
+
275
329
  .main-navigation .menu {
276
330
  display: none;
277
331
  flex-direction: column;
@@ -287,6 +341,7 @@ const isCurrent = (href: string) =>
287
341
  }
288
342
  .main-navigation .menu.open { display: flex; }
289
343
  }
344
+
290
345
  /* Phone-width header simplification (owner call 2026-08-20): the
291
346
  date box competes for pixels a phone already spends on its own
292
347
  clock, and the social tray collided with it. Both retire; the
@@ -303,6 +358,7 @@ const isCurrent = (href: string) =>
303
358
  button). Without the island the focus-within rule stands, so the
304
359
  flyout still opens with no script. */
305
360
  .navigation-search { width: 30px; position: relative; display: inline-block; text-align: center; }
361
+
306
362
  .navigation-search-form {
307
363
  position: absolute;
308
364
  right: 0;
@@ -314,18 +370,21 @@ const isCurrent = (href: string) =>
314
370
  z-index: var(--z-search-flyout);
315
371
  background-color: var(--surface);
316
372
  border: var(--stroke) solid var(--rule);
373
+
317
374
  /* Opacity fades both ways; visibility flips at once on open and
318
375
  waits out the fade on close. Under the house all-transition the
319
376
  visibility interpolation read hidden at the instant the open
320
377
  class landed, so the island's focus() on the field was refused. */
321
378
  transition: opacity 0.1s ease, visibility 0s linear 0.1s;
322
379
  }
380
+
323
381
  .navigation-search-wrap:not([data-search-js]):focus-within .navigation-search-form,
324
382
  .navigation-search-wrap.search-open .navigation-search-form {
325
383
  opacity: 1;
326
384
  visibility: visible;
327
385
  transition-delay: 0s;
328
386
  }
387
+
329
388
  /* Form anatomy comes from the .field-join house pattern (owner request
330
389
  2026-07-30, replacing the live theme's 75/25 split form). Only the
331
390
  label wrapper's sizing is contextual. */
@@ -2,15 +2,6 @@
2
2
  import { Image } from "astro:assets";
3
3
  import type { ImageMetadata } from "astro";
4
4
 
5
- /* The one responsive-image element (step 5, 2026-08-30). GIFs pass
6
- through untouched (conversion drops animation) and SVGs need no
7
- rasterizing; everything else gets responsive widths + modern formats
8
- (audit D5). Callers pick widths/sizes for their column; omitting
9
- `widths` renders at intrinsic size (MediaText's natural mode, where a
10
- `sizes` attribute would defeat natural-size rendering). No scoped CSS
11
- on purpose: styling stays with the caller and the global pattern
12
- files, which keep reaching the img because this component adds no
13
- scope of its own. */
14
5
  interface Props {
15
6
  src: ImageMetadata;
16
7
  alt: string;
@@ -21,14 +12,54 @@ interface Props {
21
12
  fetchpriority?: "high";
22
13
  style?: string;
23
14
  }
24
- const { src, alt, class: className, widths, sizes, loading = "lazy", fetchpriority, style } = Astro.props;
15
+
16
+ const {
17
+ src,
18
+ alt,
19
+ class: className,
20
+ widths,
21
+ sizes,
22
+ loading = "lazy",
23
+ fetchpriority,
24
+ style,
25
+ } = Astro.props;
26
+
25
27
  const passthrough = src.format === "gif" || src.format === "svg";
26
- /* eager is the img element's default; omitting it keeps the passthrough
27
- markup byte-compatible with the pre-step-5 sites. */
28
+ const responsive = !passthrough && widths !== undefined;
29
+ const fixed = !passthrough && widths === undefined;
28
30
  const imgLoading = loading === "eager" ? undefined : loading;
29
31
  ---
30
- {passthrough
31
- ? <img src={src.src} width={src.width} height={src.height} alt={alt} class={className} loading={imgLoading} style={style} fetchpriority={fetchpriority} />
32
- : widths
33
- ? <Image src={src} widths={widths} sizes={sizes} alt={alt} class={className} loading={loading} style={style} fetchpriority={fetchpriority} />
34
- : <Image src={src} width={src.width} alt={alt} class={className} loading={loading} style={style} fetchpriority={fetchpriority} />}
32
+
33
+ {passthrough && (
34
+ <img
35
+ src={src.src}
36
+ width={src.width}
37
+ height={src.height}
38
+ alt={alt}
39
+ class={className}
40
+ loading={imgLoading}
41
+ style={style}
42
+ fetchpriority={fetchpriority}
43
+ />
44
+ )}{responsive && (
45
+ <Image
46
+ src={src}
47
+ widths={widths}
48
+ sizes={sizes}
49
+ alt={alt}
50
+ class={className}
51
+ loading={loading}
52
+ style={style}
53
+ fetchpriority={fetchpriority}
54
+ />
55
+ )}{fixed && (
56
+ <Image
57
+ src={src}
58
+ width={src.width}
59
+ alt={alt}
60
+ class={className}
61
+ loading={loading}
62
+ style={style}
63
+ fetchpriority={fetchpriority}
64
+ />
65
+ )}
@@ -1,58 +1,76 @@
1
1
  ---
2
- /* Newsletter CTA, lifted from the BEADZ Subscribe island: the shared amber
3
- block that bridges every project back to this blog, meant to read the
4
- same everywhere it appears. Rebuilt as a no-React Astro component with
5
- the copy in the blog's own voice (the deadpan BEADZ register stays on
6
- BEADZ). Three variants: "sidebar" sits above Recent Posts, "post" sits
7
- between a post's end and the prev/next navigation, "page" is the block
8
- on /newsletter/ itself (the sidebar heading with the post blurb, no
9
- outer margins; the page's own prose introduces it).
10
-
11
- The form posts straight to Buttondown's keyless embed-subscribe
12
- endpoint. It is a real form (method + action) so it works with no
13
- JavaScript: Buttondown redirects that path to /newsletter/subscribed/.
14
- With JavaScript, src/scripts/subscribe.ts takes over the submit and
15
- writes the outcome into the status line. Spec:
16
- docs/superpowers/specs/2026-08-16-mailing-list-wiring-design.md
17
-
18
- Copy is overridable (step 9.5): title, blurb, and fine print are named
19
- slots whose fallbacks are the blog's register; placeholder and button
20
- label are props. Another register (BEADZ) overrides without forking. */
21
2
  interface Props {
22
3
  variant?: "sidebar" | "post" | "page";
23
4
  headingLevel?: 2 | 3 | 4;
24
5
  class?: string;
25
- /* The provider's form-post endpoint; the blog passes config NEWSLETTER.
26
- Required so a caller can never render a form that posts nowhere. */
27
6
  action: string;
28
- /* Human sign-up page, named in the failure message (data-public-url). */
29
7
  publicUrl?: string;
30
8
  placeholder?: string;
31
9
  buttonLabel?: string;
32
10
  }
11
+
33
12
  const {
34
- variant = "post", headingLevel = 2, class: className,
35
- action, publicUrl,
36
- placeholder = "you@somewhere.tld", buttonLabel = "Subscribe",
13
+ variant = "post",
14
+ headingLevel = 2,
15
+ class: className,
16
+ action,
17
+ publicUrl,
18
+ placeholder = "you@somewhere.tld",
19
+ buttonLabel = "Subscribe",
37
20
  } = Astro.props;
38
- /* Annotated, not cast: an `as` cast in the frontmatter can make the Astro
39
- compiler drop the Props type (see PostCard.astro). */
21
+
40
22
  const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
41
23
  ---
24
+
42
25
  <section class:list={["subscribe", `subscribe-${variant}`, className]}>
43
- <Heading class="subscribe-title"><slot name="title">{variant === "post" ? "Plenty more where this came from" : "Join the Mailing List"}</slot></Heading>
26
+ <Heading class="subscribe-title"
27
+ ><slot name="title"
28
+ >{variant === "post"
29
+ ? "Plenty more where this came from"
30
+ : "Join the Mailing List"}</slot
31
+ ></Heading
32
+ >
44
33
  {variant === "sidebar" ? (
45
- <p class="subscribe-blurb"><slot name="blurb">An email when something new goes up.</slot></p>
34
+ <p class="subscribe-blurb">
35
+ <slot name="blurb">An email when something new goes up.</slot>
36
+ </p>
46
37
  ) : (
47
- <p class="subscribe-blurb"><slot name="blurb">If you want to hear when a new post or project goes up, leave an email
48
- and I'll send a note. No schedule, no spam.</slot></p>
38
+ <p class="subscribe-blurb">
39
+ <slot name="blurb"
40
+ >If you want to hear when a new post or project goes up, leave an email
41
+ and I'll send a note. No schedule, no spam.</slot
42
+ >
43
+ </p>
49
44
  )}
50
- <form class="subscribe-form field-join" method="post" action={action} data-public-url={publicUrl} novalidate>
45
+ <form
46
+ class="subscribe-form field-join"
47
+ method="post"
48
+ action={action}
49
+ data-public-url={publicUrl}
50
+ novalidate
51
+ >
51
52
  <input type="hidden" name="embed" value="1" />
52
- <input class="subscribe-email field-join-input" type="email" name="email" placeholder={placeholder} aria-label="Email address" required />
53
- <button class="subscribe-submit field-join-button press-box press-box-shaded" type="submit">{buttonLabel}</button>
53
+ <input
54
+ class="subscribe-email field-join-input"
55
+ type="email"
56
+ name="email"
57
+ placeholder={placeholder}
58
+ aria-label="Email address"
59
+ required
60
+ />
61
+ <button
62
+ class="subscribe-submit field-join-button press-box press-box-shaded"
63
+ type="submit"
64
+ >
65
+ {buttonLabel}
66
+ </button>
54
67
  </form>
55
- <p class="subscribe-fine"><slot name="fine-print">Double opt-in. Every email has an <a href="/policies/mailing-list/">unsubscribe link</a>.</slot></p>
68
+ <p class="subscribe-fine">
69
+ <slot name="fine-print"
70
+ >Double opt-in. Every email has an
71
+ <a href="/policies/mailing-list/">unsubscribe link</a>.</slot
72
+ >
73
+ </p>
56
74
  <p class="subscribe-status" role="status"></p>
57
75
  </section>
58
76
 
@@ -78,15 +96,18 @@ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
78
96
  .subscribe-post { padding: 30px; margin-top: 30px; }
79
97
  .subscribe-sidebar { padding: 24px 22px; margin-bottom: 40px; }
80
98
  .subscribe-page { padding: 30px; }
99
+
81
100
  .subscribe-title {
82
101
  margin: 0 0 8px;
83
102
  font-family: var(--font-body);
84
103
  line-height: 1.2;
85
104
  }
105
+
86
106
  .subscribe-post .subscribe-title,
87
107
  .subscribe-page .subscribe-title { font-size: var(--font-size-md); }
88
108
  .subscribe-sidebar .subscribe-title { font-size: var(--font-size-base); }
89
109
  .subscribe-blurb { margin: 0 0 16px; font-size: var(--font-size-sm); }
110
+
90
111
  /* Form anatomy, focus rings, and press feedback all come from the
91
112
  .field-join pattern (patterns.css, house rule 2026-07-30, settled
92
113
  after trying inset and side-by-side variants); this island only sets
@@ -95,16 +116,20 @@ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
95
116
  .subscribe-form {
96
117
  --field-ink: var(--panel-rule);
97
118
  --field-text: var(--panel-ink);
119
+
98
120
  /* The email box's click highlight is the panel's own (ink on the
99
121
  amber by day, amber by night); the keyboard ring stays the site's
100
122
  everywhere in the panel (owner rule 2026-08-26). */
101
123
  --field-ring: var(--panel-ring);
124
+
102
125
  /* The well is the page surface, so it sits a step below the panel in
103
126
  both themes (white on amber by day, black on the dark panel by night). */
104
127
  --field-well: var(--surface);
105
128
  }
129
+
106
130
  .subscribe-post .subscribe-form,
107
131
  .subscribe-page .subscribe-form { max-width: 520px; }
132
+
108
133
  /* Fine print sits between form and status so an error line never
109
134
  shoves it around; the link inherits the block's ink. Owner wanted it
110
135
  smaller than the status line (2026-08-16); xs is the scale's floor,