@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.
Files changed (60) hide show
  1. package/ICONS-LICENSE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/package.json +18 -0
  5. package/src/components/CategoryCard.astro +31 -0
  6. package/src/components/CornerBadges.astro +40 -0
  7. package/src/components/Footer.astro +209 -0
  8. package/src/components/LightboxLink.astro +13 -0
  9. package/src/components/LinkListWidget.astro +19 -0
  10. package/src/components/Pagination.astro +72 -0
  11. package/src/components/PostCard.astro +166 -0
  12. package/src/components/PostNavigation.astro +61 -0
  13. package/src/components/Shell.astro +52 -0
  14. package/src/components/SiteHeader.astro +326 -0
  15. package/src/components/SmartImage.astro +34 -0
  16. package/src/components/Subscribe.astro +117 -0
  17. package/src/components/ThemeToggle.astro +41 -0
  18. package/src/components/TwoColumn.astro +12 -0
  19. package/src/components/Widget.astro +41 -0
  20. package/src/components/content/BlogImage.astro +39 -0
  21. package/src/components/content/Button.astro +57 -0
  22. package/src/components/content/Callout.astro +75 -0
  23. package/src/components/content/CodeBlock.astro +7 -0
  24. package/src/components/content/Gallery.astro +57 -0
  25. package/src/components/content/GalleryImage.astro +35 -0
  26. package/src/components/content/Group.astro +15 -0
  27. package/src/components/content/MediaText.astro +60 -0
  28. package/src/components/content/Palette.astro +42 -0
  29. package/src/components/content/Quote.astro +21 -0
  30. package/src/components/content/Spacer.astro +5 -0
  31. package/src/components/content/Step.astro +126 -0
  32. package/src/components/content/Walkthrough.astro +42 -0
  33. package/src/components/models.ts +77 -0
  34. package/src/lib/archive.ts +29 -0
  35. package/src/lib/drafts.ts +52 -0
  36. package/src/lib/format-date.ts +9 -0
  37. package/src/lib/header-date.ts +6 -0
  38. package/src/lib/ordering.ts +18 -0
  39. package/src/lib/paginate.ts +15 -0
  40. package/src/lib/reading-time.ts +4 -0
  41. package/src/lib/slug.ts +73 -0
  42. package/src/scripts/code-island.ts +75 -0
  43. package/src/scripts/core/breakpoints.ts +4 -0
  44. package/src/scripts/core/dom.ts +31 -0
  45. package/src/scripts/core/frame-loop.ts +54 -0
  46. package/src/scripts/core/icons.ts +30 -0
  47. package/src/scripts/core/island.ts +25 -0
  48. package/src/scripts/core/storage.ts +44 -0
  49. package/src/scripts/focus-mode.ts +41 -0
  50. package/src/scripts/lightbox.ts +446 -0
  51. package/src/scripts/link-tip.ts +154 -0
  52. package/src/scripts/path-player-math.ts +34 -0
  53. package/src/scripts/path-player-paint.ts +154 -0
  54. package/src/scripts/path-player.ts +341 -0
  55. package/src/scripts/plate-modal.ts +91 -0
  56. package/src/scripts/scroll-top.ts +32 -0
  57. package/src/scripts/site-header.ts +73 -0
  58. package/src/scripts/subscribe.ts +116 -0
  59. package/src/scripts/theme-toggle.ts +115 -0
  60. package/src/shiki/code-theme.mjs +16 -0
@@ -0,0 +1,41 @@
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 }
7
+ 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
+ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
11
+ ---
12
+ <section class:list={["widget", className]}>
13
+ <Heading class="widget-title">{title}</Heading>
14
+ <div class="widget-body"><slot /></div>
15
+ </section>
16
+
17
+ <style>
18
+ .widget { margin: 0 0 2em; }
19
+ /* Owner ruling 2026-07-26 (diverges from live's larger headings):
20
+ widget headers share the Subscribe panel's title scale. */
21
+ .widget-title {
22
+ margin-top: 0;
23
+ margin-bottom: 10px;
24
+ line-height: 1.2;
25
+ font-size: var(--font-size-base);
26
+ }
27
+ .widget-body :global(ul) { list-style: none; padding: 0; margin: 0; }
28
+ .widget-body :global(li) {
29
+ margin-bottom: 7px;
30
+ padding-bottom: 7px;
31
+ font-size: var(--font-size-sm);
32
+ border-bottom: 1px solid var(--divider-faint);
33
+ }
34
+ .widget-body :global(li:last-child) { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
35
+ .widget-body :global(a) {
36
+ color: var(--ink);
37
+ transition: var(--transition);
38
+ text-decoration: underline;
39
+ }
40
+ .widget-body :global(a:hover), .widget-body :global(a:focus) { color: var(--ink); }
41
+ </style>
@@ -0,0 +1,39 @@
1
+ ---
2
+ import type { ImageMetadata } from "astro";
3
+ import CornerBadges from "../CornerBadges.astro";
4
+ import SmartImage from "../SmartImage.astro";
5
+ import LightboxLink from "../LightboxLink.astro";
6
+
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;
16
+ ---
17
+ {/* 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} />
22
+ </LightboxLink>
23
+ {caption && <figcaption class="caption">{caption}</figcaption>}
24
+ </figure>
25
+
26
+ <style>
27
+ .blog-image { margin: 20px 0; }
28
+ .blog-image-frameless :global(img) { border: 0; }
29
+ /* Images narrower than the column center, matching the centered
30
+ captions (full-width images are unaffected). The link hugs the image
31
+ (fit-content, centered) rather than spanning the column, so a corner
32
+ badge lands on the image's corner and not the column's. */
33
+ .blog-image :global(.lightbox-link) { width: fit-content; max-width: 100%; margin-inline: auto; }
34
+ .blog-image :global(img) { max-width: 100%; height: auto; display: block; margin-inline: auto; }
35
+ .blog-image-narrow :global(.lightbox-link) { max-width: min(495px, 100%); }
36
+ /* Type from the .caption pattern. Fidelity pass (handoff 5.5): live
37
+ figcaptions are centered. */
38
+ figcaption { margin-top: 5px; text-align: center; }
39
+ </style>
@@ -0,0 +1,57 @@
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
+ interface Props {
15
+ variant?: "plain" | "shaded" | "accent-1" | "accent-2";
16
+ size?: "md" | "lg";
17
+ href?: string;
18
+ type?: "button" | "submit";
19
+ class?: string;
20
+ }
21
+ const { variant = "plain", size = "md", href, type = "button", class: className } = Astro.props;
22
+ const classes = [
23
+ "button",
24
+ "press-box",
25
+ variant === "shaded" && "press-box-shaded",
26
+ (variant === "accent-1" || variant === "accent-2") && "press-box-accent",
27
+ variant === "accent-1" && "press-box-accent-1",
28
+ variant === "accent-2" && "press-box-accent-2",
29
+ `button-${size}`,
30
+ ];
31
+ ---
32
+ {href ? (
33
+ <a class:list={[...classes, className]} href={href}><slot /></a>
34
+ ) : (
35
+ <button class:list={[...classes, className]} type={type}><slot /></button>
36
+ )}
37
+
38
+ <style>
39
+ .button {
40
+ display: inline-block;
41
+ font-weight: 700;
42
+ text-align: center;
43
+ }
44
+ .button-md {
45
+ padding: 7px 15px;
46
+ font-size: var(--font-size-sm);
47
+ text-transform: uppercase;
48
+ }
49
+ .button-lg {
50
+ padding: 10px 24px;
51
+ font-size: var(--font-size-md);
52
+ letter-spacing: 0.08em;
53
+ }
54
+ /* A leading icon sits on the text's baseline with a gap; the svg comes
55
+ through the slot, hence :global. */
56
+ .button :global(svg:first-child) { vertical-align: -0.15em; margin-right: 8px; }
57
+ </style>
@@ -0,0 +1,75 @@
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
+ interface Props {
10
+ type?: "note" | "info" | "tip" | "success" | "warning" | "error";
11
+ title?: string;
12
+ class?: string;
13
+ }
14
+ const { type = "note", title, class: className } = Astro.props;
15
+ const label = title ?? type;
16
+ ---
17
+ {/* role="note", not <aside>: aside is a landmark, and html-validate's
18
+ unique-landmark rule (rightly) demands unique names, which repeated
19
+ callouts on one page cannot provide. */}
20
+ <div class:list={["callout", `callout-${type}`, "rule-box", "tint-overlay", className]} role="note">
21
+ <strong class="callout-label boxed-label">{label}</strong>
22
+ <div class="callout-body"><slot /></div>
23
+ </div>
24
+
25
+ <style>
26
+ /* Box from .rule-box, tint from .tint-overlay; this component only
27
+ keys both to the type's line color and adds its margin. */
28
+ .callout {
29
+ --tint-color: var(--callout-line);
30
+ --tint-opacity: 0.06;
31
+
32
+ border-color: var(--callout-line);
33
+ margin: 30px 0;
34
+ }
35
+ .callout::before { pointer-events: none; }
36
+ .callout-label {
37
+ position: absolute;
38
+ top: 0;
39
+ left: 20px;
40
+ /* Center the label on the 2px top rule, not on the padding-box edge. */
41
+ transform: translateY(calc(-50% - 2px));
42
+ padding: 4px 10px;
43
+ font-size: var(--font-size-xs);
44
+ font-weight: 700;
45
+ text-transform: uppercase;
46
+ letter-spacing: 0.5px;
47
+ line-height: 1;
48
+ color: var(--callout-ink);
49
+ border-color: var(--callout-line);
50
+ }
51
+ .callout-body { position: relative; }
52
+ .callout-body > :global(:last-child) { margin-bottom: 0; }
53
+
54
+ .callout-note,
55
+ .callout-info,
56
+ .callout-success {
57
+ --callout-line: var(--rule);
58
+ --callout-ink: var(--ink);
59
+ }
60
+ .callout-tip,
61
+ .callout-warning {
62
+ --callout-line: var(--accent-1);
63
+ --callout-ink: var(--accent-1-ink);
64
+ }
65
+ /* Success takes the second accent (owner call 2026-08-26): cyan is the
66
+ "it worked" temperature, and it keeps the amber for tip and warning. */
67
+ .callout-success {
68
+ --callout-line: var(--accent-2);
69
+ --callout-ink: var(--accent-2-ink);
70
+ }
71
+ .callout-error {
72
+ --callout-line: var(--error-ink);
73
+ --callout-ink: var(--error-ink);
74
+ }
75
+ </style>
@@ -0,0 +1,7 @@
1
+ ---
2
+ interface Props { filename?: string; class?: string }
3
+ const { filename, class: className } = Astro.props;
4
+ ---
5
+ <div class:list={["code-block", className]} data-code-filename={filename}>
6
+ <slot />
7
+ </div>
@@ -0,0 +1,57 @@
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 }
6
+ const { columns = 3, caption, class: className } = Astro.props;
7
+ const hasCaptionSlot = Astro.slots.has("caption");
8
+ ---
9
+ {/* Frame comes from the .bracket-frame pattern, same enclosure as the
10
+ article itself (owner request 2026-07-29). */}
11
+ <figure class:list={["gallery-figure", className]}>
12
+ <div class:list={["gallery-plates", "bracket-frame", `gallery-cols-${columns}`]} style={`--gallery-cols:${columns}`}>
13
+ <slot />
14
+ </div>
15
+ {hasCaptionSlot ? (
16
+ <figcaption class="caption"><slot name="caption" /></figcaption>
17
+ ) : (
18
+ caption && <figcaption class="caption">{caption}</figcaption>
19
+ )}
20
+ </figure>
21
+
22
+ <style>
23
+ /* The figure wrapper exists so a gallery-level caption reads as part of
24
+ the gallery (owner request 2026-07-30); outer spacing lives here. */
25
+ .gallery-figure { margin: 20px 0; }
26
+ .gallery-plates {
27
+ display: grid;
28
+ grid-template-columns: repeat(var(--gallery-cols), 1fr);
29
+ gap: 12px;
30
+ }
31
+ @media (--bp-phone) { .gallery-plates { grid-template-columns: repeat(2, 1fr); } }
32
+ @media (max-width: 480px) { .gallery-plates { grid-template-columns: 1fr; } }
33
+ /* Type from the .caption pattern; centered under the plates like
34
+ BlogImage's. */
35
+ figcaption { margin-top: 5px; text-align: center; }
36
+ /* Orphan rule (owner request 2026-07-29): a lone plate on the last row
37
+ centers instead of hugging the left column. The :global() escape is
38
+ needed because plates are GalleryImage children, outside this
39
+ component's style scope. Keyed on the column count, since the nth
40
+ math differs per count; columns other than 2 and 3 get no rule. */
41
+ @media (--bp-phone-up) {
42
+ .gallery-cols-3 > :global(:nth-child(3n + 1):last-child) { grid-column: 2; }
43
+ .gallery-cols-2 > :global(:nth-child(odd):last-child) {
44
+ grid-column: 1 / -1;
45
+ justify-self: center;
46
+ width: calc(50% - 6px);
47
+ }
48
+ }
49
+ /* Below 768px every gallery is two columns, whatever it asked for. */
50
+ @media (min-width: 481px) and (--bp-phone) {
51
+ .gallery-plates > :global(:nth-child(odd):last-child) {
52
+ grid-column: 1 / -1;
53
+ justify-self: center;
54
+ width: calc(50% - 6px);
55
+ }
56
+ }
57
+ </style>
@@ -0,0 +1,35 @@
1
+ ---
2
+ import type { ImageMetadata } from "astro";
3
+ import CornerBadges from "../CornerBadges.astro";
4
+ import SmartImage from "../SmartImage.astro";
5
+ import LightboxLink from "../LightboxLink.astro";
6
+
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;
11
+ ---
12
+ <figure class:list={["gallery-plate", className]}>
13
+ <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} />
17
+ </LightboxLink>
18
+ </div>
19
+ {caption && <figcaption class="caption">{caption}</figcaption>}
20
+ </figure>
21
+
22
+ <style>
23
+ .gallery-plate { margin: 0; }
24
+ .plate-frame { border: var(--stroke) solid var(--rule); line-height: 0; }
25
+ /* The frame owns the outline; zero the default img border from
26
+ prose.css so plates don't double-line. */
27
+ .plate-frame :global(img) { width: 100%; height: 220px; object-fit: cover; border: 0; }
28
+ /* Type from the .caption pattern, tracked a touch wider under a plate.
29
+ Caption case is the author's own; the uppercase transform from the
30
+ original plate design was dropped by owner request 2026-07-26. */
31
+ figcaption {
32
+ letter-spacing: 0.04em;
33
+ margin-top: 5px;
34
+ }
35
+ </style>
@@ -0,0 +1,15 @@
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 }
8
+ const { class: className } = Astro.props;
9
+ ---
10
+ <div class:list={["content-group", "rule-box", className]}><slot /></div>
11
+
12
+ <style>
13
+ .content-group { margin: 30px 0; }
14
+ .content-group > :global(:last-child) { margin-bottom: 0; }
15
+ </style>
@@ -0,0 +1,60 @@
1
+ ---
2
+ import type { ImageMetadata } from "astro";
3
+ import CornerBadges from "../CornerBadges.astro";
4
+ import SmartImage from "../SmartImage.astro";
5
+ import LightboxLink from "../LightboxLink.astro";
6
+
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;
11
+ ---
12
+ <figure class:list={["media-text", mediaSide === "right" && "media-right", natural && "media-natural", className]}>
13
+ <div class="media-text-media">
14
+ <LightboxLink href={src.src} w={src.width} h={src.height} caption={alt}>
15
+ {natural
16
+ /* No responsive widths/sizes here: a `sizes` attribute makes the
17
+ browser report that size as the intrinsic width, which defeats
18
+ 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} />
22
+ </LightboxLink>
23
+ </div>
24
+ <figcaption class="media-text-content caption"><slot /></figcaption>
25
+ </figure>
26
+
27
+ <style>
28
+ .media-text {
29
+ display: grid;
30
+ grid-template-columns: 1fr 1fr;
31
+ gap: 20px;
32
+ align-items: center;
33
+ margin: 20px 0;
34
+ }
35
+ .media-right .media-text-media { order: 2; }
36
+ .media-text-media { margin: 0; line-height: 0; }
37
+ /* The side text is the caption: type from the .caption pattern (owner
38
+ request 2026-08-14), left-aligned while it sits in its own column;
39
+ it centers only when stacked below. */
40
+ .media-text-content { text-align: left; }
41
+ .media-text-content :global(p) { margin: 0 0 0.5em; }
42
+ .media-text-content :global(p:last-child) { margin-bottom: 0; }
43
+ /* Outline comes from the .prose img standard in prose.css. */
44
+ .media-text-media :global(img) { width: 100%; height: auto; }
45
+ /* natural: opt-in for small diagrams and cartoons that look blown out
46
+ when stretched to the media column; renders at intrinsic size,
47
+ centered in the cell (owner request 2026-07-30). */
48
+ .media-natural .media-text-media { text-align: center; }
49
+ /* The link hugs the natural-size image so a corner badge lands on the
50
+ image, not the cell (same idiom as BlogImage). */
51
+ .media-natural .media-text-media :global(.lightbox-link) { width: fit-content; max-width: 100%; margin-inline: auto; }
52
+ .media-natural .media-text-media :global(img) { width: auto; max-width: 100%; }
53
+ @media (max-width: 600px) {
54
+ /* Stacked, the caption sits below the image like BlogImage's:
55
+ centered, hugging at the same 5px. */
56
+ .media-text { grid-template-columns: 1fr; gap: 5px; }
57
+ .media-right .media-text-media { order: 0; }
58
+ .media-text-content { text-align: center; }
59
+ }
60
+ </style>
@@ -0,0 +1,42 @@
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
+ interface Entry { role: string; hex: string; note?: string }
8
+ interface Props { entries: Entry[]; class?: string }
9
+ const { entries, class: className } = Astro.props;
10
+ ---
11
+ <div class:list={["table-scroll", className]}>
12
+ <table class="palette">
13
+ <thead>
14
+ <tr>
15
+ <th><span class="screen-reader-text">Swatch</span></th>
16
+ <th>Role</th>
17
+ <th>Hex</th>
18
+ <th>Used for</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ {entries.map((e) => (
23
+ <tr>
24
+ <td><span class="palette-chip" aria-hidden="true" style={`background-color:${e.hex}`}></span></td>
25
+ <td>{e.role}</td>
26
+ <td><code>{e.hex}</code></td>
27
+ <td>{e.note}</td>
28
+ </tr>
29
+ ))}
30
+ </tbody>
31
+ </table>
32
+ </div>
33
+
34
+ <style>
35
+ .palette-chip {
36
+ display: inline-block;
37
+ width: 18px;
38
+ height: 18px;
39
+ border: var(--stroke) solid var(--rule);
40
+ vertical-align: middle;
41
+ }
42
+ </style>
@@ -0,0 +1,21 @@
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 }
10
+ const { by, when, class: className } = Astro.props;
11
+ const attribution = [by, when].filter(Boolean).join(", ");
12
+ ---
13
+ <blockquote class:list={[className]}>
14
+ <slot />
15
+ {attribution && <footer class="quote-attribution caption">~ {attribution}</footer>}
16
+ </blockquote>
17
+
18
+ <style>
19
+ /* Type from the .caption pattern, right-aligned under the quote. */
20
+ .quote-attribution { text-align: right; }
21
+ </style>
@@ -0,0 +1,5 @@
1
+ ---
2
+ interface Props { size?: number; class?: string }
3
+ const { size = 40, class: className } = Astro.props;
4
+ ---
5
+ <div aria-hidden="true" style={`height:${size}px`} class:list={["spacer", className]}></div>
@@ -0,0 +1,126 @@
1
+ ---
2
+ import type { ImageMetadata } from "astro";
3
+ import SmartImage from "../SmartImage.astro";
4
+ import LightboxLink from "../LightboxLink.astro";
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[] }) & {
15
+ alt?: string;
16
+ class?: string;
17
+ };
18
+ const { src, alt = "", images, class: className } = Astro.props;
19
+
20
+ const hasSingle = src !== undefined;
21
+ const hasMany = images !== undefined && images.length > 0;
22
+ if (hasSingle === hasMany) {
23
+ throw new Error("Step: pass exactly one of `src` (with `alt`) or a non-empty `images` array");
24
+ }
25
+ const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
26
+ ---
27
+ <li class:list={["step", className]}>
28
+ <div class="step-media">
29
+ {photos.map((p) => {
30
+ 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
+ })}
37
+ </div>
38
+ <div class="step-body"><slot /></div>
39
+ </li>
40
+
41
+ <style>
42
+ .step {
43
+ display: grid;
44
+ grid-template-columns: auto 1fr;
45
+ gap: 20px;
46
+ align-items: start;
47
+ /* prose.css pads every .prose li; rows space themselves via
48
+ the Walkthrough gap instead. */
49
+ padding: 0;
50
+ counter-increment: walkthrough-step;
51
+ }
52
+ /* Photos sit in a row, one 200px track each, so a two-photo step's cell
53
+ is 410px and its prose starts further right (owner pick M3). The row
54
+ does not wrap, so two photos is the practical cap for the 990px
55
+ prose column: three still fits, four is 830px, five overflows. */
56
+ .step-media {
57
+ position: relative;
58
+ display: grid;
59
+ grid-auto-flow: column;
60
+ grid-auto-columns: 200px;
61
+ gap: 10px;
62
+ line-height: 0;
63
+ }
64
+ /* The 2px outline comes from .prose img in prose.css. */
65
+ .step-media :global(img) { width: 100%; height: auto; }
66
+ /* The body is 1fr of the layout's column; nothing here narrows it. */
67
+ .step-body { min-width: 0; }
68
+ .step-body :global(p:last-child) { margin-bottom: 0; }
69
+
70
+ /* Number chip (owner pick B2): a 28px filled square riding the media
71
+ cell's top-left corner, half outside the frame so it never covers the
72
+ photo. The 14px margins reserve its overhang on every step of a
73
+ numbered walkthrough, chip or not, so rows stay aligned. Decorative:
74
+ the <ol> already carries list position for assistive tech. Whether
75
+ the chip shows is the parent's call: Walkthrough sets
76
+ --step-chip-display and --step-media-margin on its numbered variant
77
+ (step 5 replaced the :global(.walkthrough-numbered) parent-read). */
78
+ /* stylelint-disable-next-line no-duplicate-selectors -- split from the
79
+ grid rule above so the contract stays grouped with its own comment. */
80
+ .step-media { margin: var(--step-media-margin, 0); }
81
+ .step-media::before {
82
+ content: counter(walkthrough-step);
83
+ display: var(--step-chip-display, none);
84
+ position: absolute;
85
+ top: -14px;
86
+ left: -14px;
87
+ z-index: 1;
88
+ width: 28px;
89
+ height: 28px;
90
+ align-items: center;
91
+ justify-content: center;
92
+ background: var(--ink);
93
+ color: var(--on-ink);
94
+ font-family: var(--font-body);
95
+ font-size: 14px;
96
+ font-weight: 700;
97
+ line-height: 1;
98
+ }
99
+
100
+ /* Tablets and phone-landscape (2026-08-16 fix wave): between 600px and
101
+ about 780px a two-photo cell is still a hard 410px, which leaves the
102
+ prose a 140-310px ribbon while the row is still two-column. Owner pick
103
+ M3 keeps the row layout above 800px, so stack the media cell's photos
104
+ here without touching the row itself; the 600px rules below still take
105
+ over the row layout and stay unchanged. */
106
+ @media (max-width: 800px) {
107
+ .step-media {
108
+ grid-auto-flow: row;
109
+ grid-auto-columns: auto;
110
+ grid-template-columns: 200px;
111
+ }
112
+ }
113
+
114
+ /* Phones (owner pick P2): stack, photo centered above the prose, photos
115
+ of a multi-photo step stacking too. Same breakpoint as MediaText. */
116
+ @media (max-width: 600px) {
117
+ .step { grid-template-columns: 1fr; gap: 10px; }
118
+ .step-media {
119
+ grid-auto-flow: row;
120
+ grid-auto-columns: auto;
121
+ grid-template-columns: 200px;
122
+ justify-self: center;
123
+ margin: var(--step-media-margin-stacked, 0);
124
+ }
125
+ }
126
+ </style>
@@ -0,0 +1,42 @@
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 }
7
+ const { numbered = false, class: className } = Astro.props;
8
+ ---
9
+ {/* list-style: none drops the implicit list role in Safari/VoiceOver, so
10
+ role="list" restores it. html-validate's no-redundant-role and
11
+ prefer-native-element both flag that as unnecessary on a native <ol>,
12
+ which is true generically but wrong for this specific case; disabled
13
+ surgically for this element only. */}
14
+ <!-- [html-validate-disable-next no-redundant-role, prefer-native-element] -->
15
+ <ol class:list={["walkthrough", numbered && "walkthrough-numbered", className]} role="list">
16
+ <slot />
17
+ </ol>
18
+
19
+ <style>
20
+ /* List semantics without list styling. prose.css gives .prose ol
21
+ a left margin, indent, and decimal markers; the corner chip in Step is
22
+ the only visible number, and only when numbered. */
23
+ .walkthrough {
24
+ list-style: none;
25
+ margin: 20px 0;
26
+ padding: 0;
27
+ display: flex;
28
+ flex-direction: column;
29
+ gap: 22px;
30
+ counter-reset: walkthrough-step;
31
+ }
32
+
33
+ /* The numbered variant's contract with Step (step 5): custom
34
+ properties instead of a :global parent-read. Inherited by every
35
+ slotted Step; un-numbered walkthroughs leave the defaults (no chip,
36
+ no reserved margin). */
37
+ .walkthrough-numbered {
38
+ --step-chip-display: flex;
39
+ --step-media-margin: 14px 0 0 14px;
40
+ --step-media-margin-stacked: 14px 0 0;
41
+ }
42
+ </style>