@half-built/astro 0.1.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/ICONS-LICENSE +43 -0
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +18 -0
- package/src/components/CategoryCard.astro +31 -0
- package/src/components/CornerBadges.astro +40 -0
- package/src/components/Footer.astro +209 -0
- package/src/components/LightboxLink.astro +13 -0
- package/src/components/LinkListWidget.astro +19 -0
- package/src/components/Pagination.astro +72 -0
- package/src/components/PostCard.astro +166 -0
- package/src/components/PostNavigation.astro +61 -0
- package/src/components/Shell.astro +52 -0
- package/src/components/SiteHeader.astro +326 -0
- package/src/components/SmartImage.astro +34 -0
- package/src/components/Subscribe.astro +117 -0
- package/src/components/ThemeToggle.astro +41 -0
- package/src/components/TwoColumn.astro +12 -0
- package/src/components/Widget.astro +41 -0
- package/src/components/content/BlogImage.astro +39 -0
- package/src/components/content/Button.astro +57 -0
- package/src/components/content/Callout.astro +75 -0
- package/src/components/content/CodeBlock.astro +7 -0
- package/src/components/content/Gallery.astro +57 -0
- package/src/components/content/GalleryImage.astro +35 -0
- package/src/components/content/Group.astro +15 -0
- package/src/components/content/MediaText.astro +60 -0
- package/src/components/content/Palette.astro +42 -0
- package/src/components/content/Quote.astro +21 -0
- package/src/components/content/Spacer.astro +5 -0
- package/src/components/content/Step.astro +126 -0
- package/src/components/content/Walkthrough.astro +42 -0
- package/src/components/models.ts +77 -0
- package/src/lib/archive.ts +29 -0
- package/src/lib/drafts.ts +52 -0
- package/src/lib/format-date.ts +9 -0
- package/src/lib/header-date.ts +6 -0
- package/src/lib/ordering.ts +18 -0
- package/src/lib/paginate.ts +15 -0
- package/src/lib/reading-time.ts +4 -0
- package/src/lib/slug.ts +73 -0
- package/src/scripts/code-island.ts +75 -0
- package/src/scripts/core/breakpoints.ts +4 -0
- package/src/scripts/core/dom.ts +31 -0
- package/src/scripts/core/frame-loop.ts +54 -0
- package/src/scripts/core/icons.ts +30 -0
- package/src/scripts/core/island.ts +25 -0
- package/src/scripts/core/storage.ts +44 -0
- package/src/scripts/focus-mode.ts +41 -0
- package/src/scripts/lightbox.ts +446 -0
- package/src/scripts/link-tip.ts +154 -0
- package/src/scripts/path-player-math.ts +34 -0
- package/src/scripts/path-player-paint.ts +154 -0
- package/src/scripts/path-player.ts +341 -0
- package/src/scripts/plate-modal.ts +91 -0
- package/src/scripts/scroll-top.ts +32 -0
- package/src/scripts/site-header.ts +73 -0
- package/src/scripts/subscribe.ts +116 -0
- package/src/scripts/theme-toggle.ts +115 -0
- package/src/shiki/code-theme.mjs +16 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* View-model types for the chrome components (step 6, 2026-08-30).
|
|
2
|
+
These live with the components, not in lib/: the components are the
|
|
3
|
+
future @half-built/astro package and must not import site code. The
|
|
4
|
+
CollectionEntry mapping that produces them is src/lib/view-models.ts,
|
|
5
|
+
which stays site-side. */
|
|
6
|
+
import type { ImageMetadata } from "astro";
|
|
7
|
+
|
|
8
|
+
export interface PostCardModel {
|
|
9
|
+
href: string;
|
|
10
|
+
title: string;
|
|
11
|
+
excerpt: string;
|
|
12
|
+
author: string;
|
|
13
|
+
dateStr: string;
|
|
14
|
+
minutes: number;
|
|
15
|
+
hero: ImageMetadata;
|
|
16
|
+
heroPosition?: string;
|
|
17
|
+
draft?: boolean;
|
|
18
|
+
genai?: boolean;
|
|
19
|
+
demo?: boolean;
|
|
20
|
+
categories: { name: string; href: string }[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PostRef {
|
|
24
|
+
href: string;
|
|
25
|
+
title: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LinkListItem {
|
|
29
|
+
label: string;
|
|
30
|
+
href: string;
|
|
31
|
+
count?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
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. */
|
|
41
|
+
export interface NavItem {
|
|
42
|
+
label: string;
|
|
43
|
+
href: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SocialItem {
|
|
47
|
+
/* Screen-reader text for the icon. */
|
|
48
|
+
label: string;
|
|
49
|
+
href: string;
|
|
50
|
+
/* Inline SVG markup, rendered with set:html. */
|
|
51
|
+
icon: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
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. */
|
|
58
|
+
export interface SitemapLink {
|
|
59
|
+
label: string;
|
|
60
|
+
href: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SitemapGroup {
|
|
64
|
+
title: string;
|
|
65
|
+
links: SitemapLink[];
|
|
66
|
+
/* Collapsible on phones (a <details>, always open on wider screens). */
|
|
67
|
+
collapsible?: boolean;
|
|
68
|
+
/* A collapsible group starts closed on phones unless this is set. */
|
|
69
|
+
phoneOpen?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface EcosystemEntry {
|
|
73
|
+
key: string;
|
|
74
|
+
label: string;
|
|
75
|
+
/* null: property not deployed yet; renders visible but unlinked. */
|
|
76
|
+
href: string | null;
|
|
77
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { slugifyCategory } from "./slug";
|
|
2
|
+
|
|
3
|
+
interface PostLike { data: { date: Date; categories: string[] } }
|
|
4
|
+
|
|
5
|
+
export function groupByMonth(posts: PostLike[], locale = "en-US") {
|
|
6
|
+
const map = new Map<string, number>();
|
|
7
|
+
for (const p of posts) {
|
|
8
|
+
const d = p.data.date;
|
|
9
|
+
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
10
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
11
|
+
}
|
|
12
|
+
return [...map.entries()]
|
|
13
|
+
.sort((a, b) => (a[0] < b[0] ? 1 : -1))
|
|
14
|
+
.map(([key, count]) => {
|
|
15
|
+
const [year, month] = key.split("-");
|
|
16
|
+
const label = new Date(Date.UTC(Number(year), Number(month) - 1)).toLocaleDateString(locale, {
|
|
17
|
+
month: "long", year: "numeric", timeZone: "UTC",
|
|
18
|
+
});
|
|
19
|
+
return { year, month, label, count };
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function countByCategory(posts: PostLike[]) {
|
|
24
|
+
const map = new Map<string, number>();
|
|
25
|
+
for (const p of posts) for (const c of p.data.categories) map.set(c, (map.get(c) ?? 0) + 1);
|
|
26
|
+
return [...map.entries()]
|
|
27
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
28
|
+
.map(([name, count]) => ({ name, slug: slugifyCategory(name), count }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/* Draft visibility, pure and framework-free so it is unit-testable.
|
|
2
|
+
A draft exists in the repo but not in the deploy build; a build run
|
|
3
|
+
with SHOW_DRAFTS=1 includes it so the final look can be reviewed on
|
|
4
|
+
a production preview build (never the dev server, per house rules).
|
|
5
|
+
Every consumer goes through sortedPosts() in posts.ts, which applies
|
|
6
|
+
this filter; a call site reaching for getCollection("posts") directly
|
|
7
|
+
would leak drafts into whatever it renders. */
|
|
8
|
+
|
|
9
|
+
export interface Draftable { data: { draft?: boolean } }
|
|
10
|
+
|
|
11
|
+
export function visiblePosts<T extends Draftable>(posts: T[], showDrafts: boolean): T[] {
|
|
12
|
+
return showDrafts ? posts : posts.filter((p) => !p.data.draft);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Forward links. A published post may reference a post that is still a
|
|
16
|
+
draft (the Táltos-oid build post teases its own reflection post, owner
|
|
17
|
+
request 2026-08-24). The WhenPublished content component wraps that
|
|
18
|
+
passage and renders it only once the target is visible, so the earlier
|
|
19
|
+
post never has to be revised when the later one ships, and no link to a
|
|
20
|
+
draft leaks into a deploy build. A slug that matches no post at all is
|
|
21
|
+
a build error rather than a silently hidden paragraph: a typo must not
|
|
22
|
+
look like "still a draft". */
|
|
23
|
+
|
|
24
|
+
export interface ForwardLinkable extends Draftable { data: { draft?: boolean; slug: string } }
|
|
25
|
+
|
|
26
|
+
export function forwardLinkVisible(
|
|
27
|
+
slug: string, posts: ForwardLinkable[], showDrafts: boolean,
|
|
28
|
+
): boolean {
|
|
29
|
+
const target = posts.find((p) => p.data.slug === slug);
|
|
30
|
+
if (!target) throw new Error(`WhenPublished: no post has slug "${slug}"`);
|
|
31
|
+
return showDrafts || !target.data.draft;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Post links by slug. A hand-typed post URL bakes in the target's date,
|
|
35
|
+
so a re-dated draft silently breaks every earlier link to it. The
|
|
36
|
+
PostLink content component resolves the slug through postPath() at
|
|
37
|
+
build time instead. It resolves drafts too, and must: Astro renders
|
|
38
|
+
MDX slot children eagerly, so a PostLink inside a hidden WhenPublished
|
|
39
|
+
still runs (learned the hard way 2026-08-24). The deploy-build guard
|
|
40
|
+
against a bare link to a draft is therefore a test, not a throw here:
|
|
41
|
+
test/post-link.test.ts asserts no draft slug appears in any built
|
|
42
|
+
page. Unknown slugs throw for the same reason forwardLinkVisible's do. */
|
|
43
|
+
|
|
44
|
+
import { postPath } from "./slug";
|
|
45
|
+
|
|
46
|
+
export interface Linkable { data: { slug: string; date: Date } }
|
|
47
|
+
|
|
48
|
+
export function resolvePostHref(slug: string, posts: Linkable[]): string {
|
|
49
|
+
const target = posts.find((p) => p.data.slug === slug);
|
|
50
|
+
if (!target) throw new Error(`PostLink: no post has slug "${slug}"`);
|
|
51
|
+
return postPath(target);
|
|
52
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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. */
|
|
5
|
+
export function formatPostDate(d: Date, locale = "en-US"): string {
|
|
6
|
+
return d.toLocaleDateString(locale, {
|
|
7
|
+
year: "numeric", month: "long", day: "numeric", timeZone: "UTC",
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/* The masthead date box: "25, Aug 2026", the live theme's format. Rendered
|
|
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. */
|
|
4
|
+
export function formatHeaderDate(now: Date): string {
|
|
5
|
+
return `${now.getDate()}, ${now.toLocaleDateString("en-US", { month: "short" })} ${now.getFullYear()}`;
|
|
6
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* Publication-time ordering, pure and framework-free so it is unit-testable.
|
|
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). */
|
|
7
|
+
|
|
8
|
+
export interface Publishable { data: { date: Date; published?: Date } }
|
|
9
|
+
|
|
10
|
+
export function publishedAt(p: Publishable): Date {
|
|
11
|
+
return p.data.published ?? p.data.date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* The one newest-first comparator. Never hand-write a date sort expression
|
|
15
|
+
at a call site; use this. */
|
|
16
|
+
export function byNewest(a: Publishable, b: Publishable): number {
|
|
17
|
+
return +publishedAt(b) - +publishedAt(a);
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function paginatePosts<T>(items: T[], size: number): T[][] {
|
|
2
|
+
if (items.length === 0) return [[]];
|
|
3
|
+
const pages: T[][] = [];
|
|
4
|
+
for (let i = 0; i < items.length; i += size) pages.push(items.slice(i, i + size));
|
|
5
|
+
return pages;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/* Canonical href for an archive page: page 1 is the archive root, deeper
|
|
9
|
+
pages live at /page/N/ (trailing slash, site-wide rule). base is the
|
|
10
|
+
archive's root without its trailing slash ("" for the site root,
|
|
11
|
+
"/category/robots" for a category); Pagination.astro and the post
|
|
12
|
+
back link both call this so the rule exists once. */
|
|
13
|
+
export function archivePagePath(page: number, base = ""): string {
|
|
14
|
+
return page <= 1 ? `${base}/` : `${base}/page/${page}/`;
|
|
15
|
+
}
|
package/src/lib/slug.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function slugifyCategory(name: string): string {
|
|
2
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/* The WP-compatible date permalink is the library's documented opinion
|
|
6
|
+
(owner decision 5, 2026-08-31): migrating sites keep their URLs by
|
|
7
|
+
default. */
|
|
8
|
+
export function postPath(p: { data: { date: Date; slug: string } }): string {
|
|
9
|
+
const d = p.data.date;
|
|
10
|
+
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
11
|
+
const dd = String(d.getUTCDate()).padStart(2, "0");
|
|
12
|
+
return `/${d.getUTCFullYear()}/${mm}/${dd}/${p.data.slug}/`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Category archive href for a category name. base parameterizes the URL
|
|
16
|
+
strategy the way postPath does not yet; the default matches this site's
|
|
17
|
+
current /category/ tree. */
|
|
18
|
+
export function categoryPath(name: string, base = "/category"): string {
|
|
19
|
+
return `${base}/${slugifyCategory(name)}/`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Canonical href for a static page. One level of nesting exists today
|
|
23
|
+
(children of policies); parent chains deeper than one are not modeled
|
|
24
|
+
anywhere, so this stays flat on purpose. */
|
|
25
|
+
export function pagePath(p: { data: { slug: string; parent?: string } }): string {
|
|
26
|
+
return p.data.parent ? `/${p.data.parent}/${p.data.slug}/` : `/${p.data.slug}/`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Canonical lookup key for an internal href: root-relative, fragment
|
|
30
|
+
and query stripped, slash-terminated. An absolute href on one of the
|
|
31
|
+
site's own origins reduces to its path; anything else external is
|
|
32
|
+
null, as is a file-ish path (a dot in the last segment, /feed.xml),
|
|
33
|
+
which names a document with no page title to reveal. */
|
|
34
|
+
export function internalHrefKey(href: string, origins: readonly string[] = []): string | null {
|
|
35
|
+
let h = href;
|
|
36
|
+
for (const o of origins) {
|
|
37
|
+
if (h === o || h.startsWith(`${o}/`)) {
|
|
38
|
+
h = h.slice(o.length) || "/";
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!h.startsWith("/") || h.startsWith("//")) return null;
|
|
43
|
+
h = h.replace(/[#?].*$/, "");
|
|
44
|
+
if (h === "") return "/";
|
|
45
|
+
const last = h.slice(h.lastIndexOf("/") + 1);
|
|
46
|
+
if (last.includes(".")) return null;
|
|
47
|
+
return h.endsWith("/") ? h : `${h}/`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Top-level path segments owned by real routes. A page slugged one of these
|
|
51
|
+
(or a bare year) would shadow the archive, pagination, category, or search
|
|
52
|
+
trees under the [...slug] catch-all without any build error. */
|
|
53
|
+
export const RESERVED_PAGE_SLUGS = new Set(["page", "category", "search", "404"]);
|
|
54
|
+
|
|
55
|
+
/* Build-time guard for the [...slug] catch-all: rejects reserved top-level
|
|
56
|
+
slugs and parent references that resolve to nothing (a typo'd parent would
|
|
57
|
+
otherwise silently render the child as a top-level page). reserved is
|
|
58
|
+
parameterized so a caller with a different URL strategy can supply its
|
|
59
|
+
own set instead of forking the guard. */
|
|
60
|
+
export function assertPageRoutable(
|
|
61
|
+
p: { data: { slug: string; parent?: string } },
|
|
62
|
+
parent: { data: { slug: string } } | undefined,
|
|
63
|
+
reserved: ReadonlySet<string> = RESERVED_PAGE_SLUGS,
|
|
64
|
+
): void {
|
|
65
|
+
if (p.data.parent && !parent) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Page "${p.data.slug}" declares parent "${p.data.parent}", which matches no page slug`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (!p.data.parent && (reserved.has(p.data.slug) || /^\d{4}$/.test(p.data.slug))) {
|
|
71
|
+
throw new Error(`Page slug "${p.data.slug}" collides with a reserved route`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
2
|
+
import { docOf } from "./core/dom";
|
|
3
|
+
|
|
4
|
+
/* Code island decorator: builds the header bar (filename/language label +
|
|
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). */
|
|
8
|
+
/* Island contract (step 9): mount(root, options?) returns a destroy handle;
|
|
9
|
+
claim() makes a second mount over the same pre a no-op. */
|
|
10
|
+
export interface CodeIslandOptions {
|
|
11
|
+
selector?: string;
|
|
12
|
+
copy?: { copy: string; copied: string; failed: string };
|
|
13
|
+
resetMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const mountCodeIslands: Island<CodeIslandOptions> = (root, options = {}): IslandHandle => {
|
|
17
|
+
const {
|
|
18
|
+
selector = ".prose pre.astro-code",
|
|
19
|
+
copy = { copy: "COPY", copied: "COPIED", failed: "FAILED" },
|
|
20
|
+
resetMs = 1500,
|
|
21
|
+
} = options;
|
|
22
|
+
const doc = docOf(root);
|
|
23
|
+
/* resetTimer holds the copy-reset setTimeout id, one live per button at
|
|
24
|
+
most (a second click before the first reset overwrites it, dropping
|
|
25
|
+
the earlier timer's reference so it can no longer be cleared, which
|
|
26
|
+
is why doCopy clears the box before replacing it). A plain mutable
|
|
27
|
+
box, not a field on the mounted entry, so both doCopy and destroy()
|
|
28
|
+
close over the same cell. */
|
|
29
|
+
const mounted: { pre: Element; bar: HTMLDivElement; btn: HTMLButtonElement; onClick: () => void; resetTimer: { id: ReturnType<typeof setTimeout> | undefined } }[] = [];
|
|
30
|
+
|
|
31
|
+
for (const pre of root.querySelectorAll(selector)) {
|
|
32
|
+
if (!claim(pre, "code")) continue;
|
|
33
|
+
const bar = doc.createElement("div");
|
|
34
|
+
bar.className = "code-island-bar";
|
|
35
|
+
const label = doc.createElement("span");
|
|
36
|
+
const file = pre.closest("[data-code-filename]")?.getAttribute("data-code-filename");
|
|
37
|
+
const lang = pre.getAttribute("data-language") ?? "";
|
|
38
|
+
label.textContent = [file, lang].filter(Boolean).join(" · ");
|
|
39
|
+
const btn = doc.createElement("button");
|
|
40
|
+
btn.className = "code-copy";
|
|
41
|
+
btn.type = "button";
|
|
42
|
+
btn.textContent = copy.copy;
|
|
43
|
+
const resetTimer: { id: ReturnType<typeof setTimeout> | undefined } = { id: undefined };
|
|
44
|
+
const doCopy = async (): Promise<void> => {
|
|
45
|
+
try {
|
|
46
|
+
await navigator.clipboard.writeText(pre.textContent);
|
|
47
|
+
btn.textContent = copy.copied;
|
|
48
|
+
} catch {
|
|
49
|
+
btn.textContent = copy.failed;
|
|
50
|
+
}
|
|
51
|
+
clearTimeout(resetTimer.id);
|
|
52
|
+
resetTimer.id = setTimeout(() => {
|
|
53
|
+
btn.textContent = copy.copy;
|
|
54
|
+
}, resetMs);
|
|
55
|
+
};
|
|
56
|
+
const onClick = (): void => {
|
|
57
|
+
void doCopy();
|
|
58
|
+
};
|
|
59
|
+
btn.addEventListener("click", onClick);
|
|
60
|
+
bar.append(label, btn);
|
|
61
|
+
pre.before(bar);
|
|
62
|
+
mounted.push({ pre, bar, btn, onClick, resetTimer });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
destroy(): void {
|
|
67
|
+
for (const { pre, bar, btn, onClick, resetTimer } of mounted) {
|
|
68
|
+
btn.removeEventListener("click", onClick);
|
|
69
|
+
clearTimeout(resetTimer.id);
|
|
70
|
+
bar.remove();
|
|
71
|
+
release(pre, "code");
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* Small DOM builders shared by the islands (step 9): el() came from the
|
|
2
|
+
search page's inline script, iconButton() from lightbox.ts. One home,
|
|
3
|
+
per the review's Islands findings; the deliberately-not-included
|
|
4
|
+
focus trap stays not included (native <dialog> owns that). */
|
|
5
|
+
export function el<K extends keyof HTMLElementTagNameMap>(
|
|
6
|
+
doc: Document,
|
|
7
|
+
tag: K,
|
|
8
|
+
className?: string,
|
|
9
|
+
text?: string,
|
|
10
|
+
): HTMLElementTagNameMap[K] {
|
|
11
|
+
const node = doc.createElement(tag);
|
|
12
|
+
if (className) node.className = className;
|
|
13
|
+
if (text !== undefined) node.textContent = text;
|
|
14
|
+
return node;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function iconButton(doc: Document, className: string, label: string, svg: string): HTMLButtonElement {
|
|
18
|
+
const btn = doc.createElement("button");
|
|
19
|
+
btn.type = "button";
|
|
20
|
+
btn.className = className;
|
|
21
|
+
btn.setAttribute("aria-label", label);
|
|
22
|
+
btn.innerHTML = svg;
|
|
23
|
+
return btn;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Every island's root.ownerDocument ?? (root as Document) fallback, in one
|
|
27
|
+
place (step 11.2 docOf sweep): a root that is itself a Document (no
|
|
28
|
+
ownerDocument) resolves to itself. */
|
|
29
|
+
export function docOf(root: ParentNode): Document {
|
|
30
|
+
return root.ownerDocument ?? (root as Document);
|
|
31
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
|
3
|
+
timestamp: monotonic in a real browser, unlike Date.now(), which a
|
|
4
|
+
wall-clock adjustment can move backward; the max(0, ...) floor is
|
|
5
|
+
cheap belt-and-suspenders against that case feeding an integrator a
|
|
6
|
+
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
|
+
export interface FrameLoop {
|
|
9
|
+
start(): void;
|
|
10
|
+
stop(): void;
|
|
11
|
+
running(): boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createFrameLoop(
|
|
15
|
+
win: Window,
|
|
16
|
+
cb: (dt: number) => void,
|
|
17
|
+
options: { clamp?: number; firstDt?: number } = {},
|
|
18
|
+
): FrameLoop {
|
|
19
|
+
const { clamp = 0.05, firstDt = 0.016 } = options;
|
|
20
|
+
let handle = 0;
|
|
21
|
+
let last = 0;
|
|
22
|
+
let live = false;
|
|
23
|
+
|
|
24
|
+
function tick(now: number): void {
|
|
25
|
+
if (!live) return;
|
|
26
|
+
const dt = last === 0 ? firstDt : Math.min(clamp, Math.max(0, (now - last) / 1000));
|
|
27
|
+
last = now;
|
|
28
|
+
cb(dt);
|
|
29
|
+
// cb may have called stop() reentrantly; a fresh function body reads live without stale narrowing.
|
|
30
|
+
scheduleNext();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function scheduleNext(): void {
|
|
34
|
+
if (live) handle = win.requestAnimationFrame(tick);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
start(): void {
|
|
39
|
+
if (live) return;
|
|
40
|
+
live = true;
|
|
41
|
+
last = 0;
|
|
42
|
+
handle = win.requestAnimationFrame(tick);
|
|
43
|
+
},
|
|
44
|
+
stop(): void {
|
|
45
|
+
if (!live) return;
|
|
46
|
+
live = false;
|
|
47
|
+
win.cancelAnimationFrame(handle);
|
|
48
|
+
last = 0;
|
|
49
|
+
},
|
|
50
|
+
running(): boolean {
|
|
51
|
+
return live;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* Inline icon markup, vendored from Lucide (https://lucide.dev, ISC
|
|
2
|
+
license), replacing the Unicode glyphs whose rendering varied by
|
|
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
|
|
5
|
+
aria-labels; the svg itself is decorative and aria-hidden.
|
|
6
|
+
Home: the js package's core (owner decision 1, 2026-08-31); Lucide,
|
|
7
|
+
ISC license, attribution retained. */
|
|
8
|
+
|
|
9
|
+
/* 1em sizing: icons track their button's font size instead of a fixed
|
|
10
|
+
pixel box. */
|
|
11
|
+
/* pointer-events none: a click on a button must target the button, not
|
|
12
|
+
the decorative svg. A targeted svg detached by an innerHTML swap
|
|
13
|
+
mid-bubble made the plate-modal's veil check read a pause click as
|
|
14
|
+
outside the zone and close the dialog (found 2026-08-23). */
|
|
15
|
+
const icon = (paths: string, fill = "none"): string =>
|
|
16
|
+
`<svg viewBox="0 0 24 24" width="1em" height="1em" fill="${fill}" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" pointer-events="none" aria-hidden="true">${paths}</svg>`;
|
|
17
|
+
|
|
18
|
+
export const ICON_X = icon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>');
|
|
19
|
+
/* Play is the one filled icon: a stroke-only triangle reads as a hollow
|
|
20
|
+
arrow, not a play control. */
|
|
21
|
+
export const ICON_PLAY = icon('<polygon points="7 4 19 12 7 20 7 4"/>', "currentColor");
|
|
22
|
+
/* The AI Art badge (CornerBadges.astro). */
|
|
23
|
+
export const ICON_SPARKLES = icon('<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>');
|
|
24
|
+
export const ICON_PAUSE = icon('<line x1="9" x2="9" y1="5" y2="19"/><line x1="15" x2="15" y1="5" y2="19"/>');
|
|
25
|
+
export const ICON_ROTATE_CCW = icon('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>');
|
|
26
|
+
export const ICON_CHEVRON_LEFT = icon('<path d="m15 18-6-6 6-6"/>');
|
|
27
|
+
export const ICON_CHEVRON_RIGHT = icon('<path d="m9 18 6-6-6-6"/>');
|
|
28
|
+
/* Day/night toggle in the header (owner request 2026-08-25). */
|
|
29
|
+
export const ICON_SUN = icon('<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>');
|
|
30
|
+
export const ICON_MOON = icon('<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/* The island contract (step 9, spec sequence item 9): every client
|
|
2
|
+
behavior is mount(root, options?) returning a destroy handle, and
|
|
3
|
+
mounting twice is a no-op per element via claim(). Options carry the
|
|
4
|
+
selectors and copy that used to be baked in, defaulting to this
|
|
5
|
+
site's values, so another consumer overrides without forking. */
|
|
6
|
+
export interface IslandHandle {
|
|
7
|
+
destroy(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type Island<O = void> = (root: ParentNode, options?: O) => IslandHandle;
|
|
11
|
+
|
|
12
|
+
/* One claim per element per island name. A data attribute, not a
|
|
13
|
+
WeakSet: visible in devtools when debugging a double-mount. */
|
|
14
|
+
export function claim(target: Element, island: string): boolean {
|
|
15
|
+
const key = `data-island-${island}`;
|
|
16
|
+
if (target.hasAttribute(key)) return false;
|
|
17
|
+
target.setAttribute(key, "");
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Mirror of claim(): destroy() paths call this instead of hardcoding
|
|
22
|
+
the attribute name (Task 2 review's latent-coupling finding). */
|
|
23
|
+
export function release(target: Element, island: string): void {
|
|
24
|
+
target.removeAttribute(`data-island-${island}`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
|
3
|
+
the validator (junk parses to null and read() then removes the key),
|
|
4
|
+
and every storage touch sits inside try/catch so private mode or
|
|
5
|
+
disabled storage degrades to in-page-only state, never a thrown
|
|
6
|
+
error. */
|
|
7
|
+
export interface StoredJson<T> {
|
|
8
|
+
read(): T | null;
|
|
9
|
+
write(v: T): void;
|
|
10
|
+
clear(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function storedJson<T>(
|
|
14
|
+
key: string,
|
|
15
|
+
parse: (raw: string | null) => T | null,
|
|
16
|
+
serialize: (v: T) => string = JSON.stringify,
|
|
17
|
+
): StoredJson<T> {
|
|
18
|
+
return {
|
|
19
|
+
read(): T | null {
|
|
20
|
+
try {
|
|
21
|
+
const raw = localStorage.getItem(key);
|
|
22
|
+
const v = parse(raw);
|
|
23
|
+
if (v === null && raw !== null) localStorage.removeItem(key);
|
|
24
|
+
return v;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
write(v: T): void {
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(key, serialize(v));
|
|
32
|
+
} catch {
|
|
33
|
+
/* State lives for this page only. */
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
clear(): void {
|
|
37
|
+
try {
|
|
38
|
+
localStorage.removeItem(key);
|
|
39
|
+
} catch {
|
|
40
|
+
/* Nothing to clear. */
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
2
|
+
|
|
3
|
+
/* How did focus arrive: keyboard or pointer. A text field matches
|
|
4
|
+
:focus-visible on a mouse click as well as on a Tab, so CSS alone
|
|
5
|
+
cannot give the joined field its own click highlight while keeping
|
|
6
|
+
the site-wide keyboard ring (owner rule 2026-08-26). Stamps
|
|
7
|
+
data-focus="keyboard" | "pointer" on the root; patterns.css reads it.
|
|
8
|
+
Package-bound (owner decision 2, 2026-08-31): patterns.css keys on
|
|
9
|
+
the stamp this island writes. */
|
|
10
|
+
|
|
11
|
+
export interface FocusModeOptions {
|
|
12
|
+
/* What to listen on, capture: true, as today. Defaults to window so a
|
|
13
|
+
keydown or pointerdown anywhere in the document is caught before it
|
|
14
|
+
reaches the target that would otherwise show its own focus ring. */
|
|
15
|
+
target?: EventTarget;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const mountFocusMode: Island<FocusModeOptions> = (root, options = {}): IslandHandle => {
|
|
19
|
+
const el = root as HTMLElement;
|
|
20
|
+
const target = options.target ?? window;
|
|
21
|
+
if (!claim(el, "focus-mode")) {
|
|
22
|
+
return {
|
|
23
|
+
destroy(): void {
|
|
24
|
+
/* already claimed elsewhere: nothing here to tear down */
|
|
25
|
+
return;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const onKeydown = (): void => { el.dataset.focus = "keyboard"; };
|
|
30
|
+
const onPointerdown = (): void => { el.dataset.focus = "pointer"; };
|
|
31
|
+
target.addEventListener("keydown", onKeydown, true);
|
|
32
|
+
target.addEventListener("pointerdown", onPointerdown, true);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
destroy(): void {
|
|
36
|
+
target.removeEventListener("keydown", onKeydown, true);
|
|
37
|
+
target.removeEventListener("pointerdown", onPointerdown, true);
|
|
38
|
+
release(el, "focus-mode");
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|