@half-built/astro 0.1.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/ICONS-LICENSE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/package.json +18 -0
  5. package/src/components/CategoryCard.astro +31 -0
  6. package/src/components/CornerBadges.astro +40 -0
  7. package/src/components/Footer.astro +209 -0
  8. package/src/components/LightboxLink.astro +13 -0
  9. package/src/components/LinkListWidget.astro +19 -0
  10. package/src/components/Pagination.astro +72 -0
  11. package/src/components/PostCard.astro +166 -0
  12. package/src/components/PostNavigation.astro +61 -0
  13. package/src/components/Shell.astro +52 -0
  14. package/src/components/SiteHeader.astro +326 -0
  15. package/src/components/SmartImage.astro +34 -0
  16. package/src/components/Subscribe.astro +117 -0
  17. package/src/components/ThemeToggle.astro +41 -0
  18. package/src/components/TwoColumn.astro +12 -0
  19. package/src/components/Widget.astro +41 -0
  20. package/src/components/content/BlogImage.astro +39 -0
  21. package/src/components/content/Button.astro +57 -0
  22. package/src/components/content/Callout.astro +75 -0
  23. package/src/components/content/CodeBlock.astro +7 -0
  24. package/src/components/content/Gallery.astro +57 -0
  25. package/src/components/content/GalleryImage.astro +35 -0
  26. package/src/components/content/Group.astro +15 -0
  27. package/src/components/content/MediaText.astro +60 -0
  28. package/src/components/content/Palette.astro +42 -0
  29. package/src/components/content/Quote.astro +21 -0
  30. package/src/components/content/Spacer.astro +5 -0
  31. package/src/components/content/Step.astro +126 -0
  32. package/src/components/content/Walkthrough.astro +42 -0
  33. package/src/components/models.ts +77 -0
  34. package/src/lib/archive.ts +29 -0
  35. package/src/lib/drafts.ts +52 -0
  36. package/src/lib/format-date.ts +9 -0
  37. package/src/lib/header-date.ts +6 -0
  38. package/src/lib/ordering.ts +18 -0
  39. package/src/lib/paginate.ts +15 -0
  40. package/src/lib/reading-time.ts +4 -0
  41. package/src/lib/slug.ts +73 -0
  42. package/src/scripts/code-island.ts +75 -0
  43. package/src/scripts/core/breakpoints.ts +4 -0
  44. package/src/scripts/core/dom.ts +31 -0
  45. package/src/scripts/core/frame-loop.ts +54 -0
  46. package/src/scripts/core/icons.ts +30 -0
  47. package/src/scripts/core/island.ts +25 -0
  48. package/src/scripts/core/storage.ts +44 -0
  49. package/src/scripts/focus-mode.ts +41 -0
  50. package/src/scripts/lightbox.ts +446 -0
  51. package/src/scripts/link-tip.ts +154 -0
  52. package/src/scripts/path-player-math.ts +34 -0
  53. package/src/scripts/path-player-paint.ts +154 -0
  54. package/src/scripts/path-player.ts +341 -0
  55. package/src/scripts/plate-modal.ts +91 -0
  56. package/src/scripts/scroll-top.ts +32 -0
  57. package/src/scripts/site-header.ts +73 -0
  58. package/src/scripts/subscribe.ts +116 -0
  59. package/src/scripts/theme-toggle.ts +115 -0
  60. package/src/shiki/code-theme.mjs +16 -0
@@ -0,0 +1,61 @@
1
+ ---
2
+ /* Prev/next post links: nav.navigation.post-navigation with
3
+ nav-previous/nav-next and Previous:/Next: subtitles, in the theme's
4
+ 3px-bordered box. Previous = older post, Next = newer, as WordPress does.
5
+ Owner divergence from the live markup (2026-07-30): the newer post sits
6
+ LEFT and the older post RIGHT, matching the newest-first reading order
7
+ of the archives. Do not restore the WP left/right order for fidelity. */
8
+ import type { PostRef } from "./models";
9
+ interface Props {
10
+ class?: string;
11
+ prev?: PostRef;
12
+ next?: PostRef;
13
+ labels?: { heading?: string; next?: string; prev?: string; nav?: string };
14
+ }
15
+ const { prev, next, class: className, labels: labelsOverride = {} } = Astro.props;
16
+ const labels = { heading: "Post navigation", next: "Next:", prev: "Previous:", nav: "Posts", ...labelsOverride };
17
+ ---
18
+ {(prev || next) && (
19
+ <nav class:list={["navigation", "post-navigation", className]} aria-label={labels.nav}>
20
+ <h2 class="screen-reader-text">{labels.heading}</h2>
21
+ <div class="nav-links">
22
+ {next && (
23
+ <div class="nav-next">
24
+ <a href={next.href} rel="next">
25
+ <span class="nav-subtitle">{labels.next}</span> <span class="nav-title">{next.title}</span>
26
+ </a>
27
+ </div>
28
+ )}
29
+ {prev && (
30
+ <div class="nav-previous">
31
+ <a href={prev.href} rel="prev">
32
+ <span class="nav-subtitle">{labels.prev}</span> <span class="nav-title">{prev.title}</span>
33
+ </a>
34
+ </div>
35
+ )}
36
+ </div>
37
+ </nav>
38
+ )}
39
+
40
+ <style>
41
+ .post-navigation {
42
+ margin-top: 30px;
43
+ padding: 20px;
44
+ /* 3px is this box's design constant (the live theme's weight),
45
+ deliberately NOT var(--stroke): it does not scale with the theme
46
+ stroke. */
47
+ border: var(--stroke-3) solid var(--rule);
48
+ }
49
+ .nav-links {
50
+ display: flex;
51
+ justify-content: space-between;
52
+ gap: 20px;
53
+ flex-wrap: wrap;
54
+ font-size: var(--font-size-sm);
55
+ }
56
+ .nav-previous { text-align: right; margin-left: auto; }
57
+ .nav-links a { text-decoration: none; color: var(--ink); }
58
+ .nav-links a:hover .nav-title,
59
+ .nav-links a:focus .nav-title { text-decoration: underline; }
60
+ .nav-subtitle { font-weight: bold; }
61
+ </style>
@@ -0,0 +1,52 @@
1
+ ---
2
+ /* The document shell (step 8, 2026-08-30): doctype and head skeleton,
3
+ first-paint theme stamp, skip link, page frame, scroll-to-top
4
+ floater. Site-specific head content (meta, OG, title) arrives through
5
+ the head slot, the chrome through the header and footer slots, so a
6
+ second site reuses the frame without forking it. No scoped CSS on
7
+ purpose: the shell's layout rules live once in styles/base/shell.css
8
+ (pinned by test/base.test.ts). */
9
+ interface Props { lang?: string; skipLabel?: string; scrollTopLabel?: string; themeStorageKey: string }
10
+ const { lang = "en", skipLabel = "Skip to content", scrollTopLabel = "Scroll to top", themeStorageKey } = Astro.props;
11
+ ---
12
+ <!doctype html>
13
+ <html lang={lang}>
14
+ <head>
15
+ <meta charset="utf-8" />
16
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
17
+ {/* Theme stamp, inline and first so the dark ground paints on the
18
+ first frame: a stored choice wins (scripts/theme-toggle.ts writes
19
+ it), otherwise the system preference. Light is the absence of the
20
+ attribute. Kept tiny and dependency-free on purpose.
21
+ The storage key rides define:vars from the required themeStorageKey
22
+ prop (step 11.2), so the shell bakes in no brand of its own; the
23
+ caller (Base.astro) supplies it from lib/theme-key. */}
24
+ <script is:inline define:vars={{ themeKey: themeStorageKey }}>
25
+ (function () {
26
+ var t = null;
27
+ try { t = localStorage.getItem(themeKey); } catch (e) { /* no storage */ }
28
+ if (t === "dark" || (t === null && matchMedia("(prefers-color-scheme: dark)").matches)) {
29
+ document.documentElement.dataset.theme = "dark";
30
+ }
31
+ })();
32
+ </script>
33
+ <slot name="head" />
34
+ </head>
35
+ <body>
36
+ <div id="page" class="site">
37
+ <a class="skip-link screen-reader-text" href="#primary-content">{skipLabel}</a>
38
+ <slot name="header" />
39
+ <div id="primary-content" class="primary-site-content">
40
+ <div class="site-content shell">
41
+ <slot />
42
+ </div>
43
+ </div>
44
+ <slot name="footer" />
45
+ <a href="#page" id="scroll-to-top" class="scroll-top" aria-label={scrollTopLabel}>
46
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 15 12 7 20 15" /></svg>
47
+ </a>
48
+ </div>
49
+ {/* Site behavior scripts land here, inside the body, where the pre-step-8 markup carried them. */}
50
+ <slot name="scripts" />
51
+ </body>
52
+ </html>
@@ -0,0 +1,326 @@
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
+ import ThemeToggle from "./ThemeToggle.astro";
9
+ import type { NavItem, SocialItem } from "./models";
10
+ interface Props {
11
+ siteName: string;
12
+ tagline: string;
13
+ nav: NavItem[];
14
+ 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
+ dateStr: string;
20
+ isHome?: boolean;
21
+ searchAction?: string;
22
+ searchParam?: string;
23
+ searchLabels?: { prompt?: string; placeholder?: string; submit?: string };
24
+ }
25
+ const {
26
+ siteName,
27
+ tagline,
28
+ nav,
29
+ socials,
30
+ dateStr,
31
+ isHome = false,
32
+ searchAction = "/search/",
33
+ searchParam = "s",
34
+ searchLabels = {},
35
+ } = Astro.props;
36
+ const {
37
+ prompt: searchPrompt = "Search for:",
38
+ /* The static markup this replaces wrote the placeholder as the HTML
39
+ entity `Search &hellip;`, 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 `&#38;hellip;` in the built output. */
45
+ placeholder: searchPlaceholder = "Search …",
46
+ submit: searchSubmit = "Search",
47
+ } = searchLabels;
48
+
49
+ 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). */
52
+ const isCurrent = (href: string) =>
53
+ href === "/" ? current === "/" || current.startsWith("/page/") : current === href;
54
+ ---
55
+ <header id="masthead" class="site-header">
56
+ <div class="shell">
57
+ <div class="site-header-wrapper bracket-frame">
58
+ <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>
68
+ <a class="icon-box" href={s.href}>
69
+ <span class="screen-reader-text">{s.label}</span>
70
+ <Fragment set:html={s.icon} />
71
+ </a>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ </div></slot>
76
+ </div>
77
+ <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>
86
+ </div>
87
+ <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
109
+ 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
114
+ bare-hash anchor navigates (scroll jump + history
115
+ entry), audit finding A3. focus-within still opens the
116
+ flyout when the button takes focus. */}
117
+ <button type="button" title="Search" class="navigation-search-icon icon-box">
118
+ <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>
119
+ <span class="screen-reader-text">Search</span>
120
+ </button>
121
+ <div class="navigation-search-form">
122
+ <form role="search" class="search-form field-join" action={searchAction} method="get">
123
+ <label>
124
+ <span class="screen-reader-text">{searchPrompt}</span>
125
+ <input type="search" class="search-field field-join-input" placeholder={searchPlaceholder} name={searchParam} />
126
+ </label>
127
+ <input type="submit" class="search-submit field-join-button press-box press-box-shaded" value={searchSubmit} />
128
+ </form>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div></slot>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ </header>
139
+
140
+ <style>
141
+ .site-header { margin-block: 30px 10px; }
142
+
143
+ /* Frame comes from the .bracket-frame pattern; only the stacking context
144
+ is contextual here. */
145
+ .site-header-wrapper { z-index: 1; }
146
+
147
+ /* Top band straddles the top rule via the negative margin. */
148
+ .masthead-top {
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: space-between;
152
+ margin-top: calc(-30px + (-12px));
153
+ }
154
+ .masthead-top .date {
155
+ padding: 4px 10px;
156
+ display: grid;
157
+ place-items: center;
158
+ font-size: var(--font-size-sm);
159
+ }
160
+ /* Date box and the day/night toggle sit together on the left of the
161
+ top band, spaced like the social icons on the right. */
162
+ .header-tools { display: flex; align-items: center; gap: 10px; }
163
+ /* Bottom band's right end: the phone toggle (hidden on desktop) beside
164
+ the search icon, spaced like the top band's tools. */
165
+ .header-end { display: flex; align-items: center; gap: 10px; }
166
+ /* No background on the list itself: the header's top rule must stay
167
+ visible in the gaps between the boxed icons. */
168
+ ul.social-links {
169
+ margin: 0;
170
+ padding: 0;
171
+ list-style: none;
172
+ display: flex;
173
+ gap: 10px;
174
+ }
175
+ .masthead-brand {
176
+ display: flex;
177
+ align-items: center;
178
+ justify-content: center;
179
+ padding-block: 20px;
180
+ }
181
+ .site-branding { padding-block: 10px; text-align: center; width: 100%; }
182
+ .site-title {
183
+ font-size: var(--font-size-xl);
184
+ line-height: 1.1;
185
+ font-weight: 700;
186
+ margin: 0;
187
+ }
188
+ .site-title a { text-decoration: none; color: var(--ink); }
189
+ /* The tagline wears the accent box (owner call 2026-09-03): the
190
+ callout's combination, the same one the press box and the demo
191
+ launcher wear. Accent line, the accent's legible ink for the text,
192
+ and the surface tinted 6% with the accent as its ground, in both
193
+ themes. It replaced bare accent text, which by day was #ffaa3c on
194
+ white at 1.9:1 (test/a11y.test.ts). Static: no hover, no press. */
195
+ .site-description {
196
+ display: inline-block;
197
+ /* 14 above, 8 below (owner pick 2026-09-03, option B of three):
198
+ the box sits as its own element between the title and the nav
199
+ band rather than as a subtitle stuck to the title. */
200
+ margin: 14px 0 8px;
201
+ padding: 6px 14px;
202
+ border: var(--stroke) solid var(--accent-1);
203
+ color: var(--accent-1-ink);
204
+ background-color: color-mix(in srgb, var(--accent-1) 6%, var(--surface));
205
+ }
206
+
207
+ /* Bottom band straddles the bottom rule. */
208
+ .masthead-nav {
209
+ position: relative;
210
+ margin-bottom: calc(-30px + (-17px));
211
+ }
212
+ .header-nav-search {
213
+ width: 100%;
214
+ display: flex;
215
+ gap: 20px;
216
+ align-items: center;
217
+ justify-content: space-between;
218
+ }
219
+ .main-navigation .menu {
220
+ list-style: none;
221
+ padding: 0;
222
+ margin: 0;
223
+ display: flex;
224
+ gap: 10px;
225
+ flex-wrap: wrap;
226
+ font-size: var(--font-size-sm);
227
+ }
228
+ /* Box, ink, and press feedback from .press-box; the nav's own size,
229
+ case, and hover ring are here. */
230
+ .main-navigation .menu a {
231
+ padding: 7px 15px;
232
+ text-transform: uppercase;
233
+ font-weight: bold;
234
+ display: block;
235
+ }
236
+ .menu-item { position: relative; }
237
+ .current-menu-item > a { --tint-opacity: 0.2; }
238
+ .main-navigation .menu a:hover { outline: var(--stroke) solid var(--rule); }
239
+
240
+ .menu-toggle { display: none; }
241
+ @media (--bp-sidebar) {
242
+ .menu-toggle {
243
+ display: block;
244
+ width: 35px;
245
+ height: 35px;
246
+ position: relative;
247
+ cursor: pointer;
248
+ padding: 0;
249
+ margin: 5px;
250
+ border: var(--stroke) solid var(--rule);
251
+ background-color: var(--surface);
252
+ }
253
+ .menu-toggle span:not(.screen-reader-text) {
254
+ display: block;
255
+ position: absolute;
256
+ height: 3px;
257
+ width: calc(100% - 10px);
258
+ background: var(--ink);
259
+ border-radius: 3px;
260
+ left: 50%;
261
+ transform: translateX(-50%);
262
+ }
263
+ .menu-toggle span:nth-child(1) { top: 7px; }
264
+ .menu-toggle span:nth-child(2) { top: 14px; }
265
+ .menu-toggle span:nth-child(3) { top: 21px; }
266
+ /* The open menu is a pop-out under the hamburger, floating over the
267
+ page like the search flyout (same z tier), not a block that grows
268
+ the header (owner request 2026-08-27, after launch). The header
269
+ keeps its height, so the band keeps its desktop straddle of the
270
+ bottom rule: hamburger, toggle, and magnifier hang off the rule
271
+ instead of sitting inside the frame. */
272
+ .main-navigation { position: relative; }
273
+ .main-navigation .menu {
274
+ display: none;
275
+ flex-direction: column;
276
+ position: absolute;
277
+ top: calc(100% + 6px);
278
+ left: 0;
279
+ min-width: 220px;
280
+ padding: 10px;
281
+ z-index: var(--z-search-flyout);
282
+ background-color: var(--surface);
283
+ border: var(--stroke) solid var(--rule);
284
+ box-shadow: var(--shadow);
285
+ }
286
+ .main-navigation .menu.open { display: flex; }
287
+ }
288
+ /* Phone-width header simplification (owner call 2026-08-20): the
289
+ date box competes for pixels a phone already spends on its own
290
+ clock, and the social tray collided with it. Both retire; the
291
+ socials' job moves to the Contact page, one hamburger tap away. */
292
+ @media (--bp-phone) {
293
+ .masthead-top { display: none; }
294
+ }
295
+
296
+ /* Search flyout: icon on a primary-filled box, form revealed on focus-within
297
+ (the live theme toggles a .show class with jQuery; focus-within needs no script). */
298
+ .navigation-search { width: 30px; position: relative; display: inline-block; text-align: center; }
299
+ .navigation-search-form {
300
+ position: absolute;
301
+ right: 0;
302
+ opacity: 0;
303
+ visibility: hidden;
304
+ top: 100%;
305
+ width: 325px;
306
+ padding: 14px;
307
+ z-index: var(--z-search-flyout);
308
+ background-color: var(--surface);
309
+ border: var(--stroke) solid var(--rule);
310
+ transition: var(--transition);
311
+ }
312
+ .navigation-search-wrap:focus-within .navigation-search-form {
313
+ opacity: 1;
314
+ visibility: visible;
315
+ }
316
+ /* Form anatomy comes from the .field-join house pattern (owner request
317
+ 2026-07-30, replacing the live theme's 75/25 split form). Only the
318
+ label wrapper's sizing is contextual. */
319
+ .search-form label { flex: 1; }
320
+ .search-form .search-field { width: 100%; }
321
+ </style>
322
+
323
+ <script>
324
+ import { mountSiteHeader } from "../scripts/site-header";
325
+ mountSiteHeader(document);
326
+ </script>
@@ -0,0 +1,34 @@
1
+ ---
2
+ import { Image } from "astro:assets";
3
+ import type { ImageMetadata } from "astro";
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
+ interface Props {
15
+ src: ImageMetadata;
16
+ alt: string;
17
+ class?: string;
18
+ widths?: number[];
19
+ sizes?: string;
20
+ loading?: "lazy" | "eager";
21
+ fetchpriority?: "high";
22
+ style?: string;
23
+ }
24
+ const { src, alt, class: className, widths, sizes, loading = "lazy", fetchpriority, style } = Astro.props;
25
+ 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 imgLoading = loading === "eager" ? undefined : loading;
29
+ ---
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} />}
@@ -0,0 +1,117 @@
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
+ interface Props {
22
+ variant?: "sidebar" | "post" | "page";
23
+ headingLevel?: 2 | 3 | 4;
24
+ 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
+ action: string;
28
+ /* Human sign-up page, named in the failure message (data-public-url). */
29
+ publicUrl?: string;
30
+ placeholder?: string;
31
+ buttonLabel?: string;
32
+ }
33
+ const {
34
+ variant = "post", headingLevel = 2, class: className,
35
+ action, publicUrl,
36
+ placeholder = "you@somewhere.tld", buttonLabel = "Subscribe",
37
+ } = 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). */
40
+ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
41
+ ---
42
+ <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>
44
+ {variant === "sidebar" ? (
45
+ <p class="subscribe-blurb"><slot name="blurb">An email when something new goes up.</slot></p>
46
+ ) : (
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>
49
+ )}
50
+ <form class="subscribe-form field-join" method="post" action={action} data-public-url={publicUrl} novalidate>
51
+ <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>
54
+ </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>
56
+ <p class="subscribe-status" role="status"></p>
57
+ </section>
58
+
59
+ <script>
60
+ import { mountSubscribe } from "../scripts/subscribe";
61
+ mountSubscribe(document);
62
+ </script>
63
+
64
+ <style>
65
+ /* The BEADZ signature double rule (border plus inset outline), at this
66
+ blog's standard 2px stroke rather than BEADZ's 3px. Every color is a
67
+ --panel-* role: amber ground with warm-dark rule and button by day,
68
+ dark ground with amber rule and button by night, so the call to
69
+ action is the brightest element in the block either way. outline-offset
70
+ stays -10px; it is a design constant of the double rule. */
71
+ .subscribe {
72
+ background: var(--panel-bg);
73
+ color: var(--panel-ink);
74
+ border: var(--stroke) solid var(--panel-rule);
75
+ outline: var(--stroke) solid var(--panel-rule);
76
+ outline-offset: -10px;
77
+ }
78
+ .subscribe-post { padding: 30px; margin-top: 30px; }
79
+ .subscribe-sidebar { padding: 24px 22px; margin-bottom: 40px; }
80
+ .subscribe-page { padding: 30px; }
81
+ .subscribe-title {
82
+ margin: 0 0 8px;
83
+ font-family: var(--font-body);
84
+ line-height: 1.2;
85
+ }
86
+ .subscribe-post .subscribe-title,
87
+ .subscribe-page .subscribe-title { font-size: var(--font-size-md); }
88
+ .subscribe-sidebar .subscribe-title { font-size: var(--font-size-base); }
89
+ .subscribe-blurb { margin: 0 0 16px; font-size: var(--font-size-sm); }
90
+ /* Form anatomy, focus rings, and press feedback all come from the
91
+ .field-join pattern (patterns.css, house rule 2026-07-30, settled
92
+ after trying inset and side-by-side variants); this island only sets
93
+ the pattern's field knobs to its panel roles. The button takes none:
94
+ it is the house shaded press box, the same on the panel as anywhere. */
95
+ .subscribe-form {
96
+ --field-ink: var(--panel-rule);
97
+ --field-text: var(--panel-ink);
98
+ /* The email box's click highlight is the panel's own (ink on the
99
+ amber by day, amber by night); the keyboard ring stays the site's
100
+ everywhere in the panel (owner rule 2026-08-26). */
101
+ --field-ring: var(--panel-ring);
102
+ /* The well is the page surface, so it sits a step below the panel in
103
+ both themes (white on amber by day, black on the dark panel by night). */
104
+ --field-well: var(--surface);
105
+ }
106
+ .subscribe-post .subscribe-form,
107
+ .subscribe-page .subscribe-form { max-width: 520px; }
108
+ /* Fine print sits between form and status so an error line never
109
+ shoves it around; the link inherits the block's ink. Owner wanted it
110
+ smaller than the status line (2026-08-16); xs is the scale's floor,
111
+ so this steps below it off the token rather than hardcoding a size. */
112
+ .subscribe-fine { margin: 10px 0 0; font-size: calc(var(--font-size-xs) * 0.88); }
113
+ .subscribe-fine a { color: inherit; }
114
+ .subscribe-status { margin: 10px 0 0; font-size: var(--font-size-xs); font-weight: 700; }
115
+ .subscribe-status:empty { display: none; }
116
+ .subscribe-status-err { color: var(--panel-error); }
117
+ </style>
@@ -0,0 +1,41 @@
1
+ ---
2
+ /* Day/night toggle (owner request 2026-08-25): an .icon-box button that
3
+ flips the site theme. Both glyphs are in the button; CSS shows the
4
+ current theme's (sun by day, moon by night, owner call 2026-08-26) and
5
+ the label names the action. State and storage live in
6
+ scripts/theme-toggle.ts, which mounts every .theme-toggle on the page
7
+ and keeps them in step; the first paint is stamped by the inline
8
+ script in Shell.astro's head.
9
+
10
+ Two placements: "band" sits beside the date stamp in the header's top
11
+ band; "phone" sits beside the search icon in the bottom band and shows
12
+ only under 768px, where the top band is hidden (owner call 2026-08-20),
13
+ so phones keep a toggle (owner request 2026-08-26). */
14
+ import { ICON_SUN, ICON_MOON } from "../scripts/core/icons";
15
+ interface Props { placement?: "band" | "phone"; class?: string; label?: string }
16
+ const { placement = "band", class: className, label = "Switch to dark mode" } = Astro.props;
17
+ ---
18
+ <button type="button" class:list={["theme-toggle", "icon-box", placement === "phone" && "theme-toggle-phone", className]} aria-label={label} aria-pressed="false">
19
+ <span class="theme-toggle-moon"><Fragment set:html={ICON_MOON} /></span>
20
+ <span class="theme-toggle-sun"><Fragment set:html={ICON_SUN} /></span>
21
+ </button>
22
+
23
+ <style>
24
+ .theme-toggle { font-size: 16px; }
25
+ .theme-toggle span { display: block; line-height: 0; }
26
+ /* The glyphs keep their currentColor stroke and take a fill: the moon
27
+ in the second accent, the sun's disc in the first (owner call
28
+ 2026-08-26). :global because the svg arrives through set:html. */
29
+ .theme-toggle-moon :global(path) { fill: var(--accent-2); }
30
+ .theme-toggle-sun :global(circle) { fill: var(--accent-1); }
31
+ /* Show the glyph of the current theme. Both selectors carry the
32
+ parent class so they outrank the span rule above. */
33
+ .theme-toggle .theme-toggle-moon { display: none; }
34
+ :global(:root[data-theme="dark"]) .theme-toggle .theme-toggle-moon { display: block; }
35
+ :global(:root[data-theme="dark"]) .theme-toggle .theme-toggle-sun { display: none; }
36
+ /* The phone placement exists only where the top band does not. */
37
+ .theme-toggle-phone { display: none; }
38
+ @media (--bp-phone) {
39
+ .theme-toggle-phone { display: flex; }
40
+ }
41
+ </style>
@@ -0,0 +1,12 @@
1
+ ---
2
+ /* The two-column page body (step 8, 2026-08-30): content beside the
3
+ sidebar slot. Column grid, widths, and the collapse live once in
4
+ styles/base/shell.css; this component is structure only, so it ships
5
+ no scoped CSS and stamps no cid on the markup. */
6
+ interface Props { class?: string }
7
+ const { class: className } = Astro.props;
8
+ ---
9
+ <div class:list={["two-column", className]}>
10
+ <main class="site-main"><slot /></main>
11
+ <slot name="aside" />
12
+ </div>