@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
package/ICONS-LICENSE ADDED
@@ -0,0 +1,43 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Lucide Icons and Contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16
+
17
+ ---
18
+
19
+ The following Lucide icons are derived from the Feather project:
20
+
21
+ airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
22
+
23
+ The MIT License (MIT) (for the icons listed above)
24
+
25
+ Copyright (c) 2013-present Cole Bemis
26
+
27
+ Permission is hereby granted, free of charge, to any person obtaining a copy
28
+ of this software and associated documentation files (the "Software"), to deal
29
+ in the Software without restriction, including without limitation the rights
30
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
31
+ copies of the Software, and to permit persons to whom the Software is
32
+ furnished to do so, subject to the following conditions:
33
+
34
+ The above copyright notice and this permission notice shall be included in all
35
+ copies or substantial portions of the Software.
36
+
37
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
38
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
39
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
40
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
41
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
42
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
43
+ SOFTWARE.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Curt Henrichs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @half-built/astro
2
+
3
+ Astro components, islands, and pure helpers for the half-built design
4
+ system.
5
+
6
+ ## Provenance
7
+
8
+ Extracted from the private `half-built-robots-blog` repository, where
9
+ these components were built and used in production. The extraction
10
+ review that checked them for blog-specific assumptions before the move
11
+ is the design record for this package.
12
+
13
+ ## Icons
14
+
15
+ Default icon glyphs are derived from Lucide (https://lucide.dev), ISC
16
+ license. See `ICONS-LICENSE`.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@half-built/astro",
3
+ "version": "0.1.0",
4
+ "description": "Astro components, islands, and pure helpers for the half-built design system.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": ["src", "ICONS-LICENSE"],
8
+ "exports": {
9
+ "./components/Masthead.astro": "./src/components/SiteHeader.astro",
10
+ "./components/*": "./src/components/*",
11
+ "./content/*": "./src/components/content/*",
12
+ "./scripts/*": "./src/scripts/*",
13
+ "./lib/*": "./src/lib/*",
14
+ "./shiki/code-theme": "./src/shiki/code-theme.mjs"
15
+ },
16
+ "peerDependencies": { "astro": "^5.0.0" },
17
+ "publishConfig": { "access": "public" }
18
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ import type { ImageMetadata } from "astro";
3
+
4
+ interface Props { name: string; href: string; image: ImageMetadata; class?: string }
5
+ const { name, href, image, class: className } = Astro.props;
6
+ const bg = `background-image:url('${image.src.replace(/'/g, "%27").replace(/"/g, "%22")}')`;
7
+ ---
8
+ <a class:list={["category-single", className]} href={href}>
9
+ <span class="category-img" style={bg}></span>
10
+ <span class="category-label chip">{name}</span>
11
+ </a>
12
+
13
+ <style>
14
+ .category-single {
15
+ aspect-ratio: 4 / 2.8;
16
+ position: relative;
17
+ overflow: hidden;
18
+ display: flex;
19
+ align-items: flex-end;
20
+ border: var(--stroke) solid var(--rule);
21
+ text-decoration: none;
22
+ }
23
+ .category-img { position: absolute; inset: 0; background-size: cover; background-position: center; }
24
+ /* Chip look comes from the .chip pattern; card-specific metrics only. */
25
+ .category-label {
26
+ position: relative;
27
+ line-height: 1.2;
28
+ padding: 7px 13px;
29
+ margin: 5px;
30
+ }
31
+ </style>
@@ -0,0 +1,40 @@
1
+ ---
2
+ /* Corner badges (owner request 2026-08-27, after launch): the chip
3
+ family the archive cards' read-time chip belongs to, pinned to the
4
+ lower-right corner of the image they sit on.
5
+ - AI Art marks an AI-generated image: heroes through the heroGenAI
6
+ frontmatter flag, content images through the genai prop on
7
+ BlogImage, GalleryImage, and MediaText. Images that only went
8
+ through ordinary editing carry nothing.
9
+ - Demo marks a post that carries an interactive demo (the demo
10
+ frontmatter flag), on the card and the post hero.
11
+ Renders nothing when neither applies. The container is absolutely
12
+ positioned, so the parent supplies position: relative. pointer-events
13
+ off: a badge inside a lightbox link must not swallow the tap. */
14
+ import { ICON_PLAY, ICON_SPARKLES } from "../scripts/core/icons";
15
+
16
+ interface Props { genai?: boolean; demo?: boolean; class?: string; genaiLabel?: string; demoLabel?: string }
17
+ const { genai = false, demo = false, class: className, genaiLabel = "AI Art", demoLabel = "Demo" } = Astro.props;
18
+ ---
19
+ {(genai || demo) && (
20
+ <span class:list={["corner-badges", className]}>
21
+ {genai && <span class="chip badge-genai"><Fragment set:html={ICON_SPARKLES} /> {genaiLabel}</span>}
22
+ {demo && <span class="chip badge-demo"><Fragment set:html={ICON_PLAY} /> {demoLabel}</span>}
23
+ </span>
24
+ )}
25
+
26
+ <style>
27
+ /* Chip look comes from the .chip pattern; the read-time chip sits
28
+ 10px in from the card's bottom-left, so these sit 10px in from the
29
+ bottom-right. */
30
+ .corner-badges {
31
+ position: absolute;
32
+ right: 10px;
33
+ bottom: 10px;
34
+ display: flex;
35
+ gap: 6px;
36
+ z-index: 1;
37
+ line-height: 1;
38
+ pointer-events: none;
39
+ }
40
+ </style>
@@ -0,0 +1,209 @@
1
+ ---
2
+ import type { SitemapGroup, EcosystemEntry } from "./models";
3
+ import { BP_PHONE_MAX } from "../scripts/core/breakpoints";
4
+ /* The real half-built-robots.com footer is a single bottom bar with the
5
+ copyright line, and the live site has no footer widget row. The sitemap
6
+ below the copyright line is a deliberate divergence, owner-directed
7
+ 2026-07-30 (docs/superpowers/specs/2026-07-30-footer-sitemap-design.md).
8
+ The old theme credit stays dropped: this build is original code, not the
9
+ Adore theme. Everything below renders from the sitemap/ecosystem props
10
+ Base.astro passes in; SitemapGroup and EcosystemEntry live in models.ts. */
11
+ interface Props {
12
+ siteName: string;
13
+ legalHolder: string;
14
+ sitemap: SitemapGroup[];
15
+ ecosystem: EcosystemEntry[];
16
+ /* The entry rendered as this site itself (unlinked, bold). */
17
+ ecosystemSelf: string;
18
+ ecosystemTitle?: string;
19
+ }
20
+ const { siteName, legalHolder, sitemap, ecosystem, ecosystemSelf, ecosystemTitle = "Ecosystem" } = Astro.props;
21
+ const year = new Date().getFullYear();
22
+ ---
23
+ <footer id="colophon" class="site-footer">
24
+ <div class="bottom-footer tint-overlay">
25
+ <div class="shell">
26
+ <div class="bottom-footer-info">
27
+ <div class="site-info">
28
+ {/* Segments are unbreakable so narrow screens wrap only at
29
+ the sanctioned points: after the year and at the pipe,
30
+ which the line break replaces below the mobile breakpoint
31
+ (owner call 2026-08-20). Desktop and tablet render the
32
+ one line exactly as before. */}
33
+ <slot name="site-info"><span>
34
+ <span class="site-info-seg">Copyright © {year}</span>{" "}
35
+ <span class="site-info-seg">{legalHolder}</span>{" "}
36
+ <span class="site-info-sep" aria-hidden="true">|</span>{" "}
37
+ <a class="site-info-seg" href="/">{siteName}</a>
38
+ </span></slot>
39
+ </div>
40
+ </div>
41
+ <nav class="footer-sitemap" aria-label="Sitemap">
42
+ {sitemap.map((group) =>
43
+ group.collapsible ? (
44
+ <details class="footer-sitemap-group footer-sitemap-collapsible" open data-phone-open={group.phoneOpen ? "" : undefined}>
45
+ <summary><h2>{group.title}</h2></summary>
46
+ <ul>
47
+ {group.links.map((link) => (
48
+ <li><a href={link.href}>{link.label}</a></li>
49
+ ))}
50
+ </ul>
51
+ </details>
52
+ ) : (
53
+ <section class="footer-sitemap-group">
54
+ <h2>{group.title}</h2>
55
+ <ul>
56
+ {group.links.map((link) => (
57
+ <li><a href={link.href}>{link.label}</a></li>
58
+ ))}
59
+ </ul>
60
+ </section>
61
+ ),
62
+ )}
63
+ <details class="footer-sitemap-group footer-sitemap-collapsible" open>
64
+ <summary><h2>{ecosystemTitle}</h2></summary>
65
+ <ul>
66
+ {ecosystem.map((entry) => (
67
+ <li>
68
+ {entry.key === ecosystemSelf ? (
69
+ <span class="footer-sitemap-self">{entry.label}</span>
70
+ ) : entry.href !== null ? (
71
+ <a href={entry.href}>{entry.label}</a>
72
+ ) : (
73
+ <span class="footer-sitemap-pending">{entry.label}</span>
74
+ )}
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ </details>
79
+ </nav>
80
+ <script is:inline define:vars={{ bpPhoneMax: BP_PHONE_MAX }}>
81
+ /* Phones start the collapsible groups closed, except the ones
82
+ marked data-phone-open (Site, owner call 2026-08-27). A closed
83
+ <details> cannot be reopened by CSS, so this is the one place
84
+ script decides layout (spec 2026-08-25, item 5). Inline so it
85
+ runs before the footer paints; without script the groups stay
86
+ open, which is the graceful state.
87
+ The value rides define:vars from core/breakpoints.ts (step 11.1). */
88
+ if (window.matchMedia(`(max-width: ${bpPhoneMax}px)`).matches) {
89
+ document.querySelectorAll(".footer-sitemap-collapsible:not([data-phone-open])").forEach((d) => {
90
+ d.removeAttribute("open");
91
+ });
92
+ }
93
+ </script>
94
+ </div>
95
+ </div>
96
+ </footer>
97
+
98
+ <style>
99
+ .site-footer { margin-top: 50px; }
100
+ /* Tint comes from the .tint-overlay pattern (default 0.1 opacity). */
101
+ .bottom-footer { padding: 15px 0 20px; z-index: 1; }
102
+ .bottom-footer-info {
103
+ display: flex;
104
+ flex-wrap: wrap;
105
+ align-items: center;
106
+ justify-content: center;
107
+ position: relative;
108
+ font-size: var(--font-size-sm);
109
+ }
110
+ /* The wrapped phone lines center under each other like the sitemap
111
+ below (owner report 2026-08-25); one line on desktop, as before. */
112
+ .site-info { text-align: center; }
113
+ /* Underlined, unlike the sitemap links below: this one sits inside
114
+ running text (the copyright line), where the same ink at 0.8
115
+ opacity is not a visible link mark (WCAG 1.4.1, axe
116
+ link-in-text-block; caught by test/a11y.test.ts at phone width,
117
+ where the pipe retires and the line break is all that frames it). */
118
+ .site-info a { text-decoration: underline; opacity: 0.8; transition: var(--transition); color: var(--ink); }
119
+ .site-info-seg { white-space: nowrap; }
120
+ /* The pipe's separating job is done by the line break when the
121
+ segments wrap; below the mobile breakpoint the break is expected,
122
+ so the pipe retires there. */
123
+ @media (--bp-phone) { .site-info-sep { display: none; } }
124
+ .site-info a:hover { opacity: 1; }
125
+
126
+ /* Sitemap columns under the copyright line, centered like it (owner
127
+ feedback 2026-08-01). position: relative lifts the content above the
128
+ tint ::before, same idiom as .bottom-footer-info. */
129
+ .footer-sitemap {
130
+ position: relative;
131
+ display: flex;
132
+ flex-wrap: wrap;
133
+ justify-content: center;
134
+ gap: 20px 90px;
135
+ margin-top: 15px;
136
+ padding-top: 20px;
137
+ border-top: 1px solid var(--hairline);
138
+ font-size: var(--font-size-sm);
139
+ }
140
+ .footer-sitemap-group h2 {
141
+ margin: 0 0 8px;
142
+ font-size: var(--font-size-xs);
143
+ font-weight: 700;
144
+ text-transform: uppercase;
145
+ }
146
+ .footer-sitemap-group ul {
147
+ list-style: none;
148
+ margin: 0;
149
+ padding: 0;
150
+ }
151
+ .footer-sitemap-group li { margin: 4px 0; }
152
+ .footer-sitemap a {
153
+ text-decoration: none;
154
+ opacity: 0.8;
155
+ transition: var(--transition);
156
+ }
157
+ .footer-sitemap a:hover,
158
+ .footer-sitemap a:focus { opacity: 1; text-decoration: underline; }
159
+ /* The monochrome theme makes a color marker invisible (text already IS
160
+ the primary color), so "you are here" is weight, not hue. */
161
+ .footer-sitemap-self { font-weight: 700; }
162
+ .footer-sitemap-pending { opacity: 0.55; }
163
+
164
+ /* Collapsible groups: on desktop the summary is nothing but the
165
+ heading it replaced (no marker, no pointer). Mouse toggling is off
166
+ on desktop (the spec's call); keyboard toggling of a focused
167
+ summary remains, reopening is one keypress. */
168
+ .footer-sitemap-collapsible summary { list-style: none; cursor: default; pointer-events: none; }
169
+ .footer-sitemap-collapsible summary::-webkit-details-marker { display: none; }
170
+ .footer-sitemap-collapsible summary h2 { display: inline-block; }
171
+
172
+ /* Phones: one centered column, every group behind a boxed +/- marker
173
+ in the square grammar (owner call 2026-08-25); Feeds and Ecosystem
174
+ start closed, Site open (the inline script above). The marker pins
175
+ to the column's left edge and the title centers over the group, so
176
+ the three titles line up under the copyright line (owner call
177
+ 2026-08-27). */
178
+ @media (--bp-phone) {
179
+ .footer-sitemap {
180
+ flex-direction: column;
181
+ align-items: center;
182
+ gap: 14px;
183
+ }
184
+ .footer-sitemap-group { text-align: center; width: 100%; }
185
+ .footer-sitemap-collapsible summary {
186
+ display: block;
187
+ position: relative;
188
+ cursor: pointer;
189
+ pointer-events: auto;
190
+ }
191
+ .footer-sitemap-collapsible summary::before {
192
+ content: "+";
193
+ position: absolute;
194
+ left: 0;
195
+ top: 50%;
196
+ translate: 0 -50%;
197
+ width: 1.2em;
198
+ height: 1.2em;
199
+ line-height: 1.1em;
200
+ border: var(--stroke) solid currentColor;
201
+ font-size: var(--font-size-xs);
202
+ font-weight: 700;
203
+ text-align: center;
204
+ }
205
+ .footer-sitemap-collapsible[open] summary::before { content: "\2212"; }
206
+ .footer-sitemap-collapsible:not([open]) summary h2 { margin-bottom: 0; }
207
+ /* Keyboard focus is the site-wide ring (base/reset.css). */
208
+ }
209
+ </style>
@@ -0,0 +1,13 @@
1
+ ---
2
+ /* The build-time lightbox anchor (step 5, 2026-08-30): owner of the
3
+ data-lb-* contract that scripts/lightbox.ts itemFor() reads. The
4
+ caption string feeds both data-lb-caption and the aria-label detail;
5
+ every pre-step-5 site passed the same string to both. class:list
6
+ stays FIRST in the tag: content-components and kitchen-sink pin
7
+ `<a class="lightbox-link"` as the anchor's opening. Base styling
8
+ (display, position, cursor) lives in styles/lightbox.css; no scoped
9
+ CSS here so callers' :global-wrapped rules reach the anchor. */
10
+ interface Props extends Record<string, unknown> { href: string; w: number; h: number; caption?: string; class?: string }
11
+ const { href, w, h, caption = "", class: className, ...rest } = Astro.props;
12
+ ---
13
+ <a class:list={["lightbox-link", className]} href={href} data-lb-w={w} data-lb-h={h} data-lb-caption={caption} aria-label={`View full-size image${caption ? `: ${caption}` : ""}`} {...rest}><slot /></a>
@@ -0,0 +1,19 @@
1
+ ---
2
+ /* One list widget for the sidebar's three lists (step 6, 2026-08-30):
3
+ a Widget holding a link list, counts rendered when present. Replaces
4
+ RecentPosts, CategoriesWidget, and ArchivesWidget; the sidebar builds
5
+ the items via src/lib/view-models.ts. The class prop rides down to
6
+ Widget's root, so this file has no class:list of its own. */
7
+ import Widget from "./Widget.astro";
8
+ import type { LinkListItem } from "./models";
9
+
10
+ interface Props { title: string; items: LinkListItem[]; headingLevel?: 2 | 3 | 4; class?: string }
11
+ const { title, items, headingLevel = 2, class: className } = Astro.props;
12
+ ---
13
+ <Widget title={title} headingLevel={headingLevel} class={className}>
14
+ <ul>
15
+ {items.map((i) => (
16
+ <li><a href={i.href}>{i.label}</a>{i.count !== undefined && ` (${i.count})`}</li>
17
+ ))}
18
+ </ul>
19
+ </Widget>
@@ -0,0 +1,72 @@
1
+ ---
2
+ /* Pagination matching the live WordPress markup: nav.navigation.pagination
3
+ with .nav-links containing .page-numbers boxes and Prev/Next arrows. */
4
+ import { archivePagePath } from "../lib/paginate";
5
+ interface Props {
6
+ current: number;
7
+ total: number;
8
+ base?: string;
9
+ class?: string;
10
+ labels?: { heading?: string; prev?: string; next?: string };
11
+ }
12
+ const { current, total, base = "", class: className, labels: labelsOverride = {} } = Astro.props;
13
+ const labels = { heading: "Posts pagination", prev: "Previous", next: "Next", ...labelsOverride };
14
+ const pageHref = (n: number) => archivePagePath(n, base);
15
+ ---
16
+ {total > 1 && (
17
+ <nav class:list={["navigation", "pagination", className]} aria-label={labels.heading}>
18
+ <h2 class="screen-reader-text">{labels.heading}</h2>
19
+ <div class="nav-links">
20
+ {current > 1 && (
21
+ <a class="prev page-numbers press-box" href={pageHref(current - 1)}>
22
+ <svg viewBox="0 0 448 512" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M257.5 445.1l-22.2 22.2c-9.4 9.4-24.6 9.4-33.9 0L7 273c-9.4-9.4-9.4-24.6 0-33.9L201.4 44.7c9.4-9.4 24.6-9.4 33.9 0l22.2 22.2c9.5 9.5 9.3 25-.4 34.3L136.6 216H424c13.3 0 24 10.7 24 24v32c0 13.3-10.7 24-24 24H136.6l120.5 114.8c9.8 9.3 10 24.8.4 34.3z"/></svg>
23
+ <span class="screen-reader-text">{labels.prev}</span>
24
+ </a>
25
+ )}
26
+ {Array.from({ length: total }, (_, i) => i + 1).map((n) =>
27
+ n === current
28
+ ? <span aria-current="page" class="page-numbers current">{n}</span>
29
+ : <a class="page-numbers press-box" href={pageHref(n)}>{n}</a>
30
+ )}
31
+ {current < total && (
32
+ <a class="next page-numbers press-box" href={pageHref(current + 1)}>
33
+ <svg viewBox="0 0 448 512" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M190.5 66.9l22.2-22.2c9.4-9.4 24.6-9.4 33.9 0L441 239c9.4 9.4 9.4 24.6 0 33.9L246.6 467.3c-9.4 9.4-24.6 9.4-33.9 0l-22.2-22.2c-9.5-9.5-9.3-25 .4-34.3L311.4 296H24c-13.3 0-24-10.7-24-24v-32c0-13.3 10.7-24 24-24h287.4L190.9 101.2c-9.8-9.3-10-24.8-.4-34.3z"/></svg>
34
+ <span class="screen-reader-text">{labels.next}</span>
35
+ </a>
36
+ )}
37
+ </div>
38
+ </nav>
39
+ )}
40
+
41
+ <style>
42
+ .nav-links {
43
+ display: flex;
44
+ gap: 5px;
45
+ margin: 10px 0 20px;
46
+ justify-content: center;
47
+ }
48
+ .nav-links a,
49
+ .nav-links span {
50
+ text-decoration: none;
51
+ width: 30px;
52
+ height: 30px;
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ transition: var(--transition);
57
+ color: var(--ink);
58
+ border: 1px solid var(--ink);
59
+ }
60
+ .nav-links a:hover,
61
+ .nav-links a:focus {
62
+ color: var(--ink);
63
+ border-color: var(--rule);
64
+ }
65
+ /* Press feedback comes from .press-box on the links; the shared rule
66
+ above keeps this site's 1px cell border over the pattern's 2px. */
67
+ .nav-links span.current {
68
+ color: var(--on-ink);
69
+ background-color: var(--ink);
70
+ border-color: var(--rule);
71
+ }
72
+ </style>
@@ -0,0 +1,166 @@
1
+ ---
2
+ /* Archive/grid post card matching the live markup exactly:
3
+ article > .post-item.post-grid > .post-item-image (thumbnail link + reading
4
+ chip) + .post-item-content (categories, underlined title, meta, excerpt).
5
+ Comments are permanently dropped, so the chip carries read time only.
6
+ Fed by postCardModel() in src/lib/view-models.ts. */
7
+ import SmartImage from "./SmartImage.astro";
8
+ import CornerBadges from "./CornerBadges.astro";
9
+ import type { PostCardModel } from "./models";
10
+
11
+ interface Props extends PostCardModel { headingLevel?: 2 | 3 | 4; class?: string; minReadLabel?: string }
12
+ const {
13
+ href, title, excerpt, author, dateStr, minutes, hero, heroPosition,
14
+ draft = false, genai, demo, categories, headingLevel = 2, class: className,
15
+ minReadLabel = "min read",
16
+ } = Astro.props;
17
+ /* Annotated, not cast: an `as` cast in the frontmatter makes the Astro
18
+ compiler drop the Props type (found 2026-08-25, bisected with
19
+ convertToTSX). */
20
+ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
21
+ /* Optional per-post crop focus for the cover-fit thumbnail; see the
22
+ heroPosition comment in content.config.ts. */
23
+ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
24
+ ---
25
+ {/* The mapper guarantees an image (Henry placeholder when no featured
26
+ image), so every card takes the thumbnail treatment. */}
27
+ <article class:list={["has-post-thumbnail", className]}>
28
+ <div class="post-item post-grid">
29
+ <div class="post-item-image">
30
+ {/* alt divergence kept from pre-step-5: empty on the svg placeholder, title on a real hero; the anchor is aria-hidden either way. */}
31
+ <a class="post-thumbnail" href={href} aria-hidden="true" tabindex="-1">
32
+ <SmartImage src={hero} alt={hero.format === "svg" ? "" : title} widths={[470, 940]} sizes="(max-width: 700px) 100vw, 470px" style={heroStyle} />
33
+ </a>
34
+ {draft && <span class="draft-stamp">DRAFT</span>}
35
+ <div class="read-time-comment">
36
+ <span class="reading-time chip">
37
+ <svg viewBox="0 0 512 512" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm61.8-104.4l-84.9-61.7c-3.1-2.3-4.9-5.9-4.9-9.7V116c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v141.7l66.8 48.6c5.4 3.9 6.5 11.4 2.6 16.8L334.6 349c-3.9 5.3-11.4 6.5-16.8 2.6z"/></svg>
38
+ {`${minutes} ${minReadLabel}`}</span>
39
+ </div>
40
+ <CornerBadges genai={genai} demo={demo} />
41
+ </div>
42
+ <div class="post-item-content">
43
+ <div class="entry-cat">
44
+ {/* Owner-approved divergence from live (2026-07-28): live wraps the
45
+ category list onto extra rows; ours comma-joins on one line and
46
+ lets the browser ellipsize overflow. Do not restore the flex ul. */}
47
+ <span class="post-categories">
48
+ {categories.map((c, idx) => (
49
+ <Fragment>{idx > 0 && ", "}<a href={c.href} rel="category tag">{c.name}</a></Fragment>
50
+ ))}
51
+ </span>
52
+ </div>
53
+ <Heading class="entry-title"><a href={href} rel="bookmark">{title}</a></Heading>
54
+ <ul class="entry-meta">
55
+ <li class="post-author">
56
+ <svg viewBox="0 0 448 512" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"/></svg>
57
+ {author}
58
+ </li>
59
+ <li class="post-date">
60
+ <svg viewBox="0 0 448 512" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M148 288h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm108-12v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm96 0v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm-96 96v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm-96 0v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-40c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm96-260v352c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h48V12c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v52h128V12c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v52h48c26.5 0 48 21.5 48 48z"/></svg>
61
+ {dateStr}
62
+ </li>
63
+ </ul>
64
+ {/* Excerpts arrive display-ready from the migrator (truncation
65
+ ellipsis included when warranted); render verbatim, never decorate. */}
66
+ <div class="post-content">{excerpt}</div>
67
+ </div>
68
+ </div>
69
+ </article>
70
+
71
+ <style>
72
+ .post-item { transition: all 0.5s ease-in-out; }
73
+ /* Same outline as MediaText media and gallery plates (owner request);
74
+ on the container so the hover zoom stays clipped inside it. */
75
+ .post-item-image { position: relative; overflow: hidden; border: var(--stroke) solid var(--rule); }
76
+ .post-item-image a { display: block; height: 100%; width: 100%; line-height: 0; }
77
+ /* The image box clips overflow, so the site-wide ring sits inside. */
78
+ .post-item-image a:focus-visible { outline-offset: -3px; }
79
+ .post-item-image a :global(img) {
80
+ width: 100%;
81
+ height: 100%;
82
+ aspect-ratio: 4 / 2.8;
83
+ object-fit: cover;
84
+ transition: all 0.5s ease-in-out;
85
+ }
86
+ .post-item:hover .post-item-image :global(img) { transform: scale(1.07); }
87
+ /* Stamp from the .draft-stamp pattern; the card's size and stacking
88
+ (above the hover-zoomed image) are here. */
89
+ .draft-stamp {
90
+ z-index: 2;
91
+ font-size: var(--font-size-xl);
92
+ }
93
+ /* Chip look comes from the .chip pattern; only placement is contextual. */
94
+ .read-time-comment {
95
+ position: absolute;
96
+ bottom: 10px;
97
+ left: 10px;
98
+ z-index: 1;
99
+ }
100
+ .post-item-content { padding: 15px 0 0; padding-top: 10px; }
101
+ .entry-cat .post-categories {
102
+ display: block;
103
+ white-space: nowrap;
104
+ overflow: hidden;
105
+ text-overflow: ellipsis;
106
+ /* The links' font box stands taller than this line-height:1 row, and
107
+ overflow clips at the padding box, so without vertical breathing
108
+ room the inset focus/hover ring loses its top and bottom bands.
109
+ The negative margin gives the padding back, keeping card layout
110
+ identical (smoke test pins the containment). */
111
+ padding-block: 3px;
112
+ margin-block: -3px;
113
+ color: var(--ink);
114
+ text-transform: uppercase;
115
+ font-weight: bold;
116
+ font-size: var(--font-size-xs);
117
+ line-height: 1; /* carried over from the old li a rule; keeps card height */
118
+ letter-spacing: 0.8px;
119
+ }
120
+ .entry-cat .post-categories a {
121
+ color: inherit;
122
+ text-decoration: none;
123
+ transition: var(--transition);
124
+ }
125
+ .entry-cat .post-categories a:hover {
126
+ outline: 2px solid currentColor;
127
+ outline-offset: -2px; /* Draws outline inside link to avoid clipping by container overflow */
128
+ }
129
+ .entry-cat .post-categories a:focus-visible { outline-offset: -2px; }
130
+ /* Owner-approved divergence from live (2026-07-26): the live theme
131
+ word-breaks titles mid-word (word-break: break-all); ours wraps at
132
+ word boundaries on purpose. Do not add break-all for fidelity. */
133
+ .entry-title {
134
+ margin-block: 5px 8px;
135
+ line-height: 1.3;
136
+ font-size: var(--font-size-base);
137
+ font-family: var(--font-body);
138
+ }
139
+ .entry-title a {
140
+ color: var(--ink);
141
+ display: inline;
142
+ text-decoration-line: underline;
143
+ transition: var(--transition);
144
+ }
145
+ .entry-title a:hover { outline: 2px solid currentColor; }
146
+ .entry-meta {
147
+ margin: 0 0 10px;
148
+ padding: 0;
149
+ list-style: none;
150
+ display: flex;
151
+ flex-wrap: wrap;
152
+ row-gap: 5px;
153
+ column-gap: 10px;
154
+ align-items: center;
155
+ }
156
+ .entry-meta li {
157
+ margin-bottom: 0;
158
+ font-size: var(--font-size-xs);
159
+ text-transform: capitalize;
160
+ font-weight: 500;
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 5px;
164
+ }
165
+ .post-content { font-size: var(--font-size-sm); }
166
+ </style>