@half-built/astro 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,173 +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`) keep a `genai` boolean as authoring sugar for MDX and take
47
- `badges` for anything else.
48
-
49
- ## EditorNote
50
-
51
- `content/EditorNote.astro` is a reminder block for content that must
52
- not ship: by default it renders only when the consuming build runs in
53
- dev mode, and a deploy build emits nothing for it. A consumer with a
54
- wider preview concept (the blog's SHOW_DRAFTS builds, for example)
55
- passes its own gate through the `shown` prop; the component reads no
56
- consumer config itself.
57
-
58
- ## WhenPublished and PostLink
59
-
60
- `content/WhenPublished.astro` renders its children only when the post
61
- at `slug` is visible, so a published post can tease one still in
62
- draft and the passage appears on its own when the target ships.
63
- `content/PostLink.astro` links to a post by slug and resolves the
64
- href at build time through `postPath`, so a re-dated post does not
65
- strand the links pointing at it. Both take the consumer's collection
66
- as the `posts` prop (the full collection, drafts included, so an
67
- unknown slug can throw instead of hiding as "still a draft");
68
- `WhenPublished` also takes the consumer's draft gate as `showDrafts`,
69
- and `PostLink` accepts an optional `tip` carried as `data-tooltip` for
70
- the link-tip island. A consumer wraps each in a one-line site
71
- component that injects `getCollection` and its own gate, the way the
72
- blog does. The resolvers live in `lib/drafts.ts` for consumers that
73
- want the logic without the components.
74
-
75
- ## Derived excerpts
76
-
77
- Posts carry no excerpt frontmatter. `lib/excerpt.ts` derives the card
78
- text from the post body at build time: the opening prose, consecutive
79
- paragraphs joined by a space, cut at a word boundary within
80
- `EXCERPT_LIMIT` (200 characters) and always ended with an ellipsis,
81
- the reader's cue that the post continues. The run stops at the first
82
- heading, list, blockquote, code fence, or image line, so a card never
83
- crosses into a later section; components and the lead-break are
84
- invisible to it. `content/ExcerptStart.astro` on its own line moves
85
- the start to the paragraph after it, for a post that opens on a TL;DR
86
- or an aside. `postExcerpt(entry)` is the policy: a published post
87
- with no prose fails the build naming the slug, a draft warns once per
88
- build and renders blank. It takes any entry shaped
89
- `{ body?, data: { slug, draft? } }`, so a `CollectionEntry` passes
90
- with no cast. A consumer calls `postExcerpt` from every place a
91
- summary renders (cards, meta description, feed, search index) and
92
- never stores the result. The blog is the reference consumer and
93
- derives all four from it. The rule was settled on the blog's 54 posts
94
- on 2026-09-13 and moved here on 2026-09-14.
95
-
96
- ## Live code colors
97
-
98
- `shiki/code-theme` bakes its amber values into every highlighted span
99
- at build time. `shiki/code-vars` is a Shiki transformer that rewrites
100
- those baked values to the css package's `--code-token-*` and
101
- `--code-*` custom properties, so highlighted code follows a runtime
102
- palette override. The variables resolve to the same hexes the theme
103
- bakes, so adopting the transformer changes no rendered pixel on its
104
- own. Pass it beside the theme: the `transformers` prop of
105
- `astro:components`' `Code`, or `markdown.shikiConfig.transformers` in
106
- an Astro config.
107
-
108
- ## Palette token entries
109
-
110
- A `content/Palette.astro` entry may carry `token` (a custom property
111
- name) instead of `hex`: the swatch then paints `var(token)` and
112
- follows the live cascade with no script, and the hex cell renders
113
- empty with a `data-token-hex` attribute for a consumer script to fill
114
- from computed styles. Entries with `hex` render exactly as before.
115
- The table's scroll box is a keyboard tab stop named by the `label`
116
- prop (default "Palette"), since the table scrolls sideways below the
117
- column's width.
118
-
119
- ## Ecosystem island
120
-
121
- `scripts/ecosystem` fills the Footer's Ecosystem column from a shared
122
- JSON document, so adding a property to a family of sites does not mean
123
- rebuilding every one of them.
124
-
125
- ```js
126
- import { mountEcosystem } from "@half-built/astro/scripts/ecosystem";
127
-
128
- void mountEcosystem(document, {
129
- endpoint: "https://example.com/ecosystem.json",
130
- selfKey: "ui",
131
- });
132
- ```
133
-
134
- The endpoint is a parameter and the package ships no default. `Footer`
135
- keeps taking `ecosystem` and `ecosystemSelf` as typed props, and those
136
- props are the static baseline the island replaces. Every failure path
137
- leaves that baseline standing: no JavaScript, a dead endpoint, a
138
- malformed payload, or a document that does not contain `selfKey`.
139
-
140
- The document is `{ version: 1, entries: [...] }` where each entry has
141
- `key`, `label`, `href` (null renders unlinked), `priority` (ascending,
142
- 0 highest) and `family`. Entries are sorted with the self entry's own
143
- family first, then by priority, and capped at `limit`, default 6.
144
-
145
- ## Footer reserve for bottom-docked controls
146
-
147
- A control cluster fixed to the viewport's bottom corner takes no room
148
- in flow, so the page's last lines end underneath it. Set the css
149
- package's `--dock-bottom` token to the room the cluster occupies
150
- (its height, inset, and air) and `Footer` pads its band by that much
151
- below the last link, so the page scrolls far enough to clear the
152
- cluster. The token is `0px` by default and can be set inside the same
153
- media block that pins the cluster.
154
-
155
- ## Search flyout
156
-
157
- `scripts/site-header` drives the masthead's search flyout as a
158
- disclosure: the magnifier toggles it open and closed, opening moves
159
- focus to the field, Escape closes and returns focus to the magnifier,
160
- and a press or keyboard focus leaving the flyout closes it. The island
161
- marks the wrap `data-search-js`; without it, the stylesheet's
162
- focus-within rule opens the flyout on focus alone, so it still works
163
- with no script.
164
-
165
- ## Import notes
166
-
167
- Wildcard subpath imports need explicit file extensions under
168
- TypeScript's bundler mode: `@half-built/astro/lib/slug.ts` and
169
- `@half-built/astro/components/Shell.astro`, not extensionless forms.
170
- Vite resolves either; `tsc --noEmit` only accepts the explicit one.
171
-
172
- The `Masthead.astro` export is an alias for `SiteHeader.astro`, the
173
- 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.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Astro components, islands, and pure helpers for the half-built design system.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,5 +1,16 @@
1
1
  ---
2
- interface Props extends Record<string, unknown> {
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Owned =
5
+ | "href"
6
+ | "aria-label"
7
+ | "data-lb-w"
8
+ | "data-lb-h"
9
+ | "data-lb-caption"
10
+ | "class"
11
+ | "class:list";
12
+
13
+ interface Props extends Omit<HTMLAttributes<"a">, Owned> {
3
14
  href: string;
4
15
  w: number;
5
16
  h: number;
@@ -11,12 +22,12 @@ const { href, w, h, caption = "", class: className, ...rest } = Astro.props;
11
22
  ---
12
23
 
13
24
  <a
25
+ {...rest}
14
26
  class:list={["lightbox-link", className]}
15
27
  href={href}
16
28
  data-lb-w={w}
17
29
  data-lb-h={h}
18
30
  data-lb-caption={caption}
19
31
  aria-label={`View full-size image${caption ? `: ${caption}` : ""}`}
20
- {...rest}
21
32
  ><slot
22
33
  /></a>
@@ -0,0 +1,33 @@
1
+ ---
2
+ import type { HTMLAttributes } from "astro/types";
3
+
4
+ type Owned =
5
+ | "type"
6
+ | "aria-haspopup"
7
+ | "aria-expanded"
8
+ | "aria-label"
9
+ | "data-popout-label"
10
+ | "class"
11
+ | "class:list";
12
+
13
+ interface Props extends Omit<HTMLAttributes<"button">, Owned> {
14
+ label: string;
15
+ trigger?: string;
16
+ class?: string;
17
+ }
18
+
19
+ const { label, trigger = "Note", class: className, ...rest } = Astro.props;
20
+ ---
21
+
22
+ <button
23
+ {...rest}
24
+ type="button"
25
+ class:list={["popout-trigger", className]}
26
+ aria-haspopup="dialog"
27
+ aria-expanded="false"
28
+ aria-label={`${trigger}: ${label}`}
29
+ data-popout-label={label}
30
+ >
31
+ {trigger}
32
+ </button
33
+ ><template class="popout-content"><slot /></template>
@@ -31,8 +31,8 @@ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
31
31
  const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
32
32
  ---
33
33
 
34
- {/* The mapper guarantees an image (Henry placeholder when no featured
35
- image), so every card takes the thumbnail treatment. */}
34
+ {/* The mapper guarantees an image (a consumer's own placeholder when no
35
+ featured image), so every card takes the thumbnail treatment. */}
36
36
  <article class:list={["has-post-thumbnail", className]}>
37
37
  <div class="post-item post-grid">
38
38
  <div class="post-item-image">
@@ -47,12 +47,14 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
47
47
  />
48
48
  </a>
49
49
  {draft && <span class="draft-stamp">DRAFT</span>}
50
- <div class="read-time-comment">
51
- <span class="reading-time chip">
52
- <Icon name="clock" size={12} />
53
- {`${minutes} ${minReadLabel}`}</span>
50
+ <div class="card-chips">
51
+ <div class="read-time-comment">
52
+ <span class="reading-time chip">
53
+ <Icon name="clock" size={12} />
54
+ {`${minutes} ${minReadLabel}`}</span>
55
+ </div>
56
+ <CornerBadges badges={badges} />
54
57
  </div>
55
- <CornerBadges badges={badges} />
56
58
  </div>
57
59
  <div class="post-item-content">
58
60
  <div class="entry-cat">
@@ -114,18 +116,38 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
114
116
  font-size: var(--font-size-xl);
115
117
  }
116
118
 
117
- /* Chip look comes from the .chip pattern; only placement is
118
- contextual. display: flex, not a plain block: a block wrapper
119
- gives the inline-flex chip a text line box whose descender gap
120
- floats it off the true bottom, so this chip and the corner badges
121
- (already a flex container) sat at different offsets (owner catch
122
- 2026-09-06). */
123
- .read-time-comment {
119
+ /* One row along the image's bottom edge: the read-time chip at the
120
+ left, the corner badges pushed right. wrap-reverse keeps the read
121
+ time on the bottom line and lifts the badges to a line above it
122
+ when a narrow card cannot fit both, instead of overlapping (owner
123
+ catch 2026-09-28). pointer-events: none lets a press fall through
124
+ to the thumbnail link. */
125
+ .card-chips {
124
126
  position: absolute;
127
+ right: 10px;
125
128
  bottom: 10px;
126
129
  left: 10px;
127
130
  z-index: 1;
128
131
  display: flex;
132
+ flex-wrap: wrap-reverse;
133
+ gap: 6px;
134
+ align-items: flex-end;
135
+ pointer-events: none;
136
+ }
137
+
138
+ /* In the row, CornerBadges gives up its own corner placement. */
139
+ .card-chips :global(.corner-badges) {
140
+ position: static;
141
+ margin-left: auto;
142
+ }
143
+
144
+ /* Chip look comes from the .chip pattern. display: flex, not a plain
145
+ block: a block wrapper gives the inline-flex chip a text line box
146
+ whose descender gap floats it off the true bottom, so this chip and
147
+ the corner badges (already a flex container) sat at different
148
+ offsets (owner catch 2026-09-06). */
149
+ .read-time-comment {
150
+ display: flex;
129
151
  }
130
152
  .post-item-content { padding: 15px 0 0; padding-top: 10px; }
131
153
 
@@ -27,7 +27,7 @@ const {
27
27
  attribute. Kept tiny and dependency-free on purpose.
28
28
  The storage key rides define:vars from the required themeStorageKey
29
29
  prop (step 11.2), so the shell bakes in no brand of its own; the
30
- caller (Base.astro) supplies it from lib/theme-key. */}
30
+ caller (the consumer's layout) supplies its own key. */}
31
31
  <script is:inline define:vars={{ themeKey: themeStorageKey }}>
32
32
  (function () {
33
33
  var t = null;