@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 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:
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ // atelic-check-images: fails a build whose public/images holds a photo over budget or a JPEG that is not a sharing card.
3
+ import { main } from "../src/scripts/check-images";
4
+
5
+ main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "3.5.0",
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 ported prose. A deliberately tiny vocabulary — headings,
3
- * paragraphs, lists — because that is what survives extraction from a CMS
4
- * intact, and because anything richer invites rewriting content that was
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
- // biome-ignore lint/suspicious/noArrayIndexKey: static ported prose; position plus content is the stable key (see above)
42
- <Heading className="pb-heading" key={`h-${i}-${block.text?.slice(0, 24)}`}>
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
- </Heading>
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;