@atelic-action/ui 3.3.1 → 3.5.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 +6 -4
- package/bin/atelic-print-sheets.sh +6 -6
- package/dist/artifact/readers.js +1 -1
- package/dist/vite/artifact-config.d.ts +1 -1
- package/dist/vite/artifact-config.js +4 -4
- package/package.json +1 -1
- package/src/artifact/readers.ts +1 -1
- package/src/components/DataTable.tsx +20 -0
- package/src/components/Hint.tsx +138 -0
- package/src/components/index.ts +1 -0
- package/src/scripts/check-wall.ts +5 -5
- package/src/scripts/reader-link.ts +6 -6
- package/src/styles/components.css +91 -0
- package/src/vite/artifact-config.ts +5 -5
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ Every export, grouped by the entry it is imported from. The gallery column links
|
|
|
22
22
|
| `Checklist` | `@atelic-action/ui/components` | A short list of checks, each passed or failed | [#checklist](https://ui.atelic.me/#checklist) |
|
|
23
23
|
| `IconList` | `@atelic-action/ui/components` | A list whose bullets are icons, one for the list or one per item, each in a tone | [#icon-list](https://ui.atelic.me/#icon-list) |
|
|
24
24
|
| `DataTable` | `@atelic-action/ui/components` | A table whose first column names each row, sortable, stacking on a phone | [#data-table](https://ui.atelic.me/#data-table) |
|
|
25
|
+
| `Hint` | `@atelic-action/ui/components` | An info icon that opens a short explainer beside a word, or beside a table heading | [#hint](https://ui.atelic.me/#hint) |
|
|
25
26
|
| `ReportCard` | `@atelic-action/ui/components` | A name, a grade, and a line per row, in labeled groups, each row a link to its section | [#report-card](https://ui.atelic.me/#report-card) |
|
|
26
27
|
| `reportCardLabels`, `ReportCardLabels` | `@atelic-action/ui/components` | The words a screen reader hears around a report card's letters by default, and their type | none |
|
|
27
28
|
| `Rank` | `@atelic-action/ui/components` | A figure with its badge in a tone, for a table cell: a rank, a place in an answer, or a dash | [#rank](https://ui.atelic.me/#rank) |
|
|
@@ -366,7 +367,7 @@ The pieces import no stylesheet of their own. A site imports `@atelic-action/ui/
|
|
|
366
367
|
// vite.config.ts
|
|
367
368
|
import { artifactConfig } from "@atelic-action/ui/vite";
|
|
368
369
|
|
|
369
|
-
export default artifactConfig({ pages: ["/
|
|
370
|
+
export default artifactConfig({ pages: ["/the-read", "/proposal", "/report"] });
|
|
370
371
|
```
|
|
371
372
|
|
|
372
373
|
`artifactConfig` returns the TanStack Start plugin with prerendering on, the crawler following links but kept out of `/images/` (where a prerendered page would overwrite a real file), one prerendered page for `/`, for each path the site names, and for `/404`, and no sitemap; the React plugin; `privateChunks()`; the `@` alias to the site's `src`, resolved from the directory Vite is run in; and `ssr.noExternal: ["@atelic-action/ui", "@atelic-action/artifact"]`, so the documents compile too. Nothing links to an artifact page, so a page a site leaves out of `pages` ships with no HTML in it. `artifactPages` is the list alone.
|
|
@@ -375,7 +376,7 @@ A site with a real need of its own passes `extend`, which is merged over the res
|
|
|
375
376
|
|
|
376
377
|
```ts
|
|
377
378
|
export default artifactConfig({
|
|
378
|
-
pages: ["/
|
|
379
|
+
pages: ["/the-read"],
|
|
379
380
|
extend: { server: { port: 5180 } },
|
|
380
381
|
});
|
|
381
382
|
```
|
|
@@ -398,7 +399,7 @@ The package installs the scripts every artifact site used to carry as files, so
|
|
|
398
399
|
| `atelic-stamp-health` | Writes `dist/client/health.json` with the status, the commit, and the build time | Last step of `build` |
|
|
399
400
|
| `atelic-check-links` | Asks for every external link in the prerendered pages and exits 1 when one is dead | `check:links`, after a build |
|
|
400
401
|
| `atelic-check-wall <host> [path ...]` | Asks a deployed host for each document and held file with no cookie, and passes only when the wall answers every one and the sign in functions respond | `check:wall`, before any private link goes out |
|
|
401
|
-
| `atelic-reader-link <email> [path] [--token <token>] [--qr \| --no-qr]` | Mints a named reader: prints the sealed line for `analytics.identify` and the link to send, and writes `public/images/qr.png` for a public
|
|
402
|
+
| `atelic-reader-link <email> [path] [--token <token>] [--qr \| --no-qr]` | Mints a named reader: prints the sealed line for `analytics.identify` and the link to send, and writes `public/images/qr.png` for a public read's first reader | `reader` |
|
|
402
403
|
| `atelic-serve-static` | Serves `dist/client` on `127.0.0.1` the way Vercel does, on `PORT` or 4173 | The `webServer` command in `playwright.config.ts` |
|
|
403
404
|
| `atelic-print-sheets <url or dist/client> <out.pdf> [sheets] [page] [port]` | Prints a page to PDF with headless Chrome, reports the sheet count, and fails when a count is given and missed | `print` |
|
|
404
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` |
|
|
@@ -703,7 +704,8 @@ The blocks a page composes, each styled in `styles/components.css` inside the `a
|
|
|
703
704
|
| `IconList` | A list whose bullets are icons | `icon` and `tone` on the list set every bullet; an item's own override them. The bullet is decoration, so a screen reader hears only the items. A dot when no icon is set; the text's color when no tone is |
|
|
704
705
|
| `ReportCard` | A name, a grade, and one line on it per row, in labeled groups, each row's parts centered on it | A row with `href` is a link from edge to edge with a chevron at its end; a link inside `line` still goes where it points. `before` sets the earlier grade faded beside the grade; no `grade` draws a dash read as not graded. `legend` explains the letters beside the `title`, or under the card with `legendAt="foot"`; `labels` sets the screen reader's words. The grade is `GradeChip` |
|
|
705
706
|
| `Rank` | A figure with its badge, for a table cell | `figure` is the rank, the place, or a dash for none; `tone` colors the badge and `meaning` names it for a screen reader; `href` links the figure, in a new tab with `newTab`. The figure's slot is a fixed width, so the badges line up down a column |
|
|
706
|
-
| `DataTable` | A table whose first column names each row, sortable by any column marked `sortable` | A click on a heading sorts on a wide screen. On a phone each row stacks, every fact wearing its column's label, and a dropdown sorts. A cell is a node, or `{ display, sort }` when what it shows is not what it sorts by; a formatted number in a string (`"$12,047"`, `"0.24%"`) sorts as a number. It renders in `defaultSort` on the server. Every other row sits on a tinted ground; `striped={false}` turns that off. A column marked `fit` hugs its content, for a swatch or a mark beside a figure |
|
|
707
|
+
| `DataTable` | A table whose first column names each row, sortable by any column marked `sortable` | A click on a heading sorts on a wide screen. On a phone each row stacks, every fact wearing its column's label, and a dropdown sorts. A cell is a node, or `{ display, sort }` when what it shows is not what it sorts by; a formatted number in a string (`"$12,047"`, `"0.24%"`) sorts as a number. It renders in `defaultSort` on the server. Every other row sits on a tinted ground; `striped={false}` turns that off. A column marked `fit` hugs its content, for a swatch or a mark beside a figure. A column with a `hint` draws a `Hint` beside its heading, before the label when the column is right aligned; on a phone the heading row is hidden, so the hint is too |
|
|
708
|
+
| `Hint` | An info icon that opens a short explainer beside a word | Opens on hover or focus, stays open on a click or a tap, closes on Escape or a tap elsewhere. The bubble opens from the icon's left edge and slides left when it would run off the screen. `side="before"` puts the icon before its word, for a right aligned heading. `label` is what a screen reader calls the button; the bubble is its description. Print hides it |
|
|
707
709
|
| `StatRow` | A row of large figures, each over what it counts and its `period`. `variant="quiet"` is the glance row for a working page: a light figure in the text color over a small capitalized label, hairlines between; `unit` sets a figure's unit small beside it | `trend` draws an arrow before a figure that is itself a change. `deltas` list comparisons beneath: an arrow, how far, against what (`basis`), and the earlier figure (`was`), each in its own tone, since a spend falling and a cost falling read differently |
|
|
708
710
|
| `Callout` | One finding: an icon in its tone beside the title, the explainer under it | `variant="box"` sets it apart on a tinted ground, for something missing or owed |
|
|
709
711
|
| `Disclosure` | A section the reader opens and closes, in two forms chosen by whether `open` is passed | Controlled (`open` and `onToggle`), it is an app's card: the caller remembers what was folded, a closed body is not rendered, the label is the toggle, `meta` (a count, a flag) sits beside it outside its name, and `headingLevel` makes the label a real heading. Uncontrolled, it is a document's fold: a native `details` with a `summary` line that carries the point and `defaultOpen`, so it works before any script loads, find in page opens it, and it prints open. `Collapsible`, the fold's old name, was folded in at 0.18.0 |
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Print an artifact page to PDF with headless Chrome and report its sheet
|
|
3
|
-
# count. The
|
|
3
|
+
# count. The read flows across as many letter pages as it needs, its
|
|
4
4
|
# native disclosures printed open, so its count is a record of the print and not a
|
|
5
5
|
# layout check. The monthly page is one sheet by construction, so pass 1 for
|
|
6
6
|
# it and the script fails when it prints as anything else.
|
|
7
7
|
#
|
|
8
8
|
# Usage: atelic-print-sheets <url | dist/client> <out.pdf> [expected sheets] [page] [port]
|
|
9
|
-
# atelic-print-sheets dist/client public/
|
|
9
|
+
# atelic-print-sheets dist/client public/the-read.pdf
|
|
10
10
|
# atelic-print-sheets dist/client ../2026-10-01-report.pdf 1 /report
|
|
11
|
-
# atelic-print-sheets https://summit.atelic.me/
|
|
11
|
+
# atelic-print-sheets https://summit.atelic.me/the-read ../2026-09-10-the-read.pdf
|
|
12
12
|
# A site offers the same as `bun run print <...>`. Both paths are read from
|
|
13
13
|
# the directory the command is run in, which is the site's root.
|
|
14
14
|
#
|
|
15
15
|
# A directory is served on a local port for the print (the page loads its
|
|
16
16
|
# fonts and images by absolute path) and the server is stopped after; the
|
|
17
|
-
# page is the path printed from it, /
|
|
18
|
-
# as given. The
|
|
17
|
+
# page is the path printed from it, /the-read unless named. A URL is printed
|
|
18
|
+
# as given. The read's PDF belongs in public/ as the-read.pdf beside the
|
|
19
19
|
# page (on a private artifact the wall holds it like the page, see
|
|
20
20
|
# ACCESS-GATE.md), and a dated copy goes in the client folder of the
|
|
21
21
|
# practice repo.
|
|
@@ -24,7 +24,7 @@ set -euo pipefail
|
|
|
24
24
|
TARGET=${1:?url or built directory required}
|
|
25
25
|
OUT=${2:?output pdf path required}
|
|
26
26
|
EXPECT=${3:-}
|
|
27
|
-
PAGE=${4:-/
|
|
27
|
+
PAGE=${4:-/the-read}
|
|
28
28
|
PORT=${5:-8765}
|
|
29
29
|
CHROME=${CHROME:-"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"}
|
|
30
30
|
[[ -x "$CHROME" ]] || { echo "Chrome not found at $CHROME" >&2; exit 1; }
|
package/dist/artifact/readers.js
CHANGED
|
@@ -110,7 +110,7 @@ export async function whoIsReading(visit) {
|
|
|
110
110
|
return openReader(visit.readers, token);
|
|
111
111
|
}
|
|
112
112
|
/** The link to send a reader: the artifact's page with their token on it. */
|
|
113
|
-
export function readerLink(siteURL, token, path = "/
|
|
113
|
+
export function readerLink(siteURL, token, path = "/the-read") {
|
|
114
114
|
const url = new URL(path, siteURL);
|
|
115
115
|
url.searchParams.set("k", token);
|
|
116
116
|
return url.toString();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type UserConfig } from "vite";
|
|
2
2
|
export interface ArtifactConfigOptions {
|
|
3
3
|
/**
|
|
4
|
-
* The path of each artifact page the site carries, such as "/
|
|
4
|
+
* The path of each artifact page the site carries, such as "/the-read".
|
|
5
5
|
* The root and the 404 page are always prerendered and need no entry.
|
|
6
6
|
*/
|
|
7
7
|
pages: readonly string[];
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* import { artifactConfig } from "@atelic-action/ui/vite";
|
|
7
7
|
*
|
|
8
|
-
* export default artifactConfig({ pages: ["/
|
|
8
|
+
* export default artifactConfig({ pages: ["/the-read", "/proposal", "/report"] });
|
|
9
9
|
*
|
|
10
10
|
* The site's own paths (the `@` alias to its src) are found from the
|
|
11
11
|
* directory Vite is run in, which is the site's root.
|
|
@@ -19,7 +19,7 @@ import { privateChunks } from "./private-chunks.js";
|
|
|
19
19
|
export function artifactPages(pages) {
|
|
20
20
|
const named = pages.filter((path) => path !== "/" && path !== "/404");
|
|
21
21
|
return [
|
|
22
|
-
// The root holds no document: it sends the reader to /
|
|
22
|
+
// The root holds no document: it sends the reader to /the-read
|
|
23
23
|
// (vercel.json does the same on the host), and its prerendered
|
|
24
24
|
// index.html is what a static preview serves at the bare host.
|
|
25
25
|
{ path: "/", prerender: { enabled: true } },
|
|
@@ -44,10 +44,10 @@ export function artifactConfig({ pages, extend }) {
|
|
|
44
44
|
prerender: {
|
|
45
45
|
enabled: true,
|
|
46
46
|
crawlLinks: true,
|
|
47
|
-
// The
|
|
47
|
+
// The read links each screenshot crop to its full image, and
|
|
48
48
|
// the crawler follows every href it finds: without this filter it
|
|
49
49
|
// prerenders /images/<shot>.png as an HTML page and writes that
|
|
50
|
-
// over the real file in dist/client (found on a client's
|
|
50
|
+
// over the real file in dist/client (found on a client's read,
|
|
51
51
|
// 2026-09-24).
|
|
52
52
|
filter: ({ path }) => !path.startsWith("/images/"),
|
|
53
53
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atelic-action/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.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/readers.ts
CHANGED
|
@@ -139,7 +139,7 @@ export async function whoIsReading(visit: {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/** The link to send a reader: the artifact's page with their token on it. */
|
|
142
|
-
export function readerLink(siteURL: string, token: string, path = "/
|
|
142
|
+
export function readerLink(siteURL: string, token: string, path = "/the-read"): string {
|
|
143
143
|
const url = new URL(path, siteURL);
|
|
144
144
|
url.searchParams.set("k", token);
|
|
145
145
|
return url.toString();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
|
2
2
|
import { type ReactNode, useId, useMemo, useState } from "react";
|
|
3
|
+
import { Hint } from "./Hint";
|
|
3
4
|
|
|
4
5
|
export type SortDirection = "asc" | "desc";
|
|
5
6
|
|
|
@@ -15,6 +16,10 @@ export interface DataTableColumn {
|
|
|
15
16
|
firstSort?: SortDirection;
|
|
16
17
|
/** The column hugs its content (a swatch, a mark) instead of sharing the width. */
|
|
17
18
|
fit?: boolean;
|
|
19
|
+
/** A short explainer behind an info icon beside the heading, for a measure a reader may not know. */
|
|
20
|
+
hint?: ReactNode;
|
|
21
|
+
/** What a screen reader calls the hint's button; defaults to "About" and the label. */
|
|
22
|
+
hintLabel?: string;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
/** A cell whose display is not what it sorts by, e.g. an icon over a number. */
|
|
@@ -140,9 +145,22 @@ export function DataTable({
|
|
|
140
145
|
{columns.map((column, index) => {
|
|
141
146
|
const active = sort?.column === index;
|
|
142
147
|
const Icon = !active ? ArrowUpDown : sort?.direction === "asc" ? ArrowUp : ArrowDown;
|
|
148
|
+
// The explainer's icon sits after the label, or before it when the
|
|
149
|
+
// column is right aligned, so the icon stays on the inside edge.
|
|
150
|
+
const before = column.align === "right";
|
|
151
|
+
const hint = column.hint ? (
|
|
152
|
+
<Hint
|
|
153
|
+
label={column.hintLabel ?? `About ${column.label}`}
|
|
154
|
+
side={before ? "before" : "after"}
|
|
155
|
+
>
|
|
156
|
+
{column.hint}
|
|
157
|
+
</Hint>
|
|
158
|
+
) : null;
|
|
143
159
|
const head = {
|
|
144
160
|
"data-align": column.align ?? "left",
|
|
145
161
|
"data-fit": column.fit || undefined,
|
|
162
|
+
// The hint button sits in the heading; the heading is still named by its label.
|
|
163
|
+
"aria-label": column.hint && column.label ? column.label : undefined,
|
|
146
164
|
"aria-sort": column.sortable
|
|
147
165
|
? active
|
|
148
166
|
? sort?.direction === "asc"
|
|
@@ -154,6 +172,7 @@ export function DataTable({
|
|
|
154
172
|
return (
|
|
155
173
|
// biome-ignore lint/suspicious/noArrayIndexKey: columns sit in order, and two may share a blank label
|
|
156
174
|
<th scope="col" key={index} {...head}>
|
|
175
|
+
{before && hint}
|
|
157
176
|
{column.sortable ? (
|
|
158
177
|
<button
|
|
159
178
|
type="button"
|
|
@@ -166,6 +185,7 @@ export function DataTable({
|
|
|
166
185
|
) : (
|
|
167
186
|
column.label
|
|
168
187
|
)}
|
|
188
|
+
{!before && hint}
|
|
169
189
|
</th>
|
|
170
190
|
);
|
|
171
191
|
})}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Info } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
export interface HintProps {
|
|
13
|
+
/** What the button is called, for a screen reader, e.g. "About First paint". */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Which side of its word the icon sits on; `before` for a right aligned heading. */
|
|
16
|
+
side?: "before" | "after";
|
|
17
|
+
className?: string;
|
|
18
|
+
/** The bubble's text: a sentence or two in plain words. */
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An info bubble: a small icon beside a word that opens a short explainer.
|
|
24
|
+
* It opens on hover or focus and stays open on a click or a tap, so it works
|
|
25
|
+
* on a phone, and Escape or a tap elsewhere closes it. The bubble opens
|
|
26
|
+
* from the icon's left edge and flips to its right edge when it would run
|
|
27
|
+
* off the screen. It is read to a screen reader as the button's
|
|
28
|
+
* description; print hides the icon, since a printed sheet cannot be tapped.
|
|
29
|
+
*/
|
|
30
|
+
export function Hint({ label, side = "after", className, children }: HintProps) {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const [pinned, setPinned] = useState(false);
|
|
33
|
+
// Keyboard focus holds the bubble open until it leaves; mouse focus, which
|
|
34
|
+
// a click also gives the button, must not, or a later hover sticks open.
|
|
35
|
+
const [keyboard, setKeyboard] = useState(false);
|
|
36
|
+
const [shift, setShift] = useState(0);
|
|
37
|
+
const id = useId();
|
|
38
|
+
const root = useRef<HTMLSpanElement>(null);
|
|
39
|
+
const bubble = useRef<HTMLSpanElement>(null);
|
|
40
|
+
|
|
41
|
+
const close = useCallback(() => {
|
|
42
|
+
setOpen(false);
|
|
43
|
+
setPinned(false);
|
|
44
|
+
setKeyboard(false);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return;
|
|
49
|
+
const away = (event: PointerEvent) => {
|
|
50
|
+
if (!root.current?.contains(event.target as Node)) close();
|
|
51
|
+
};
|
|
52
|
+
const key = (event: KeyboardEvent) => {
|
|
53
|
+
if (event.key === "Escape") close();
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener("pointerdown", away);
|
|
56
|
+
document.addEventListener("keydown", key);
|
|
57
|
+
return () => {
|
|
58
|
+
document.removeEventListener("pointerdown", away);
|
|
59
|
+
document.removeEventListener("keydown", key);
|
|
60
|
+
};
|
|
61
|
+
}, [open, close]);
|
|
62
|
+
|
|
63
|
+
// Opens from the icon's left edge, then slides left just far enough to
|
|
64
|
+
// stay on screen, and never past the screen's left edge.
|
|
65
|
+
useLayoutEffect(() => {
|
|
66
|
+
if (!open || !bubble.current || !root.current) return;
|
|
67
|
+
const icon = root.current.getBoundingClientRect();
|
|
68
|
+
const width = bubble.current.getBoundingClientRect().width;
|
|
69
|
+
const room = document.documentElement.clientWidth - 8;
|
|
70
|
+
const slide = Math.min(0, room - (icon.left + width));
|
|
71
|
+
setShift(Math.max(slide, 8 - icon.left));
|
|
72
|
+
}, [open]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: focusout bubbles to the root so focus leaving the button or the bubble closes it
|
|
76
|
+
<span
|
|
77
|
+
ref={root}
|
|
78
|
+
className={["hint", className].filter(Boolean).join(" ")}
|
|
79
|
+
data-side={side}
|
|
80
|
+
data-open={open || undefined}
|
|
81
|
+
onBlur={(event) => {
|
|
82
|
+
// Focus moving between the button and the bubble is not leaving.
|
|
83
|
+
if (root.current?.contains(event.relatedTarget as Node | null)) return;
|
|
84
|
+
close();
|
|
85
|
+
}}
|
|
86
|
+
onPointerEnter={(event) => {
|
|
87
|
+
// A finger never hovers: a tap opens through the click instead.
|
|
88
|
+
if (event.pointerType !== "touch") setOpen(true);
|
|
89
|
+
}}
|
|
90
|
+
onPointerLeave={(event) => {
|
|
91
|
+
if (event.pointerType === "touch" || pinned || keyboard) return;
|
|
92
|
+
setOpen(false);
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
aria-label={label}
|
|
98
|
+
aria-describedby={id}
|
|
99
|
+
aria-expanded={open}
|
|
100
|
+
onFocus={(event) => {
|
|
101
|
+
// :focus-visible is the browser's own word for keyboard focus; a press
|
|
102
|
+
// that focuses the button does not match it. Where the selector is
|
|
103
|
+
// unknown, focus counts as the keyboard's.
|
|
104
|
+
let visible = true;
|
|
105
|
+
try {
|
|
106
|
+
visible = event.currentTarget.matches(":focus-visible");
|
|
107
|
+
} catch {
|
|
108
|
+
visible = true;
|
|
109
|
+
}
|
|
110
|
+
if (visible) {
|
|
111
|
+
setKeyboard(true);
|
|
112
|
+
setOpen(true);
|
|
113
|
+
}
|
|
114
|
+
}}
|
|
115
|
+
onClick={() => {
|
|
116
|
+
if (pinned) close();
|
|
117
|
+
else {
|
|
118
|
+
setPinned(true);
|
|
119
|
+
setOpen(true);
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<Info size={14} aria-hidden="true" />
|
|
124
|
+
</button>
|
|
125
|
+
<span
|
|
126
|
+
ref={bubble}
|
|
127
|
+
role="tooltip"
|
|
128
|
+
id={id}
|
|
129
|
+
className="hint-bubble"
|
|
130
|
+
style={shift ? { left: shift } : undefined}
|
|
131
|
+
// A press on the bubble's text must not pull focus off the button.
|
|
132
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
133
|
+
>
|
|
134
|
+
{children}
|
|
135
|
+
</span>
|
|
136
|
+
</span>
|
|
137
|
+
);
|
|
138
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
type DisclosureProps,
|
|
24
24
|
} from "./Disclosure";
|
|
25
25
|
export { GateLogin, type GateLoginProps } from "./GateLogin";
|
|
26
|
+
export { Hint, type HintProps } from "./Hint";
|
|
26
27
|
export { IconList, type IconListItem, type IconListProps } from "./IconList";
|
|
27
28
|
export { NotFound, type NotFoundProps } from "./NotFound";
|
|
28
29
|
export { Rank, type RankProps } from "./Rank";
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* readable by anyone holding the link, and a 5xx is a host that is down
|
|
7
7
|
* rather than walled; both fail.
|
|
8
8
|
*
|
|
9
|
-
* The documents are /
|
|
9
|
+
* The documents are /the-read, plus /report and /proposal when this repo
|
|
10
10
|
* carries those routes. The wall holds files too (needsSession in @atelic-action/ui/gate), so it
|
|
11
|
-
* also asks for the printed copy at /
|
|
11
|
+
* also asks for the printed copy at /the-read.pdf, the first real screenshot
|
|
12
12
|
* under public/images/, and, when the repo has been built, the first chunk
|
|
13
13
|
* under /assets/doc/, where a document's content compiles to. Each of those
|
|
14
14
|
* must answer with the same redirect: the wall holds a path whether or not
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* Usage: atelic-check-wall <host> [path ...], or `bun run check:wall <host>`
|
|
31
31
|
* in a site, from the site's root
|
|
32
32
|
* bun run check:wall summit.atelic.me
|
|
33
|
-
* bun run check:wall https://summit.atelic.me /
|
|
33
|
+
* bun run check:wall https://summit.atelic.me /the-read /report
|
|
34
34
|
* The sign in functions are asked only when no path is named.
|
|
35
35
|
* Exits 1 unless every path answers as it must.
|
|
36
36
|
*/
|
|
@@ -93,10 +93,10 @@ export interface WallTargets {
|
|
|
93
93
|
|
|
94
94
|
/** The paths to ask for: the documents this repo carries, the held files, then the open mark. */
|
|
95
95
|
export function wallProbes({ hasRoute, image, docChunk, brand }: WallTargets): Probe[] {
|
|
96
|
-
const probes: Probe[] = [{ path: "/
|
|
96
|
+
const probes: Probe[] = [{ path: "/the-read", kind: "document" }];
|
|
97
97
|
if (hasRoute("report")) probes.push({ path: "/report", kind: "document" });
|
|
98
98
|
if (hasRoute("proposal")) probes.push({ path: "/proposal", kind: "document" });
|
|
99
|
-
probes.push({ path: "/
|
|
99
|
+
probes.push({ path: "/the-read.pdf", kind: "file" });
|
|
100
100
|
if (image) probes.push({ path: image, kind: "file" });
|
|
101
101
|
if (docChunk) probes.push({ path: docChunk, kind: "file" });
|
|
102
102
|
if (brand) probes.push({ path: brand, kind: "open" });
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* email encrypted under it. The token itself appears only in the link, so
|
|
9
9
|
* keep the link; it cannot be printed again.
|
|
10
10
|
*
|
|
11
|
-
* A
|
|
11
|
+
* A read's reader also gets the printed copy's QR: public/images/qr.png
|
|
12
12
|
* is written to carry the same link, so a scan of the sheet lands on that
|
|
13
13
|
* person's contact as an emailed open does. Mint the reader before printing.
|
|
14
14
|
*
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Usage: atelic-reader-link <email> [path] [--token <existing token>] [--qr | --no-qr],
|
|
20
20
|
* or `bun run reader <email>` in a site, from the site's root.
|
|
21
|
-
* path defaults to /
|
|
21
|
+
* path defaults to /the-read. --token seals an email under a token that is
|
|
22
22
|
* already in a sent link, so that link keeps working. --qr writes the QR
|
|
23
23
|
* for this reader even when another is already named; --no-qr never writes it.
|
|
24
24
|
*/
|
|
@@ -51,8 +51,8 @@ export function parseReaderArgs(flags: string[]): ReaderArgs | undefined {
|
|
|
51
51
|
const at = args.indexOf("--token");
|
|
52
52
|
const given = at === -1 ? undefined : args[at + 1];
|
|
53
53
|
const rest = at === -1 ? args : args.filter((_, index) => index !== at && index !== at + 1);
|
|
54
|
-
const [email, givenPath = "/
|
|
55
|
-
// "
|
|
54
|
+
const [email, givenPath = "/the-read"] = rest;
|
|
55
|
+
// "the-read", "/the-read", and "/the-read/" are one page.
|
|
56
56
|
const path = `/${givenPath.replace(/^\/+|\/+$/g, "")}`;
|
|
57
57
|
|
|
58
58
|
if (!email?.includes("@") || (at !== -1 && !given)) return undefined;
|
|
@@ -91,13 +91,13 @@ export async function main(flags: string[], cwd: string = process.cwd()): Promis
|
|
|
91
91
|
// in and never by a token, so its QR stays on the bare host.
|
|
92
92
|
const named = site.analytics?.identify.length ?? 0;
|
|
93
93
|
const sheetIsFree = named === 0 || forceQR;
|
|
94
|
-
if (path === "/
|
|
94
|
+
if (path === "/the-read" && !noQR && site.access === "public" && !sheetIsFree) {
|
|
95
95
|
console.log("");
|
|
96
96
|
console.log(
|
|
97
97
|
"Left public/images/qr.png alone: a reader is already named, and the sheet is theirs. Pass --qr to give the sheet to this reader.",
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
|
-
if (path === "/
|
|
100
|
+
if (path === "/the-read" && !noQR && site.access === "public" && sheetIsFree) {
|
|
101
101
|
const qr = resolve(cwd, "public/images/qr.png");
|
|
102
102
|
await QRCode.toFile(qr, link, { width: 330, margin: 4, errorCorrectionLevel: "M" });
|
|
103
103
|
console.log("");
|
|
@@ -899,6 +899,78 @@
|
|
|
899
899
|
white-space: nowrap;
|
|
900
900
|
}
|
|
901
901
|
|
|
902
|
+
/* ---- Hint: an info icon that opens a short bubble on hover, focus, or a
|
|
903
|
+
tap, from the icon's left edge unless that runs off the screen; hidden
|
|
904
|
+
in print ---- */
|
|
905
|
+
.atelic-ui .hint {
|
|
906
|
+
position: relative;
|
|
907
|
+
display: inline-block;
|
|
908
|
+
vertical-align: middle;
|
|
909
|
+
margin-left: 4px;
|
|
910
|
+
line-height: 0;
|
|
911
|
+
}
|
|
912
|
+
.atelic-ui .hint[data-side="before"] {
|
|
913
|
+
margin: 0 4px 0 0;
|
|
914
|
+
}
|
|
915
|
+
.atelic-ui .hint > button {
|
|
916
|
+
display: inline-grid;
|
|
917
|
+
place-items: center;
|
|
918
|
+
width: 18px;
|
|
919
|
+
height: 18px;
|
|
920
|
+
padding: 0;
|
|
921
|
+
border: 0;
|
|
922
|
+
border-radius: 999px;
|
|
923
|
+
background: transparent;
|
|
924
|
+
color: var(--neutral-500);
|
|
925
|
+
cursor: pointer;
|
|
926
|
+
}
|
|
927
|
+
.atelic-ui .hint > button:hover,
|
|
928
|
+
.atelic-ui .hint > button:focus-visible,
|
|
929
|
+
.atelic-ui .hint[data-open] > button {
|
|
930
|
+
color: var(--ink);
|
|
931
|
+
}
|
|
932
|
+
.atelic-ui .hint-bubble {
|
|
933
|
+
display: none;
|
|
934
|
+
position: absolute;
|
|
935
|
+
top: calc(100% + 6px);
|
|
936
|
+
left: 0;
|
|
937
|
+
z-index: 20;
|
|
938
|
+
width: max-content;
|
|
939
|
+
max-width: min(280px, 80vw);
|
|
940
|
+
padding: 8px 10px;
|
|
941
|
+
border: 1px solid var(--neutral-200);
|
|
942
|
+
border-radius: var(--radius-sm);
|
|
943
|
+
background: var(--ui-card, var(--card));
|
|
944
|
+
box-shadow: var(--shadow-md, 0 6px 20px rgb(0 0 0 / 0.1));
|
|
945
|
+
color: var(--ink);
|
|
946
|
+
font-family: var(--font-sans, inherit);
|
|
947
|
+
font-size: 0.8rem;
|
|
948
|
+
font-weight: 400;
|
|
949
|
+
line-height: 1.45;
|
|
950
|
+
letter-spacing: normal;
|
|
951
|
+
text-transform: none;
|
|
952
|
+
text-align: left;
|
|
953
|
+
white-space: normal;
|
|
954
|
+
text-wrap: pretty;
|
|
955
|
+
}
|
|
956
|
+
/* Bridges the gap to the icon so the pointer can cross into the bubble. */
|
|
957
|
+
.atelic-ui .hint-bubble::before {
|
|
958
|
+
content: "";
|
|
959
|
+
position: absolute;
|
|
960
|
+
left: 0;
|
|
961
|
+
right: 0;
|
|
962
|
+
top: -8px;
|
|
963
|
+
height: 8px;
|
|
964
|
+
}
|
|
965
|
+
.atelic-ui .hint[data-open] .hint-bubble {
|
|
966
|
+
display: block;
|
|
967
|
+
}
|
|
968
|
+
@media print {
|
|
969
|
+
.atelic-ui .hint {
|
|
970
|
+
display: none;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
902
974
|
/* ---- DataTable: on a phone each row stacks, its facts wearing their
|
|
903
975
|
column's label, and a dropdown sorts; wide, it is a table whose
|
|
904
976
|
headings sort ---- */
|
|
@@ -1058,6 +1130,19 @@
|
|
|
1058
1130
|
flex: none;
|
|
1059
1131
|
opacity: 0.55;
|
|
1060
1132
|
}
|
|
1133
|
+
/* A Hint's button is not a sort button: keep its own look against the bare heading rules. */
|
|
1134
|
+
.atelic-ui .data-table thead th .hint > button {
|
|
1135
|
+
display: inline-grid;
|
|
1136
|
+
color: var(--neutral-500);
|
|
1137
|
+
}
|
|
1138
|
+
.atelic-ui .data-table thead th .hint > button:hover,
|
|
1139
|
+
.atelic-ui .data-table thead th .hint > button:focus-visible,
|
|
1140
|
+
.atelic-ui .data-table thead th .hint[data-open] > button {
|
|
1141
|
+
color: var(--ink);
|
|
1142
|
+
}
|
|
1143
|
+
.atelic-ui .data-table thead th .hint > button svg {
|
|
1144
|
+
opacity: 1;
|
|
1145
|
+
}
|
|
1061
1146
|
.atelic-ui .data-table thead th button[data-active] svg {
|
|
1062
1147
|
opacity: 1;
|
|
1063
1148
|
}
|
|
@@ -1076,6 +1161,12 @@
|
|
|
1076
1161
|
border-bottom: 0;
|
|
1077
1162
|
}
|
|
1078
1163
|
}
|
|
1164
|
+
/* The phone hides the heading row, so a column hint goes with it. */
|
|
1165
|
+
@media screen and (width < 721px) {
|
|
1166
|
+
.atelic-ui .data-table thead .hint {
|
|
1167
|
+
display: none;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1079
1170
|
@media print {
|
|
1080
1171
|
.atelic-ui .data-table thead th button svg {
|
|
1081
1172
|
display: none;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* import { artifactConfig } from "@atelic-action/ui/vite";
|
|
7
7
|
*
|
|
8
|
-
* export default artifactConfig({ pages: ["/
|
|
8
|
+
* export default artifactConfig({ pages: ["/the-read", "/proposal", "/report"] });
|
|
9
9
|
*
|
|
10
10
|
* The site's own paths (the `@` alias to its src) are found from the
|
|
11
11
|
* directory Vite is run in, which is the site's root.
|
|
@@ -18,7 +18,7 @@ import { privateChunks } from "./private-chunks.js";
|
|
|
18
18
|
|
|
19
19
|
export interface ArtifactConfigOptions {
|
|
20
20
|
/**
|
|
21
|
-
* The path of each artifact page the site carries, such as "/
|
|
21
|
+
* The path of each artifact page the site carries, such as "/the-read".
|
|
22
22
|
* The root and the 404 page are always prerendered and need no entry.
|
|
23
23
|
*/
|
|
24
24
|
pages: readonly string[];
|
|
@@ -40,7 +40,7 @@ export interface ArtifactPage {
|
|
|
40
40
|
export function artifactPages(pages: readonly string[]): ArtifactPage[] {
|
|
41
41
|
const named = pages.filter((path) => path !== "/" && path !== "/404");
|
|
42
42
|
return [
|
|
43
|
-
// The root holds no document: it sends the reader to /
|
|
43
|
+
// The root holds no document: it sends the reader to /the-read
|
|
44
44
|
// (vercel.json does the same on the host), and its prerendered
|
|
45
45
|
// index.html is what a static preview serves at the bare host.
|
|
46
46
|
{ path: "/", prerender: { enabled: true } },
|
|
@@ -66,10 +66,10 @@ export function artifactConfig({ pages, extend }: ArtifactConfigOptions): UserCo
|
|
|
66
66
|
prerender: {
|
|
67
67
|
enabled: true,
|
|
68
68
|
crawlLinks: true,
|
|
69
|
-
// The
|
|
69
|
+
// The read links each screenshot crop to its full image, and
|
|
70
70
|
// the crawler follows every href it finds: without this filter it
|
|
71
71
|
// prerenders /images/<shot>.png as an HTML page and writes that
|
|
72
|
-
// over the real file in dist/client (found on a client's
|
|
72
|
+
// over the real file in dist/client (found on a client's read,
|
|
73
73
|
// 2026-09-24).
|
|
74
74
|
filter: ({ path }) => !path.startsWith("/images/"),
|
|
75
75
|
},
|