@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 +3 -3
- package/package.json +1 -1
- package/src/artifact/Shot.tsx +41 -5
- package/src/primitives/ProseBlocks.tsx +153 -50
- package/src/sections/BlogIndex.tsx +1 -5
- package/src/styles/artifact.css +14 -0
- package/src/styles/primitives.css +125 -19
- package/src/styles/sections.css +10 -26
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
|
|
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.
|
|
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",
|
package/src/artifact/Shot.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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>
|
package/src/styles/artifact.css
CHANGED
|
@@ -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
|
|
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 {
|