@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.
- package/README.md +286 -190
- 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/models.ts +11 -10
- 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
|
@@ -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 (
|
|
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="
|
|
51
|
-
<
|
|
52
|
-
<
|
|
53
|
-
|
|
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
|
-
/*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
.
|
|
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 (
|
|
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;
|
|
@@ -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>
|
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/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
|
|