@half-built/astro 0.10.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.
@@ -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;
@@ -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
  }
@@ -1,10 +1,15 @@
1
- /* The rAF loop with dt clamp (step 10), one home for the pattern
2
- henry-loose.ts and path-player.ts each carried. now is the rAF
1
+ /* The rAF loop with dt clamp (step 10), one home for a pattern that
2
+ used to be scattered across the site's own scripts and path-player.ts.
3
+ now is the rAF
3
4
  timestamp: monotonic in a real browser, unlike Date.now(), which a
4
5
  wall-clock adjustment can move backward; the max(0, ...) floor is
5
6
  cheap belt-and-suspenders against that case feeding an integrator a
6
7
  negative dt. stop() resets the clock so a stop/start gap (a hidden
7
- tab, a closed dialog) never arrives as one giant dt. */
8
+ tab, a closed dialog) never arrives as one giant dt. A stop, a
9
+ start, or both called reentrantly from inside cb keep exactly one
10
+ scheduled chain: each generation is stamped at schedule time, and a
11
+ tick from a stale generation is dropped instead of rescheduling
12
+ itself alongside the newer chain. */
8
13
  export interface FrameLoop {
9
14
  start(): void;
10
15
  stop(): void;
@@ -20,21 +25,25 @@ export function createFrameLoop(
20
25
  let handle = 0;
21
26
  let last = 0;
22
27
  let live = false;
28
+ let gen = 0;
23
29
 
24
- function tick(now: number): void {
25
- if (!live) return;
30
+ function tick(now: number, mine: number): void {
31
+ if (!live || mine !== gen) return;
26
32
 
27
33
  const elapsed = Math.max(0, (now - last) / 1000);
28
34
  const dt = last === 0 ? firstDt : Math.min(clamp, elapsed);
29
35
 
30
36
  last = now;
31
37
  cb(dt);
32
- // cb may have called stop() reentrantly; a fresh function body reads live without stale narrowing.
33
- scheduleNext();
34
- }
35
38
 
36
- function scheduleNext(): void {
37
- if (live) handle = win.requestAnimationFrame(tick);
39
+ // cb may have called stop() and/or start() reentrantly; gen moves
40
+ // on either call, so mine still matching gen here already means
41
+ // this chain is still the live one.
42
+ if (mine === gen) {
43
+ handle = win.requestAnimationFrame((now2) => {
44
+ tick(now2, mine);
45
+ });
46
+ }
38
47
  }
39
48
 
40
49
  return {
@@ -42,11 +51,17 @@ export function createFrameLoop(
42
51
  if (live) return;
43
52
  live = true;
44
53
  last = 0;
45
- handle = win.requestAnimationFrame(tick);
54
+ gen++;
55
+ const mine = gen;
56
+
57
+ handle = win.requestAnimationFrame((now) => {
58
+ tick(now, mine);
59
+ });
46
60
  },
47
61
  stop(): void {
48
62
  if (!live) return;
49
63
  live = false;
64
+ gen++;
50
65
  win.cancelAnimationFrame(handle);
51
66
  last = 0;
52
67
  },
@@ -1,7 +1,7 @@
1
1
  /* Inline icon markup, vendored from Lucide (https://lucide.dev, ISC
2
2
  license), replacing the Unicode glyphs whose rendering varied by
3
3
  platform font. Same line style as the site's existing inline SVGs
4
- (the to-top chevron in Base.astro). Buttons carry their own
4
+ (the to-top chevron in the consumer's layout). Buttons carry their own
5
5
  aria-labels; the svg itself is decorative and aria-hidden.
6
6
  Home: the js package's core (owner decision 1, 2026-08-31); Lucide,
7
7
  ISC license, attribution retained. */
@@ -98,7 +98,6 @@ export function iconMarkup(
98
98
 
99
99
  export const ICON_X = iconMarkup("x");
100
100
  export const ICON_PLAY = iconMarkup("play");
101
- export const ICON_SPARKLES = iconMarkup("sparkles");
102
101
  export const ICON_PAUSE = iconMarkup("pause");
103
102
  export const ICON_ROTATE_CCW = iconMarkup("rotate-ccw");
104
103
  export const ICON_CHEVRON_LEFT = iconMarkup("chevron-left");
@@ -0,0 +1,67 @@
1
+ /* Pure placement shared by the measured islands (link-tip, popout;
2
+ spec docs/superpowers/specs/2026-09-26-popout-design.md moved it
3
+ here from link-tip.ts). Measure then place, never predict. */
4
+
5
+ const GAP = 1; /* px between anchor and tip; the old bottom: calc(100% + 1px) */
6
+ const INSET = 2; /* px of horizontal lead-out past the anchor edge */
7
+
8
+ export const EDGE = 12; /* px of breathing room against either viewport edge */
9
+
10
+ export type TipPlace = "above" | "below";
11
+ export type TipAlign = "start" | "end";
12
+ export interface AnchorRect {
13
+ left: number;
14
+ right: number;
15
+ top: number;
16
+ bottom: number;
17
+ }
18
+ export interface TipSize {
19
+ width: number;
20
+ height: number;
21
+ }
22
+ export interface ViewportSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+ export interface TipPosition {
27
+ x: number;
28
+ y: number;
29
+ place: TipPlace;
30
+ }
31
+
32
+ /* Pure placement for the singleton tip: start/end alignment against
33
+ the anchor, horizontal clamp into [edge, vw - edge] with the left
34
+ edge winning when both bind, and a vertical flip when the preferred
35
+ side would leave the viewport. All measured values; no prediction. */
36
+ export function placeTip(
37
+ anchor: AnchorRect,
38
+ tip: TipSize,
39
+ viewport: ViewportSize,
40
+ place: TipPlace = "above",
41
+ align: TipAlign = "start",
42
+ edge: number = EDGE,
43
+ ): TipPosition {
44
+ const startX = anchor.left - INSET;
45
+ const endX = anchor.right + INSET - tip.width;
46
+ let x = align === "start" ? startX : endX;
47
+
48
+ const overRight = x + tip.width - (viewport.width - edge);
49
+ if (overRight > 0) x -= overRight;
50
+ if (x < edge) x = edge;
51
+ let finalPlace = place;
52
+
53
+ if (place === "above" && anchor.top - GAP - tip.height < 0) {
54
+ finalPlace = "below";
55
+ }
56
+
57
+ if (place === "below" && anchor.bottom + GAP + tip.height > viewport.height) {
58
+ finalPlace = "above";
59
+ }
60
+
61
+ const y =
62
+ finalPlace === "above"
63
+ ? anchor.top - GAP - tip.height
64
+ : anchor.bottom + GAP;
65
+
66
+ return { x: Math.round(x), y: Math.round(y), place: finalPlace };
67
+ }
@@ -1,5 +1,6 @@
1
1
  /* The localStorage read/validate/write codec (step 10), one home for
2
- the pattern henry-loose.ts and stasis-state.ts each carried: parse is
2
+ a pattern that used to be scattered across the site's own scripts:
3
+ parse is
3
4
  the validator (junk parses to null and read() then removes the key),
4
5
  and every storage touch sits inside try/catch so private mode or
5
6
  disabled storage degrades to in-page-only state, never a thrown
@@ -68,7 +68,12 @@ export function validateDocument(raw: unknown): EcosystemDocument | null {
68
68
 
69
69
  /** The self entry's family first, each group by ascending priority and
70
70
  then label, capped at limit. Null when selfKey is absent, which is
71
- the refusal that keeps a site out of a list missing itself. */
71
+ the refusal that keeps a site out of a list missing itself.
72
+
73
+ The self entry always survives the cap: a footer that would render
74
+ the cap without the site's own entry is worse than one that swaps
75
+ out its lowest-priority stranger, so the self entry takes the last
76
+ slot when sorting alone would have dropped it past the limit. */
72
77
  export function sortEntries(
73
78
  entries: EcosystemDocEntry[],
74
79
  selfKey: string,
@@ -77,15 +82,18 @@ export function sortEntries(
77
82
  const self = entries.find((entry) => entry.key === selfKey);
78
83
  if (!self) return null;
79
84
  const own = self.family;
80
- return [...entries]
81
- .sort((a, b) => {
82
- const aOwn = a.family === own ? 0 : 1;
83
- const bOwn = b.family === own ? 0 : 1;
84
- if (aOwn !== bOwn) return aOwn - bOwn;
85
- if (a.priority !== b.priority) return a.priority - b.priority;
86
- return a.label.localeCompare(b.label);
87
- })
88
- .slice(0, limit);
85
+
86
+ const sorted = [...entries].sort((a, b) => {
87
+ const aOwn = a.family === own ? 0 : 1;
88
+ const bOwn = b.family === own ? 0 : 1;
89
+ if (aOwn !== bOwn) return aOwn - bOwn;
90
+ if (a.priority !== b.priority) return a.priority - b.priority;
91
+ return a.label.localeCompare(b.label);
92
+ });
93
+
94
+ const capped = sorted.slice(0, limit);
95
+ if (limit > 0 && !capped.includes(self)) capped[capped.length - 1] = self;
96
+ return capped;
89
97
  }
90
98
 
91
99
  const CACHE_KEY = "half-built-ecosystem";
@@ -15,11 +15,41 @@ export interface FocusModeOptions {
15
15
  target?: EventTarget;
16
16
  }
17
17
 
18
+ /* Text entry is not navigation: a reader who clicked into a field and
19
+ types keeps the click highlight. Tab always means keyboard. */
20
+ function isTextEntry(t: EventTarget | null): boolean {
21
+ if (!(t instanceof HTMLElement)) return false;
22
+
23
+ if (
24
+ t.isContentEditable ||
25
+ t instanceof HTMLTextAreaElement ||
26
+ t instanceof HTMLSelectElement
27
+ ) {
28
+ return true;
29
+ }
30
+
31
+ if (!(t instanceof HTMLInputElement)) return false;
32
+
33
+ return ![
34
+ "checkbox",
35
+ "radio",
36
+ "button",
37
+ "submit",
38
+ "reset",
39
+ "range",
40
+ "color",
41
+ "file",
42
+ "image",
43
+ ].includes(t.type);
44
+ }
45
+
18
46
  export const mountFocusMode: Island<FocusModeOptions> = (
19
47
  root,
20
48
  options = {},
21
49
  ): IslandHandle => {
22
- const el = root as HTMLElement;
50
+ const el =
51
+ root instanceof Document ? root.documentElement : (root as HTMLElement);
52
+
23
53
  const target = options.target ?? window;
24
54
 
25
55
  if (!claim(el, "focus-mode")) {
@@ -31,7 +61,9 @@ export const mountFocusMode: Island<FocusModeOptions> = (
31
61
  };
32
62
  }
33
63
 
34
- const onKeydown = (): void => {
64
+ const onKeydown = (ev: Event): void => {
65
+ if (!(ev instanceof KeyboardEvent)) return;
66
+ if (ev.key !== "Tab" && isTextEntry(ev.target)) return;
35
67
  el.dataset.focus = "keyboard";
36
68
  };
37
69