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