@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,21 +1,30 @@
1
1
  ---
2
- /* Day/night toggle (owner request 2026-08-25): an .icon-box button that
3
- flips the site theme. Both glyphs are in the button; CSS shows the
4
- current theme's (sun by day, moon by night, owner call 2026-08-26) and
5
- the label names the action. State and storage live in
6
- scripts/theme-toggle.ts, which mounts every .theme-toggle on the page
7
- and keeps them in step; the first paint is stamped by the inline
8
- script in Shell.astro's head.
9
-
10
- Two placements: "band" sits beside the date stamp in the header's top
11
- band; "phone" sits beside the search icon in the bottom band and shows
12
- only under 768px, where the top band is hidden (owner call 2026-08-20),
13
- so phones keep a toggle (owner request 2026-08-26). */
14
2
  import { ICON_SUN, ICON_MOON } from "../scripts/core/icons";
15
- interface Props { placement?: "band" | "phone"; class?: string; label?: string }
16
- const { placement = "band", class: className, label = "Switch to dark mode" } = Astro.props;
3
+
4
+ interface Props {
5
+ placement?: "band" | "phone";
6
+ class?: string;
7
+ label?: string;
8
+ }
9
+
10
+ const {
11
+ placement = "band",
12
+ class: className,
13
+ label = "Switch to dark mode",
14
+ } = Astro.props;
17
15
  ---
18
- <button type="button" class:list={["theme-toggle", "icon-box", placement === "phone" && "theme-toggle-phone", className]} aria-label={label} aria-pressed="false">
16
+
17
+ <button
18
+ type="button"
19
+ class:list={[
20
+ "theme-toggle",
21
+ "icon-box",
22
+ placement === "phone" && "theme-toggle-phone",
23
+ className,
24
+ ]}
25
+ aria-label={label}
26
+ aria-pressed="false"
27
+ >
19
28
  <span class="theme-toggle-moon"><Fragment set:html={ICON_MOON} /></span>
20
29
  <span class="theme-toggle-sun"><Fragment set:html={ICON_SUN} /></span>
21
30
  </button>
@@ -23,18 +32,22 @@ const { placement = "band", class: className, label = "Switch to dark mode" } =
23
32
  <style>
24
33
  .theme-toggle { font-size: 16px; }
25
34
  .theme-toggle span { display: block; line-height: 0; }
35
+
26
36
  /* The glyphs keep their currentColor stroke and take a fill: the moon
27
37
  in the second accent, the sun's disc in the first (owner call
28
38
  2026-08-26). :global because the svg arrives through set:html. */
29
39
  .theme-toggle-moon :global(path) { fill: var(--accent-2); }
30
40
  .theme-toggle-sun :global(circle) { fill: var(--accent-1); }
41
+
31
42
  /* Show the glyph of the current theme. Both selectors carry the
32
43
  parent class so they outrank the span rule above. */
33
44
  .theme-toggle .theme-toggle-moon { display: none; }
34
45
  :global(:root[data-theme="dark"]) .theme-toggle .theme-toggle-moon { display: block; }
35
46
  :global(:root[data-theme="dark"]) .theme-toggle .theme-toggle-sun { display: none; }
47
+
36
48
  /* The phone placement exists only where the top band does not. */
37
49
  .theme-toggle-phone { display: none; }
50
+
38
51
  @media (--bp-phone) {
39
52
  .theme-toggle-phone { display: flex; }
40
53
  }
@@ -1,11 +1,11 @@
1
1
  ---
2
- /* The two-column page body (step 8, 2026-08-30): content beside the
3
- sidebar slot. Column grid, widths, and the collapse live once in
4
- styles/base/shell.css; this component is structure only, so it ships
5
- no scoped CSS and stamps no cid on the markup. */
6
- interface Props { class?: string }
2
+ interface Props {
3
+ class?: string;
4
+ }
5
+
7
6
  const { class: className } = Astro.props;
8
7
  ---
8
+
9
9
  <div class:list={["two-column", className]}>
10
10
  <main class="site-main"><slot /></main>
11
11
  <slot name="aside" />
@@ -1,14 +1,14 @@
1
1
  ---
2
- /* Sidebar widget: plain block heading + list, matching the live site's
3
- Gutenberg block widgets (which rendered h2.wp-block-heading inside
4
- .widget_block; renamed at extraction, step 11.1).
5
- List links keep their natural underline: they are proper links. */
6
- interface Props { title: string; headingLevel?: 2 | 3 | 4; class?: string }
2
+ interface Props {
3
+ title: string;
4
+ headingLevel?: 2 | 3 | 4;
5
+ class?: string;
6
+ }
7
+
7
8
  const { title, headingLevel = 2, class: className } = Astro.props;
8
- /* Annotated, not cast: an `as` cast in the frontmatter can make the Astro
9
- compiler drop the Props type (see PostCard.astro). */
10
9
  const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
11
10
  ---
11
+
12
12
  <section class:list={["widget", className]}>
13
13
  <Heading class="widget-title">{title}</Heading>
14
14
  <div class="widget-body"><slot /></div>
@@ -16,6 +16,7 @@ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
16
16
 
17
17
  <style>
18
18
  .widget { margin: 0 0 2em; }
19
+
19
20
  /* Owner ruling 2026-07-26 (diverges from live's larger headings):
20
21
  widget headers share the Subscribe panel's title scale. */
21
22
  .widget-title {
@@ -25,6 +26,7 @@ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
25
26
  font-size: var(--font-size-base);
26
27
  }
27
28
  .widget-body :global(ul) { list-style: none; padding: 0; margin: 0; }
29
+
28
30
  .widget-body :global(li) {
29
31
  margin-bottom: 7px;
30
32
  padding-bottom: 7px;
@@ -32,6 +34,7 @@ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
32
34
  border-bottom: 1px solid var(--divider-faint);
33
35
  }
34
36
  .widget-body :global(li:last-child) { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
37
+
35
38
  .widget-body :global(a) {
36
39
  color: var(--ink);
37
40
  transition: var(--transition);
@@ -1,24 +1,57 @@
1
1
  ---
2
2
  import type { ImageMetadata } from "astro";
3
3
  import CornerBadges from "../CornerBadges.astro";
4
+ import type { Badge } from "../../scripts/core/badges";
4
5
  import SmartImage from "../SmartImage.astro";
5
6
  import LightboxLink from "../LightboxLink.astro";
6
7
 
7
- /* `frameless` is the sanctioned exception to the site-wide 2px image
8
- outline (prose.css): the mascot on the brand page floats free (owner
9
- ruling 2026-07-26). Everything else takes the default. */
10
- /* `narrow` caps the image at the half-column responsive step (495px),
11
- centered; for shots that read fine small (close-ups, tall crops). */
12
- /* `genai` marks an AI-generated image with the AI Art corner badge
13
- (CornerBadges.astro). */
14
- interface Props { src: ImageMetadata; alt?: string; caption?: string; frameless?: boolean; narrow?: boolean; genai?: boolean; class?: string }
15
- const { src, alt = "", caption, frameless = false, narrow = false, genai = false, class: className } = Astro.props;
8
+ interface Props {
9
+ src: ImageMetadata;
10
+ alt?: string;
11
+ caption?: string;
12
+ frameless?: boolean;
13
+ narrow?: boolean;
14
+ badges?: Badge[];
15
+ class?: string;
16
+ }
17
+
18
+ const {
19
+ src,
20
+ alt = "",
21
+ caption,
22
+ frameless = false,
23
+ narrow = false,
24
+ badges = [],
25
+ class: className,
26
+ } = Astro.props;
16
27
  ---
28
+
17
29
  {/* Image branching and lb-attrs live in the primitives (step 5). */}
18
- <figure class:list={["blog-image", frameless && "blog-image-frameless", narrow && "blog-image-narrow", className]}>
19
- <LightboxLink href={src.src} w={src.width} h={src.height} caption={caption ?? alt}>
20
- <SmartImage src={src} alt={alt} widths={[495, 990, Math.min(src.width, 1980)]} sizes={narrow ? "(max-width: 1024px) 50vw, 495px" : "(max-width: 1024px) 100vw, 990px"} />
21
- <CornerBadges genai={genai} />
30
+ <figure
31
+ class:list={[
32
+ "blog-image",
33
+ frameless && "blog-image-frameless",
34
+ narrow && "blog-image-narrow",
35
+ className,
36
+ ]}
37
+ >
38
+ <LightboxLink
39
+ href={src.src}
40
+ w={src.width}
41
+ h={src.height}
42
+ caption={caption ?? alt}
43
+ >
44
+ <SmartImage
45
+ src={src}
46
+ alt={alt}
47
+ widths={[495, 990, Math.min(src.width, 1980)]}
48
+ sizes={
49
+ narrow
50
+ ? "(max-width: 1024px) 50vw, 495px"
51
+ : "(max-width: 1024px) 100vw, 990px"
52
+ }
53
+ />
54
+ <CornerBadges badges={badges} />
22
55
  </LightboxLink>
23
56
  {caption && <figcaption class="caption">{caption}</figcaption>}
24
57
  </figure>
@@ -26,6 +59,7 @@ const { src, alt = "", caption, frameless = false, narrow = false, genai = false
26
59
  <style>
27
60
  .blog-image { margin: 20px 0; }
28
61
  .blog-image-frameless :global(img) { border: 0; }
62
+
29
63
  /* Images narrower than the column center, matching the centered
30
64
  captions (full-width images are unaffected). The link hugs the image
31
65
  (fit-content, centered) rather than spanning the column, so a corner
@@ -33,6 +67,7 @@ const { src, alt = "", caption, frameless = false, narrow = false, genai = false
33
67
  .blog-image :global(.lightbox-link) { width: fit-content; max-width: 100%; margin-inline: auto; }
34
68
  .blog-image :global(img) { max-width: 100%; height: auto; display: block; margin-inline: auto; }
35
69
  .blog-image-narrow :global(.lightbox-link) { max-width: min(495px, 100%); }
70
+
36
71
  /* Type from the .caption pattern. Fidelity pass (handoff 5.5): live
37
72
  figcaptions are centered. */
38
73
  figcaption { margin-top: 5px; text-align: center; }
@@ -1,16 +1,4 @@
1
1
  ---
2
- /* The house button (owner request 2026-08-26): one component over the
3
- press-box patterns so every button on the site, and later every site
4
- on the library, draws the same three looks.
5
-
6
- variant: "plain" is the ink press box (nav links, pagination); "shaded"
7
- is the same box on the nav's current-item shade; "accent-1"
8
- wears the brand amber and "accent-2" the cyan, both in the callout
9
- combination (accent line and ink on a faint tint, filling solid while
10
- held). size: "md" is the nav-box size, "lg" the call-to-action size.
11
- With href it renders an anchor, otherwise a button (type "button"
12
- unless told otherwise). Put an icon first in the slot and it gets the
13
- gap; the text is the slot. */
14
2
  interface Props {
15
3
  variant?: "plain" | "shaded" | "accent-1" | "accent-2";
16
4
  size?: "md" | "lg";
@@ -18,7 +6,15 @@ interface Props {
18
6
  type?: "button" | "submit";
19
7
  class?: string;
20
8
  }
21
- const { variant = "plain", size = "md", href, type = "button", class: className } = Astro.props;
9
+
10
+ const {
11
+ variant = "plain",
12
+ size = "md",
13
+ href,
14
+ type = "button",
15
+ class: className,
16
+ } = Astro.props;
17
+
22
18
  const classes = [
23
19
  "button",
24
20
  "press-box",
@@ -29,6 +25,7 @@ const classes = [
29
25
  `button-${size}`,
30
26
  ];
31
27
  ---
28
+
32
29
  {href ? (
33
30
  <a class:list={[...classes, className]} href={href}><slot /></a>
34
31
  ) : (
@@ -41,16 +38,19 @@ const classes = [
41
38
  font-weight: 700;
42
39
  text-align: center;
43
40
  }
41
+
44
42
  .button-md {
45
43
  padding: 7px 15px;
46
44
  font-size: var(--font-size-sm);
47
45
  text-transform: uppercase;
48
46
  }
47
+
49
48
  .button-lg {
50
49
  padding: 10px 24px;
51
50
  font-size: var(--font-size-md);
52
51
  letter-spacing: 0.08em;
53
52
  }
53
+
54
54
  /* A leading icon sits on the text's baseline with a gap; the svg comes
55
55
  through the slot, hence :global. */
56
56
  .button :global(svg:first-child) { vertical-align: -0.15em; margin-right: 8px; }
@@ -1,23 +1,27 @@
1
1
  ---
2
- /* Admonition panel (Confluence-style info/warning/error blocks), owner
3
- request 2026-08-21, rendered in the house line system: a 2px box in the
4
- type's line color, a low-opacity tint of the same color, and a label
5
- straddling the top rule the way the theme's date boxes do. Inks stay in
6
- the house families (graphite, amber, cyan, red): note and info read
7
- graphite and the type is told by the label text, tip and warning take
8
- the first accent, success the second, error the red. */
9
2
  interface Props {
10
3
  type?: "note" | "info" | "tip" | "success" | "warning" | "error";
11
4
  title?: string;
12
5
  class?: string;
13
6
  }
7
+
14
8
  const { type = "note", title, class: className } = Astro.props;
15
9
  const label = title ?? type;
16
10
  ---
11
+
17
12
  {/* role="note", not <aside>: aside is a landmark, and html-validate's
18
13
  unique-landmark rule (rightly) demands unique names, which repeated
19
14
  callouts on one page cannot provide. */}
20
- <div class:list={["callout", `callout-${type}`, "rule-box", "tint-overlay", className]} role="note">
15
+ <div
16
+ class:list={[
17
+ "callout",
18
+ `callout-${type}`,
19
+ "rule-box",
20
+ "tint-overlay",
21
+ className,
22
+ ]}
23
+ role="note"
24
+ >
21
25
  <strong class="callout-label boxed-label">{label}</strong>
22
26
  <div class="callout-body"><slot /></div>
23
27
  </div>
@@ -33,10 +37,12 @@ const label = title ?? type;
33
37
  margin: 30px 0;
34
38
  }
35
39
  .callout::before { pointer-events: none; }
40
+
36
41
  .callout-label {
37
42
  position: absolute;
38
43
  top: 0;
39
44
  left: 20px;
45
+
40
46
  /* Center the label on the 2px top rule, not on the padding-box edge. */
41
47
  transform: translateY(calc(-50% - 2px));
42
48
  padding: 4px 10px;
@@ -57,17 +63,20 @@ const label = title ?? type;
57
63
  --callout-line: var(--rule);
58
64
  --callout-ink: var(--ink);
59
65
  }
66
+
60
67
  .callout-tip,
61
68
  .callout-warning {
62
69
  --callout-line: var(--accent-1);
63
70
  --callout-ink: var(--accent-1-ink);
64
71
  }
72
+
65
73
  /* Success takes the second accent (owner call 2026-08-26): cyan is the
66
74
  "it worked" temperature, and it keeps the amber for tip and warning. */
67
75
  .callout-success {
68
76
  --callout-line: var(--accent-2);
69
77
  --callout-ink: var(--accent-2-ink);
70
78
  }
79
+
71
80
  .callout-error {
72
81
  --callout-line: var(--error-ink);
73
82
  --callout-ink: var(--error-ink);
@@ -1,7 +1,12 @@
1
1
  ---
2
- interface Props { filename?: string; class?: string }
2
+ interface Props {
3
+ filename?: string;
4
+ class?: string;
5
+ }
6
+
3
7
  const { filename, class: className } = Astro.props;
4
8
  ---
9
+
5
10
  <div class:list={["code-block", className]} data-code-filename={filename}>
6
11
  <slot />
7
12
  </div>
@@ -1,28 +1,23 @@
1
1
  ---
2
- /* Editor note: a reminder in content that renders on the dev server
3
- and stays out of deploy builds. Moved up from the blog's
4
- content/EditorNote.astro (owner call 2026-09-06: it is generic and
5
- should have crossed in the 11.3 extraction; the blog swaps onto this
6
- copy at its next pin bump). The blog's gate read its own SHOW_DRAFTS
7
- config, which a package component must not do, so visibility is the
8
- `shown` prop instead: the default is the consumer build's dev mode,
9
- and a consumer with a preview concept passes its own wider gate.
10
- Anatomy follows Callout (box, label straddling the top rule), but the
11
- rule is dashed and amber: nothing else in the system is dashed, so a
12
- note cannot be mistaken for content or for a callout. */
2
+ interface Props {
3
+ title?: string;
4
+ shown?: boolean;
5
+ class?: string;
6
+ }
7
+
8
+ const {
9
+ title,
10
+ shown = import.meta.env.MODE === "development",
11
+ class: className,
12
+ } = Astro.props;
13
13
 
14
- /* The label always reads "Editor's Note", with the title after a colon
15
- when one is given (owner call 2026-08-27). */
16
- interface Props { title?: string; shown?: boolean; class?: string }
17
- const { title, shown = import.meta.env.MODE === "development", class: className } = Astro.props;
18
14
  const label = title ? `Editor's Note: ${title}` : "Editor's Note";
19
15
  ---
20
- {shown && (
21
- <div class:list={["editor-note", "rule-box", className]} role="note">
22
- <strong class="editor-note-label boxed-label">{label}</strong>
23
- <div class="editor-note-body"><slot /></div>
24
- </div>
25
- )}
16
+
17
+ {shown && <div class:list={["editor-note", "rule-box", className]} role="note">
18
+ <strong class="editor-note-label boxed-label">{label}</strong>
19
+ <div class="editor-note-body"><slot /></div>
20
+ </div>}
26
21
 
27
22
  <style>
28
23
  /* Box from .rule-box; the dashed amber stroke and the margin are here. */
@@ -32,10 +27,12 @@ const label = title ? `Editor's Note: ${title}` : "Editor's Note";
32
27
  border-color: var(--accent-1);
33
28
  margin: 30px 0;
34
29
  }
30
+
35
31
  .editor-note-label {
36
32
  position: absolute;
37
33
  top: 0;
38
34
  left: 20px;
35
+
39
36
  /* Center the label on the 2px top rule, not on the padding-box edge. */
40
37
  transform: translateY(calc(-50% - 2px));
41
38
  padding: 4px 10px;
@@ -47,6 +44,7 @@ const label = title ? `Editor's Note: ${title}` : "Editor's Note";
47
44
  color: var(--accent-1-ink);
48
45
  border-color: var(--accent-1);
49
46
  }
47
+
50
48
  /* Reads as an annotation, not a paragraph: a step smaller than prose. */
51
49
  .editor-note-body {
52
50
  position: relative;
@@ -1,15 +1,21 @@
1
1
  ---
2
- /* The caption is a string prop for the common case; a named `caption`
3
- slot takes over when the caption needs markup (a link in an edit
4
- aside, owner request 2026-08-27). */
5
- interface Props { columns?: number; caption?: string; class?: string }
2
+ interface Props {
3
+ columns?: number;
4
+ caption?: string;
5
+ class?: string;
6
+ }
7
+
6
8
  const { columns = 3, caption, class: className } = Astro.props;
7
9
  const hasCaptionSlot = Astro.slots.has("caption");
8
10
  ---
11
+
9
12
  {/* Frame comes from the .bracket-frame pattern, same enclosure as the
10
13
  article itself (owner request 2026-07-29). */}
11
14
  <figure class:list={["gallery-figure", className]}>
12
- <div class:list={["gallery-plates", "bracket-frame", `gallery-cols-${columns}`]} style={`--gallery-cols:${columns}`}>
15
+ <div
16
+ class:list={["gallery-plates", "bracket-frame", `gallery-cols-${columns}`]}
17
+ style={`--gallery-cols:${columns}`}
18
+ >
13
19
  <slot />
14
20
  </div>
15
21
  {hasCaptionSlot ? (
@@ -23,16 +29,21 @@ const hasCaptionSlot = Astro.slots.has("caption");
23
29
  /* The figure wrapper exists so a gallery-level caption reads as part of
24
30
  the gallery (owner request 2026-07-30); outer spacing lives here. */
25
31
  .gallery-figure { margin: 20px 0; }
32
+
26
33
  .gallery-plates {
27
34
  display: grid;
28
35
  grid-template-columns: repeat(var(--gallery-cols), 1fr);
29
36
  gap: 12px;
30
37
  }
38
+
31
39
  @media (--bp-phone) { .gallery-plates { grid-template-columns: repeat(2, 1fr); } }
40
+
32
41
  @media (max-width: 480px) { .gallery-plates { grid-template-columns: 1fr; } }
42
+
33
43
  /* Type from the .caption pattern; centered under the plates like
34
44
  BlogImage's. */
35
45
  figcaption { margin-top: 5px; text-align: center; }
46
+
36
47
  /* Orphan rule (owner request 2026-07-29): a lone plate on the last row
37
48
  centers instead of hugging the left column. The :global() escape is
38
49
  needed because plates are GalleryImage children, outside this
@@ -40,12 +51,14 @@ const hasCaptionSlot = Astro.slots.has("caption");
40
51
  math differs per count; columns other than 2 and 3 get no rule. */
41
52
  @media (--bp-phone-up) {
42
53
  .gallery-cols-3 > :global(:nth-child(3n + 1):last-child) { grid-column: 2; }
54
+
43
55
  .gallery-cols-2 > :global(:nth-child(odd):last-child) {
44
56
  grid-column: 1 / -1;
45
57
  justify-self: center;
46
58
  width: calc(50% - 6px);
47
59
  }
48
60
  }
61
+
49
62
  /* Below 768px every gallery is two columns, whatever it asked for. */
50
63
  @media (min-width: 481px) and (--bp-phone) {
51
64
  .gallery-plates > :global(:nth-child(odd):last-child) {
@@ -1,19 +1,36 @@
1
1
  ---
2
2
  import type { ImageMetadata } from "astro";
3
3
  import CornerBadges from "../CornerBadges.astro";
4
+ import type { Badge } from "../../scripts/core/badges";
4
5
  import SmartImage from "../SmartImage.astro";
5
6
  import LightboxLink from "../LightboxLink.astro";
6
7
 
7
- /* `genai` marks an AI-generated plate with the AI Art corner badge
8
- (CornerBadges.astro). */
9
- interface Props { src: ImageMetadata; alt?: string; caption?: string; genai?: boolean; class?: string }
10
- const { src, alt = "", caption, genai = false, class: className } = Astro.props;
8
+ interface Props {
9
+ src: ImageMetadata;
10
+ alt?: string;
11
+ caption?: string;
12
+ badges?: Badge[];
13
+ class?: string;
14
+ }
15
+
16
+ const { src, alt = "", caption, badges = [], class: className } = Astro.props;
11
17
  ---
18
+
12
19
  <figure class:list={["gallery-plate", className]}>
13
20
  <div class="plate-frame">
14
- <LightboxLink href={src.src} w={src.width} h={src.height} caption={caption ?? alt}>
15
- <SmartImage src={src} alt={alt} widths={[320, 640]} sizes="(max-width: 600px) 100vw, 320px" />
16
- <CornerBadges genai={genai} />
21
+ <LightboxLink
22
+ href={src.src}
23
+ w={src.width}
24
+ h={src.height}
25
+ caption={caption ?? alt}
26
+ >
27
+ <SmartImage
28
+ src={src}
29
+ alt={alt}
30
+ widths={[320, 640]}
31
+ sizes="(max-width: 600px) 100vw, 320px"
32
+ />
33
+ <CornerBadges badges={badges} />
17
34
  </LightboxLink>
18
35
  </div>
19
36
  {caption && <figcaption class="caption">{caption}</figcaption>}
@@ -22,9 +39,11 @@ const { src, alt = "", caption, genai = false, class: className } = Astro.props;
22
39
  <style>
23
40
  .gallery-plate { margin: 0; }
24
41
  .plate-frame { border: var(--stroke) solid var(--rule); line-height: 0; }
42
+
25
43
  /* The frame owns the outline; zero the default img border from
26
44
  prose.css so plates don't double-line. */
27
45
  .plate-frame :global(img) { width: 100%; height: 220px; object-fit: cover; border: 0; }
46
+
28
47
  /* Type from the .caption pattern, tracked a touch wider under a plate.
29
48
  Caption case is the author's own; the uppercase transform from the
30
49
  original plate design was dropped by owner request 2026-07-26. */
@@ -1,12 +1,11 @@
1
1
  ---
2
- /* Boxed prose container. Born in phase 1 as a bare passthrough (the
3
- migration target for Gutenberg's Group block); styled 2026-08-21 to
4
- match the style guide's description of it, as the label-less,
5
- tint-less sibling of Callout's box. The box itself is the .rule-box
6
- pattern; only the outer margin is this component's. */
7
- interface Props { class?: string }
2
+ interface Props {
3
+ class?: string;
4
+ }
5
+
8
6
  const { class: className } = Astro.props;
9
7
  ---
8
+
10
9
  <div class:list={["content-group", "rule-box", className]}><slot /></div>
11
10
 
12
11
  <style>
@@ -1,24 +1,53 @@
1
1
  ---
2
2
  import type { ImageMetadata } from "astro";
3
3
  import CornerBadges from "../CornerBadges.astro";
4
+ import type { Badge } from "../../scripts/core/badges";
4
5
  import SmartImage from "../SmartImage.astro";
5
6
  import LightboxLink from "../LightboxLink.astro";
6
7
 
7
- /* `genai` marks an AI-generated image with the AI Art corner badge
8
- (CornerBadges.astro). */
9
- interface Props { src: ImageMetadata; alt?: string; mediaSide?: "left" | "right"; natural?: boolean; genai?: boolean; class?: string }
10
- const { src, alt = "", mediaSide = "left", natural = false, genai = false, class: className } = Astro.props;
8
+ interface Props {
9
+ src: ImageMetadata;
10
+ alt?: string;
11
+ mediaSide?: "left" | "right";
12
+ natural?: boolean;
13
+ badges?: Badge[];
14
+ class?: string;
15
+ }
16
+
17
+ const {
18
+ src,
19
+ alt = "",
20
+ mediaSide = "left",
21
+ natural = false,
22
+ badges = [],
23
+ class: className,
24
+ } = Astro.props;
11
25
  ---
12
- <figure class:list={["media-text", mediaSide === "right" && "media-right", natural && "media-natural", className]}>
26
+
27
+ <figure
28
+ class:list={[
29
+ "media-text",
30
+ mediaSide === "right" && "media-right",
31
+ natural && "media-natural",
32
+ className,
33
+ ]}
34
+ >
13
35
  <div class="media-text-media">
14
36
  <LightboxLink href={src.src} w={src.width} h={src.height} caption={alt}>
15
- {natural
37
+ {natural ? (
16
38
  /* No responsive widths/sizes here: a `sizes` attribute makes the
17
39
  browser report that size as the intrinsic width, which defeats
18
40
  the natural-size rendering below. */
19
- ? <SmartImage src={src} alt={alt} />
20
- : <SmartImage src={src} alt={alt} widths={[480, 960]} sizes="(max-width: 600px) 100vw, 480px" />}
21
- <CornerBadges genai={genai} />
41
+ <SmartImage src={src} alt={alt} />
42
+ ) : (
43
+ <SmartImage
44
+ src={src}
45
+ alt={alt}
46
+ widths={[480, 960]}
47
+ sizes="(max-width: 600px) 100vw, 480px"
48
+ />
49
+ )}
50
+ <CornerBadges badges={badges} />
22
51
  </LightboxLink>
23
52
  </div>
24
53
  <figcaption class="media-text-content caption"><slot /></figcaption>
@@ -34,22 +63,27 @@ const { src, alt = "", mediaSide = "left", natural = false, genai = false, class
34
63
  }
35
64
  .media-right .media-text-media { order: 2; }
36
65
  .media-text-media { margin: 0; line-height: 0; }
66
+
37
67
  /* The side text is the caption: type from the .caption pattern (owner
38
68
  request 2026-08-14), left-aligned while it sits in its own column;
39
69
  it centers only when stacked below. */
40
70
  .media-text-content { text-align: left; }
41
71
  .media-text-content :global(p) { margin: 0 0 0.5em; }
42
72
  .media-text-content :global(p:last-child) { margin-bottom: 0; }
73
+
43
74
  /* Outline comes from the .prose img standard in prose.css. */
44
75
  .media-text-media :global(img) { width: 100%; height: auto; }
76
+
45
77
  /* natural: opt-in for small diagrams and cartoons that look blown out
46
78
  when stretched to the media column; renders at intrinsic size,
47
79
  centered in the cell (owner request 2026-07-30). */
48
80
  .media-natural .media-text-media { text-align: center; }
81
+
49
82
  /* The link hugs the natural-size image so a corner badge lands on the
50
83
  image, not the cell (same idiom as BlogImage). */
51
84
  .media-natural .media-text-media :global(.lightbox-link) { width: fit-content; max-width: 100%; margin-inline: auto; }
52
85
  .media-natural .media-text-media :global(img) { width: auto; max-width: 100%; }
86
+
53
87
  @media (max-width: 600px) {
54
88
  /* Stacked, the caption sits below the image like BlogImage's:
55
89
  centered, hugging at the same 5px. */