@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 +286 -173
- package/package.json +1 -1
- package/src/components/LightboxLink.astro +13 -2
- package/src/components/Popout.astro +33 -0
- package/src/components/PostCard.astro +36 -14
- package/src/components/Shell.astro +1 -1
- package/src/components/ThemeToggle.astro +0 -1
- package/src/components/TwoColumn.astro +4 -2
- package/src/components/content/BlogImage.astro +1 -2
- package/src/components/content/PostLink.astro +11 -4
- package/src/components/content/WhenPublished.astro +1 -1
- package/src/components/models.ts +11 -10
- package/src/lib/drafts.ts +22 -7
- package/src/lib/excerpt.ts +2 -2
- package/src/lib/format-date.ts +3 -3
- package/src/lib/header-date.ts +1 -1
- package/src/lib/ordering.ts +5 -4
- package/src/scripts/code-island.ts +49 -18
- package/src/scripts/core/frame-loop.ts +26 -11
- package/src/scripts/core/icons.ts +1 -2
- package/src/scripts/core/placement.ts +67 -0
- package/src/scripts/core/storage.ts +2 -1
- package/src/scripts/ecosystem.ts +18 -10
- package/src/scripts/focus-mode.ts +34 -2
- package/src/scripts/lightbox.ts +69 -17
- package/src/scripts/link-tip.ts +5 -66
- package/src/scripts/path-player.ts +24 -10
- package/src/scripts/plate-modal.ts +17 -6
- package/src/scripts/popout.ts +462 -0
- package/src/scripts/scroll-top.ts +2 -2
- package/src/scripts/site-header.ts +8 -6
- package/src/scripts/subscribe.ts +1 -2
- package/src/scripts/theme-toggle.ts +22 -10
- package/src/shiki/code-theme.mjs +2 -1
- package/src/shiki/code-vars.mjs +24 -2
|
@@ -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
|
-
<
|
|
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
|
|
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>
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
---
|
|
2
|
-
import {
|
|
2
|
+
import { postLinkHref, type Linkable } from "../../lib/drafts";
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
slug: string;
|
|
6
6
|
posts: Linkable[];
|
|
7
|
+
showDrafts?: boolean;
|
|
7
8
|
tip?: string;
|
|
8
9
|
class?: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
const { slug, posts, tip, class: className } = Astro.props;
|
|
12
|
-
const href =
|
|
12
|
+
const { slug, posts, showDrafts = false, tip, class: className } = Astro.props;
|
|
13
|
+
const href = postLinkHref(slug, posts, showDrafts);
|
|
13
14
|
---
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
{href ? (
|
|
17
|
+
<a href={href} data-tooltip={tip} class={className}>
|
|
18
|
+
<slot />
|
|
19
|
+
</a>
|
|
20
|
+
) : (
|
|
21
|
+
<slot />
|
|
22
|
+
)}
|
package/src/components/models.ts
CHANGED
|
@@ -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
|
|
36
|
-
|
|
37
|
-
site share one definition without the
|
|
38
|
-
SocialItem carries its icon as inline
|
|
39
|
-
|
|
40
|
-
of the package) is a lookup the caller
|
|
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
|
|
55
|
-
footer-sitemap comment
|
|
56
|
-
schema"; this is that move.
|
|
57
|
-
|
|
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;
|
package/src/lib/drafts.ts
CHANGED
|
@@ -43,17 +43,21 @@ export function forwardLinkVisible(
|
|
|
43
43
|
/* Post links by slug. A hand-typed post URL bakes in the target's date,
|
|
44
44
|
so a re-dated draft silently breaks every earlier link to it. The
|
|
45
45
|
PostLink content component resolves the slug through postPath() at
|
|
46
|
-
build time instead.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
build time instead. postLinkHref() is its policy: a visible target
|
|
47
|
+
gets its href, a hidden one gets null and the component renders its
|
|
48
|
+
text with no link (owner decision 2026-09-26), so a draft's name can
|
|
49
|
+
sit in published prose and becomes a link on its own when the draft
|
|
50
|
+
ships. resolvePostHref() still resolves drafts: consumers use it for
|
|
51
|
+
lookups keyed by href, and Astro renders MDX slot children eagerly,
|
|
52
|
+
so a PostLink inside a hidden WhenPublished still runs. The
|
|
53
|
+
deploy-build guard against a link to a draft stays a consumer test
|
|
54
|
+
(no draft slug in any built page). Unknown slugs throw for the same
|
|
55
|
+
reason forwardLinkVisible's do. */
|
|
52
56
|
|
|
53
57
|
import { postPath } from "./slug";
|
|
54
58
|
|
|
55
59
|
export interface Linkable {
|
|
56
|
-
data: { slug: string; date: Date };
|
|
60
|
+
data: { slug: string; date: Date; draft?: boolean };
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
export function resolvePostHref(slug: string, posts: Linkable[]): string {
|
|
@@ -61,3 +65,14 @@ export function resolvePostHref(slug: string, posts: Linkable[]): string {
|
|
|
61
65
|
if (!target) throw new Error(`PostLink: no post has slug "${slug}"`);
|
|
62
66
|
return postPath(target);
|
|
63
67
|
}
|
|
68
|
+
|
|
69
|
+
export function postLinkHref(
|
|
70
|
+
slug: string,
|
|
71
|
+
posts: Linkable[],
|
|
72
|
+
showDrafts: boolean,
|
|
73
|
+
): string | null {
|
|
74
|
+
const target = posts.find((p) => p.data.slug === slug);
|
|
75
|
+
if (!target) throw new Error(`PostLink: no post has slug "${slug}"`);
|
|
76
|
+
if (target.data.draft && !showDrafts) return null;
|
|
77
|
+
return resolvePostHref(slug, posts);
|
|
78
|
+
}
|
package/src/lib/excerpt.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Card text derived from a post body (owner call 2026-09-13; the rule
|
|
2
|
-
is
|
|
3
|
-
|
|
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
|
package/src/lib/format-date.ts
CHANGED
|
@@ -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
|
|
3
|
-
placeholder along. Locale parameterized for N sites;
|
|
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",
|
package/src/lib/header-date.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/lib/ordering.ts
CHANGED
|
@@ -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
|
|
4
|
-
|
|
5
|
-
through these instead of picking fields ad hoc:
|
|
6
|
-
the same-day ordering bug shipped twice
|
|
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
|
|
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
|
-
/*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
close over the same
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
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(
|
|
94
|
+
clearTimeout(timers.announce);
|
|
95
|
+
|
|
96
|
+
timers.announce = setTimeout(() => {
|
|
97
|
+
status.textContent = outcome;
|
|
98
|
+
}, 0);
|
|
99
|
+
|
|
100
|
+
clearTimeout(timers.reset);
|
|
72
101
|
|
|
73
|
-
|
|
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,
|
|
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,
|
|
120
|
+
for (const { pre, bar, btn, onClick, timers } of mounted) {
|
|
91
121
|
btn.removeEventListener("click", onClick);
|
|
92
|
-
clearTimeout(
|
|
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
|
|
2
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/src/scripts/ecosystem.ts
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 =
|
|
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
|
|