@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@half-built/astro",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Astro components, islands, and pure helpers for the half-built design system.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- /* The links' font box stands taller than this line-height:1 row, and
161
- overflow clips at the padding box, so without vertical breathing
162
- room the inset focus/hover ring loses its top and bottom bands.
163
- The negative margin gives the padding back, keeping card layout
164
- identical (smoke test pins the containment). */
165
- padding-block: 3px;
166
- margin-block: -3px;
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);