@half-built/astro 0.10.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
@@ -43,8 +43,9 @@ The two house conventions ship as `GENAI_BADGE` and `DEMO_BADGE`; a
43
43
  site's view-model lists the badges a post carries, in order, and can
44
44
  add its own without a package change. `PostCardModel.badges` carries
45
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.
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`.
48
49
 
49
50
  ## EditorNote
50
51
 
@@ -120,7 +121,9 @@ palette override. The variables resolve to the same hexes the theme
120
121
  bakes, so adopting the transformer changes no rendered pixel on its
121
122
  own. Pass it beside the theme: the `transformers` prop of
122
123
  `astro:components`' `Code`, or `markdown.shikiConfig.transformers` in
123
- an Astro config.
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.
124
127
 
125
128
  ## Palette token entries
126
129
 
@@ -140,7 +143,7 @@ JSON document, so adding a property to a family of sites does not mean
140
143
  rebuilding every one of them.
141
144
 
142
145
  ```js
143
- import { mountEcosystem } from "@half-built/astro/scripts/ecosystem";
146
+ import { mountEcosystem } from "@half-built/astro/scripts/ecosystem.ts";
144
147
 
145
148
  void mountEcosystem(document, {
146
149
  endpoint: "https://example.com/ecosystem.json",
@@ -169,6 +172,12 @@ below the last link, so the page scrolls far enough to clear the
169
172
  cluster. The token is `0px` by default and can be set inside the same
170
173
  media block that pins the cluster.
171
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
+
172
181
  ## Search flyout
173
182
 
174
183
  `scripts/site-header` drives the masthead's search flyout as a
@@ -179,6 +188,93 @@ marks the wrap `data-search-js`; without it, the stylesheet's
179
188
  focus-within rule opens the flyout on focus alone, so it still works
180
189
  with no script.
181
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
+
182
278
  ## Import notes
183
279
 
184
280
  Wildcard subpath imports need explicit file extensions under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@half-built/astro",
3
- "version": "0.10.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;
@@ -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
 
@@ -135,13 +157,18 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
135
157
  overflow: hidden;
136
158
  text-overflow: ellipsis;
137
159
 
138
- /* The links' font box stands taller than this line-height:1 row, and
139
- overflow clips at the padding box, so without vertical breathing
140
- room the inset focus/hover ring loses its top and bottom bands.
141
- The negative margin gives the padding back, keeping card layout
142
- identical (smoke test pins the containment). */
143
- padding-block: 3px;
144
- 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;
145
172
  color: var(--ink);
146
173
  text-transform: uppercase;
147
174
  font-weight: bold;
@@ -151,6 +178,7 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
151
178
  }
152
179
 
153
180
  .entry-cat .post-categories a {
181
+ padding-block: 4px;
154
182
  color: inherit;
155
183
  text-decoration: none;
156
184
  transition: var(--transition);
@@ -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;
@@ -23,7 +23,6 @@ const {
23
23
  className,
24
24
  ]}
25
25
  aria-label={label}
26
- aria-pressed="false"
27
26
  >
28
27
  <span class="theme-toggle-moon"><Fragment set:html={ICON_MOON} /></span>
29
28
  <span class="theme-toggle-sun"><Fragment set:html={ICON_SUN} /></span>
@@ -1,12 +1,14 @@
1
1
  ---
2
2
  interface Props {
3
3
  class?: string;
4
+ mainTag?: "main" | "div";
4
5
  }
5
6
 
6
- const { class: className } = Astro.props;
7
+ const { class: className, mainTag = "main" } = Astro.props;
8
+ const Main = mainTag;
7
9
  ---
8
10
 
9
11
  <div class:list={["two-column", className]}>
10
- <main class="site-main"><slot /></main>
12
+ <Main class="site-main"><slot /></Main>
11
13
  <slot name="aside" />
12
14
  </div>
@@ -68,7 +68,6 @@ const {
68
68
  .blog-image :global(img) { max-width: 100%; height: auto; display: block; margin-inline: auto; }
69
69
  .blog-image-narrow :global(.lightbox-link) { max-width: min(495px, 100%); }
70
70
 
71
- /* Type from the .caption pattern. Fidelity pass (handoff 5.5): live
72
- figcaptions are centered. */
71
+ /* Type from the .caption pattern; figcaptions are centered. */
73
72
  figcaption { margin-top: 5px; text-align: center; }
74
73
  </style>
@@ -32,12 +32,13 @@ export interface LinkListItem {
32
32
  }
33
33
 
34
34
  /* Chrome view-models (step 9.5, 2026-08-31): the header renders site
35
- identity it is handed, never the blog's config. NavItem is the shape
36
- config.ts's NAV already had; it moved here so the component and the
37
- site share one definition without the component importing site code.
38
- SocialItem carries its icon as inline SVG markup: the site's registry
39
- (social-icons.ts, including the Henry portfolio glyph that stays out
40
- of the package) is a lookup the caller performs, not the component. */
35
+ identity it is handed, never a consumer's own config. NavItem is the
36
+ shape a consumer's own site navigation config already used; it moved
37
+ here so the component and the site share one definition without the
38
+ component importing site code. SocialItem carries its icon as inline
39
+ SVG markup: a site's own icon registry (including any site-specific
40
+ brand glyphs that stay out of the package) is a lookup the caller
41
+ performs, not the component. */
41
42
  export interface NavItem {
42
43
  label: string;
43
44
  href: string;
@@ -51,10 +52,10 @@ export interface SocialItem {
51
52
  icon: string;
52
53
  }
53
54
 
54
- /* Footer view-models (step 9.5). These are the interfaces config.ts's
55
- footer-sitemap comment always called "the future component-library
56
- schema"; this is that move. The blog's data (FOOTER_SITEMAP,
57
- ECOSYSTEM) stays in config.ts. */
55
+ /* Footer view-models (step 9.5). These are the interfaces a consumer's
56
+ own footer-sitemap comment called "the future component-library
57
+ schema"; this is that move. A consumer's own footer data stays in its
58
+ own config, not here. */
58
59
  export interface SitemapLink {
59
60
  label: string;
60
61
  href: string;
@@ -1,6 +1,6 @@
1
1
  /* Card text derived from a post body (owner call 2026-09-13; the rule
2
- is recorded in the blog's docs/superpowers/specs/2026-09-13-derived-excerpts-design.md
3
- and summarized in this package's README, "Derived excerpts"). The
2
+ was settled at the blog and is summarized in this package's README,
3
+ "Derived excerpts"). The
4
4
  card is the opening prose, consecutive paragraphs joined, cut at a
5
5
  word boundary within EXCERPT_LIMIT and always ended with an
6
6
  ellipsis, so every listing samples the post's own opening and the
@@ -1,7 +1,7 @@
1
1
  /* Package lib (step 11.2): the post date formatter, split from
2
- post-display.ts so the package's formatDate never drags the Henry
3
- placeholder along. Locale parameterized for N sites; en-US is the
4
- house default. */
2
+ post-display.ts so the package's formatDate never drags a
3
+ site-specific placeholder along. Locale parameterized for N sites;
4
+ en-US is the house default. */
5
5
  export function formatPostDate(d: Date, locale = "en-US"): string {
6
6
  return d.toLocaleDateString(locale, {
7
7
  year: "numeric",
@@ -1,6 +1,6 @@
1
1
  /* The masthead date box: "25, Aug 2026", the live theme's format. Rendered
2
2
  at build time as the no-JS fallback (SiteHeader.astro) and refreshed on
3
- load by Base.astro's script, from this one definition. */
3
+ load by the consumer's layout script, from this one definition. */
4
4
  export function formatHeaderDate(now: Date): string {
5
5
  return `${now.getDate()}, ${now.toLocaleDateString("en-US", { month: "short" })} ${now.getFullYear()}`;
6
6
  }
@@ -1,9 +1,10 @@
1
1
  /* Publication-time ordering, pure and framework-free so it is unit-testable.
2
2
  Single source of truth for a post's publication instant: `date` is the
3
- date-only permalink field; `published` carries the full WP timestamp when
4
- the migrator knew it. Every consumer (sorting, feeds, display) must go
5
- through these instead of picking fields ad hoc: hand-picked fields are how
6
- the same-day ordering bug shipped twice (audit A1/A2). */
3
+ date-only permalink field; `published` carries the full source timestamp
4
+ when a consumer's import knew it. Every consumer (sorting, feeds,
5
+ display) must go through these instead of picking fields ad hoc:
6
+ hand-picked fields are how the same-day ordering bug shipped twice
7
+ (audit A1/A2). */
7
8
 
8
9
  export interface Publishable {
9
10
  data: { date: Date; published?: Date };
@@ -3,8 +3,8 @@ import { docOf } from "./core/dom";
3
3
 
4
4
  /* Code island decorator: builds the header bar (filename/language label +
5
5
  copy button) above every fenced block in article content. Runs client-side
6
- from Base.astro; extracted to a module so the DOM behavior is testable
7
- under jsdom (a phase-1 carry-over closed 2026-07-28). */
6
+ from the consumer's layout; extracted to a module so the DOM behavior is
7
+ testable under jsdom (a phase-1 carry-over closed 2026-07-28). */
8
8
  /* Island contract (step 9): mount(root, options?) returns a destroy handle;
9
9
  claim() makes a second mount over the same pre a no-op. */
10
10
  export interface CodeIslandOptions {
@@ -25,18 +25,22 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (
25
25
 
26
26
  const doc = docOf(root);
27
27
 
28
- /* resetTimer holds the copy-reset setTimeout id, one live per button at
29
- most (a second click before the first reset overwrites it, dropping
30
- the earlier timer's reference so it can no longer be cleared, which
31
- is why doCopy clears the box before replacing it). A plain mutable
32
- box, not a field on the mounted entry, so both doCopy and destroy()
33
- close over the same cell. */
28
+ /* timers holds the two pending setTimeout ids per button: the queued
29
+ status announcement and the label reset, one live each at most (a
30
+ second click before either fires overwrites its id, dropping the
31
+ earlier reference so it can no longer be cleared, which is why doCopy
32
+ clears each before replacing it). A plain mutable box, not a field on
33
+ the mounted entry, so both doCopy and destroy() close over the same
34
+ cells and destroy() can cancel both. */
34
35
  const mounted: {
35
36
  pre: Element;
36
37
  bar: HTMLDivElement;
37
38
  btn: HTMLButtonElement;
38
39
  onClick: () => void;
39
- resetTimer: { id: ReturnType<typeof setTimeout> | undefined };
40
+ timers: {
41
+ announce: ReturnType<typeof setTimeout> | undefined;
42
+ reset: ReturnType<typeof setTimeout> | undefined;
43
+ };
40
44
  }[] = [];
41
45
 
42
46
  for (const pre of root.querySelectorAll(selector)) {
@@ -45,6 +49,13 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (
45
49
  bar.className = "code-island-bar";
46
50
  const label = doc.createElement("span");
47
51
 
52
+ /* Visually the button's own text already shows the outcome, but a
53
+ screen reader is not sat watching the button: role="status" gets
54
+ the outcome announced as a live region without moving focus. */
55
+ const status = doc.createElement("span");
56
+ status.className = "screen-reader-text";
57
+ status.setAttribute("role", "status");
58
+
48
59
  const file = pre
49
60
  .closest("[data-code-filename]")
50
61
  ?.getAttribute("data-code-filename");
@@ -56,22 +67,41 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (
56
67
  btn.type = "button";
57
68
  btn.textContent = copy.copy;
58
69
 
59
- const resetTimer: { id: ReturnType<typeof setTimeout> | undefined } = {
60
- id: undefined,
61
- };
70
+ const timers: {
71
+ announce: ReturnType<typeof setTimeout> | undefined;
72
+ reset: ReturnType<typeof setTimeout> | undefined;
73
+ } = { announce: undefined, reset: undefined };
62
74
 
63
75
  const doCopy = async (): Promise<void> => {
76
+ /* Cleared synchronously, before the outcome is known, so a repeat
77
+ copy inside the reset window is a real DOM change rather than
78
+ the same string written over itself. Setting the outcome text
79
+ itself waits one tick (below) so the clear is its own observable
80
+ step; a live region that never changes never gets announced. */
81
+ status.textContent = "";
82
+
83
+ let outcome: string;
84
+
64
85
  try {
65
86
  await navigator.clipboard.writeText(pre.textContent);
66
87
  btn.textContent = copy.copied;
88
+ outcome = copy.copied;
67
89
  } catch {
68
90
  btn.textContent = copy.failed;
91
+ outcome = copy.failed;
69
92
  }
70
93
 
71
- clearTimeout(resetTimer.id);
94
+ clearTimeout(timers.announce);
95
+
96
+ timers.announce = setTimeout(() => {
97
+ status.textContent = outcome;
98
+ }, 0);
99
+
100
+ clearTimeout(timers.reset);
72
101
 
73
- resetTimer.id = setTimeout(() => {
102
+ timers.reset = setTimeout(() => {
74
103
  btn.textContent = copy.copy;
104
+ status.textContent = "";
75
105
  }, resetMs);
76
106
  };
77
107
 
@@ -80,16 +110,17 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (
80
110
  };
81
111
 
82
112
  btn.addEventListener("click", onClick);
83
- bar.append(label, btn);
113
+ bar.append(label, btn, status);
84
114
  pre.before(bar);
85
- mounted.push({ pre, bar, btn, onClick, resetTimer });
115
+ mounted.push({ pre, bar, btn, onClick, timers });
86
116
  }
87
117
 
88
118
  return {
89
119
  destroy(): void {
90
- for (const { pre, bar, btn, onClick, resetTimer } of mounted) {
120
+ for (const { pre, bar, btn, onClick, timers } of mounted) {
91
121
  btn.removeEventListener("click", onClick);
92
- clearTimeout(resetTimer.id);
122
+ clearTimeout(timers.announce);
123
+ clearTimeout(timers.reset);
93
124
  bar.remove();
94
125
  release(pre, "code");
95
126
  }