@half-built/astro 0.5.0 → 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 +33 -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 +8 -11
  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 +7 -15
  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
package/README.md CHANGED
@@ -12,8 +12,39 @@ is the design record for this package.
12
12
 
13
13
  ## Icons
14
14
 
15
- Default icon glyphs are derived from Lucide (https://lucide.dev), ISC
16
- license. See `ICONS-LICENSE`.
15
+ Every glyph the package draws comes from one registry,
16
+ `scripts/core/icons.ts`, derived from Lucide (https://lucide.dev), ISC
17
+ license; see `ICONS-LICENSE`. Templates render one through
18
+ `components/Icon.astro`:
19
+
20
+ ```astro
21
+ <Icon name="search" size={14} />
22
+ ```
23
+
24
+ `name` is a registry key (`x`, `play`, `sparkles`, `pause`,
25
+ `rotate-ccw`, `chevron-left`, `chevron-right`, `chevron-up`,
26
+ `arrow-left`, `arrow-right`, `sun`, `moon`, `search`, `clock`, `user`,
27
+ `calendar`, `circle`), `size` a CSS
28
+ length or pixel count (default `1em`, tracking the parent's font
29
+ size), `strokeWidth` defaults to 2.5, and `class` lands on the svg.
30
+ The icon is decorative by contract (aria-hidden, pointer-events none),
31
+ so the accessible name belongs to the button or link around it. The
32
+ svg arrives through `set:html` and carries no scoped-style attribute;
33
+ style it from the parent with `:global(svg)`. Client scripts take the
34
+ `ICON_*` strings from the same file. Add a glyph to the registry,
35
+ never as inline `<svg>` in a component; a test enforces that.
36
+
37
+ ## Corner badges
38
+
39
+ `CornerBadges.astro` takes its chips as data, `badges: Badge[]` from
40
+ `scripts/core/badges.ts`: a `key` (rendered as the `badge-<key>` class,
41
+ the styling hook), a `label`, and an `icon` name from the registry.
42
+ The two house conventions ship as `GENAI_BADGE` and `DEMO_BADGE`; a
43
+ site's view-model lists the badges a post carries, in order, and can
44
+ add its own without a package change. `PostCardModel.badges` carries
45
+ them to the card. The content components (`BlogImage`, `GalleryImage`,
46
+ `MediaText`) keep a `genai` boolean as authoring sugar for MDX and take
47
+ `badges` for anything else.
17
48
 
18
49
  ## EditorNote
19
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@half-built/astro",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Astro components, islands, and pure helpers for the half-built design system.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,7 +9,10 @@
9
9
  "directory": "packages/astro"
10
10
  },
11
11
  "type": "module",
12
- "files": ["src", "ICONS-LICENSE"],
12
+ "files": [
13
+ "src",
14
+ "ICONS-LICENSE"
15
+ ],
13
16
  "exports": {
14
17
  "./components/Masthead.astro": "./src/components/SiteHeader.astro",
15
18
  "./components/*": "./src/components/*",
@@ -19,6 +22,10 @@
19
22
  "./shiki/code-theme": "./src/shiki/code-theme.mjs",
20
23
  "./shiki/code-vars": "./src/shiki/code-vars.mjs"
21
24
  },
22
- "peerDependencies": { "astro": "^5.0.0" },
23
- "publishConfig": { "access": "public" }
25
+ "peerDependencies": {
26
+ "astro": "^5.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
24
31
  }
@@ -1,10 +1,17 @@
1
1
  ---
2
2
  import type { ImageMetadata } from "astro";
3
3
 
4
- interface Props { name: string; href: string; image: ImageMetadata; class?: string }
4
+ interface Props {
5
+ name: string;
6
+ href: string;
7
+ image: ImageMetadata;
8
+ class?: string;
9
+ }
10
+
5
11
  const { name, href, image, class: className } = Astro.props;
6
12
  const bg = `background-image:url('${image.src.replace(/'/g, "%27").replace(/"/g, "%22")}')`;
7
13
  ---
14
+
8
15
  <a class:list={["category-single", className]} href={href}>
9
16
  <span class="category-img" style={bg}></span>
10
17
  <span class="category-label chip">{name}</span>
@@ -21,6 +28,7 @@ const bg = `background-image:url('${image.src.replace(/'/g, "%27").replace(/"/g,
21
28
  text-decoration: none;
22
29
  }
23
30
  .category-img { position: absolute; inset: 0; background-size: cover; background-position: center; }
31
+
24
32
  /* Chip look comes from the .chip pattern; card-specific metrics only. */
25
33
  .category-label {
26
34
  position: relative;
@@ -1,27 +1,20 @@
1
1
  ---
2
- /* Corner badges (owner request 2026-08-27, after launch): the chip
3
- family the archive cards' read-time chip belongs to, pinned to the
4
- lower-right corner of the image they sit on.
5
- - AI Art marks an AI-generated image: heroes through the heroGenAI
6
- frontmatter flag, content images through the genai prop on
7
- BlogImage, GalleryImage, and MediaText. Images that only went
8
- through ordinary editing carry nothing.
9
- - Demo marks a post that carries an interactive demo (the demo
10
- frontmatter flag), on the card and the post hero.
11
- Renders nothing when neither applies. The container is absolutely
12
- positioned, so the parent supplies position: relative. pointer-events
13
- off: a badge inside a lightbox link must not swallow the tap. */
14
- import { ICON_PLAY, ICON_SPARKLES } from "../scripts/core/icons";
2
+ import Icon from "./Icon.astro";
3
+ import type { Badge } from "../scripts/core/badges";
15
4
 
16
- interface Props { genai?: boolean; demo?: boolean; class?: string; genaiLabel?: string; demoLabel?: string }
17
- const { genai = false, demo = false, class: className, genaiLabel = "AI Art", demoLabel = "Demo" } = Astro.props;
5
+ interface Props {
6
+ badges?: Badge[];
7
+ class?: string;
8
+ }
9
+
10
+ const { badges = [], class: className } = Astro.props;
18
11
  ---
19
- {(genai || demo) && (
20
- <span class:list={["corner-badges", className]}>
21
- {genai && <span class="chip badge-genai"><Fragment set:html={ICON_SPARKLES} /> {genaiLabel}</span>}
22
- {demo && <span class="chip badge-demo"><Fragment set:html={ICON_PLAY} /> {demoLabel}</span>}
23
- </span>
24
- )}
12
+
13
+ {badges.length > 0 && <span class:list={["corner-badges", className]}>
14
+ {badges.map((badge) => <span class:list={["chip", `badge-${badge.key}`]}>
15
+ <Icon name={badge.icon} /> {badge.label}
16
+ </span>)}
17
+ </span>}
25
18
 
26
19
  <style>
27
20
  /* Chip look comes from the .chip pattern; the read-time chip sits
@@ -1,25 +1,28 @@
1
1
  ---
2
2
  import type { SitemapGroup, EcosystemEntry } from "./models";
3
3
  import { BP_PHONE_MAX } from "../scripts/core/breakpoints";
4
- /* The real half-built-robots.com footer is a single bottom bar with the
5
- copyright line, and the live site has no footer widget row. The sitemap
6
- below the copyright line is a deliberate divergence, owner-directed
7
- 2026-07-30 (docs/superpowers/specs/2026-07-30-footer-sitemap-design.md).
8
- The old theme credit stays dropped: this build is original code, not the
9
- Adore theme. Everything below renders from the sitemap/ecosystem props
10
- Base.astro passes in; SitemapGroup and EcosystemEntry live in models.ts. */
4
+
11
5
  interface Props {
12
6
  siteName: string;
13
7
  legalHolder: string;
14
8
  sitemap: SitemapGroup[];
15
9
  ecosystem: EcosystemEntry[];
16
- /* The entry rendered as this site itself (unlinked, bold). */
17
10
  ecosystemSelf: string;
18
11
  ecosystemTitle?: string;
19
12
  }
20
- const { siteName, legalHolder, sitemap, ecosystem, ecosystemSelf, ecosystemTitle = "Ecosystem" } = Astro.props;
13
+
14
+ const {
15
+ siteName,
16
+ legalHolder,
17
+ sitemap,
18
+ ecosystem,
19
+ ecosystemSelf,
20
+ ecosystemTitle = "Ecosystem",
21
+ } = Astro.props;
22
+
21
23
  const year = new Date().getFullYear();
22
24
  ---
25
+
23
26
  <footer id="colophon" class="site-footer">
24
27
  <div class="bottom-footer tint-overlay">
25
28
  <div class="shell">
@@ -30,32 +33,38 @@ const year = new Date().getFullYear();
30
33
  which the line break replaces below the mobile breakpoint
31
34
  (owner call 2026-08-20). Desktop and tablet render the
32
35
  one line exactly as before. */}
33
- <slot name="site-info"><span>
36
+ <slot name="site-info"
37
+ ><span>
34
38
  <span class="site-info-seg">Copyright © {year}</span>{" "}
35
39
  <span class="site-info-seg">{legalHolder}</span>{" "}
36
40
  <span class="site-info-sep" aria-hidden="true">|</span>{" "}
37
41
  <a class="site-info-seg" href="/">{siteName}</a>
38
- </span></slot>
42
+ </span></slot
43
+ >
39
44
  </div>
40
45
  </div>
41
46
  <nav class="footer-sitemap" aria-label="Sitemap">
42
47
  {sitemap.map((group) =>
43
48
  group.collapsible ? (
44
- <details class="footer-sitemap-group footer-sitemap-collapsible" open data-phone-open={group.phoneOpen ? "" : undefined}>
49
+ <details
50
+ class="footer-sitemap-group footer-sitemap-collapsible"
51
+ open
52
+ data-phone-open={group.phoneOpen ? "" : undefined}
53
+ >
45
54
  <summary><h2>{group.title}</h2></summary>
46
55
  <ul>
47
- {group.links.map((link) => (
48
- <li><a href={link.href}>{link.label}</a></li>
49
- ))}
56
+ {group.links.map((link) => <li>
57
+ <a href={link.href}>{link.label}</a>
58
+ </li>)}
50
59
  </ul>
51
60
  </details>
52
61
  ) : (
53
62
  <section class="footer-sitemap-group">
54
63
  <h2>{group.title}</h2>
55
64
  <ul>
56
- {group.links.map((link) => (
57
- <li><a href={link.href}>{link.label}</a></li>
58
- ))}
65
+ {group.links.map((link) => <li>
66
+ <a href={link.href}>{link.label}</a>
67
+ </li>)}
59
68
  </ul>
60
69
  </section>
61
70
  ),
@@ -63,17 +72,23 @@ const year = new Date().getFullYear();
63
72
  <details class="footer-sitemap-group footer-sitemap-collapsible" open>
64
73
  <summary><h2>{ecosystemTitle}</h2></summary>
65
74
  <ul data-ecosystem>
66
- {ecosystem.map((entry) => (
67
- <li>
68
- {entry.key === ecosystemSelf ? (
69
- <span class="footer-sitemap-self" aria-current="page">{entry.label}</span>
70
- ) : entry.href !== null ? (
71
- <a href={entry.href}>{entry.label}</a>
72
- ) : (
73
- <span class="footer-sitemap-pending">{entry.label}</span>
74
- )}
75
- </li>
76
- ))}
75
+ {ecosystem.map((entry) => {
76
+ /* Three states, one block each: this site, a live
77
+ property, a property with no href yet. */
78
+ const self = entry.key === ecosystemSelf;
79
+ const live = !self && entry.href !== null;
80
+ const pending = !self && !live;
81
+
82
+ return <li>
83
+ {self && <span class="footer-sitemap-self" aria-current="page"
84
+ >{entry.label}</span
85
+ >}{live && <a href={entry.href}
86
+ >{entry.label}</a
87
+ >}{pending && <span class="footer-sitemap-pending"
88
+ >{entry.label}</span
89
+ >}
90
+ </li>;
91
+ })}
77
92
  </ul>
78
93
  </details>
79
94
  </nav>
@@ -97,12 +112,14 @@ const year = new Date().getFullYear();
97
112
 
98
113
  <style>
99
114
  .site-footer { margin-top: 50px; }
115
+
100
116
  /* Tint comes from the .tint-overlay pattern (default 0.1 opacity).
101
117
  The bottom padding adds --dock-bottom (tokens/primitives.css): a
102
118
  site with a viewport-fixed bottom cluster sets it to the room
103
119
  that cluster occupies, and the tinted band runs on below the last
104
120
  link by that much, so the page scrolls far enough to clear it. */
105
121
  .bottom-footer { padding: 15px 0 calc(20px + var(--dock-bottom, 0px)); z-index: 1; }
122
+
106
123
  .bottom-footer-info {
107
124
  display: flex;
108
125
  flex-wrap: wrap;
@@ -111,9 +128,11 @@ const year = new Date().getFullYear();
111
128
  position: relative;
112
129
  font-size: var(--font-size-sm);
113
130
  }
131
+
114
132
  /* The wrapped phone lines center under each other like the sitemap
115
133
  below (owner report 2026-08-25); one line on desktop, as before. */
116
134
  .site-info { text-align: center; }
135
+
117
136
  /* Underlined, unlike the sitemap links below: this one sits inside
118
137
  running text (the copyright line), where the same ink at 0.8
119
138
  opacity is not a visible link mark (WCAG 1.4.1, axe
@@ -121,6 +140,7 @@ const year = new Date().getFullYear();
121
140
  where the pipe retires and the line break is all that frames it). */
122
141
  .site-info a { text-decoration: underline; opacity: 0.8; transition: var(--transition); color: var(--ink); }
123
142
  .site-info-seg { white-space: nowrap; }
143
+
124
144
  /* The pipe's separating job is done by the line break when the
125
145
  segments wrap; below the mobile breakpoint the break is expected,
126
146
  so the pipe retires there. */
@@ -141,25 +161,30 @@ const year = new Date().getFullYear();
141
161
  border-top: 1px solid var(--hairline);
142
162
  font-size: var(--font-size-sm);
143
163
  }
164
+
144
165
  .footer-sitemap-group h2 {
145
166
  margin: 0 0 8px;
146
167
  font-size: var(--font-size-xs);
147
168
  font-weight: 700;
148
169
  text-transform: uppercase;
149
170
  }
171
+
150
172
  .footer-sitemap-group ul {
151
173
  list-style: none;
152
174
  margin: 0;
153
175
  padding: 0;
154
176
  }
155
177
  .footer-sitemap-group li { margin: 4px 0; }
178
+
156
179
  .footer-sitemap a {
157
180
  text-decoration: none;
158
181
  opacity: 0.8;
159
182
  transition: var(--transition);
160
183
  }
184
+
161
185
  .footer-sitemap a:hover,
162
186
  .footer-sitemap a:focus { opacity: 1; text-decoration: underline; }
187
+
163
188
  /* The monochrome theme makes a color marker invisible (text already IS
164
189
  the primary color), so "you are here" is weight, not hue. */
165
190
  .footer-sitemap-self { font-weight: 700; }
@@ -186,12 +211,14 @@ const year = new Date().getFullYear();
186
211
  gap: 14px;
187
212
  }
188
213
  .footer-sitemap-group { text-align: center; width: 100%; }
214
+
189
215
  .footer-sitemap-collapsible summary {
190
216
  display: block;
191
217
  position: relative;
192
218
  cursor: pointer;
193
219
  pointer-events: auto;
194
220
  }
221
+
195
222
  .footer-sitemap-collapsible summary::before {
196
223
  content: "+";
197
224
  position: absolute;
@@ -208,6 +235,7 @@ const year = new Date().getFullYear();
208
235
  }
209
236
  .footer-sitemap-collapsible[open] summary::before { content: "\2212"; }
210
237
  .footer-sitemap-collapsible:not([open]) summary h2 { margin-bottom: 0; }
238
+
211
239
  /* Keyboard focus is the site-wide ring (base/reset.css). */
212
240
  }
213
241
  </style>
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { iconMarkup, type IconName } from "../scripts/core/icons";
3
+
4
+ interface Props {
5
+ name: IconName;
6
+ size?: number | string;
7
+ strokeWidth?: number;
8
+ class?: string;
9
+ }
10
+
11
+ const { name, size, strokeWidth, class: className } = Astro.props;
12
+ ---
13
+
14
+ <Fragment
15
+ set:html={iconMarkup(name, { size, strokeWidth, class: className })}
16
+ />
@@ -1,13 +1,22 @@
1
1
  ---
2
- /* The build-time lightbox anchor (step 5, 2026-08-30): owner of the
3
- data-lb-* contract that scripts/lightbox.ts itemFor() reads. The
4
- caption string feeds both data-lb-caption and the aria-label detail;
5
- every pre-step-5 site passed the same string to both. class:list
6
- stays FIRST in the tag: content-components and kitchen-sink pin
7
- `<a class="lightbox-link"` as the anchor's opening. Base styling
8
- (display, position, cursor) lives in styles/lightbox.css; no scoped
9
- CSS here so callers' :global-wrapped rules reach the anchor. */
10
- interface Props extends Record<string, unknown> { href: string; w: number; h: number; caption?: string; class?: string }
2
+ interface Props extends Record<string, unknown> {
3
+ href: string;
4
+ w: number;
5
+ h: number;
6
+ caption?: string;
7
+ class?: string;
8
+ }
9
+
11
10
  const { href, w, h, caption = "", class: className, ...rest } = Astro.props;
12
11
  ---
13
- <a class:list={["lightbox-link", className]} href={href} data-lb-w={w} data-lb-h={h} data-lb-caption={caption} aria-label={`View full-size image${caption ? `: ${caption}` : ""}`} {...rest}><slot /></a>
12
+
13
+ <a
14
+ class:list={["lightbox-link", className]}
15
+ href={href}
16
+ data-lb-w={w}
17
+ data-lb-h={h}
18
+ data-lb-caption={caption}
19
+ aria-label={`View full-size image${caption ? `: ${caption}` : ""}`}
20
+ {...rest}
21
+ ><slot
22
+ /></a>
@@ -1,19 +1,21 @@
1
1
  ---
2
- /* One list widget for the sidebar's three lists (step 6, 2026-08-30):
3
- a Widget holding a link list, counts rendered when present. Replaces
4
- RecentPosts, CategoriesWidget, and ArchivesWidget; the sidebar builds
5
- the items via src/lib/view-models.ts. The class prop rides down to
6
- Widget's root, so this file has no class:list of its own. */
7
2
  import Widget from "./Widget.astro";
8
3
  import type { LinkListItem } from "./models";
9
4
 
10
- interface Props { title: string; items: LinkListItem[]; headingLevel?: 2 | 3 | 4; class?: string }
5
+ interface Props {
6
+ title: string;
7
+ items: LinkListItem[];
8
+ headingLevel?: 2 | 3 | 4;
9
+ class?: string;
10
+ }
11
+
11
12
  const { title, items, headingLevel = 2, class: className } = Astro.props;
12
13
  ---
14
+
13
15
  <Widget title={title} headingLevel={headingLevel} class={className}>
14
16
  <ul>
15
- {items.map((i) => (
16
- <li><a href={i.href}>{i.label}</a>{i.count !== undefined && ` (${i.count})`}</li>
17
- ))}
17
+ {items.map((i) => <li>
18
+ <a href={i.href}>{i.label}</a>{i.count !== undefined && ` (${i.count})`}
19
+ </li>)}
18
20
  </ul>
19
21
  </Widget>
@@ -1,7 +1,7 @@
1
1
  ---
2
- /* Pagination matching the live WordPress markup: nav.navigation.pagination
3
- with .nav-links containing .page-numbers boxes and Prev/Next arrows. */
4
2
  import { archivePagePath } from "../lib/paginate";
3
+ import Icon from "./Icon.astro";
4
+
5
5
  interface Props {
6
6
  current: number;
7
7
  total: number;
@@ -9,34 +9,54 @@ interface Props {
9
9
  class?: string;
10
10
  labels?: { heading?: string; prev?: string; next?: string };
11
11
  }
12
- const { current, total, base = "", class: className, labels: labelsOverride = {} } = Astro.props;
13
- const labels = { heading: "Posts pagination", prev: "Previous", next: "Next", ...labelsOverride };
12
+
13
+ const {
14
+ current,
15
+ total,
16
+ base = "",
17
+ class: className,
18
+ labels: labelsOverride = {},
19
+ } = Astro.props;
20
+
21
+ const labels = {
22
+ heading: "Posts pagination",
23
+ prev: "Previous",
24
+ next: "Next",
25
+ ...labelsOverride,
26
+ };
27
+
14
28
  const pageHref = (n: number) => archivePagePath(n, base);
15
29
  ---
16
- {total > 1 && (
17
- <nav class:list={["navigation", "pagination", className]} aria-label={labels.heading}>
18
- <h2 class="screen-reader-text">{labels.heading}</h2>
19
- <div class="nav-links">
20
- {current > 1 && (
21
- <a class="prev page-numbers press-box" href={pageHref(current - 1)}>
22
- <svg viewBox="0 0 448 512" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M257.5 445.1l-22.2 22.2c-9.4 9.4-24.6 9.4-33.9 0L7 273c-9.4-9.4-9.4-24.6 0-33.9L201.4 44.7c9.4-9.4 24.6-9.4 33.9 0l22.2 22.2c9.5 9.5 9.3 25-.4 34.3L136.6 216H424c13.3 0 24 10.7 24 24v32c0 13.3-10.7 24-24 24H136.6l120.5 114.8c9.8 9.3 10 24.8.4 34.3z"/></svg>
23
- <span class="screen-reader-text">{labels.prev}</span>
24
- </a>
25
- )}
26
- {Array.from({ length: total }, (_, i) => i + 1).map((n) =>
27
- n === current
28
- ? <span aria-current="page" class="page-numbers current">{n}</span>
29
- : <a class="page-numbers press-box" href={pageHref(n)}>{n}</a>
30
- )}
31
- {current < total && (
32
- <a class="next page-numbers press-box" href={pageHref(current + 1)}>
33
- <svg viewBox="0 0 448 512" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M190.5 66.9l22.2-22.2c9.4-9.4 24.6-9.4 33.9 0L441 239c9.4 9.4 9.4 24.6 0 33.9L246.6 467.3c-9.4 9.4-24.6 9.4-33.9 0l-22.2-22.2c-9.5-9.5-9.3-25 .4-34.3L311.4 296H24c-13.3 0-24-10.7-24-24v-32c0-13.3 10.7-24 24-24h287.4L190.9 101.2c-9.8-9.3-10-24.8-.4-34.3z"/></svg>
34
- <span class="screen-reader-text">{labels.next}</span>
35
- </a>
36
- )}
37
- </div>
38
- </nav>
39
- )}
30
+
31
+ {total > 1 && <nav
32
+ class:list={["navigation", "pagination", className]}
33
+ aria-label={labels.heading}
34
+ >
35
+ <h2 class="screen-reader-text">{labels.heading}</h2>
36
+ <div class="nav-links">
37
+ {current > 1 && <a
38
+ class="prev page-numbers press-box"
39
+ href={pageHref(current - 1)}
40
+ >
41
+ <Icon name="arrow-left" size={14} />
42
+ <span class="screen-reader-text">{labels.prev}</span>
43
+ </a>}
44
+ {Array.from({ length: total }, (_, i) => i + 1).map((n) =>
45
+ n === current ? (
46
+ <span aria-current="page" class="page-numbers current">{n}</span>
47
+ ) : (
48
+ <a class="page-numbers press-box" href={pageHref(n)}>{n}</a>
49
+ ),
50
+ )}
51
+ {current < total && <a
52
+ class="next page-numbers press-box"
53
+ href={pageHref(current + 1)}
54
+ >
55
+ <Icon name="arrow-right" size={14} />
56
+ <span class="screen-reader-text">{labels.next}</span>
57
+ </a>}
58
+ </div>
59
+ </nav>}
40
60
 
41
61
  <style>
42
62
  .nav-links {
@@ -45,6 +65,7 @@ const pageHref = (n: number) => archivePagePath(n, base);
45
65
  margin: 10px 0 20px;
46
66
  justify-content: center;
47
67
  }
68
+
48
69
  .nav-links a,
49
70
  .nav-links span {
50
71
  text-decoration: none;
@@ -57,11 +78,13 @@ const pageHref = (n: number) => archivePagePath(n, base);
57
78
  color: var(--ink);
58
79
  border: 1px solid var(--ink);
59
80
  }
81
+
60
82
  .nav-links a:hover,
61
83
  .nav-links a:focus {
62
84
  color: var(--ink);
63
85
  border-color: var(--rule);
64
86
  }
87
+
65
88
  /* Press feedback comes from .press-box on the links; the shared rule
66
89
  above keeps this site's 1px cell border over the pattern's 2px. */
67
90
  .nav-links span.current {