@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 +1 -1
- package/package.json +1 -1
- package/src/primitives/ProseBlocks.tsx +153 -50
- package/src/sections/BlogIndex.tsx +1 -5
- package/src/styles/primitives.css +125 -19
- package/src/styles/sections.css +10 -26
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
|
|
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.
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
62
|
+
inTile = false,
|
|
70
63
|
}: {
|
|
71
64
|
src: string;
|
|
72
65
|
alt?: string;
|
|
73
66
|
poster?: string;
|
|
74
|
-
|
|
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((
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
/*
|
|
233
|
-
|
|
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
|
-
|
|
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-
|
|
239
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
355
|
+
.atelic-ui .pb-lightbox-caption {
|
|
356
|
+
margin: 10px 0 0;
|
|
357
|
+
text-align: center;
|
|
251
358
|
font-size: 0.9rem;
|
|
252
|
-
|
|
253
|
-
color: var(--neutral-600, var(--neutral-700));
|
|
359
|
+
color: var(--neutral-700);
|
|
254
360
|
}
|
|
255
361
|
|
|
256
362
|
.atelic-ui .pb-body {
|
package/src/styles/sections.css
CHANGED
|
@@ -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
|
-
|
|
92
|
-
|
|
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 {
|