@atelic-action/ui 3.5.0 → 3.6.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 +157 -8
- package/src/scripts/check-images.ts +96 -0
- package/src/styles/primitives.css +30 -0
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 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) |
|
|
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.6.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,127 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
* One block of
|
|
3
|
-
* paragraphs, lists
|
|
4
|
-
* intact, and because anything richer invites rewriting
|
|
5
|
-
* meant to be carried across verbatim.
|
|
5
|
+
* One block of prose carried as data. A deliberately tiny vocabulary:
|
|
6
|
+
* headings, paragraphs, lists, and a figure, because that is what survives
|
|
7
|
+
* extraction from a CMS intact, and because anything richer invites rewriting
|
|
8
|
+
* content that was meant to be carried across verbatim.
|
|
6
9
|
*/
|
|
7
10
|
export interface ProseBlock {
|
|
8
|
-
type: "h" | "p" | "ul";
|
|
11
|
+
type: "h" | "p" | "ul" | "figure";
|
|
9
12
|
/** Present for "h" and "p". */
|
|
10
13
|
text?: string;
|
|
14
|
+
/**
|
|
15
|
+
* For "h": the heading's level under the page's own h1, where the
|
|
16
|
+
* component's `headingLevel` is the default. A post with sections and
|
|
17
|
+
* sub sections marks the sub sections 3 and leaves the rest alone.
|
|
18
|
+
*/
|
|
19
|
+
level?: 2 | 3;
|
|
11
20
|
/** Present for "ul". */
|
|
12
21
|
items?: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Present for "figure": the file the figure shows. A `.mp4` or `.webm`
|
|
24
|
+
* plays as a silent loop (a short clip of an exercise, say), anything
|
|
25
|
+
* else is an image. Both load lazily, since a figure sits in running prose
|
|
26
|
+
* below the fold.
|
|
27
|
+
*/
|
|
28
|
+
src?: string;
|
|
29
|
+
/** For "figure": what the figure shows, for a reader who cannot see it. */
|
|
30
|
+
alt?: string;
|
|
31
|
+
/** For "figure": a line under it. */
|
|
32
|
+
caption?: string;
|
|
33
|
+
/**
|
|
34
|
+
* 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.
|
|
37
|
+
*/
|
|
38
|
+
width?: number;
|
|
39
|
+
height?: number;
|
|
40
|
+
/** For a clip: a still shown until it plays, and to a reader who prefers reduced motion. */
|
|
41
|
+
poster?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CLIP = /\.(mp4|webm)([?#].*)?$/i;
|
|
45
|
+
|
|
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
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A silent looping clip. It plays only while it is on screen and pauses when
|
|
61
|
+
* it leaves, and it fetches nothing until it first comes into view, so a post
|
|
62
|
+
* with a clip beside every exercise costs nothing up front. A reader who asked
|
|
63
|
+
* for reduced motion gets it still, with controls to start it.
|
|
64
|
+
*/
|
|
65
|
+
function Clip({
|
|
66
|
+
src,
|
|
67
|
+
alt,
|
|
68
|
+
poster,
|
|
69
|
+
style,
|
|
70
|
+
}: {
|
|
71
|
+
src: string;
|
|
72
|
+
alt?: string;
|
|
73
|
+
poster?: string;
|
|
74
|
+
style?: ReturnType<typeof frameStyle>;
|
|
75
|
+
}) {
|
|
76
|
+
const ref = useRef<HTMLVideoElement>(null);
|
|
77
|
+
const [still, setStill] = useState(false);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const video = ref.current;
|
|
80
|
+
if (!video) return;
|
|
81
|
+
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
|
|
82
|
+
setStill(true);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// A pause that interrupts a pending play rejects it with an AbortError;
|
|
86
|
+
// that is the reader scrolling past, not autoplay being refused.
|
|
87
|
+
let observer: IntersectionObserver | undefined;
|
|
88
|
+
const play = () =>
|
|
89
|
+
video.play()?.catch((err: unknown) => {
|
|
90
|
+
if ((err as { name?: string } | null)?.name === "AbortError") return;
|
|
91
|
+
// Autoplay refused: hand the clip to the reader and stop steering it.
|
|
92
|
+
setStill(true);
|
|
93
|
+
observer?.disconnect();
|
|
94
|
+
});
|
|
95
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
96
|
+
play();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
observer = new IntersectionObserver(
|
|
100
|
+
(entries) => {
|
|
101
|
+
if (entries[entries.length - 1]?.isIntersecting) play();
|
|
102
|
+
else video.pause();
|
|
103
|
+
},
|
|
104
|
+
{ rootMargin: "200px 0px" },
|
|
105
|
+
);
|
|
106
|
+
observer.observe(video);
|
|
107
|
+
return () => observer?.disconnect();
|
|
108
|
+
}, []);
|
|
109
|
+
return (
|
|
110
|
+
// biome-ignore lint/a11y/useMediaCaption: a silent loop has no audio to caption; the figcaption and aria-label carry the words
|
|
111
|
+
<video
|
|
112
|
+
ref={ref}
|
|
113
|
+
src={src}
|
|
114
|
+
poster={poster}
|
|
115
|
+
aria-label={alt}
|
|
116
|
+
style={style}
|
|
117
|
+
loop
|
|
118
|
+
muted
|
|
119
|
+
playsInline
|
|
120
|
+
controls={still}
|
|
121
|
+
// A still clip never plays, so without a poster it needs its first frame loaded.
|
|
122
|
+
preload={still && !poster ? "metadata" : "none"}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
13
125
|
}
|
|
14
126
|
|
|
15
127
|
export interface ProseBlocksProps {
|
|
@@ -33,15 +145,52 @@ export interface ProseBlocksProps {
|
|
|
33
145
|
*/
|
|
34
146
|
export function ProseBlocks({ blocks, headingLevel = "h2", className }: ProseBlocksProps) {
|
|
35
147
|
const Heading = headingLevel;
|
|
148
|
+
const withBase = useWithBase();
|
|
36
149
|
return (
|
|
37
150
|
<div className={["prose-blocks", className].filter(Boolean).join(" ")}>
|
|
38
151
|
{blocks.map((block, i) => {
|
|
39
152
|
if (block.type === "h") {
|
|
153
|
+
const Tag = block.level ? (`h${block.level}` as const) : Heading;
|
|
40
154
|
return (
|
|
41
|
-
|
|
42
|
-
|
|
155
|
+
<Tag
|
|
156
|
+
className={["pb-heading", block.level === 3 ? "pb-h3" : ""].filter(Boolean).join(" ")}
|
|
157
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
|
|
158
|
+
key={`h-${i}-${block.text?.slice(0, 24)}`}
|
|
159
|
+
>
|
|
43
160
|
{block.text}
|
|
44
|
-
</
|
|
161
|
+
</Tag>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
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>
|
|
45
194
|
);
|
|
46
195
|
}
|
|
47
196
|
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
|
+
}
|
|
@@ -223,6 +223,36 @@
|
|
|
223
223
|
margin-top: 0;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/* A sub section heading sits closer to its section and steps down a size. */
|
|
227
|
+
.atelic-ui .pb-heading.pb-h3 {
|
|
228
|
+
margin: 28px 0 10px;
|
|
229
|
+
font-size: 1.02rem;
|
|
230
|
+
}
|
|
231
|
+
|
|
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. */
|
|
234
|
+
.atelic-ui .pb-figure {
|
|
235
|
+
margin: 24px 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.atelic-ui .pb-figure img,
|
|
239
|
+
.atelic-ui .pb-figure video {
|
|
240
|
+
display: block;
|
|
241
|
+
width: 100%;
|
|
242
|
+
height: auto;
|
|
243
|
+
margin-inline: auto;
|
|
244
|
+
object-fit: cover;
|
|
245
|
+
border-radius: 14px;
|
|
246
|
+
background: var(--surface-alt, var(--surface));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.atelic-ui .pb-caption {
|
|
250
|
+
margin-top: 10px;
|
|
251
|
+
font-size: 0.9rem;
|
|
252
|
+
line-height: 1.5;
|
|
253
|
+
color: var(--neutral-600, var(--neutral-700));
|
|
254
|
+
}
|
|
255
|
+
|
|
226
256
|
.atelic-ui .pb-body {
|
|
227
257
|
margin: 0 0 16px;
|
|
228
258
|
font-size: 1rem;
|