@atelic-action/ui 3.7.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 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 |
@@ -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.7.0",
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",
@@ -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
- <img src={shot.src} alt={shot.alt} loading="lazy" />
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>
@@ -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 ----