@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.
- package/README.md +50 -2
- package/package.json +11 -4
- package/src/components/CategoryCard.astro +9 -1
- package/src/components/CornerBadges.astro +14 -21
- package/src/components/Footer.astro +57 -29
- package/src/components/Icon.astro +16 -0
- package/src/components/LightboxLink.astro +19 -10
- package/src/components/LinkListWidget.astro +11 -9
- package/src/components/Pagination.astro +51 -28
- package/src/components/PostCard.astro +54 -22
- package/src/components/PostNavigation.astro +40 -29
- package/src/components/Shell.astro +26 -12
- package/src/components/SiteHeader.astro +139 -80
- package/src/components/SmartImage.astro +48 -17
- package/src/components/Subscribe.astro +60 -35
- package/src/components/ThemeToggle.astro +28 -15
- package/src/components/TwoColumn.astro +5 -5
- package/src/components/Widget.astro +10 -7
- package/src/components/content/BlogImage.astro +48 -13
- package/src/components/content/Button.astro +13 -13
- package/src/components/content/Callout.astro +17 -8
- package/src/components/content/CodeBlock.astro +6 -1
- package/src/components/content/EditorNote.astro +19 -21
- package/src/components/content/Gallery.astro +18 -5
- package/src/components/content/GalleryImage.astro +26 -7
- package/src/components/content/Group.astro +5 -6
- package/src/components/content/MediaText.astro +43 -9
- package/src/components/content/Palette.astro +37 -21
- package/src/components/content/PostLink.astro +15 -0
- package/src/components/content/Quote.astro +10 -9
- package/src/components/content/Spacer.astro +11 -2
- package/src/components/content/Step.astro +34 -15
- package/src/components/content/Walkthrough.astro +10 -6
- package/src/components/content/WhenPublished.astro +14 -0
- package/src/components/models.ts +2 -2
- package/src/lib/archive.ts +18 -4
- package/src/lib/drafts.ts +16 -5
- package/src/lib/format-date.ts +4 -1
- package/src/lib/ordering.ts +3 -1
- package/src/lib/paginate.ts +5 -1
- package/src/lib/slug.ts +30 -7
- package/src/scripts/code-island.ts +27 -4
- package/src/scripts/core/badges.ts +28 -0
- package/src/scripts/core/dom.ts +6 -1
- package/src/scripts/core/frame-loop.ts +4 -1
- package/src/scripts/core/icons.ts +92 -15
- package/src/scripts/ecosystem.ts +72 -12
- package/src/scripts/focus-mode.ts +14 -3
- package/src/scripts/lightbox.ts +227 -41
- package/src/scripts/link-tip.ts +55 -11
- package/src/scripts/path-player-math.ts +11 -2
- package/src/scripts/path-player-paint.ts +54 -8
- package/src/scripts/path-player.ts +131 -29
- package/src/scripts/plate-modal.ts +45 -9
- package/src/scripts/scroll-top.ts +6 -1
- package/src/scripts/site-header.ts +34 -6
- package/src/scripts/subscribe.ts +50 -9
- package/src/scripts/theme-toggle.ts +32 -5
- package/src/shiki/code-theme.mjs +25 -5
- 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
|
-
|
|
51
|
-
the component's own documented opinion (not a prop). */
|
|
37
|
+
|
|
52
38
|
const isCurrent = (href: string) =>
|
|
53
|
-
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"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
</
|
|
75
|
-
|
|
65
|
+
</li>)}
|
|
66
|
+
</ul>
|
|
67
|
+
</div></slot
|
|
68
|
+
>
|
|
76
69
|
</div>
|
|
77
70
|
<div class="masthead-brand">
|
|
78
|
-
<slot name="band-brand"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
?
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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"
|
|
89
|
-
|
|
90
|
-
<div class="header-
|
|
91
|
-
<
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
+
const responsive = !passthrough && widths !== undefined;
|
|
29
|
+
const fixed = !passthrough && widths === undefined;
|
|
28
30
|
const imgLoading = loading === "eager" ? undefined : loading;
|
|
29
31
|
---
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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",
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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"
|
|
48
|
-
|
|
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
|
|
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
|
|
53
|
-
|
|
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"
|
|
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,
|