@half-built/astro 0.4.2 → 0.6.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.
Files changed (60) hide show
  1. package/README.md +50 -2
  2. package/package.json +11 -4
  3. package/src/components/CategoryCard.astro +9 -1
  4. package/src/components/CornerBadges.astro +14 -21
  5. package/src/components/Footer.astro +57 -29
  6. package/src/components/Icon.astro +16 -0
  7. package/src/components/LightboxLink.astro +19 -10
  8. package/src/components/LinkListWidget.astro +11 -9
  9. package/src/components/Pagination.astro +51 -28
  10. package/src/components/PostCard.astro +54 -22
  11. package/src/components/PostNavigation.astro +40 -29
  12. package/src/components/Shell.astro +26 -12
  13. package/src/components/SiteHeader.astro +139 -80
  14. package/src/components/SmartImage.astro +48 -17
  15. package/src/components/Subscribe.astro +60 -35
  16. package/src/components/ThemeToggle.astro +28 -15
  17. package/src/components/TwoColumn.astro +5 -5
  18. package/src/components/Widget.astro +10 -7
  19. package/src/components/content/BlogImage.astro +48 -13
  20. package/src/components/content/Button.astro +13 -13
  21. package/src/components/content/Callout.astro +17 -8
  22. package/src/components/content/CodeBlock.astro +6 -1
  23. package/src/components/content/EditorNote.astro +19 -21
  24. package/src/components/content/Gallery.astro +18 -5
  25. package/src/components/content/GalleryImage.astro +26 -7
  26. package/src/components/content/Group.astro +5 -6
  27. package/src/components/content/MediaText.astro +43 -9
  28. package/src/components/content/Palette.astro +37 -21
  29. package/src/components/content/PostLink.astro +15 -0
  30. package/src/components/content/Quote.astro +10 -9
  31. package/src/components/content/Spacer.astro +11 -2
  32. package/src/components/content/Step.astro +34 -15
  33. package/src/components/content/Walkthrough.astro +10 -6
  34. package/src/components/content/WhenPublished.astro +14 -0
  35. package/src/components/models.ts +2 -2
  36. package/src/lib/archive.ts +18 -4
  37. package/src/lib/drafts.ts +16 -5
  38. package/src/lib/format-date.ts +4 -1
  39. package/src/lib/ordering.ts +3 -1
  40. package/src/lib/paginate.ts +5 -1
  41. package/src/lib/slug.ts +30 -7
  42. package/src/scripts/code-island.ts +27 -4
  43. package/src/scripts/core/badges.ts +28 -0
  44. package/src/scripts/core/dom.ts +6 -1
  45. package/src/scripts/core/frame-loop.ts +4 -1
  46. package/src/scripts/core/icons.ts +92 -15
  47. package/src/scripts/ecosystem.ts +72 -12
  48. package/src/scripts/focus-mode.ts +14 -3
  49. package/src/scripts/lightbox.ts +227 -41
  50. package/src/scripts/link-tip.ts +55 -11
  51. package/src/scripts/path-player-math.ts +11 -2
  52. package/src/scripts/path-player-paint.ts +54 -8
  53. package/src/scripts/path-player.ts +131 -29
  54. package/src/scripts/plate-modal.ts +45 -9
  55. package/src/scripts/scroll-top.ts +6 -1
  56. package/src/scripts/site-header.ts +34 -6
  57. package/src/scripts/subscribe.ts +50 -9
  58. package/src/scripts/theme-toggle.ts +32 -5
  59. package/src/shiki/code-theme.mjs +25 -5
  60. package/src/shiki/code-vars.mjs +5 -1
@@ -1,25 +1,31 @@
1
1
  ---
2
- /* Color-chip palette table, the treatment lifted from the BEADZ brand
3
- page: bordered swatch, role, hex. Hand-authored-page component (the
4
- migrator never emits it). Chip color is per-entry data, so it rides an
5
- inline background-color, which the html-validate gate whitelists for
6
- exactly this dynamic-value case.
7
- An entry may carry `token` instead of `hex` (owner ask 2026-09-06,
8
- the reference site's live token sheet): the chip then paints
9
- var(token), following the live cascade with no script, and the hex
10
- cell renders empty with data-token-hex for a consumer script to
11
- fill from computed styles; the component itself stays inert. */
12
- interface Entry { role: string; hex?: string; token?: string; note?: string }
13
- interface Props { entries: Entry[]; class?: string; label?: string }
2
+ interface Entry {
3
+ role: string;
4
+ hex?: string;
5
+ token?: string;
6
+ note?: string;
7
+ }
8
+
9
+ interface Props {
10
+ entries: Entry[];
11
+ class?: string;
12
+ label?: string;
13
+ }
14
+
14
15
  const { entries, class: className, label = "Palette" } = Astro.props;
15
16
  ---
17
+
16
18
  {/* The scroll box is a tab stop with a name: below the column's width
17
19
  the table scrolls sideways, and a scrolling region a keyboard cannot
18
20
  reach fails WCAG 2.1.1 (axe scrollable-region-focusable). A named
19
21
  <section> is the region landmark natively; a div with role=region
20
22
  fails html-validate's prefer-native-element (the blog's gate caught
21
23
  it at the 0.4.0 pin bump). */}
22
- <section class:list={["table-scroll", className]} tabindex="0" aria-label={label}>
24
+ <section
25
+ class:list={["table-scroll", className]}
26
+ tabindex="0"
27
+ aria-label={label}
28
+ >
23
29
  <table class="palette">
24
30
  <thead>
25
31
  <tr>
@@ -30,14 +36,24 @@ const { entries, class: className, label = "Palette" } = Astro.props;
30
36
  </tr>
31
37
  </thead>
32
38
  <tbody>
33
- {entries.map((e) => (
34
- <tr>
35
- <td><span class="palette-chip" aria-hidden="true" style={`background-color:${e.token ? `var(${e.token})` : e.hex}`}></span></td>
36
- <td>{e.role}</td>
37
- <td>{e.token ? <code data-token-hex={e.token}></code> : <code>{e.hex}</code>}</td>
38
- <td>{e.note}</td>
39
- </tr>
40
- ))}
39
+ {entries.map((e) => <tr>
40
+ <td>
41
+ <span
42
+ class="palette-chip"
43
+ aria-hidden="true"
44
+ style={`background-color:${e.token ? `var(${e.token})` : e.hex}`}
45
+ ></span>
46
+ </td>
47
+ <td>{e.role}</td>
48
+ <td>
49
+ {e.token ? (
50
+ <code data-token-hex={e.token}></code>
51
+ ) : (
52
+ <code>{e.hex}</code>
53
+ )}
54
+ </td>
55
+ <td>{e.note}</td>
56
+ </tr>)}
41
57
  </tbody>
42
58
  </table>
43
59
  </section>
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { resolvePostHref, type Linkable } from "../../lib/drafts";
3
+
4
+ interface Props {
5
+ slug: string;
6
+ posts: Linkable[];
7
+ tip?: string;
8
+ class?: string;
9
+ }
10
+
11
+ const { slug, posts, tip, class: className } = Astro.props;
12
+ const href = resolvePostHref(slug, posts);
13
+ ---
14
+
15
+ <a href={href} data-tooltip={tip} class={className}><slot /></a>
@@ -1,18 +1,19 @@
1
1
  ---
2
- /* Attributed quote (spec 2026-08-14-quote-attribution-design.md).
3
- Renders a plain blockquote so the boxed treatment in prose.css applies
4
- unchanged; the provenance line is opt-in and most quotes should not
5
- carry one (Nomenclature callouts and quotes already introduced by the
6
- surrounding prose stay bare markdown). The tilde prefix stands in for
7
- the traditional em dash, which is banned house-wide; the tilde is
8
- already the site's separator (post titles). */
9
- interface Props { by?: string; when?: string; class?: string }
2
+ interface Props {
3
+ by?: string;
4
+ when?: string;
5
+ class?: string;
6
+ }
7
+
10
8
  const { by, when, class: className } = Astro.props;
11
9
  const attribution = [by, when].filter(Boolean).join(", ");
12
10
  ---
11
+
13
12
  <blockquote class:list={[className]}>
14
13
  <slot />
15
- {attribution && <footer class="quote-attribution caption">~ {attribution}</footer>}
14
+ {attribution && <footer class="quote-attribution caption">
15
+ ~ {attribution}
16
+ </footer>}
16
17
  </blockquote>
17
18
 
18
19
  <style>
@@ -1,5 +1,14 @@
1
1
  ---
2
- interface Props { size?: number; class?: string }
2
+ interface Props {
3
+ size?: number;
4
+ class?: string;
5
+ }
6
+
3
7
  const { size = 40, class: className } = Astro.props;
4
8
  ---
5
- <div aria-hidden="true" style={`height:${size}px`} class:list={["spacer", className]}></div>
9
+
10
+ <div
11
+ aria-hidden="true"
12
+ style={`height:${size}px`}
13
+ class:list={["spacer", className]}
14
+ ></div>
@@ -3,36 +3,49 @@ import type { ImageMetadata } from "astro";
3
3
  import SmartImage from "../SmartImage.astro";
4
4
  import LightboxLink from "../LightboxLink.astro";
5
5
 
6
- /* One row of a Walkthrough (spec
7
- docs/superpowers/specs/2026-08-15-walkthrough-steps-design.md): photo(s)
8
- on the left at a fixed 200px per photo, the author's prose beside at body
9
- size. The prose is the step's text, not a caption; alt feeds the lightbox
10
- caption. Below 600px the row stacks, photo centered above the prose. */
11
- interface Photo { src: ImageMetadata; alt?: string }
12
- /* Exactly one of `src` (with `alt`) or `images`: the union says so to
13
- typed callers; the runtime check below says so to MDX. */
14
- type Props = ({ src: ImageMetadata; images?: never } | { src?: never; images: Photo[] }) & {
6
+ interface Photo {
7
+ src: ImageMetadata;
8
+ alt?: string;
9
+ }
10
+
11
+ type Props = (
12
+ { src: ImageMetadata; images?: never } | { src?: never; images: Photo[] }
13
+ ) & {
15
14
  alt?: string;
16
15
  class?: string;
17
16
  };
17
+
18
18
  const { src, alt = "", images, class: className } = Astro.props;
19
19
 
20
20
  const hasSingle = src !== undefined;
21
21
  const hasMany = images !== undefined && images.length > 0;
22
+
22
23
  if (hasSingle === hasMany) {
23
- throw new Error("Step: pass exactly one of `src` (with `alt`) or a non-empty `images` array");
24
+ throw new Error(
25
+ "Step: pass exactly one of `src` (with `alt`) or a non-empty `images` array",
26
+ );
24
27
  }
28
+
25
29
  const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
26
30
  ---
31
+
27
32
  <li class:list={["step", className]}>
28
33
  <div class="step-media">
29
34
  {photos.map((p) => {
30
35
  const a = p.alt ?? "";
31
- return (
32
- <LightboxLink href={p.src.src} w={p.src.width} h={p.src.height} caption={a}>
33
- <SmartImage src={p.src} alt={a} widths={[200, Math.min(p.src.width, 400)]} sizes="200px" />
34
- </LightboxLink>
35
- );
36
+ return <LightboxLink
37
+ href={p.src.src}
38
+ w={p.src.width}
39
+ h={p.src.height}
40
+ caption={a}
41
+ >
42
+ <SmartImage
43
+ src={p.src}
44
+ alt={a}
45
+ widths={[200, Math.min(p.src.width, 400)]}
46
+ sizes="200px"
47
+ />
48
+ </LightboxLink>;
36
49
  })}
37
50
  </div>
38
51
  <div class="step-body"><slot /></div>
@@ -44,11 +57,13 @@ const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
44
57
  grid-template-columns: auto 1fr;
45
58
  gap: 20px;
46
59
  align-items: start;
60
+
47
61
  /* prose.css pads every .prose li; rows space themselves via
48
62
  the Walkthrough gap instead. */
49
63
  padding: 0;
50
64
  counter-increment: walkthrough-step;
51
65
  }
66
+
52
67
  /* Photos sit in a row, one 200px track each, so a two-photo step's cell
53
68
  is 410px and its prose starts further right (owner pick M3). The row
54
69
  does not wrap, so two photos is the practical cap for the 990px
@@ -61,8 +76,10 @@ const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
61
76
  gap: 10px;
62
77
  line-height: 0;
63
78
  }
79
+
64
80
  /* The 2px outline comes from .prose img in prose.css. */
65
81
  .step-media :global(img) { width: 100%; height: auto; }
82
+
66
83
  /* The body is 1fr of the layout's column; nothing here narrows it. */
67
84
  .step-body { min-width: 0; }
68
85
  .step-body :global(p:last-child) { margin-bottom: 0; }
@@ -78,6 +95,7 @@ const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
78
95
  /* stylelint-disable-next-line no-duplicate-selectors -- split from the
79
96
  grid rule above so the contract stays grouped with its own comment. */
80
97
  .step-media { margin: var(--step-media-margin, 0); }
98
+
81
99
  .step-media::before {
82
100
  content: counter(walkthrough-step);
83
101
  display: var(--step-chip-display, none);
@@ -115,6 +133,7 @@ const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
115
133
  of a multi-photo step stacking too. Same breakpoint as MediaText. */
116
134
  @media (max-width: 600px) {
117
135
  .step { grid-template-columns: 1fr; gap: 10px; }
136
+
118
137
  .step-media {
119
138
  grid-auto-flow: row;
120
139
  grid-auto-columns: auto;
@@ -1,18 +1,22 @@
1
1
  ---
2
- /* Step-by-step photo walkthrough (spec
3
- docs/superpowers/specs/2026-08-15-walkthrough-steps-design.md): an ordered
4
- list of Step rows. Every photo inside forms one lightbox set; lightbox.ts
5
- setFor() keys on the .walkthrough class, so keep it on the <ol>. */
6
- interface Props { numbered?: boolean; class?: string }
2
+ interface Props {
3
+ numbered?: boolean;
4
+ class?: string;
5
+ }
6
+
7
7
  const { numbered = false, class: className } = Astro.props;
8
8
  ---
9
+
9
10
  {/* list-style: none drops the implicit list role in Safari/VoiceOver, so
10
11
  role="list" restores it. html-validate's no-redundant-role and
11
12
  prefer-native-element both flag that as unnecessary on a native <ol>,
12
13
  which is true generically but wrong for this specific case; disabled
13
14
  surgically for this element only. */}
14
15
  <!-- [html-validate-disable-next no-redundant-role, prefer-native-element] -->
15
- <ol class:list={["walkthrough", numbered && "walkthrough-numbered", className]} role="list">
16
+ <ol
17
+ class:list={["walkthrough", numbered && "walkthrough-numbered", className]}
18
+ role="list"
19
+ >
16
20
  <slot />
17
21
  </ol>
18
22
 
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { forwardLinkVisible, type ForwardLinkable } from "../../lib/drafts";
3
+
4
+ interface Props {
5
+ slug: string;
6
+ posts: ForwardLinkable[];
7
+ showDrafts?: boolean;
8
+ }
9
+
10
+ const { slug, posts, showDrafts = false } = Astro.props;
11
+ const visible = forwardLinkVisible(slug, posts, showDrafts);
12
+ ---
13
+
14
+ {visible && <slot />}
@@ -4,6 +4,7 @@
4
4
  CollectionEntry mapping that produces them is src/lib/view-models.ts,
5
5
  which stays site-side. */
6
6
  import type { ImageMetadata } from "astro";
7
+ import type { Badge } from "../scripts/core/badges";
7
8
 
8
9
  export interface PostCardModel {
9
10
  href: string;
@@ -15,8 +16,7 @@ export interface PostCardModel {
15
16
  hero: ImageMetadata;
16
17
  heroPosition?: string;
17
18
  draft?: boolean;
18
- genai?: boolean;
19
- demo?: boolean;
19
+ badges?: Badge[];
20
20
  categories: { name: string; href: string }[];
21
21
  }
22
22
 
@@ -1,28 +1,42 @@
1
1
  import { slugifyCategory } from "./slug";
2
2
 
3
- interface PostLike { data: { date: Date; categories: string[] } }
3
+ interface PostLike {
4
+ data: { date: Date; categories: string[] };
5
+ }
4
6
 
5
7
  export function groupByMonth(posts: PostLike[], locale = "en-US") {
6
8
  const map = new Map<string, number>();
9
+
7
10
  for (const p of posts) {
8
11
  const d = p.data.date;
9
12
  const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
10
13
  map.set(key, (map.get(key) ?? 0) + 1);
11
14
  }
15
+
12
16
  return [...map.entries()]
13
17
  .sort((a, b) => (a[0] < b[0] ? 1 : -1))
14
18
  .map(([key, count]) => {
15
19
  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",
20
+
21
+ const label = new Date(
22
+ Date.UTC(Number(year), Number(month) - 1),
23
+ ).toLocaleDateString(locale, {
24
+ month: "long",
25
+ year: "numeric",
26
+ timeZone: "UTC",
18
27
  });
28
+
19
29
  return { year, month, label, count };
20
30
  });
21
31
  }
22
32
 
23
33
  export function countByCategory(posts: PostLike[]) {
24
34
  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);
35
+
36
+ for (const p of posts) {
37
+ for (const c of p.data.categories) map.set(c, (map.get(c) ?? 0) + 1);
38
+ }
39
+
26
40
  return [...map.entries()]
27
41
  .sort((a, b) => a[0].localeCompare(b[0]))
28
42
  .map(([name, count]) => ({ name, slug: slugifyCategory(name), count }));
package/src/lib/drafts.ts CHANGED
@@ -6,9 +6,14 @@
6
6
  this filter; a call site reaching for getCollection("posts") directly
7
7
  would leak drafts into whatever it renders. */
8
8
 
9
- export interface Draftable { data: { draft?: boolean } }
9
+ export interface Draftable {
10
+ data: { draft?: boolean };
11
+ }
10
12
 
11
- export function visiblePosts<T extends Draftable>(posts: T[], showDrafts: boolean): T[] {
13
+ export function visiblePosts<T extends Draftable>(
14
+ posts: T[],
15
+ showDrafts: boolean,
16
+ ): T[] {
12
17
  return showDrafts ? posts : posts.filter((p) => !p.data.draft);
13
18
  }
14
19
 
@@ -21,10 +26,14 @@ export function visiblePosts<T extends Draftable>(posts: T[], showDrafts: boolea
21
26
  a build error rather than a silently hidden paragraph: a typo must not
22
27
  look like "still a draft". */
23
28
 
24
- export interface ForwardLinkable extends Draftable { data: { draft?: boolean; slug: string } }
29
+ export interface ForwardLinkable extends Draftable {
30
+ data: { draft?: boolean; slug: string };
31
+ }
25
32
 
26
33
  export function forwardLinkVisible(
27
- slug: string, posts: ForwardLinkable[], showDrafts: boolean,
34
+ slug: string,
35
+ posts: ForwardLinkable[],
36
+ showDrafts: boolean,
28
37
  ): boolean {
29
38
  const target = posts.find((p) => p.data.slug === slug);
30
39
  if (!target) throw new Error(`WhenPublished: no post has slug "${slug}"`);
@@ -43,7 +52,9 @@ export function forwardLinkVisible(
43
52
 
44
53
  import { postPath } from "./slug";
45
54
 
46
- export interface Linkable { data: { slug: string; date: Date } }
55
+ export interface Linkable {
56
+ data: { slug: string; date: Date };
57
+ }
47
58
 
48
59
  export function resolvePostHref(slug: string, posts: Linkable[]): string {
49
60
  const target = posts.find((p) => p.data.slug === slug);
@@ -4,6 +4,9 @@
4
4
  house default. */
5
5
  export function formatPostDate(d: Date, locale = "en-US"): string {
6
6
  return d.toLocaleDateString(locale, {
7
- year: "numeric", month: "long", day: "numeric", timeZone: "UTC",
7
+ year: "numeric",
8
+ month: "long",
9
+ day: "numeric",
10
+ timeZone: "UTC",
8
11
  });
9
12
  }
@@ -5,7 +5,9 @@
5
5
  through these instead of picking fields ad hoc: hand-picked fields are how
6
6
  the same-day ordering bug shipped twice (audit A1/A2). */
7
7
 
8
- export interface Publishable { data: { date: Date; published?: Date } }
8
+ export interface Publishable {
9
+ data: { date: Date; published?: Date };
10
+ }
9
11
 
10
12
  export function publishedAt(p: Publishable): Date {
11
13
  return p.data.published ?? p.data.date;
@@ -1,7 +1,11 @@
1
1
  export function paginatePosts<T>(items: T[], size: number): T[][] {
2
2
  if (items.length === 0) return [[]];
3
3
  const pages: T[][] = [];
4
- for (let i = 0; i < items.length; i += size) pages.push(items.slice(i, i + size));
4
+
5
+ for (let i = 0; i < items.length; i += size) {
6
+ pages.push(items.slice(i, i + size));
7
+ }
8
+
5
9
  return pages;
6
10
  }
7
11
 
package/src/lib/slug.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export function slugifyCategory(name: string): string {
2
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2
+ return name
3
+ .toLowerCase()
4
+ .replace(/[^a-z0-9]+/g, "-")
5
+ .replace(/^-|-$/g, "");
3
6
  }
4
7
 
5
8
  /* The WP-compatible date permalink is the library's documented opinion
@@ -22,8 +25,12 @@ export function categoryPath(name: string, base = "/category"): string {
22
25
  /* Canonical href for a static page. One level of nesting exists today
23
26
  (children of policies); parent chains deeper than one are not modeled
24
27
  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}/`;
28
+ export function pagePath(p: {
29
+ data: { slug: string; parent?: string };
30
+ }): string {
31
+ return p.data.parent
32
+ ? `/${p.data.parent}/${p.data.slug}/`
33
+ : `/${p.data.slug}/`;
27
34
  }
28
35
 
29
36
  /* Canonical lookup key for an internal href: root-relative, fragment
@@ -31,14 +38,19 @@ export function pagePath(p: { data: { slug: string; parent?: string } }): string
31
38
  site's own origins reduces to its path; anything else external is
32
39
  null, as is a file-ish path (a dot in the last segment, /feed.xml),
33
40
  which names a document with no page title to reveal. */
34
- export function internalHrefKey(href: string, origins: readonly string[] = []): string | null {
41
+ export function internalHrefKey(
42
+ href: string,
43
+ origins: readonly string[] = [],
44
+ ): string | null {
35
45
  let h = href;
46
+
36
47
  for (const o of origins) {
37
48
  if (h === o || h.startsWith(`${o}/`)) {
38
49
  h = h.slice(o.length) || "/";
39
50
  break;
40
51
  }
41
52
  }
53
+
42
54
  if (!h.startsWith("/") || h.startsWith("//")) return null;
43
55
  h = h.replace(/[#?].*$/, "");
44
56
  if (h === "") return "/";
@@ -50,7 +62,12 @@ export function internalHrefKey(href: string, origins: readonly string[] = []):
50
62
  /* Top-level path segments owned by real routes. A page slugged one of these
51
63
  (or a bare year) would shadow the archive, pagination, category, or search
52
64
  trees under the [...slug] catch-all without any build error. */
53
- export const RESERVED_PAGE_SLUGS = new Set(["page", "category", "search", "404"]);
65
+ export const RESERVED_PAGE_SLUGS = new Set([
66
+ "page",
67
+ "category",
68
+ "search",
69
+ "404",
70
+ ]);
54
71
 
55
72
  /* Build-time guard for the [...slug] catch-all: rejects reserved top-level
56
73
  slugs and parent references that resolve to nothing (a typo'd parent would
@@ -67,7 +84,13 @@ export function assertPageRoutable(
67
84
  `Page "${p.data.slug}" declares parent "${p.data.parent}", which matches no page slug`,
68
85
  );
69
86
  }
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`);
87
+
88
+ if (
89
+ !p.data.parent &&
90
+ (reserved.has(p.data.slug) || /^\d{4}$/.test(p.data.slug))
91
+ ) {
92
+ throw new Error(
93
+ `Page slug "${p.data.slug}" collides with a reserved route`,
94
+ );
72
95
  }
73
96
  }
@@ -13,34 +13,53 @@ export interface CodeIslandOptions {
13
13
  resetMs?: number;
14
14
  }
15
15
 
16
- export const mountCodeIslands: Island<CodeIslandOptions> = (root, options = {}): IslandHandle => {
16
+ export const mountCodeIslands: Island<CodeIslandOptions> = (
17
+ root,
18
+ options = {},
19
+ ): IslandHandle => {
17
20
  const {
18
21
  selector = ".prose pre.astro-code",
19
22
  copy = { copy: "COPY", copied: "COPIED", failed: "FAILED" },
20
23
  resetMs = 1500,
21
24
  } = options;
25
+
22
26
  const doc = docOf(root);
27
+
23
28
  /* resetTimer holds the copy-reset setTimeout id, one live per button at
24
29
  most (a second click before the first reset overwrites it, dropping
25
30
  the earlier timer's reference so it can no longer be cleared, which
26
31
  is why doCopy clears the box before replacing it). A plain mutable
27
32
  box, not a field on the mounted entry, so both doCopy and destroy()
28
33
  close over the same cell. */
29
- const mounted: { pre: Element; bar: HTMLDivElement; btn: HTMLButtonElement; onClick: () => void; resetTimer: { id: ReturnType<typeof setTimeout> | undefined } }[] = [];
34
+ const mounted: {
35
+ pre: Element;
36
+ bar: HTMLDivElement;
37
+ btn: HTMLButtonElement;
38
+ onClick: () => void;
39
+ resetTimer: { id: ReturnType<typeof setTimeout> | undefined };
40
+ }[] = [];
30
41
 
31
42
  for (const pre of root.querySelectorAll(selector)) {
32
43
  if (!claim(pre, "code")) continue;
33
44
  const bar = doc.createElement("div");
34
45
  bar.className = "code-island-bar";
35
46
  const label = doc.createElement("span");
36
- const file = pre.closest("[data-code-filename]")?.getAttribute("data-code-filename");
47
+
48
+ const file = pre
49
+ .closest("[data-code-filename]")
50
+ ?.getAttribute("data-code-filename");
51
+
37
52
  const lang = pre.getAttribute("data-language") ?? "";
38
53
  label.textContent = [file, lang].filter(Boolean).join(" · ");
39
54
  const btn = doc.createElement("button");
40
55
  btn.className = "code-copy";
41
56
  btn.type = "button";
42
57
  btn.textContent = copy.copy;
43
- const resetTimer: { id: ReturnType<typeof setTimeout> | undefined } = { id: undefined };
58
+
59
+ const resetTimer: { id: ReturnType<typeof setTimeout> | undefined } = {
60
+ id: undefined,
61
+ };
62
+
44
63
  const doCopy = async (): Promise<void> => {
45
64
  try {
46
65
  await navigator.clipboard.writeText(pre.textContent);
@@ -48,14 +67,18 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (root, options = {}):
48
67
  } catch {
49
68
  btn.textContent = copy.failed;
50
69
  }
70
+
51
71
  clearTimeout(resetTimer.id);
72
+
52
73
  resetTimer.id = setTimeout(() => {
53
74
  btn.textContent = copy.copy;
54
75
  }, resetMs);
55
76
  };
77
+
56
78
  const onClick = (): void => {
57
79
  void doCopy();
58
80
  };
81
+
59
82
  btn.addEventListener("click", onClick);
60
83
  bar.append(label, btn);
61
84
  pre.before(bar);
@@ -0,0 +1,28 @@
1
+ /* A corner badge is data the caller hands to CornerBadges.astro: a key
2
+ (which becomes the chip's badge-<key> class, the styling hook), the
3
+ label, and a glyph from the icon registry. No lookup table in the
4
+ package (owner call 2026-09-10): a site's view-model lists the
5
+ badges a post carries, in the order it wants them, and can invent one
6
+ without a package release. The two house conventions ship as
7
+ constants so every site spells them the same way. */
8
+ import type { IconName } from "./icons";
9
+
10
+ export interface Badge {
11
+ key: string;
12
+ label: string;
13
+ icon: IconName;
14
+ }
15
+
16
+ /* The pixels were generated (a hero, a content image). */
17
+ export const GENAI_BADGE: Badge = {
18
+ key: "genai",
19
+ label: "AI Art",
20
+ icon: "sparkles",
21
+ };
22
+
23
+ /* The post carries an interactive demo. */
24
+ export const DEMO_BADGE: Badge = {
25
+ key: "demo",
26
+ label: "Demo",
27
+ icon: "play",
28
+ };
@@ -14,7 +14,12 @@ export function el<K extends keyof HTMLElementTagNameMap>(
14
14
  return node;
15
15
  }
16
16
 
17
- export function iconButton(doc: Document, className: string, label: string, svg: string): HTMLButtonElement {
17
+ export function iconButton(
18
+ doc: Document,
19
+ className: string,
20
+ label: string,
21
+ svg: string,
22
+ ): HTMLButtonElement {
18
23
  const btn = doc.createElement("button");
19
24
  btn.type = "button";
20
25
  btn.className = className;
@@ -23,7 +23,10 @@ export function createFrameLoop(
23
23
 
24
24
  function tick(now: number): void {
25
25
  if (!live) return;
26
- const dt = last === 0 ? firstDt : Math.min(clamp, Math.max(0, (now - last) / 1000));
26
+
27
+ const elapsed = Math.max(0, (now - last) / 1000);
28
+ const dt = last === 0 ? firstDt : Math.min(clamp, elapsed);
29
+
27
30
  last = now;
28
31
  cb(dt);
29
32
  // cb may have called stop() reentrantly; a fresh function body reads live without stale narrowing.