@atelic-action/ui 3.5.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -98,7 +98,7 @@ Every export, grouped by the entry it is imported from. The gallery column links
98
98
  | `SocialStrip` | `@atelic-action/ui/sections` | A static grid of the business's social photos linking to the profile, never a live embed | [#social-strip](https://ui.atelic.me/#social-strip) |
99
99
  | `SplitSection` | `@atelic-action/ui/sections` | The two column band: copy beside copy, or media beside copy | [#split-section](https://ui.atelic.me/#split-section) |
100
100
  | `SiteConfig` and its member types, `formatClock`, `formatHoursRange`, `isClosed`, `isHoneypotFilled`, `trackEvent` | `@atelic-action/ui/sections` | The site contract the sections read, and the hours, honeypot, and lead event helpers | none |
101
- | `ImageFrame`, `ProseBlocks`, `QuoteCard`, `StarRow`, `TickList` | `@atelic-action/ui/primitives` | The marketing primitives: a framed image, prose blocks, a quote card, a row of filled stars, a tick list | [#marketing-primitives](https://ui.atelic.me/#marketing-primitives) |
101
+ | `ImageFrame`, `ProseBlocks`, `QuoteCard`, `StarRow`, `TickList` | `@atelic-action/ui/primitives` | The marketing primitives: a framed image, prose blocks (headings at two levels, paragraphs, lists, and figures: small captioned tiles in rows, an image or a silent looping clip played only while on screen, each opening large in a dialog), a quote card, a row of filled stars, a tick list | [#marketing-primitives](https://ui.atelic.me/#marketing-primitives) |
102
102
  | `SiteShell` | `@atelic-action/ui/chrome` | A marketing page's whole chrome: skip link, header, main, footer, sticky bar, mounting the base path provider | none |
103
103
  | `BasePathProvider`, `useBasePath`, `useWithBase`, `joinBase`, `stripBase`, `hasBase`, `normalizeBasePath` | `@atelic-action/ui/routing` | The base path a site is served under, read by Button, Chip, and every section that builds a link | none |
104
104
  | `joinBase`, `stripBase`, `hasBase`, `normalizeBasePath` | `@atelic-action/ui/routing/base-path` | The same four pure helpers, built, for what Node runs (a Playwright spec, a script) | none |
@@ -405,6 +405,7 @@ The package installs the scripts every artifact site used to carry as files, so
405
405
  | `atelic-seal-document <config.json> [out.json]` | Seals a document into the payload that `@atelic-action/artifact`'s `Gate` reads, one wrapped key for each reader. The payload names no one: no address, and readers numbered `r1`, `r2` whatever the config calls them | `seal` |
406
406
  | `atelic-clean-sitemap` | Strips fragment and trailing slash duplicates out of `dist/client/sitemap.xml`; says so and passes when a based build wrote none | A marketing site's `build`, after vite |
407
407
  | `atelic-check-base-path` | Fails the build when a root path in `dist/client` lacks the base path, or `vercel.json` lacks (or strays from) the two rewrites a based cut needs; a `VITE_BASE_PATH` rehearsal only warns about `vercel.json` off Vercel | Last step of a marketing site's `build` |
408
+ | `atelic-check-images` | Fails the build when `public/images` holds an image over 250 KB, a PNG over 64 KB, or a JPEG that is not a sharing card (`og.jpg` or `<name>-og.jpg`): every photo ships as WebP at about 2x its rendered width (template-marketing's CLAUDE.md, Image budget) | First step of a marketing site's `build` |
408
409
  | `atelic-contact-canary` | Posts a token bearing test message to `$SITE_URL/api/contact` and passes only on a Resend id and the expected destination (`CONTACT_EXPECTED_TO`, `CONTACT_CANARY_TOKEN`); a `.invalid` `SITE_URL` is the off switch | `check:contact`, run daily by the canary workflow |
409
410
 
410
411
  A site's scripts, whole:
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ // atelic-check-images: fails a build whose public/images holds a photo over budget or a JPEG that is not a sharing card.
3
+ import { main } from "../src/scripts/check-images";
4
+
5
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "3.5.0",
3
+ "version": "3.7.0",
4
4
  "description": "Shared UI for the Atelic templates: site chrome, base styles, the component library, the access gate, and the app shell they install",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -19,6 +19,7 @@
19
19
  "bin": {
20
20
  "atelic-check-access": "./bin/atelic-check-access.ts",
21
21
  "atelic-check-base-path": "./bin/atelic-check-base-path.ts",
22
+ "atelic-check-images": "./bin/atelic-check-images.ts",
22
23
  "atelic-check-links": "./bin/atelic-check-links.ts",
23
24
  "atelic-check-wall": "./bin/atelic-check-wall.ts",
24
25
  "atelic-clean-sitemap": "./bin/atelic-clean-sitemap.ts",
@@ -1,15 +1,251 @@
1
+ import { X } from "lucide-react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useWithBase } from "../routing/BasePathProvider";
4
+
1
5
  /**
2
- * One block of ported prose. A deliberately tiny vocabulary — headings,
3
- * paragraphs, lists — because that is what survives extraction from a CMS
4
- * intact, and because anything richer invites rewriting content that was
5
- * meant to be carried across verbatim.
6
+ * One block of prose carried as data. A deliberately tiny vocabulary:
7
+ * headings, paragraphs, lists, and a figure, because that is what survives
8
+ * extraction from a CMS intact, and because anything richer invites rewriting
9
+ * content that was meant to be carried across verbatim.
6
10
  */
7
11
  export interface ProseBlock {
8
- type: "h" | "p" | "ul";
12
+ type: "h" | "p" | "ul" | "figure";
9
13
  /** Present for "h" and "p". */
10
14
  text?: string;
15
+ /**
16
+ * For "h": the heading's level under the page's own h1, where the
17
+ * component's `headingLevel` is the default. A post with sections and
18
+ * sub sections marks the sub sections 3 and leaves the rest alone.
19
+ */
20
+ level?: 2 | 3;
11
21
  /** Present for "ul". */
12
22
  items?: string[];
23
+ /**
24
+ * Present for "figure": the file the figure shows. A `.mp4` or `.webm`
25
+ * plays as a silent loop (a short clip of an exercise, say), anything
26
+ * else is an image. Both load lazily, since a figure sits in running prose
27
+ * below the fold.
28
+ */
29
+ src?: string;
30
+ /** For "figure": what the figure shows, for a reader who cannot see it. */
31
+ alt?: string;
32
+ /** For "figure": a line under it. */
33
+ caption?: string;
34
+ /**
35
+ * For "figure": the file's own width and height in pixels. With both, the
36
+ * tile keeps the file's shape at the row's height and holds it before the
37
+ * file arrives (no layout shift); without them it is 4 by 5.
38
+ */
39
+ width?: number;
40
+ height?: number;
41
+ /** For a clip: a still shown until it plays, and to a reader who prefers reduced motion. */
42
+ poster?: string;
43
+ }
44
+
45
+ const CLIP = /\.(mp4|webm)([?#].*)?$/i;
46
+
47
+ /** A tile keeps its file's shape; the row sets its height. */
48
+ function tileStyle(width?: number, height?: number) {
49
+ return { aspectRatio: width && height ? `${width} / ${height}` : "4 / 5" };
50
+ }
51
+
52
+ /**
53
+ * A silent looping clip. It plays only while it is on screen and pauses when
54
+ * it leaves, and it fetches nothing until it first comes into view, so a post
55
+ * with a clip beside every exercise costs nothing up front. A reader who asked
56
+ * for reduced motion gets it still, with controls to start it.
57
+ */
58
+ function Clip({
59
+ src,
60
+ alt,
61
+ poster,
62
+ inTile = false,
63
+ }: {
64
+ src: string;
65
+ alt?: string;
66
+ poster?: string;
67
+ /** Inside a button, where native controls would nest interactive content; the lightbox has them. */
68
+ inTile?: boolean;
69
+ }) {
70
+ const ref = useRef<HTMLVideoElement>(null);
71
+ const [still, setStill] = useState(false);
72
+ useEffect(() => {
73
+ const video = ref.current;
74
+ if (!video) return;
75
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
76
+ setStill(true);
77
+ return;
78
+ }
79
+ // A pause that interrupts a pending play rejects it with an AbortError;
80
+ // that is the reader scrolling past, not autoplay being refused.
81
+ let observer: IntersectionObserver | undefined;
82
+ const play = () =>
83
+ video.play()?.catch((err: unknown) => {
84
+ if ((err as { name?: string } | null)?.name === "AbortError") return;
85
+ // Autoplay refused: hand the clip to the reader and stop steering it.
86
+ setStill(true);
87
+ observer?.disconnect();
88
+ });
89
+ if (typeof IntersectionObserver === "undefined") {
90
+ play();
91
+ return;
92
+ }
93
+ observer = new IntersectionObserver(
94
+ (entries) => {
95
+ if (entries[entries.length - 1]?.isIntersecting) play();
96
+ else video.pause();
97
+ },
98
+ { rootMargin: "200px 0px" },
99
+ );
100
+ observer.observe(video);
101
+ return () => observer?.disconnect();
102
+ }, []);
103
+ return (
104
+ // biome-ignore lint/a11y/useMediaCaption: a silent loop has no audio to caption; the figcaption and aria-label carry the words
105
+ <video
106
+ ref={ref}
107
+ src={src}
108
+ poster={poster}
109
+ aria-label={alt}
110
+ loop
111
+ muted
112
+ playsInline
113
+ controls={still && !inTile}
114
+ // A still clip never plays, so without a poster it needs its first frame loaded.
115
+ preload={still && !poster ? "metadata" : "none"}
116
+ />
117
+ );
118
+ }
119
+
120
+ /**
121
+ * One figure: a small tile, the caption laid over its foot, that opens the
122
+ * file large in a modal dialog. The platform's dialog gives Escape, focus
123
+ * containment, and focus return to the tile; a click on the backdrop closes
124
+ * it too. The large clip mounts only while the dialog is open, so it costs
125
+ * nothing until a reader asks for it.
126
+ */
127
+ function Figure({ block, withBase }: { block: ProseBlock; withBase: (path: string) => string }) {
128
+ const dialogRef = useRef<HTMLDialogElement>(null);
129
+ const [open, setOpen] = useState(false);
130
+ const src = block.src ? withBase(block.src) : "";
131
+ const poster = block.poster ? withBase(block.poster) : undefined;
132
+ const clip = CLIP.test(src);
133
+ // The description names the tile; the caption is laid over it and read from the figcaption.
134
+ const label = block.alt || block.caption || "Figure";
135
+ // A reader who asked for reduced motion gets the large clip still, with controls.
136
+ const [reduceMotion, setReduceMotion] = useState(false);
137
+
138
+ useEffect(() => {
139
+ const dialog = dialogRef.current;
140
+ if (!dialog) return;
141
+ if (open && !dialog.open) dialog.showModal();
142
+ if (!open && dialog.open) dialog.close();
143
+ }, [open]);
144
+
145
+ if (!src) {
146
+ return (
147
+ <figure className="pb-figure">
148
+ <div className="ph" style={tileStyle(block.width, block.height)}>
149
+ {block.alt ?? "Figure"}
150
+ </div>
151
+ {block.caption ? <figcaption className="pb-caption">{block.caption}</figcaption> : null}
152
+ </figure>
153
+ );
154
+ }
155
+
156
+ return (
157
+ <figure className="pb-figure">
158
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: the backdrop click is a pointer shortcut; the dialog closes on Escape natively and has a close button */}
159
+ <dialog
160
+ ref={dialogRef}
161
+ className="pb-lightbox"
162
+ aria-label={label}
163
+ onClose={() => setOpen(false)}
164
+ onClick={(event) => {
165
+ // A click that lands outside the panel's box is a click on the backdrop; the
166
+ // panel's own padding and blank areas also target the dialog, so test coordinates.
167
+ if (event.target !== event.currentTarget) return;
168
+ const box = event.currentTarget.getBoundingClientRect();
169
+ const outside =
170
+ event.clientX < box.left ||
171
+ event.clientX > box.right ||
172
+ event.clientY < box.top ||
173
+ event.clientY > box.bottom;
174
+ if (outside) setOpen(false);
175
+ }}
176
+ >
177
+ <button
178
+ type="button"
179
+ className="pb-lightbox-close"
180
+ aria-label="Close"
181
+ onClick={() => setOpen(false)}
182
+ >
183
+ <X size={20} strokeWidth={2} aria-hidden="true" />
184
+ </button>
185
+ {open ? (
186
+ clip ? (
187
+ // biome-ignore lint/a11y/useMediaCaption: a silent loop has no audio to caption; the caption below carries the words
188
+ <video
189
+ src={src}
190
+ poster={poster}
191
+ aria-label={block.alt}
192
+ width={block.width}
193
+ height={block.height}
194
+ autoPlay={!reduceMotion}
195
+ loop
196
+ muted
197
+ playsInline
198
+ controls
199
+ />
200
+ ) : (
201
+ <img src={src} alt={block.alt ?? ""} width={block.width} height={block.height} />
202
+ )
203
+ ) : null}
204
+ {open && block.caption ? <p className="pb-lightbox-caption">{block.caption}</p> : null}
205
+ </dialog>
206
+ <button
207
+ type="button"
208
+ className="pb-tile"
209
+ style={tileStyle(block.width, block.height)}
210
+ aria-label={`Open larger: ${label}`}
211
+ onClick={() => {
212
+ setReduceMotion(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false);
213
+ setOpen(true);
214
+ }}
215
+ >
216
+ {clip ? (
217
+ <Clip src={src} alt={block.alt} poster={poster} inTile />
218
+ ) : (
219
+ <img
220
+ src={src}
221
+ alt={block.alt ?? ""}
222
+ width={block.width}
223
+ height={block.height}
224
+ loading="lazy"
225
+ decoding="async"
226
+ />
227
+ )}
228
+ </button>
229
+ {block.caption ? <figcaption className="pb-caption">{block.caption}</figcaption> : null}
230
+ </figure>
231
+ );
232
+ }
233
+
234
+ /** Runs of consecutive figures become one row of tiles; everything else stands alone. */
235
+ function segments(blocks: ProseBlock[]) {
236
+ const out: Array<
237
+ { figures: ProseBlock[]; start: number } | { block: ProseBlock; index: number }
238
+ > = [];
239
+ blocks.forEach((block, index) => {
240
+ const last = out[out.length - 1];
241
+ if (block.type === "figure") {
242
+ if (last && "figures" in last) last.figures.push(block);
243
+ else out.push({ figures: [block], start: index });
244
+ } else {
245
+ out.push({ block, index });
246
+ }
247
+ });
248
+ return out;
13
249
  }
14
250
 
15
251
  export interface ProseBlocksProps {
@@ -33,15 +269,31 @@ export interface ProseBlocksProps {
33
269
  */
34
270
  export function ProseBlocks({ blocks, headingLevel = "h2", className }: ProseBlocksProps) {
35
271
  const Heading = headingLevel;
272
+ const withBase = useWithBase();
36
273
  return (
37
274
  <div className={["prose-blocks", className].filter(Boolean).join(" ")}>
38
- {blocks.map((block, i) => {
275
+ {segments(blocks).map((segment) => {
276
+ if ("figures" in segment) {
277
+ return (
278
+ <div className="pb-figures" key={`figures-${segment.start}`}>
279
+ {segment.figures.map((block, j) => (
280
+ // biome-ignore lint/suspicious/noArrayIndexKey: static prose; position plus file is the stable key (see above)
281
+ <Figure block={block} withBase={withBase} key={`figure-${j}-${block.src ?? ""}`} />
282
+ ))}
283
+ </div>
284
+ );
285
+ }
286
+ const { block, index: i } = segment;
39
287
  if (block.type === "h") {
288
+ const Tag = block.level ? (`h${block.level}` as const) : Heading;
40
289
  return (
41
- // biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
42
- <Heading className="pb-heading" key={`h-${i}-${block.text?.slice(0, 24)}`}>
290
+ <Tag
291
+ className={["pb-heading", block.level === 3 ? "pb-h3" : ""].filter(Boolean).join(" ")}
292
+ // biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
293
+ key={`h-${i}-${block.text?.slice(0, 24)}`}
294
+ >
43
295
  {block.text}
44
- </Heading>
296
+ </Tag>
45
297
  );
46
298
  }
47
299
  if (block.type === "ul") {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Fails the build when a photo in public/images is over the house budget or
3
+ * ships as JPEG where WebP belongs. The rule is template-marketing's CLAUDE.md,
4
+ * Image budget: every photo ships as WebP at quality about 75, sized to about
5
+ * 2x its rendered width, and the Open Graph cards alone stay JPEG, because
6
+ * some link previews still will not read WebP. A card is any JPEG whose file
7
+ * name is `og` or ends in `-og`.
8
+ *
9
+ * Usage: atelic-check-images (from the site's root)
10
+ * Exits 1 on any problem.
11
+ */
12
+ import { existsSync, readdirSync, statSync } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+
15
+ /** Where a site keeps the images it serves, from the site's root. */
16
+ export const IMAGES_ROOT = "public/images";
17
+
18
+ /** The most any one image may weigh, in bytes. */
19
+ export const IMAGE_BUDGET = 250 * 1024;
20
+
21
+ /** A PNG past this is a photo saved in the wrong format; a mark or a logo is far smaller. */
22
+ export const PNG_BUDGET = 64 * 1024;
23
+
24
+ export interface ImageFile {
25
+ /** The path from the site's root. */
26
+ path: string;
27
+ bytes: number;
28
+ }
29
+
30
+ export interface ImageReport {
31
+ checked: number;
32
+ problems: string[];
33
+ }
34
+
35
+ const CARD = /(^|\/)([\w-]+-)?og\.jpe?g$/i;
36
+
37
+ /** Whether a JPEG is a sharing card, the one place JPEG still belongs. */
38
+ export function isCard(path: string): boolean {
39
+ return CARD.test(path);
40
+ }
41
+
42
+ const kb = (bytes: number) => `${Math.round(bytes / 1024)} KB`;
43
+
44
+ /** The audit, kept pure so it can be tested without a site. */
45
+ export function auditImages(files: ImageFile[]): ImageReport {
46
+ const problems: string[] = [];
47
+ for (const file of files) {
48
+ const lower = file.path.toLowerCase();
49
+ if (/\.jpe?g$/.test(lower) && !isCard(file.path)) {
50
+ problems.push(
51
+ `${file.path} ships as JPEG (${kb(file.bytes)}); convert it to WebP, or name it og.jpg or <name>-og.jpg if it is a sharing card`,
52
+ );
53
+ }
54
+ if (lower.endsWith(".png") && file.bytes > PNG_BUDGET) {
55
+ problems.push(
56
+ `${file.path} is a ${kb(file.bytes)} PNG; a photo ships as WebP, and a mark or a logo stays under ${kb(PNG_BUDGET)}`,
57
+ );
58
+ }
59
+ if (file.bytes > IMAGE_BUDGET) {
60
+ problems.push(
61
+ `${file.path} weighs ${kb(file.bytes)}, over the ${kb(IMAGE_BUDGET)} budget; size it to about 2x its rendered width at quality about 75`,
62
+ );
63
+ }
64
+ }
65
+ return { checked: files.length, problems };
66
+ }
67
+
68
+ const IMAGE = /\.(webp|avif|jpe?g|png|gif|svg)$/i;
69
+
70
+ function imageFiles(dir: string, root: string): ImageFile[] {
71
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
72
+ const path = join(dir, entry.name);
73
+ if (entry.isDirectory()) return imageFiles(path, root);
74
+ if (!IMAGE.test(entry.name)) return [];
75
+ return [{ path: join(IMAGES_ROOT, path.slice(root.length + 1)), bytes: statSync(path).size }];
76
+ });
77
+ }
78
+
79
+ /** The command: audits the images of the site in `cwd`. */
80
+ export function main(cwd: string = process.cwd()): void {
81
+ const root = resolve(cwd, IMAGES_ROOT);
82
+ if (!existsSync(root)) {
83
+ console.log(`[images] no ${IMAGES_ROOT} in ${cwd}; nothing to check`);
84
+ return;
85
+ }
86
+ const report = auditImages(imageFiles(root, root));
87
+ if (report.problems.length > 0) {
88
+ for (const problem of report.problems) console.error(`[images] ${problem}`);
89
+ const count = report.problems.length;
90
+ console.error(
91
+ `[images] ${count} problem${count === 1 ? "" : "s"}; the budget is template-marketing's CLAUDE.md, Image budget`,
92
+ );
93
+ process.exit(1);
94
+ }
95
+ console.log(`[images] ${report.checked} images checked, all within budget`);
96
+ }
@@ -1,4 +1,3 @@
1
- import { ArrowUpRight } from "lucide-react";
2
1
  import { displayDate } from "../lib/displayDate";
3
2
  import { useWithBase } from "../routing/BasePathProvider";
4
3
 
@@ -42,10 +41,7 @@ export function BlogIndex({ items, basePath = "/blog", id, className }: BlogInde
42
41
  <time dateTime={item.publishedAt}>{displayDate(item.publishedAt)}</time>
43
42
  {item.readingMinutes ? <span> · {item.readingMinutes} min read</span> : null}
44
43
  </span>
45
- <h2 className="bi-title">
46
- {item.title}
47
- <ArrowUpRight className="bi-arrow" size={20} strokeWidth={2} aria-hidden="true" />
48
- </h2>
44
+ <h2 className="bi-title">{item.title}</h2>
49
45
  {item.description && <p className="bi-excerpt">{item.description}</p>}
50
46
  </a>
51
47
  </li>
@@ -204,13 +204,10 @@
204
204
  }
205
205
 
206
206
  /* ---- ProseBlocks (prose-blocks.css) ---- */
207
- /* Body prose for ported documents: legal pages and blog posts. Measure is
208
- capped tighter than the marketing sections and the type steps down, because
209
- this is read continuously rather than scanned. Tokens only. */
210
-
211
- .atelic-ui .prose-blocks {
212
- max-width: 70ch;
213
- }
207
+ /* Body prose for documents carried as data: legal pages and blog posts. It
208
+ takes the width of the page it sits in, the site's own, rather than a
209
+ measure of its own, so no page or site wears a column of its own size
210
+ (2026-10-26). Tokens only. */
214
211
 
215
212
  .atelic-ui .pb-heading {
216
213
  margin: 40px 0 14px;
@@ -223,6 +220,145 @@
223
220
  margin-top: 0;
224
221
  }
225
222
 
223
+ /* A sub section heading sits closer to its section and steps down a size. */
224
+ .atelic-ui .pb-heading.pb-h3 {
225
+ margin: 28px 0 10px;
226
+ font-size: 1.02rem;
227
+ }
228
+
229
+ /* Figures in running prose sit as small tiles in a row, each at the row's
230
+ height and its own shape, with the caption laid over its foot. A tile
231
+ opens its file large in a dialog. A run of figures in the data is one
232
+ row; the row wraps on a narrow screen. */
233
+ .atelic-ui .pb-figures {
234
+ --pb-tile-height: 240px;
235
+ display: flex;
236
+ flex-wrap: wrap;
237
+ gap: 12px;
238
+ margin: 20px 0 28px;
239
+ }
240
+
241
+ @media (max-width: 600px) {
242
+ .atelic-ui .pb-figures {
243
+ --pb-tile-height: 190px;
244
+ gap: 8px;
245
+ }
246
+ }
247
+
248
+ .atelic-ui .pb-figure {
249
+ position: relative;
250
+ margin: 0;
251
+ height: var(--pb-tile-height);
252
+ min-width: 0;
253
+ max-width: 100%;
254
+ }
255
+
256
+ /* A placeholder keeps its own look and takes only the tile's shape. */
257
+ .atelic-ui .pb-figure .ph {
258
+ height: 100%;
259
+ max-width: 100%;
260
+ }
261
+
262
+ .atelic-ui .pb-tile {
263
+ display: block;
264
+ height: 100%;
265
+ max-width: 100%;
266
+ padding: 0;
267
+ border: 0;
268
+ border-radius: 12px;
269
+ overflow: hidden;
270
+ background: var(--surface-alt, var(--surface));
271
+ cursor: zoom-in;
272
+ }
273
+
274
+ .atelic-ui .pb-tile:focus-visible {
275
+ outline: 2px solid var(--primary);
276
+ outline-offset: 3px;
277
+ }
278
+
279
+ .atelic-ui .pb-tile img,
280
+ .atelic-ui .pb-tile video {
281
+ display: block;
282
+ width: 100%;
283
+ height: 100%;
284
+ object-fit: cover;
285
+ }
286
+
287
+ /* The caption sits over the tile's foot on a shade, and lets a tap through. */
288
+ .atelic-ui .pb-figure .pb-caption {
289
+ position: absolute;
290
+ inset: auto 0 0;
291
+ margin: 0;
292
+ padding: 22px 10px 0;
293
+ border-bottom: 8px solid transparent;
294
+ border-radius: 0 0 12px 12px;
295
+ background: linear-gradient(to top, rgb(0 0 0 / 0.62), rgb(0 0 0 / 0));
296
+ color: #fff;
297
+ font-size: 0.78rem;
298
+ font-weight: 600;
299
+ line-height: 1.3;
300
+ display: -webkit-box;
301
+ -webkit-box-orient: vertical;
302
+ -webkit-line-clamp: 3;
303
+ overflow: hidden;
304
+ pointer-events: none;
305
+ }
306
+
307
+ /* The panel fits what it shows, so a portrait clip is not adrift in a wide frame. */
308
+ .atelic-ui .pb-lightbox {
309
+ width: fit-content;
310
+ max-width: min(92vw, 1100px);
311
+ max-height: 92vh;
312
+ padding: 16px;
313
+ border: 0;
314
+ border-radius: 16px;
315
+ background: var(--surface);
316
+ color: var(--ink);
317
+ }
318
+
319
+ /* Page scroll holds still while the lightbox is open. */
320
+ html:has(dialog.pb-lightbox[open]) {
321
+ overflow: hidden;
322
+ }
323
+
324
+ .atelic-ui .pb-lightbox::backdrop {
325
+ background: rgb(0 0 0 / 0.72);
326
+ }
327
+
328
+ .atelic-ui .pb-lightbox img,
329
+ .atelic-ui .pb-lightbox video {
330
+ display: block;
331
+ max-width: 100%;
332
+ max-height: calc(92vh - 120px);
333
+ width: auto;
334
+ height: auto;
335
+ margin-inline: auto;
336
+ border-radius: 10px;
337
+ }
338
+
339
+ .atelic-ui .pb-lightbox-close {
340
+ display: flex;
341
+ margin: 0 0 10px auto;
342
+ padding: 6px;
343
+ border: 0;
344
+ border-radius: 999px;
345
+ background: transparent;
346
+ color: var(--ink);
347
+ cursor: pointer;
348
+ }
349
+
350
+ .atelic-ui .pb-lightbox-close:focus-visible {
351
+ outline: 2px solid var(--primary);
352
+ outline-offset: 2px;
353
+ }
354
+
355
+ .atelic-ui .pb-lightbox-caption {
356
+ margin: 10px 0 0;
357
+ text-align: center;
358
+ font-size: 0.9rem;
359
+ color: var(--neutral-700);
360
+ }
361
+
226
362
  .atelic-ui .pb-body {
227
363
  margin: 0 0 16px;
228
364
  font-size: 1rem;
@@ -13,7 +13,6 @@
13
13
  margin: 0;
14
14
  padding: 0;
15
15
  list-style: none;
16
- max-width: 760px;
17
16
  }
18
17
 
19
18
  .atelic-ui .bi-item {
@@ -42,42 +41,22 @@
42
41
  color: var(--neutral-500);
43
42
  }
44
43
 
44
+ /* A title fills the line before it wraps, where the base headings balance
45
+ their lines; pretty still keeps a last word from standing alone. */
45
46
  .atelic-ui .bi-title {
46
47
  margin: 0 0 10px;
47
48
  font-size: 1.4rem;
48
49
  line-height: 1.25;
49
- }
50
-
51
- .atelic-ui .bi-arrow {
52
- margin-left: 8px;
53
- vertical-align: baseline;
54
- opacity: 0;
55
- transform: translate(-4px, 2px);
56
- transition:
57
- opacity 0.18s ease,
58
- transform 0.18s ease;
59
- }
60
-
61
- .atelic-ui .bi-link:hover .bi-arrow,
62
- .atelic-ui .bi-link:focus-visible .bi-arrow {
63
- opacity: 1;
64
- transform: translate(0, 2px);
50
+ text-wrap: pretty;
65
51
  }
66
52
 
67
53
  .atelic-ui .bi-excerpt {
68
54
  margin: 0;
69
- max-width: 62ch;
70
55
  font-size: 0.98rem;
71
56
  line-height: 1.65;
72
57
  color: var(--neutral-700);
73
58
  }
74
59
 
75
- @media (prefers-reduced-motion: reduce) {
76
- .atelic-ui .bi-arrow {
77
- transition: none;
78
- }
79
- }
80
-
81
60
  @media (max-width: 600px) {
82
61
  .atelic-ui .bi-title {
83
62
  font-size: 1.2rem;
@@ -88,8 +67,12 @@
88
67
  /* A single article. The title is the content, so it sits in the flow rather
89
68
  than inside a hero band that would push the writing below the fold. */
90
69
 
91
- .atelic-ui .bp-wrap {
92
- max-width: 760px;
70
+ /* The article takes the site's own width, like every other page, so no
71
+ page or site wears a column of its own size (Forni, 2026-10-26). The
72
+ body steps up a size on a wide screen to suit the longer line. */
73
+ .atelic-ui .bp-body .pb-body,
74
+ .atelic-ui .bp-body .pb-list li {
75
+ font-size: clamp(1rem, 0.9rem + 0.35vw, 1.125rem);
93
76
  }
94
77
 
95
78
  .atelic-ui .bp-back {
@@ -112,6 +95,7 @@
112
95
  margin: 0 0 14px;
113
96
  font-size: clamp(1.9rem, 4vw, 2.6rem);
114
97
  line-height: 1.15;
98
+ text-wrap: pretty;
115
99
  }
116
100
 
117
101
  .atelic-ui .bp-meta {