@atelic-action/ui 3.6.0 → 3.8.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
@@ -39,7 +39,7 @@ Every export, grouped by the entry it is imported from. The gallery column links
39
39
  | `Button`, `Chip`, `Eyebrow`, `Lead`, `SectionHeading` | `@atelic-action/ui/primitives` | The small pieces a page is set in | [#primitives](https://ui.atelic.me/#primitives) |
40
40
  | `GradeChip`, `gradeLabel`, `WriteupGrade` | `@atelic-action/ui/artifact` | A section's grade, A to D, as a chip in its color, the letter alone, and the grade type | [#grade-chip](https://ui.atelic.me/#grade-chip) |
41
41
  | `SignOff`, `WriteupPerson` | `@atelic-action/ui/artifact` | The close a document ends on, and the person it names | [#sign-off](https://ui.atelic.me/#sign-off) |
42
- | `Shot`, `WriteupShot` | `@atelic-action/ui/artifact` | The owner's own screen, opening the full picture, over its caption | [#shot](https://ui.atelic.me/#shot) |
42
+ | `Shot`, `WriteupShot` | `@atelic-action/ui/artifact` | The owner's own screen, opening the full picture, over its caption; a portrait crop is marked `data-portrait` and held to 200px (1.8in in print) | [#shot](https://ui.atelic.me/#shot) |
43
43
  | `Paragraph`, `Runs`, `ArtifactAnchor`, `isEmpty`, `ArtifactLink`, `ArtifactParagraph` | `@atelic-action/ui/artifact` | Plain data text a routine can write as JSON: a paragraph, its runs alone, one link, the empty test, and the two types | [#paragraph](https://ui.atelic.me/#paragraph) |
44
44
  | `ArtifactShell` | `@atelic-action/ui/artifact` | The chrome around a document page: skip link, dark header, credit band, watermark | [#site-header](https://ui.atelic.me/#site-header) (its dark bar) |
45
45
  | `AppErrorBoundary`, `ErrorFallback` | `@atelic-action/ui/artifact` | The error boundary and the page it falls back to | none |
@@ -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 (headings at two levels, paragraphs, lists, and a figure that is an image or a silent looping clip played only while on screen), 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 |
@@ -338,7 +338,7 @@ The two pieces a reader sees are ordinary components, in `@atelic-action/ui/comp
338
338
 
339
339
  `@atelic-action/ui/artifact` is the pieces an artifact document is built from and the site around it, so a fix to a piece reaches every business's documents with one version bump. The composed documents themselves (`Writeup`, `Read`, `Report`, `Note`, and the sealed `Gate`, with their content types and their stylesheet) live in [`@atelic-action/artifact`](https://github.com/atelic-action/artifact) since 2.0.0, which depends on this package for everything below:
340
340
 
341
- - **The pieces:** `GradeChip` and `gradeLabel` with the `WriteupGrade` type, `SignOff` with the `WriteupPerson` type, `Shot` with the `WriteupShot` type, and the plain data text (`Paragraph`, `Runs`, `ArtifactAnchor`, and `isEmpty`, with the `ArtifactLink` and `ArtifactParagraph` types: a paragraph is a string or runs of text and `{ text, href }` links, so a routine can write a document as JSON).
341
+ - **The pieces:** `GradeChip` and `gradeLabel` with the `WriteupGrade` type, `SignOff` with the `WriteupPerson` type, `Shot` with the `WriteupShot` type (a crop taller than it is wide is measured in the browser, marked `data-portrait`, and held to 200px wide on screen and 1.8in in print; a layout that sizes its shots itself sets `--op-shot-portrait-max: none` to opt out, on screen and in print alike), and the plain data text (`Paragraph`, `Runs`, `ArtifactAnchor`, and `isEmpty`, with the `ArtifactLink` and `ArtifactParagraph` types: a paragraph is a string or runs of text and `{ text, href }` links, so a routine can write a document as JSON).
342
342
  - **The shell:** `ArtifactShell`, the `.atelic-ui` scope with the skip link, the dark header, the credit band, and the watermark around a page.
343
343
  - **The contract and the head:** the `ArtifactConfig` types, and `buildPageHead` and `canonicalURL` with their `PageMeta` type.
344
344
  - **Who opened it:** `mintToken`, `sealReader`, `openReader`, `readerId`, `readerLink`, and `whoIsReading`. The same six are also built and exported alone as `@atelic-action/ui/artifact/readers`, because a Playwright spec and a script run under Node, which compiles nothing inside `node_modules`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "3.6.0",
3
+ "version": "3.8.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",
@@ -1,4 +1,5 @@
1
1
  import { Maximize2 } from "lucide-react";
2
+ import { useEffect, useRef, useState } from "react";
2
3
  import { newTabProps } from "../lib/newTabProps";
3
4
 
4
5
  export interface WriteupShot {
@@ -10,6 +11,12 @@ export interface WriteupShot {
10
11
  caption: string;
11
12
  }
12
13
 
14
+ /** The src of a loaded image taller than it is wide; null for any other. */
15
+ function portraitSrcOf(img: HTMLImageElement): string | null {
16
+ if (!img.naturalWidth || !img.naturalHeight) return null;
17
+ return img.naturalHeight > img.naturalWidth ? img.getAttribute("src") : null;
18
+ }
19
+
13
20
  /**
14
21
  * The owner's own screen: the crop, opening the full screenshot in a new tab
15
22
  * when it has one, over its caption. The writeup, the read, and the note in
@@ -18,21 +25,50 @@ export interface WriteupShot {
18
25
  * own sheet. A linked crop says it opens: a hover title, and an expand badge
19
26
  * hidden by default that a document may turn on (the note does, on its
20
27
  * thumbnails).
28
+ *
29
+ * A crop taller than it is wide (a phone screen) marks the figure
30
+ * `data-portrait`, and the base sheet holds it far narrower than its column.
31
+ * The mark is measured in the browser, after the image has a size: on load,
32
+ * or in an effect when the image finished loading before hydration, so the
33
+ * prerendered page carries no mark and nothing reads the window at render.
21
34
  */
22
35
  export function Shot({ shot }: { shot: WriteupShot }) {
36
+ const img = useRef<HTMLImageElement>(null);
37
+ // The src the portrait mark was measured on, so a new src never wears the
38
+ // last image's mark while it loads or if it fails.
39
+ const [portraitSrc, setPortraitSrc] = useState<string | null>(null);
40
+ const portrait = portraitSrc !== null && portraitSrc === shot.src;
41
+
42
+ // An image that loaded before hydration fired its load event before React
43
+ // was listening, so read its size once on mount. A later src change fires
44
+ // load again, which the handler below measures.
45
+ useEffect(() => {
46
+ const current = img.current;
47
+ if (current?.complete) setPortraitSrc(portraitSrcOf(current));
48
+ }, []);
49
+
50
+ const picture = (
51
+ <img
52
+ ref={img}
53
+ src={shot.src}
54
+ alt={shot.alt}
55
+ loading="lazy"
56
+ onLoad={(event) => setPortraitSrc(portraitSrcOf(event.currentTarget))}
57
+ onError={() => setPortraitSrc(null)}
58
+ />
59
+ );
60
+
23
61
  return (
24
- <figure className="op-shot">
62
+ <figure className="op-shot" data-portrait={portrait ? "" : undefined}>
25
63
  {shot.href ? (
26
64
  <a href={shot.href} title="Open the full picture" {...newTabProps(shot.href, true)}>
27
- <img src={shot.src} alt={shot.alt} loading="lazy" />
65
+ {picture}
28
66
  <span className="op-shot-open" aria-hidden="true">
29
67
  <Maximize2 size={12} />
30
68
  </span>
31
69
  </a>
32
70
  ) : (
33
- <span className="op-shot-frame">
34
- <img src={shot.src} alt={shot.alt} loading="lazy" />
35
- </span>
71
+ <span className="op-shot-frame">{picture}</span>
36
72
  )}
37
73
  <figcaption>{shot.caption}</figcaption>
38
74
  </figure>
@@ -1,3 +1,4 @@
1
+ import { X } from "lucide-react";
1
2
  import { useEffect, useRef, useState } from "react";
2
3
  import { useWithBase } from "../routing/BasePathProvider";
3
4
 
@@ -32,8 +33,8 @@ export interface ProseBlock {
32
33
  caption?: string;
33
34
  /**
34
35
  * For "figure": the file's own width and height in pixels. With both, the
35
- * figure holds its shape before the file arrives (no layout shift), and a
36
- * tall one is narrowed so it never runs taller than most of the screen.
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.
37
38
  */
38
39
  width?: number;
39
40
  height?: number;
@@ -43,17 +44,9 @@ export interface ProseBlock {
43
44
 
44
45
  const CLIP = /\.(mp4|webm)([?#].*)?$/i;
45
46
 
46
- /**
47
- * The box a figure's file draws in: its own shape, and never taller than most
48
- * of the screen, so a portrait clip in a text column does not fill a desktop
49
- * from top to bottom. Nothing without both dimensions.
50
- */
51
- function frameStyle(width?: number, height?: number) {
52
- if (!width || !height) return undefined;
53
- return {
54
- aspectRatio: `${width} / ${height}`,
55
- maxWidth: `min(100%, calc(75vh * ${width} / ${height}))`,
56
- };
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" };
57
50
  }
58
51
 
59
52
  /**
@@ -66,12 +59,13 @@ function Clip({
66
59
  src,
67
60
  alt,
68
61
  poster,
69
- style,
62
+ inTile = false,
70
63
  }: {
71
64
  src: string;
72
65
  alt?: string;
73
66
  poster?: string;
74
- style?: ReturnType<typeof frameStyle>;
67
+ /** Inside a button, where native controls would nest interactive content; the lightbox has them. */
68
+ inTile?: boolean;
75
69
  }) {
76
70
  const ref = useRef<HTMLVideoElement>(null);
77
71
  const [still, setStill] = useState(false);
@@ -113,17 +107,147 @@ function Clip({
113
107
  src={src}
114
108
  poster={poster}
115
109
  aria-label={alt}
116
- style={style}
117
110
  loop
118
111
  muted
119
112
  playsInline
120
- controls={still}
113
+ controls={still && !inTile}
121
114
  // A still clip never plays, so without a poster it needs its first frame loaded.
122
115
  preload={still && !poster ? "metadata" : "none"}
123
116
  />
124
117
  );
125
118
  }
126
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;
249
+ }
250
+
127
251
  export interface ProseBlocksProps {
128
252
  blocks: ProseBlock[];
129
253
  /**
@@ -148,7 +272,18 @@ export function ProseBlocks({ blocks, headingLevel = "h2", className }: ProseBlo
148
272
  const withBase = useWithBase();
149
273
  return (
150
274
  <div className={["prose-blocks", className].filter(Boolean).join(" ")}>
151
- {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;
152
287
  if (block.type === "h") {
153
288
  const Tag = block.level ? (`h${block.level}` as const) : Heading;
154
289
  return (
@@ -161,38 +296,6 @@ export function ProseBlocks({ blocks, headingLevel = "h2", className }: ProseBlo
161
296
  </Tag>
162
297
  );
163
298
  }
164
- if (block.type === "figure") {
165
- const src = block.src ? withBase(block.src) : "";
166
- const style = frameStyle(block.width, block.height);
167
- return (
168
- // biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
169
- <figure className="pb-figure" key={`figure-${i}-${src.slice(-24)}`}>
170
- {!src ? (
171
- <div className="ph">{block.alt ?? "Figure"}</div>
172
- ) : CLIP.test(src) ? (
173
- <Clip
174
- src={src}
175
- alt={block.alt}
176
- poster={block.poster ? withBase(block.poster) : undefined}
177
- style={style}
178
- />
179
- ) : (
180
- <img
181
- src={src}
182
- alt={block.alt ?? ""}
183
- width={block.width}
184
- height={block.height}
185
- style={style}
186
- loading="lazy"
187
- decoding="async"
188
- />
189
- )}
190
- {block.caption ? (
191
- <figcaption className="pb-caption">{block.caption}</figcaption>
192
- ) : null}
193
- </figure>
194
- );
195
- }
196
299
  if (block.type === "ul") {
197
300
  return (
198
301
  // biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
@@ -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>
@@ -109,10 +109,24 @@
109
109
  line-height: 1.45;
110
110
  color: var(--op-ink-faint, var(--neutral-500));
111
111
  }
112
+ /* A portrait crop (a phone screen, marked by Shot once its image has a
113
+ size) is held far narrower than its column, at the column's start, so
114
+ it never stands as a tall wall beside its findings. The cap is on the
115
+ figure, so the frame, its border, and the caption all hug the picture.
116
+ A layout that sizes its shots itself (fixed frames side by side) sets
117
+ --op-shot-portrait-max to none to keep them all the same size. The one
118
+ property governs print as well; a document that sets a size of its own
119
+ restates it under @media print for the sheet. */
120
+ .atelic-ui .op-shot[data-portrait] {
121
+ max-width: var(--op-shot-portrait-max, 200px);
122
+ }
112
123
  @media print {
113
124
  .atelic-ui .op-shot a {
114
125
  cursor: auto;
115
126
  }
127
+ .atelic-ui .op-shot[data-portrait] {
128
+ max-width: var(--op-shot-portrait-max, 1.8in);
129
+ }
116
130
  }
117
131
 
118
132
  /* ---- print: the shell's chrome steps aside on every artifact page ----
@@ -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;
@@ -229,28 +226,137 @@
229
226
  font-size: 1.02rem;
230
227
  }
231
228
 
232
- /* A figure in running prose: the image or the silent clip at the column's
233
- width, a rounded frame, and a caption in the body's quiet color. */
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
+
234
248
  .atelic-ui .pb-figure {
235
- margin: 24px 0;
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%;
236
260
  }
237
261
 
238
- .atelic-ui .pb-figure img,
239
- .atelic-ui .pb-figure video {
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 {
240
281
  display: block;
241
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;
242
334
  height: auto;
243
335
  margin-inline: auto;
244
- object-fit: cover;
245
- border-radius: 14px;
246
- background: var(--surface-alt, var(--surface));
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;
247
353
  }
248
354
 
249
- .atelic-ui .pb-caption {
250
- margin-top: 10px;
355
+ .atelic-ui .pb-lightbox-caption {
356
+ margin: 10px 0 0;
357
+ text-align: center;
251
358
  font-size: 0.9rem;
252
- line-height: 1.5;
253
- color: var(--neutral-600, var(--neutral-700));
359
+ color: var(--neutral-700);
254
360
  }
255
361
 
256
362
  .atelic-ui .pb-body {
@@ -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 {