@half-built/astro 0.11.0 → 0.11.1
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 +286 -286
- package/package.json +1 -1
- package/src/components/Footer.astro +9 -0
- package/src/components/PostCard.astro +13 -7
package/README.md
CHANGED
|
@@ -1,286 +1,286 @@
|
|
|
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
|
-
Every glyph the package draws comes from one registry,
|
|
16
|
-
`scripts/core/icons.ts`, derived from Lucide (https://lucide.dev), ISC
|
|
17
|
-
license; see `ICONS-LICENSE`. Templates render one through
|
|
18
|
-
`components/Icon.astro`:
|
|
19
|
-
|
|
20
|
-
```astro
|
|
21
|
-
<Icon name="search" size={14} />
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
`name` is a registry key (`x`, `play`, `sparkles`, `pause`,
|
|
25
|
-
`rotate-ccw`, `chevron-left`, `chevron-right`, `chevron-up`,
|
|
26
|
-
`arrow-left`, `arrow-right`, `sun`, `moon`, `search`, `clock`, `user`,
|
|
27
|
-
`calendar`, `circle`), `size` a CSS
|
|
28
|
-
length or pixel count (default `1em`, tracking the parent's font
|
|
29
|
-
size), `strokeWidth` defaults to 2.5, and `class` lands on the svg.
|
|
30
|
-
The icon is decorative by contract (aria-hidden, pointer-events none),
|
|
31
|
-
so the accessible name belongs to the button or link around it. The
|
|
32
|
-
svg arrives through `set:html` and carries no scoped-style attribute;
|
|
33
|
-
style it from the parent with `:global(svg)`. Client scripts take the
|
|
34
|
-
`ICON_*` strings from the same file. Add a glyph to the registry,
|
|
35
|
-
never as inline `<svg>` in a component; a test enforces that.
|
|
36
|
-
|
|
37
|
-
## Corner badges
|
|
38
|
-
|
|
39
|
-
`CornerBadges.astro` takes its chips as data, `badges: Badge[]` from
|
|
40
|
-
`scripts/core/badges.ts`: a `key` (rendered as the `badge-<key>` class,
|
|
41
|
-
the styling hook), a `label`, and an `icon` name from the registry.
|
|
42
|
-
The two house conventions ship as `GENAI_BADGE` and `DEMO_BADGE`; a
|
|
43
|
-
site's view-model lists the badges a post carries, in order, and can
|
|
44
|
-
add its own without a package change. `PostCardModel.badges` carries
|
|
45
|
-
them to the card. The content components (`BlogImage`, `GalleryImage`,
|
|
46
|
-
`MediaText`) take the same `badges` array, for example
|
|
47
|
-
`badges={[GENAI_BADGE]}` with `GENAI_BADGE` imported from
|
|
48
|
-
`@half-built/astro/scripts/core/badges.ts`.
|
|
49
|
-
|
|
50
|
-
## EditorNote
|
|
51
|
-
|
|
52
|
-
`content/EditorNote.astro` is a reminder block for content that must
|
|
53
|
-
not ship: by default it renders only when the consuming build runs in
|
|
54
|
-
dev mode, and a deploy build emits nothing for it. A consumer with a
|
|
55
|
-
wider preview concept (the blog's SHOW_DRAFTS builds, for example)
|
|
56
|
-
passes its own gate through the `shown` prop; the component reads no
|
|
57
|
-
consumer config itself.
|
|
58
|
-
|
|
59
|
-
## WhenPublished and PostLink
|
|
60
|
-
|
|
61
|
-
`content/PostLink.astro` links to a post by slug and resolves the
|
|
62
|
-
href at build time through `postPath`, so a re-dated post does not
|
|
63
|
-
strand the links pointing at it. While the target is hidden (a draft, with the
|
|
64
|
-
draft gate off) it renders its text alone, with no element and no
|
|
65
|
-
styling, and becomes a link on its own when the post ships. That makes
|
|
66
|
-
it the right tool for a draft's name in published prose.
|
|
67
|
-
|
|
68
|
-
`content/WhenPublished.astro` renders its children only when the post
|
|
69
|
-
at `slug` is visible. Use it when a whole phrase only makes sense once
|
|
70
|
-
the post exists ("its own post", "see it here"). An optional `fallback`
|
|
71
|
-
slot renders while the target is hidden:
|
|
72
|
-
|
|
73
|
-
```mdx
|
|
74
|
-
<WhenPublished slug="my-draft">
|
|
75
|
-
It has <PostLink slug="my-draft">its own post</PostLink> now.
|
|
76
|
-
<Fragment slot="fallback">A post on it is coming.</Fragment>
|
|
77
|
-
</WhenPublished>
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
Both take the consumer's collection as the `posts` prop (the full
|
|
81
|
-
collection, drafts included, so an unknown slug can throw instead of
|
|
82
|
-
hiding as "still a draft") and the consumer's draft gate as
|
|
83
|
-
`showDrafts`. `showDrafts` defaults to false, so a preview build must
|
|
84
|
-
pass its own draft gate through to `PostLink` to keep draft links
|
|
85
|
-
live. `PostLink` also accepts an optional `tip` carried as
|
|
86
|
-
`data-tooltip` for the link-tip island; a hidden `PostLink` drops it. A
|
|
87
|
-
consumer wraps each in a one-line site component that injects
|
|
88
|
-
`getCollection` and its own gate, the way the blog does. The resolvers
|
|
89
|
-
(`postLinkHref`, `resolvePostHref`, `forwardLinkVisible`) live in
|
|
90
|
-
`lib/drafts.ts` for consumers that want the logic without the
|
|
91
|
-
components.
|
|
92
|
-
|
|
93
|
-
## Derived excerpts
|
|
94
|
-
|
|
95
|
-
Posts carry no excerpt frontmatter. `lib/excerpt.ts` derives the card
|
|
96
|
-
text from the post body at build time: the opening prose, consecutive
|
|
97
|
-
paragraphs joined by a space, cut at a word boundary within
|
|
98
|
-
`EXCERPT_LIMIT` (200 characters) and always ended with an ellipsis,
|
|
99
|
-
the reader's cue that the post continues. The run stops at the first
|
|
100
|
-
heading, list, blockquote, code fence, or image line, so a card never
|
|
101
|
-
crosses into a later section; components and the lead-break are
|
|
102
|
-
invisible to it. `content/ExcerptStart.astro` on its own line moves
|
|
103
|
-
the start to the paragraph after it, for a post that opens on a TL;DR
|
|
104
|
-
or an aside. `postExcerpt(entry)` is the policy: a published post
|
|
105
|
-
with no prose fails the build naming the slug, a draft warns once per
|
|
106
|
-
build and renders blank. It takes any entry shaped
|
|
107
|
-
`{ body?, data: { slug, draft? } }`, so a `CollectionEntry` passes
|
|
108
|
-
with no cast. A consumer calls `postExcerpt` from every place a
|
|
109
|
-
summary renders (cards, meta description, feed, search index) and
|
|
110
|
-
never stores the result. The blog is the reference consumer and
|
|
111
|
-
derives all four from it. The rule was settled on the blog's 54 posts
|
|
112
|
-
on 2026-09-13 and moved here on 2026-09-14.
|
|
113
|
-
|
|
114
|
-
## Live code colors
|
|
115
|
-
|
|
116
|
-
`shiki/code-theme` bakes its amber values into every highlighted span
|
|
117
|
-
at build time. `shiki/code-vars` is a Shiki transformer that rewrites
|
|
118
|
-
those baked values to the css package's `--code-token-*` and
|
|
119
|
-
`--code-*` custom properties, so highlighted code follows a runtime
|
|
120
|
-
palette override. The variables resolve to the same hexes the theme
|
|
121
|
-
bakes, so adopting the transformer changes no rendered pixel on its
|
|
122
|
-
own. Pass it beside the theme: the `transformers` prop of
|
|
123
|
-
`astro:components`' `Code`, or `markdown.shikiConfig.transformers` in
|
|
124
|
-
an Astro config. It also encodes the `>` that Shiki's HTML serializer
|
|
125
|
-
leaves raw in `Code` output, so that output passes the tooling
|
|
126
|
-
html-validate preset.
|
|
127
|
-
|
|
128
|
-
## Palette token entries
|
|
129
|
-
|
|
130
|
-
A `content/Palette.astro` entry may carry `token` (a custom property
|
|
131
|
-
name) instead of `hex`: the swatch then paints `var(token)` and
|
|
132
|
-
follows the live cascade with no script, and the hex cell renders
|
|
133
|
-
empty with a `data-token-hex` attribute for a consumer script to fill
|
|
134
|
-
from computed styles. Entries with `hex` render exactly as before.
|
|
135
|
-
The table's scroll box is a keyboard tab stop named by the `label`
|
|
136
|
-
prop (default "Palette"), since the table scrolls sideways below the
|
|
137
|
-
column's width.
|
|
138
|
-
|
|
139
|
-
## Ecosystem island
|
|
140
|
-
|
|
141
|
-
`scripts/ecosystem` fills the Footer's Ecosystem column from a shared
|
|
142
|
-
JSON document, so adding a property to a family of sites does not mean
|
|
143
|
-
rebuilding every one of them.
|
|
144
|
-
|
|
145
|
-
```js
|
|
146
|
-
import { mountEcosystem } from "@half-built/astro/scripts/ecosystem.ts";
|
|
147
|
-
|
|
148
|
-
void mountEcosystem(document, {
|
|
149
|
-
endpoint: "https://example.com/ecosystem.json",
|
|
150
|
-
selfKey: "ui",
|
|
151
|
-
});
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
The endpoint is a parameter and the package ships no default. `Footer`
|
|
155
|
-
keeps taking `ecosystem` and `ecosystemSelf` as typed props, and those
|
|
156
|
-
props are the static baseline the island replaces. Every failure path
|
|
157
|
-
leaves that baseline standing: no JavaScript, a dead endpoint, a
|
|
158
|
-
malformed payload, or a document that does not contain `selfKey`.
|
|
159
|
-
|
|
160
|
-
The document is `{ version: 1, entries: [...] }` where each entry has
|
|
161
|
-
`key`, `label`, `href` (null renders unlinked), `priority` (ascending,
|
|
162
|
-
0 highest) and `family`. Entries are sorted with the self entry's own
|
|
163
|
-
family first, then by priority, and capped at `limit`, default 6.
|
|
164
|
-
|
|
165
|
-
## Footer reserve for bottom-docked controls
|
|
166
|
-
|
|
167
|
-
A control cluster fixed to the viewport's bottom corner takes no room
|
|
168
|
-
in flow, so the page's last lines end underneath it. Set the css
|
|
169
|
-
package's `--dock-bottom` token to the room the cluster occupies
|
|
170
|
-
(its height, inset, and air) and `Footer` pads its band by that much
|
|
171
|
-
below the last link, so the page scrolls far enough to clear the
|
|
172
|
-
cluster. The token is `0px` by default and can be set inside the same
|
|
173
|
-
media block that pins the cluster.
|
|
174
|
-
|
|
175
|
-
## TwoColumn
|
|
176
|
-
|
|
177
|
-
`components/TwoColumn.astro` renders its main column as `<main>`; pass
|
|
178
|
-
`mainTag="div"` for a demo or nested use where the page already has
|
|
179
|
-
its own main.
|
|
180
|
-
|
|
181
|
-
## Search flyout
|
|
182
|
-
|
|
183
|
-
`scripts/site-header` drives the masthead's search flyout as a
|
|
184
|
-
disclosure: the magnifier toggles it open and closed, opening moves
|
|
185
|
-
focus to the field, Escape closes and returns focus to the magnifier,
|
|
186
|
-
and a press or keyboard focus leaving the flyout closes it. The island
|
|
187
|
-
marks the wrap `data-search-js`; without it, the stylesheet's
|
|
188
|
-
focus-within rule opens the flyout on focus alone, so it still works
|
|
189
|
-
with no script.
|
|
190
|
-
|
|
191
|
-
## Lightbox
|
|
192
|
-
|
|
193
|
-
`scripts/lightbox` opens the images the content components link
|
|
194
|
-
through `LightboxLink` (`BlogImage`, `GalleryImage`, `MediaText`,
|
|
195
|
-
`Step`) in a full-window viewer. Links inside one `Gallery` or
|
|
196
|
-
`Walkthrough` form a set with previous and next buttons, thumbnails,
|
|
197
|
-
and the arrow keys.
|
|
198
|
-
|
|
199
|
-
```js
|
|
200
|
-
import "@half-built/css/plate-modal.css";
|
|
201
|
-
import "@half-built/css/lightbox.css";
|
|
202
|
-
import { mountLightbox } from "@half-built/astro/scripts/lightbox.ts";
|
|
203
|
-
|
|
204
|
-
mountLightbox(document);
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
The image opens at fit: contained in the window, very tall images fit
|
|
208
|
-
to width, and small images never scale past 100%. The wheel or a
|
|
209
|
-
trackpad zooms under the pointer in proportion to the scroll; a
|
|
210
|
-
sideways scroll does nothing. `+` and `-` step the zoom, `0` returns to fit, and a drag pans. Once
|
|
211
|
-
the image is panned mostly out of view a HOME box appears that also
|
|
212
|
-
returns to fit. Double-click at the fit view
|
|
213
|
-
zooms to 100% under the cursor; anywhere else it returns to fit.
|
|
214
|
-
|
|
215
|
-
A resize or rotation while the viewer is open refits the image to the
|
|
216
|
-
new window. Any zoom and pan the reader had set is discarded, on
|
|
217
|
-
purpose: the old view was measured against a window that no longer
|
|
218
|
-
exists.
|
|
219
|
-
|
|
220
|
-
`mountLightbox` takes options: `selector` (default `a.lightbox-link`)
|
|
221
|
-
picks the links, `groupSelector` names the containers whose links form
|
|
222
|
-
one set, and `labels` renames the viewer's controls for a site that is
|
|
223
|
-
not in English.
|
|
224
|
-
|
|
225
|
-
## Popout
|
|
226
|
-
|
|
227
|
-
`components/Popout.astro` puts a short note behind a table cell: detail
|
|
228
|
-
worth keeping but not worth a column. It renders a trigger button and a
|
|
229
|
-
template holding its slot; `scripts/popout` opens the note. Like
|
|
230
|
-
link-tip, the mounted island is a document-wide singleton: `root` only
|
|
231
|
-
names the document, and triggers anywhere in it share the one surface.
|
|
232
|
-
|
|
233
|
-
```astro
|
|
234
|
-
<Popout label="Sample row · Sample product">
|
|
235
|
-
A short note with a <a href="#">link</a> and <code>code</code>.
|
|
236
|
-
</Popout>
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
```js
|
|
240
|
-
import "@half-built/css/popout.css";
|
|
241
|
-
import { mountPopouts } from "@half-built/astro/scripts/popout.ts";
|
|
242
|
-
|
|
243
|
-
mountPopouts(document);
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
`label` is required. It captions the open note and names the trigger
|
|
247
|
-
for assistive tech (`<trigger>: <label>`, so `Note: <label>` by
|
|
248
|
-
default), so write it to identify the row. `trigger` changes the button
|
|
249
|
-
text from `Note`. The slot takes inline markup: links, code, emphasis.
|
|
250
|
-
|
|
251
|
-
Above the phone breakpoint the note opens below its trigger (above it
|
|
252
|
-
when there is no room below) and stays until Escape, the close box, a
|
|
253
|
-
press outside, or another trigger. It also closes by itself when its
|
|
254
|
-
trigger scrolls out of view or when keyboard focus leaves it. Shift+Tab
|
|
255
|
-
off its first stop returns to the trigger; Tab off its last stop moves
|
|
256
|
-
on to whatever follows the trigger. On a wide touch device a
|
|
257
|
-
drag that starts outside the note closes it. At phone width it opens as
|
|
258
|
-
a modal bottom sheet that closes on the close box, Escape or Back, a
|
|
259
|
-
tap on the backdrop, or a downward swipe. One note is open at a time.
|
|
260
|
-
With JavaScript off the trigger does nothing.
|
|
261
|
-
|
|
262
|
-
A resize or rotation that crosses the phone breakpoint, in either
|
|
263
|
-
direction, closes an open note instead of turning the box into a sheet
|
|
264
|
-
or the sheet into a box. The reader reopens it and it opens in the mode
|
|
265
|
-
for the new width. Focus is not moved back to the trigger, which may
|
|
266
|
-
have scrolled away in the new layout. Resizes that stay on one side of
|
|
267
|
-
the breakpoint keep the note open: the box re-places itself, and the
|
|
268
|
-
sheet rides out the URL bar collapsing and the keyboard opening.
|
|
269
|
-
|
|
270
|
-
`mountPopouts` takes options: `selector` (default `.popout-trigger`)
|
|
271
|
-
picks the triggers, `edge` sets the anchored note's room against the
|
|
272
|
-
viewport edges in px, and `closeLabel` (default `Close`) names the
|
|
273
|
-
close box for a site that is not in English.
|
|
274
|
-
|
|
275
|
-
Not covered yet: structured content (lists, sub-tables, images),
|
|
276
|
-
triggers on chart marks, and hover previews.
|
|
277
|
-
|
|
278
|
-
## Import notes
|
|
279
|
-
|
|
280
|
-
Wildcard subpath imports need explicit file extensions under
|
|
281
|
-
TypeScript's bundler mode: `@half-built/astro/lib/slug.ts` and
|
|
282
|
-
`@half-built/astro/components/Shell.astro`, not extensionless forms.
|
|
283
|
-
Vite resolves either; `tsc --noEmit` only accepts the explicit one.
|
|
284
|
-
|
|
285
|
-
The `Masthead.astro` export is an alias for `SiteHeader.astro`, the
|
|
286
|
-
same component under its public name.
|
|
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
|
+
Every glyph the package draws comes from one registry,
|
|
16
|
+
`scripts/core/icons.ts`, derived from Lucide (https://lucide.dev), ISC
|
|
17
|
+
license; see `ICONS-LICENSE`. Templates render one through
|
|
18
|
+
`components/Icon.astro`:
|
|
19
|
+
|
|
20
|
+
```astro
|
|
21
|
+
<Icon name="search" size={14} />
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`name` is a registry key (`x`, `play`, `sparkles`, `pause`,
|
|
25
|
+
`rotate-ccw`, `chevron-left`, `chevron-right`, `chevron-up`,
|
|
26
|
+
`arrow-left`, `arrow-right`, `sun`, `moon`, `search`, `clock`, `user`,
|
|
27
|
+
`calendar`, `circle`), `size` a CSS
|
|
28
|
+
length or pixel count (default `1em`, tracking the parent's font
|
|
29
|
+
size), `strokeWidth` defaults to 2.5, and `class` lands on the svg.
|
|
30
|
+
The icon is decorative by contract (aria-hidden, pointer-events none),
|
|
31
|
+
so the accessible name belongs to the button or link around it. The
|
|
32
|
+
svg arrives through `set:html` and carries no scoped-style attribute;
|
|
33
|
+
style it from the parent with `:global(svg)`. Client scripts take the
|
|
34
|
+
`ICON_*` strings from the same file. Add a glyph to the registry,
|
|
35
|
+
never as inline `<svg>` in a component; a test enforces that.
|
|
36
|
+
|
|
37
|
+
## Corner badges
|
|
38
|
+
|
|
39
|
+
`CornerBadges.astro` takes its chips as data, `badges: Badge[]` from
|
|
40
|
+
`scripts/core/badges.ts`: a `key` (rendered as the `badge-<key>` class,
|
|
41
|
+
the styling hook), a `label`, and an `icon` name from the registry.
|
|
42
|
+
The two house conventions ship as `GENAI_BADGE` and `DEMO_BADGE`; a
|
|
43
|
+
site's view-model lists the badges a post carries, in order, and can
|
|
44
|
+
add its own without a package change. `PostCardModel.badges` carries
|
|
45
|
+
them to the card. The content components (`BlogImage`, `GalleryImage`,
|
|
46
|
+
`MediaText`) take the same `badges` array, for example
|
|
47
|
+
`badges={[GENAI_BADGE]}` with `GENAI_BADGE` imported from
|
|
48
|
+
`@half-built/astro/scripts/core/badges.ts`.
|
|
49
|
+
|
|
50
|
+
## EditorNote
|
|
51
|
+
|
|
52
|
+
`content/EditorNote.astro` is a reminder block for content that must
|
|
53
|
+
not ship: by default it renders only when the consuming build runs in
|
|
54
|
+
dev mode, and a deploy build emits nothing for it. A consumer with a
|
|
55
|
+
wider preview concept (the blog's SHOW_DRAFTS builds, for example)
|
|
56
|
+
passes its own gate through the `shown` prop; the component reads no
|
|
57
|
+
consumer config itself.
|
|
58
|
+
|
|
59
|
+
## WhenPublished and PostLink
|
|
60
|
+
|
|
61
|
+
`content/PostLink.astro` links to a post by slug and resolves the
|
|
62
|
+
href at build time through `postPath`, so a re-dated post does not
|
|
63
|
+
strand the links pointing at it. While the target is hidden (a draft, with the
|
|
64
|
+
draft gate off) it renders its text alone, with no element and no
|
|
65
|
+
styling, and becomes a link on its own when the post ships. That makes
|
|
66
|
+
it the right tool for a draft's name in published prose.
|
|
67
|
+
|
|
68
|
+
`content/WhenPublished.astro` renders its children only when the post
|
|
69
|
+
at `slug` is visible. Use it when a whole phrase only makes sense once
|
|
70
|
+
the post exists ("its own post", "see it here"). An optional `fallback`
|
|
71
|
+
slot renders while the target is hidden:
|
|
72
|
+
|
|
73
|
+
```mdx
|
|
74
|
+
<WhenPublished slug="my-draft">
|
|
75
|
+
It has <PostLink slug="my-draft">its own post</PostLink> now.
|
|
76
|
+
<Fragment slot="fallback">A post on it is coming.</Fragment>
|
|
77
|
+
</WhenPublished>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Both take the consumer's collection as the `posts` prop (the full
|
|
81
|
+
collection, drafts included, so an unknown slug can throw instead of
|
|
82
|
+
hiding as "still a draft") and the consumer's draft gate as
|
|
83
|
+
`showDrafts`. `showDrafts` defaults to false, so a preview build must
|
|
84
|
+
pass its own draft gate through to `PostLink` to keep draft links
|
|
85
|
+
live. `PostLink` also accepts an optional `tip` carried as
|
|
86
|
+
`data-tooltip` for the link-tip island; a hidden `PostLink` drops it. A
|
|
87
|
+
consumer wraps each in a one-line site component that injects
|
|
88
|
+
`getCollection` and its own gate, the way the blog does. The resolvers
|
|
89
|
+
(`postLinkHref`, `resolvePostHref`, `forwardLinkVisible`) live in
|
|
90
|
+
`lib/drafts.ts` for consumers that want the logic without the
|
|
91
|
+
components.
|
|
92
|
+
|
|
93
|
+
## Derived excerpts
|
|
94
|
+
|
|
95
|
+
Posts carry no excerpt frontmatter. `lib/excerpt.ts` derives the card
|
|
96
|
+
text from the post body at build time: the opening prose, consecutive
|
|
97
|
+
paragraphs joined by a space, cut at a word boundary within
|
|
98
|
+
`EXCERPT_LIMIT` (200 characters) and always ended with an ellipsis,
|
|
99
|
+
the reader's cue that the post continues. The run stops at the first
|
|
100
|
+
heading, list, blockquote, code fence, or image line, so a card never
|
|
101
|
+
crosses into a later section; components and the lead-break are
|
|
102
|
+
invisible to it. `content/ExcerptStart.astro` on its own line moves
|
|
103
|
+
the start to the paragraph after it, for a post that opens on a TL;DR
|
|
104
|
+
or an aside. `postExcerpt(entry)` is the policy: a published post
|
|
105
|
+
with no prose fails the build naming the slug, a draft warns once per
|
|
106
|
+
build and renders blank. It takes any entry shaped
|
|
107
|
+
`{ body?, data: { slug, draft? } }`, so a `CollectionEntry` passes
|
|
108
|
+
with no cast. A consumer calls `postExcerpt` from every place a
|
|
109
|
+
summary renders (cards, meta description, feed, search index) and
|
|
110
|
+
never stores the result. The blog is the reference consumer and
|
|
111
|
+
derives all four from it. The rule was settled on the blog's 54 posts
|
|
112
|
+
on 2026-09-13 and moved here on 2026-09-14.
|
|
113
|
+
|
|
114
|
+
## Live code colors
|
|
115
|
+
|
|
116
|
+
`shiki/code-theme` bakes its amber values into every highlighted span
|
|
117
|
+
at build time. `shiki/code-vars` is a Shiki transformer that rewrites
|
|
118
|
+
those baked values to the css package's `--code-token-*` and
|
|
119
|
+
`--code-*` custom properties, so highlighted code follows a runtime
|
|
120
|
+
palette override. The variables resolve to the same hexes the theme
|
|
121
|
+
bakes, so adopting the transformer changes no rendered pixel on its
|
|
122
|
+
own. Pass it beside the theme: the `transformers` prop of
|
|
123
|
+
`astro:components`' `Code`, or `markdown.shikiConfig.transformers` in
|
|
124
|
+
an Astro config. It also encodes the `>` that Shiki's HTML serializer
|
|
125
|
+
leaves raw in `Code` output, so that output passes the tooling
|
|
126
|
+
html-validate preset.
|
|
127
|
+
|
|
128
|
+
## Palette token entries
|
|
129
|
+
|
|
130
|
+
A `content/Palette.astro` entry may carry `token` (a custom property
|
|
131
|
+
name) instead of `hex`: the swatch then paints `var(token)` and
|
|
132
|
+
follows the live cascade with no script, and the hex cell renders
|
|
133
|
+
empty with a `data-token-hex` attribute for a consumer script to fill
|
|
134
|
+
from computed styles. Entries with `hex` render exactly as before.
|
|
135
|
+
The table's scroll box is a keyboard tab stop named by the `label`
|
|
136
|
+
prop (default "Palette"), since the table scrolls sideways below the
|
|
137
|
+
column's width.
|
|
138
|
+
|
|
139
|
+
## Ecosystem island
|
|
140
|
+
|
|
141
|
+
`scripts/ecosystem` fills the Footer's Ecosystem column from a shared
|
|
142
|
+
JSON document, so adding a property to a family of sites does not mean
|
|
143
|
+
rebuilding every one of them.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { mountEcosystem } from "@half-built/astro/scripts/ecosystem.ts";
|
|
147
|
+
|
|
148
|
+
void mountEcosystem(document, {
|
|
149
|
+
endpoint: "https://example.com/ecosystem.json",
|
|
150
|
+
selfKey: "ui",
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The endpoint is a parameter and the package ships no default. `Footer`
|
|
155
|
+
keeps taking `ecosystem` and `ecosystemSelf` as typed props, and those
|
|
156
|
+
props are the static baseline the island replaces. Every failure path
|
|
157
|
+
leaves that baseline standing: no JavaScript, a dead endpoint, a
|
|
158
|
+
malformed payload, or a document that does not contain `selfKey`.
|
|
159
|
+
|
|
160
|
+
The document is `{ version: 1, entries: [...] }` where each entry has
|
|
161
|
+
`key`, `label`, `href` (null renders unlinked), `priority` (ascending,
|
|
162
|
+
0 highest) and `family`. Entries are sorted with the self entry's own
|
|
163
|
+
family first, then by priority, and capped at `limit`, default 6.
|
|
164
|
+
|
|
165
|
+
## Footer reserve for bottom-docked controls
|
|
166
|
+
|
|
167
|
+
A control cluster fixed to the viewport's bottom corner takes no room
|
|
168
|
+
in flow, so the page's last lines end underneath it. Set the css
|
|
169
|
+
package's `--dock-bottom` token to the room the cluster occupies
|
|
170
|
+
(its height, inset, and air) and `Footer` pads its band by that much
|
|
171
|
+
below the last link, so the page scrolls far enough to clear the
|
|
172
|
+
cluster. The token is `0px` by default and can be set inside the same
|
|
173
|
+
media block that pins the cluster.
|
|
174
|
+
|
|
175
|
+
## TwoColumn
|
|
176
|
+
|
|
177
|
+
`components/TwoColumn.astro` renders its main column as `<main>`; pass
|
|
178
|
+
`mainTag="div"` for a demo or nested use where the page already has
|
|
179
|
+
its own main.
|
|
180
|
+
|
|
181
|
+
## Search flyout
|
|
182
|
+
|
|
183
|
+
`scripts/site-header` drives the masthead's search flyout as a
|
|
184
|
+
disclosure: the magnifier toggles it open and closed, opening moves
|
|
185
|
+
focus to the field, Escape closes and returns focus to the magnifier,
|
|
186
|
+
and a press or keyboard focus leaving the flyout closes it. The island
|
|
187
|
+
marks the wrap `data-search-js`; without it, the stylesheet's
|
|
188
|
+
focus-within rule opens the flyout on focus alone, so it still works
|
|
189
|
+
with no script.
|
|
190
|
+
|
|
191
|
+
## Lightbox
|
|
192
|
+
|
|
193
|
+
`scripts/lightbox` opens the images the content components link
|
|
194
|
+
through `LightboxLink` (`BlogImage`, `GalleryImage`, `MediaText`,
|
|
195
|
+
`Step`) in a full-window viewer. Links inside one `Gallery` or
|
|
196
|
+
`Walkthrough` form a set with previous and next buttons, thumbnails,
|
|
197
|
+
and the arrow keys.
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
import "@half-built/css/plate-modal.css";
|
|
201
|
+
import "@half-built/css/lightbox.css";
|
|
202
|
+
import { mountLightbox } from "@half-built/astro/scripts/lightbox.ts";
|
|
203
|
+
|
|
204
|
+
mountLightbox(document);
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The image opens at fit: contained in the window, very tall images fit
|
|
208
|
+
to width, and small images never scale past 100%. The wheel or a
|
|
209
|
+
trackpad zooms under the pointer in proportion to the scroll; a
|
|
210
|
+
sideways scroll does nothing. `+` and `-` step the zoom, `0` returns to fit, and a drag pans. Once
|
|
211
|
+
the image is panned mostly out of view a HOME box appears that also
|
|
212
|
+
returns to fit. Double-click at the fit view
|
|
213
|
+
zooms to 100% under the cursor; anywhere else it returns to fit.
|
|
214
|
+
|
|
215
|
+
A resize or rotation while the viewer is open refits the image to the
|
|
216
|
+
new window. Any zoom and pan the reader had set is discarded, on
|
|
217
|
+
purpose: the old view was measured against a window that no longer
|
|
218
|
+
exists.
|
|
219
|
+
|
|
220
|
+
`mountLightbox` takes options: `selector` (default `a.lightbox-link`)
|
|
221
|
+
picks the links, `groupSelector` names the containers whose links form
|
|
222
|
+
one set, and `labels` renames the viewer's controls for a site that is
|
|
223
|
+
not in English.
|
|
224
|
+
|
|
225
|
+
## Popout
|
|
226
|
+
|
|
227
|
+
`components/Popout.astro` puts a short note behind a table cell: detail
|
|
228
|
+
worth keeping but not worth a column. It renders a trigger button and a
|
|
229
|
+
template holding its slot; `scripts/popout` opens the note. Like
|
|
230
|
+
link-tip, the mounted island is a document-wide singleton: `root` only
|
|
231
|
+
names the document, and triggers anywhere in it share the one surface.
|
|
232
|
+
|
|
233
|
+
```astro
|
|
234
|
+
<Popout label="Sample row · Sample product">
|
|
235
|
+
A short note with a <a href="#">link</a> and <code>code</code>.
|
|
236
|
+
</Popout>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
import "@half-built/css/popout.css";
|
|
241
|
+
import { mountPopouts } from "@half-built/astro/scripts/popout.ts";
|
|
242
|
+
|
|
243
|
+
mountPopouts(document);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`label` is required. It captions the open note and names the trigger
|
|
247
|
+
for assistive tech (`<trigger>: <label>`, so `Note: <label>` by
|
|
248
|
+
default), so write it to identify the row. `trigger` changes the button
|
|
249
|
+
text from `Note`. The slot takes inline markup: links, code, emphasis.
|
|
250
|
+
|
|
251
|
+
Above the phone breakpoint the note opens below its trigger (above it
|
|
252
|
+
when there is no room below) and stays until Escape, the close box, a
|
|
253
|
+
press outside, or another trigger. It also closes by itself when its
|
|
254
|
+
trigger scrolls out of view or when keyboard focus leaves it. Shift+Tab
|
|
255
|
+
off its first stop returns to the trigger; Tab off its last stop moves
|
|
256
|
+
on to whatever follows the trigger. On a wide touch device a
|
|
257
|
+
drag that starts outside the note closes it. At phone width it opens as
|
|
258
|
+
a modal bottom sheet that closes on the close box, Escape or Back, a
|
|
259
|
+
tap on the backdrop, or a downward swipe. One note is open at a time.
|
|
260
|
+
With JavaScript off the trigger does nothing.
|
|
261
|
+
|
|
262
|
+
A resize or rotation that crosses the phone breakpoint, in either
|
|
263
|
+
direction, closes an open note instead of turning the box into a sheet
|
|
264
|
+
or the sheet into a box. The reader reopens it and it opens in the mode
|
|
265
|
+
for the new width. Focus is not moved back to the trigger, which may
|
|
266
|
+
have scrolled away in the new layout. Resizes that stay on one side of
|
|
267
|
+
the breakpoint keep the note open: the box re-places itself, and the
|
|
268
|
+
sheet rides out the URL bar collapsing and the keyboard opening.
|
|
269
|
+
|
|
270
|
+
`mountPopouts` takes options: `selector` (default `.popout-trigger`)
|
|
271
|
+
picks the triggers, `edge` sets the anchored note's room against the
|
|
272
|
+
viewport edges in px, and `closeLabel` (default `Close`) names the
|
|
273
|
+
close box for a site that is not in English.
|
|
274
|
+
|
|
275
|
+
Not covered yet: structured content (lists, sub-tables, images),
|
|
276
|
+
triggers on chart marks, and hover previews.
|
|
277
|
+
|
|
278
|
+
## Import notes
|
|
279
|
+
|
|
280
|
+
Wildcard subpath imports need explicit file extensions under
|
|
281
|
+
TypeScript's bundler mode: `@half-built/astro/lib/slug.ts` and
|
|
282
|
+
`@half-built/astro/components/Shell.astro`, not extensionless forms.
|
|
283
|
+
Vite resolves either; `tsc --noEmit` only accepts the explicit one.
|
|
284
|
+
|
|
285
|
+
The `Masthead.astro` export is an alias for `SiteHeader.astro`, the
|
|
286
|
+
same component under its public name.
|
package/package.json
CHANGED
|
@@ -193,6 +193,15 @@ const year = new Date().getFullYear();
|
|
|
193
193
|
}
|
|
194
194
|
.footer-sitemap-group { text-align: center; width: 100%; }
|
|
195
195
|
|
|
196
|
+
/* 24px tap targets (WCAG 2.2 target size, 2.5.8). The phone lists
|
|
197
|
+
sat at a 23.3px pitch with 16px link boxes. A 24px line on every
|
|
198
|
+
row keeps the rhythm within a pixel, including the rows that are
|
|
199
|
+
plain text (the current site, pending entries), and the
|
|
200
|
+
inline-block link takes the whole line box as its target
|
|
201
|
+
(spec 2026-09-29-wcag-2-2-design.md). */
|
|
202
|
+
.footer-sitemap-group li { margin: 0; line-height: 24px; }
|
|
203
|
+
.footer-sitemap a { display: inline-block; }
|
|
204
|
+
|
|
196
205
|
.footer-sitemap-collapsible summary {
|
|
197
206
|
display: block;
|
|
198
207
|
position: relative;
|
|
@@ -157,13 +157,18 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
|
|
|
157
157
|
overflow: hidden;
|
|
158
158
|
text-overflow: ellipsis;
|
|
159
159
|
|
|
160
|
-
/*
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
160
|
+
/* Each link's box is padded to 24px (WCAG 2.2 target size, 2.5.8).
|
|
161
|
+
The links' font box already stands about 2px taller than this
|
|
162
|
+
line-height:1 row on each side, and overflow clips at the padding
|
|
163
|
+
box, so the row pads 6px to keep the whole 24px box, and with it
|
|
164
|
+
the inset focus/hover ring, inside the clip. The negative margin
|
|
165
|
+
gives the padding back above; below it gives back 2px less,
|
|
166
|
+
because 24px boxes that touch the title link fail 2.5.8's spacing
|
|
167
|
+
check (spec 2026-09-29-wcag-2-2-design.md, owner-approved nudge:
|
|
168
|
+
the title and everything under it sit 2px lower). The smoke tests
|
|
169
|
+
pin the containment and the 24px height. */
|
|
170
|
+
padding-block: 6px;
|
|
171
|
+
margin-block: -6px -4px;
|
|
167
172
|
color: var(--ink);
|
|
168
173
|
text-transform: uppercase;
|
|
169
174
|
font-weight: bold;
|
|
@@ -173,6 +178,7 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
|
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
.entry-cat .post-categories a {
|
|
181
|
+
padding-block: 4px;
|
|
176
182
|
color: inherit;
|
|
177
183
|
text-decoration: none;
|
|
178
184
|
transition: var(--transition);
|