@atelic-action/ui 3.6.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 (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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "3.6.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",
@@ -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>
@@ -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 {