@aiquants/daily-report 0.22.1 → 0.23.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/CHANGELOG.md +190 -0
- package/README.md +791 -113
- package/dist/client.d.mts +35 -149
- package/dist/client.d.ts +35 -149
- package/dist/client.js +4 -6
- package/dist/client.mjs +4 -6
- package/dist/{ids-stream-BnM6L-Sk.d.ts → ids-stream-BR5RSj5u.d.ts} +157 -148
- package/dist/{ids-stream-DCuiaTit.d.mts → ids-stream-DvB2h1dj.d.mts} +157 -148
- package/dist/index.d.mts +29 -9
- package/dist/index.d.ts +29 -9
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/server.d.mts +143 -58
- package/dist/server.d.ts +143 -58
- package/dist/server.js +5 -4
- package/dist/server.mjs +5 -4
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/{types-CqgYQgbH.d.mts → types-DkERw8NE.d.mts} +2 -1
- package/dist/{types-CqgYQgbH.d.ts → types-DkERw8NE.d.ts} +2 -1
- package/package.json +31 -14
- package/dist/logger-D3krZrNK.d.mts +0 -15
- package/dist/logger-D3krZrNK.d.ts +0 -15
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Follows the `@aiquants/authz-*` DI layering — every app-specific concern (auth
|
|
|
8
8
|
| :--- | :--- | :--- |
|
|
9
9
|
| `@aiquants/daily-report` | Types, zod SSE schemas, business-date / text / comment utilities | isomorphic |
|
|
10
10
|
| `@aiquants/daily-report/client` | `DailyReportPage`, list/detail components, hooks, action context, route helpers | browser (React) |
|
|
11
|
-
| `@aiquants/daily-report/server` | `createDailyReportServer` (service + loaders/actions), `defineDailyReportSchema`, SSE fan-out reader, SQL result cache | node |
|
|
11
|
+
| `@aiquants/daily-report/server` | `createDailyReportServer` (service + loaders/actions), `defineDailyReportSchema`, SSE fan-out reader, SQL result cache, the sharp thumbnail renderer | node |
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -22,21 +22,51 @@ Monorepo (already a pnpm workspace package):
|
|
|
22
22
|
External projects:
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
pnpm add @aiquants/daily-report react react-dom react-router drizzle-orm zod
|
|
25
|
+
pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay @aiquants/resize-panels react react-dom react-router drizzle-orm zod
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
`react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**.
|
|
28
|
+
`@aiquants/virtualscroll` / `@aiquants/swipe-overlay` / `@aiquants/resize-panels` / `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are **peer dependencies**. React must be **19 or later** (`react` / `react-dom` `>=19`): the rows register their keyboard focus targets with ref callbacks that return a cleanup.
|
|
29
|
+
`@aiquants/virtualscroll` must be **3.8.0 or later**: both views pass its scroll-bar option `enableArrowButtonTabStops: false`, which takes the scroll bar's arrow buttons out of the Tab order (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)). The label catalog reuses its 7 engine keys (see [Localization](#localization-locale--labels)).
|
|
30
|
+
That floor is declared once, in `peer-floors.json` (`{ "@aiquants/virtualscroll": "3.8.0" }`), and every publish path runs `scripts/check-peer-floors.mjs` (`pnpm run check:peer-floors`) right after the leak check: each `publish:*` script before its version bump (so a refusal leaves no bumped version behind), and `prepublishOnly` before every `pnpm publish`, a bare one included (for example a re-run after a `publish:*` whose registry step failed).
|
|
31
|
+
`workspace:^` publishes `^<version>` of the linked workspace package (`node_modules/@aiquants/virtualscroll/package.json`), so while that version is below the floor the check exits 1 and the publish stops (exit 2 for a configuration error, such as a floor that names no `workspace:` peer).
|
|
32
|
+
`@aiquants/sse` (the SSE wire contract, server response helpers and the reopening client) is a regular **dependency**: it arrives transitively, so consumers do not declare it.
|
|
33
|
+
The server entry needs **Node.js 20.3 or later** (`engines.node` `>=20.3.0`): the thumbnail stage deadlines combine the generation's signal with a timer through `AbortSignal.any`. `src/server/node-engine-floor.spec.ts` reads the server-side modules' syntax tree and fails when one of them uses a listed runtime API newer than the declared floor.
|
|
34
|
+
Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest) / `pnpm run typecheck:examples` (the host examples under `examples/`, which are not shipped) / `pnpm run check:bundle` (the gzip size at level 9 of every shipped ESM entry and the standalone stylesheet — `dist/client.mjs`, `dist/index.mjs`, `dist/server.mjs`, `dist/styles/daily-report.standalone.css` — against `bundleBudget` in `package.json`; an `.mjs` or `.css` target of `exports` without a budget fails the check).
|
|
35
|
+
`pnpm run verify` runs the type checks, Biome (`src/` and `scripts/`), markdownlint, the two count ratchets (docstrings, then the no-fallback guard), the unit tests with per-file coverage floors, the coverage ratchet, the examples, the build and, last, the bundle check.
|
|
36
|
+
Every source file under `src` (without specs, tests, declarations and test helpers) and every gate script under `scripts` (`scripts/**/*.mjs`) has a committed floor of branch and function coverage in `coverage-floors.json`: Vitest fails a file below its floor, `pnpm run check:coverage` (`scripts/ratchet-coverage.mjs`) fails a file without an entry or an entry without a file, and `pnpm run coverage:ratchet` raises each floor to the measured percentage rounded down after a whole-suite coverage run (a file at 100 % stays at 100; no floor is ever lowered by the script).
|
|
37
|
+
Each gate script is a two-statement command-line entry around the `main({ packageRoot, argv })` of its module under `scripts/lib/` (the shared exit codes, JSON readers and error descriptions are `scripts/lib/cli.mjs`); the specs run `main` in the test process against temporary packages and keep one subprocess test of each entry, so the scripts' logic is measured by the same floors as `src` (all at 100).
|
|
38
|
+
A new file enters the ratchet only at 100 % of both metrics: the script records no entry below that, so a file that cannot reach 100 % needs a committed entry with a non-empty `exemption` (the reason, reviewed with the floors), which the script keeps while it raises the floors.
|
|
39
|
+
Two source guards keep per-file counts that only go down, through one engine (`scripts/lib/count-ratchet.mjs`): each file must match its entry exactly and a file without an entry counts 0, so a count below the entry fails too until the baseline is lowered (a fixed violation cannot come back unnoticed); an entry for a file the scan does not cover is stale; the exit code is 0 (pass), 1 (findings, each with its fix) or 2 (a configuration error, nothing proven); and `--write` lowers the entries to the measured counts and drops those at 0 and the stale ones, never raising or adding one.
|
|
40
|
+
The guards are the violations of the workspace's docstring and comment language rules, in `docstring-baseline.json` (`scripts/ratchet-docstrings.mjs`: `pnpm run check:docstrings` verifies, `pnpm run docstrings:ratchet` lowers), and the implicit value substitutions (`??` / `||` fallbacks) of the production sources, in `no-fallback-baseline.json` at the package root (`scripts/check-no-fallback.mjs`: `pnpm run check:no-fallback` verifies, `pnpm run no-fallback:ratchet` lowers).
|
|
41
|
+
A substitution that the specification or a port contract defines is a reviewed exception of the no-fallback guard instead (`EXCEPTIONS` in `scripts/lib/check-no-fallback.mjs`: the file, the exact expression, the number of `occurrences` it covers and the reason), and its matches are not counted; an exception that matches another number of expressions than it declares fails (exit 1: fewer means the expression was fixed or rewritten, more a new copy that needs its own review), and a malformed list is exit 2.
|
|
42
|
+
Every TypeScript or JavaScript code block of this README names its source in its info string: an example file, or a `#region` of one, which `src/docs-examples.spec.ts` compares byte for byte (`ts examples/<file>.ts[#<region>]`), or `illustrative` for a fragment that is not type-checked.
|
|
43
|
+
|
|
44
|
+
**Browser floor**: Chrome 121, Firefox 122, Safari 17.4 (Baseline 2024). The client uses each platform feature below as it is, with no feature detection and no second code path, so on an engine under the floor the listed behaviour fails and nothing else replaces it. Two CSS features of the table are newer than parts of the floor, and the row says what those engines draw:
|
|
45
|
+
|
|
46
|
+
| Feature | Supported from | Used for | Below the floor |
|
|
47
|
+
| --- | --- | --- | --- |
|
|
48
|
+
| `Element.checkVisibility({ visibilityProperty: true })` | Chrome 121, Firefox 122, Safari 17.4 (the method alone: Chrome 105, Firefox 106) | Finding Tab stops for the `Ctrl+Home` / `Ctrl+End` exits and the mobile overlay's Tab wrap | Without the method (Safari before 17.4) those key handlers throw a `TypeError`: the exit keys keep their browser meaning, and at the overlay's ends Tab moves to the browser's own interface instead of wrapping (the page behind the dialog stays inert). With the method but not the option (Chrome 105–120, Firefox 106–121) an element hidden by `visibility: hidden` counts as a stop, so an exit key whose nearest stop is such an element is consumed while focus stays where it was |
|
|
49
|
+
| `:has()` | Chrome 105, Firefox 121, Safari 15.4 | The List card's selection ring and keyboard focus outline, and the forced-colours selection outline of a List row with a card (all read from the card's primary button) | List cards show neither the selection nor keyboard focus (the DetailList is unaffected) |
|
|
50
|
+
| Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count; the tile frame's height (`100cqi` of the frame's own inline-size container); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
|
|
51
|
+
| CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` (see [Attachment display](#attachment-display)) | Chrome 121–124, inside the floor, drop that height declaration: the frame's `aspect-ratio: 480 / 320`, always declared beside it, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart |
|
|
52
|
+
| `scrollbar-gutter: stable` | Chrome 94, Firefox 97, Safari 18.2 | The side pane's tab panels keep their scroll bar's width whatever their content's height | Safari 17.4–18.1, inside the floor, ignore it; their default scroll bars overlay the content and take no width |
|
|
53
|
+
| `<dialog>` with `showModal()` | Chrome 37, Firefox 98, Safari 15.4 | The mobile detail overlay (a modal dialog in the top layer) | Opening the overlay throws a `TypeError` from a layout effect, which React hands to the nearest error boundary |
|
|
54
|
+
|
|
55
|
+
File names are cut at code-point boundaries rather than grapheme boundaries, because `Intl.Segmenter` (Firefox 125) is above the floor (see [Attachment display](#attachment-display)).
|
|
29
56
|
|
|
30
57
|
### Tailwind CSS
|
|
31
58
|
|
|
32
59
|
This package has **no hand-written component CSS**, so it ships **no components-only artifact** — only a standalone build for non-Tailwind hosts. Components carry Tailwind utility classes in JSX. Pick one consumption mode:
|
|
33
60
|
|
|
34
|
-
**Tailwind v4 host (required if your app builds Tailwind itself)** — add the package
|
|
61
|
+
**Tailwind v4 host (required if your app builds Tailwind itself)** — add the package to your app's scan targets (the published package ships its classes in `dist`; inside the monorepo, scan `src` without the specs and test helpers, as the package's own standalone build does) so all utilities are generated in one canonical build, define the `dark:` variant, and map the shadcn tokens the components reference (`bg-background`, `text-muted-foreground`, ... do not exist in Tailwind's default theme, so those utilities are not generated unless the tokens are mapped):
|
|
35
62
|
|
|
36
63
|
```css
|
|
37
64
|
/* app tailwind.css (Tailwind v4) */
|
|
38
|
-
@source "../node_modules/@aiquants/daily-report/
|
|
39
|
-
/* monorepo:
|
|
65
|
+
@source "../node_modules/@aiquants/daily-report/dist/**/*.mjs";
|
|
66
|
+
/* monorepo: scan the sources, without the specs and test helpers (their literals style no element):
|
|
67
|
+
@source "../../../../packages/daily-report/src/**/*.{ts,tsx}";
|
|
68
|
+
@source not "../../../../packages/daily-report/src/**/*.spec.{ts,tsx}";
|
|
69
|
+
@source not "../../../../packages/daily-report/src/**/test-helpers/**"; */
|
|
40
70
|
@plugin "tailwindcss-animate";
|
|
41
71
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
42
72
|
@theme inline {
|
|
@@ -48,8 +78,9 @@ This package has **no hand-written component CSS**, so it ships **no components-
|
|
|
48
78
|
|
|
49
79
|
The host must also:
|
|
50
80
|
|
|
51
|
-
- Load the `tailwindcss-animate` plugin. The components use `animate-in` / `fade-in` / `zoom-in-95` entrance animations; without `@plugin "tailwindcss-animate"` those classes are not generated and the animations silently no-op.
|
|
81
|
+
- Load the `tailwindcss-animate` plugin. The components use `animate-in` / `fade-in` / `zoom-in-95` / `slide-in-from-bottom-2` entrance animations (all under `motion-safe:`); without `@plugin "tailwindcss-animate"` those classes are not generated and the animations silently no-op.
|
|
52
82
|
- Define the shadcn CSS variables themselves (`--background`, `--muted`, `--radius`, ...).
|
|
83
|
+
- Map the radius scale as the standalone build does: `--radius-lg: var(--radius)` and `--radius-xl: calc(var(--radius) + 4px)` (with `-md` and `-sm` below). The tab bars are `rounded-xl` with a 4 px inset around `rounded-lg` tabs, so they stay concentric only when xl − lg = 4 px for your `--radius`.
|
|
53
84
|
- Import the `@aiquants/virtualscroll` peer CSS once. Do NOT `@import` the standalone build alongside your own Tailwind build: it duplicates utilities your build also emits, and concatenated builds cannot reproduce the single-build canonical order (base utilities before variants), so base/variant winners flip regardless of `@layer` wrapping (e.g. a later duplicate `.flex` beats an earlier `.sm:hidden`).
|
|
54
85
|
|
|
55
86
|
**Non-Tailwind host** — import the single self-contained standalone build (`pnpm run build:css` → `dist/styles/daily-report.standalone.css`):
|
|
@@ -63,11 +94,12 @@ The standalone build bundles every JSX utility (and maps the shadcn tokens), but
|
|
|
63
94
|
## Required database schema
|
|
64
95
|
|
|
65
96
|
Seven tables: `DailyReportHub` (core/cross-source), `DailyReportInternal` (in-app content), `DailyReportComment`, `DailyReportLabel`, `DailyReportHub_Label`, `DailyReportUserStatus` (read status & stars), and the optional `DailyReportAttachment` (attachment metadata — omit it and the attachment feature degrades gracefully: `attachments` stays an empty array and the attachment endpoint returns 404 for every token).
|
|
66
|
-
Attachment delivery additionally requires the `attachmentIdCodec` and `readAttachment` DI ports
|
|
97
|
+
Attachment delivery additionally requires the `attachmentIdCodec` and `readAttachment` DI ports — service configuration keys (`DailyReportServiceConfig`), which `createDailyReportServer` accepts because `DailyReportServerConfig` extends the service configuration; see [Attachments](#attachments).
|
|
98
|
+
The factory then exposes `dailyReportServer.attachment.loader`, which the host app must mount on its own byte-serving route (e.g. `daily_report.api.attachment.$token` — a route separate from the `:endpoint` JSON router, whose fixed `Content-Type: application/json` + CSP headers are incompatible with byte delivery). The route must name its parameter `token`: a route without it is a configuration error that every request answers with 500 and an `error` log line (see **Thumbnail endpoint** below). The same route and loader also serve thumbnails (`?thumbnail=tile`); no extra route is needed.
|
|
67
99
|
|
|
68
100
|
Existing apps can inject their own drizzle models (structural typing — see `DailyReportTables`). Greenfield projects can generate definitions:
|
|
69
101
|
|
|
70
|
-
```ts
|
|
102
|
+
```ts illustrative
|
|
71
103
|
import { defineDailyReportSchema } from "@aiquants/daily-report/server"
|
|
72
104
|
const tables = defineDailyReportSchema("dbo_app", { userTable: Users })
|
|
73
105
|
```
|
|
@@ -76,7 +108,7 @@ const tables = defineDailyReportSchema("dbo_app", { userTable: Users })
|
|
|
76
108
|
|
|
77
109
|
## Server wiring (DI)
|
|
78
110
|
|
|
79
|
-
```ts
|
|
111
|
+
```ts illustrative
|
|
80
112
|
// app/services/daily-report/config.server.ts
|
|
81
113
|
import { createDailyReportServer, type DailyReportDb, type DailyReportRedisProvider } from "@aiquants/daily-report/server"
|
|
82
114
|
|
|
@@ -98,7 +130,7 @@ export const dailyReportServer = createDailyReportServer({
|
|
|
98
130
|
|
|
99
131
|
Route mounts (React Router v7, flexible file convention):
|
|
100
132
|
|
|
101
|
-
```ts
|
|
133
|
+
```ts illustrative
|
|
102
134
|
// daily_report._index/loader.server.ts
|
|
103
135
|
export const loader = async (args) => {
|
|
104
136
|
const r = await dailyReportServer.index.loader(args)
|
|
@@ -121,13 +153,19 @@ export const loader = (args) => dailyReportServer.sse.loader(args)
|
|
|
121
153
|
- `draftLabelName` / `draftLabelNames` — Database label names representing draft states (single string or array of candidates like `["Draft", "Work in Progress"]`). Used for server-side cross-user visibility filtering (hiding drafts from other users) and `isDraft` evaluation.
|
|
122
154
|
- `resolveVisibleSourceTypes(request)` — **Optional row-level authorization port.** Returns the `Hub.source_type` values this request may view. Applies uniformly to every server data path: list (ids stream), business-date list, detail, comments, attachment bytes, and SSE. See [Source-type visibility](#source-type-visibility) below.
|
|
123
155
|
- `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the handler returns `400` before touching the service. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user could otherwise flush all caches without limit).
|
|
124
|
-
-
|
|
156
|
+
- `attachmentIdCodec` / `readAttachment` / `attachmentThumbnailRenderer` / `attachmentMaxBytes` — Attachment delivery ports and size limit, owned by the service. See [Attachments](#attachments).
|
|
157
|
+
- Primary tuning parameters: `idsTtlMs` (180s) / `businessDateTtlMs` (300s) / `streamKey` / `streamMaxLen` / `loginRedirectPath`, plus the attachment tuning keys listed under [Attachments](#attachments).
|
|
158
|
+
|
|
159
|
+
**Configuration errors** follow one convention on the server and on the client: the message reads `[daily-report] <path> must be <expectation>`, `<path>` being the public key or argument to fix, and ends with `; got <value>` only when it shows a value.
|
|
160
|
+
A rejected value — a wrong type included, also a port or a function given with the wrong type — is a `RangeError` whose message ends with `; got <value>` (a string as JSON; a number, a boolean, `null` or `undefined` as written; an array as `array`; anything else only its `typeof`). An unknown key is a `RangeError` that lists the known keys as JSON strings: `<path> must be one of "<key>", "<key>", …; got "<unknown key>"`. A missing (`undefined`) function or port is a `TypeError`, without `; got`.
|
|
161
|
+
For example `[daily-report] attachmentConcurrency must be an integer >= 1; got "2"`, `[daily-report] config key must be one of "apiBasePath", "ssePath", …; got "title"` and `[daily-report] readAttachment must be injected when attachmentIdCodec is given`.
|
|
162
|
+
The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configKeyError`, `configPortError`) are the only code that constructs a `RangeError` or a `TypeError`, so every such message has the prefix and the form: `src/host-facing-errors.spec.ts` reads the sources' syntax tree (specs and test helpers aside) and fails on a construction anywhere else.
|
|
125
163
|
|
|
126
164
|
### Source-type visibility
|
|
127
165
|
|
|
128
166
|
Restrict which report categories a viewer may see, without the package depending on any authorization library. The port receives the request and returns plain strings; your app decides the policy.
|
|
129
167
|
|
|
130
|
-
```ts
|
|
168
|
+
```ts illustrative
|
|
131
169
|
createDailyReportServer({
|
|
132
170
|
// …existing config
|
|
133
171
|
resolveVisibleSourceTypes: async (request, { reason }) => {
|
|
@@ -163,7 +201,7 @@ createDailyReportServer({
|
|
|
163
201
|
|
|
164
202
|
Return `{ read, comment }` from the same port to restrict **reading and writing comments** independently of the report bodies — this is what connects a per-category comment permission (e.g. `daily_report_legacy_comment`) to actual access control.
|
|
165
203
|
|
|
166
|
-
```ts
|
|
204
|
+
```ts illustrative
|
|
167
205
|
resolveVisibleSourceTypes: async (request, { reason }) => {
|
|
168
206
|
const grants = await yourAuthz.grantsFor(request, { skipCache: reason === "refresh" })
|
|
169
207
|
return {
|
|
@@ -186,6 +224,370 @@ resolveVisibleSourceTypes: async (request, { reason }) => {
|
|
|
186
224
|
- **Writing to external sources remains impossible regardless** — `addComment` rejects any non-`Internal` source type outright, so for external categories the comment dimension effectively controls *reading*.
|
|
187
225
|
- **Cache**: the dimension is folded into the cache-key digest, so revoking only the comment grant invalidates immediately. When `comment` follows `read` (the default) the digest is byte-identical to before, so existing deployments see no cache churn.
|
|
188
226
|
|
|
227
|
+
### Attachments
|
|
228
|
+
|
|
229
|
+
Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {apiBasePath}/attachment/{token}`. The query string names exactly one delivery:
|
|
230
|
+
|
|
231
|
+
| Delivery | URL | Response |
|
|
232
|
+
| --- | --- | --- |
|
|
233
|
+
| inline | `{apiBasePath}/attachment/{token}` | The original (shown by the browser for inline-safe types) |
|
|
234
|
+
| download | `...?download=1` | The original with `Content-Disposition: attachment` |
|
|
235
|
+
| thumbnail | `...?thumbnail=<variant>` | A downscaled preview in the named variant, when a renderer port is injected |
|
|
236
|
+
|
|
237
|
+
- **Build and parse the URL with the shared codec** (root entry `@aiquants/daily-report`). `buildDailyReportAttachmentUrl(apiBasePath, token, request)` takes `{ kind: "inline" }`, `{ kind: "download" }` or `{ kind: "thumbnail", variant }` (the `DailyReportAttachmentDeliveryRequest` union), and `parseDailyReportAttachmentQuery(searchParams)` reads a query back into the same request or into `{ kind: "invalid", reason: "thumbnail_variant" | "download_value" | "conflict" }` (`DailyReportAttachmentInvalidQuery`). The parameter names are private to the codec.
|
|
238
|
+
- **Variants** form the closed, frozen list `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` (root entry). It has one variant, `tile`: the box 480 × 320 a preview fits in, twice the largest tile frame of 240 × 160 CSS px. `isDailyReportAttachmentThumbnailVariant(value)` accepts the list's own keys only, exactly (`Tile`, `" tile"` and `toString` are not variants).
|
|
239
|
+
- **Parsing is strict.** `thumbnail` must be one known variant name, `download` must be exactly `1`, the two cannot be combined, and neither may repeat. Anything else — `?thumbnail=1`, `?thumbnail=true`, `?download=true`, `?download=yes`, `?thumbnail=tile&download=1` — answers **400** `{"error":{"message":"Invalid attachment request"}}` right after authentication, before any port or database query runs, and writes no log line. Other parameters are ignored, and names are case-sensitive (`?Download=1` is an unknown parameter, so the request stays inline).
|
|
240
|
+
- **The original's type**: the declared type is the row's `file_type` when it is a valid media type, otherwise the type the read port reported. An inline-safe declared type (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `application/pdf`, `text/plain`) is sent as itself, inline (as an attachment for a download); any other declared type, and a missing one, is sent as `application/octet-stream` with `Content-Disposition: attachment`, because the declared type comes from outside the package and an inline `text/html` or `image/svg+xml` would run script in the host's origin.
|
|
241
|
+
- **Methods**: the original (inline and download) answers `GET` and `HEAD` (a HEAD reads metadata only and reports the same `Content-Length` as GET); any other method answers **405** with `Allow: GET, HEAD`. The thumbnail answers `GET` only (see **Thumbnail endpoint** below). Both 405s are checked right after the query, before the ports and the token.
|
|
242
|
+
|
|
243
|
+
**Configuration**
|
|
244
|
+
|
|
245
|
+
| Key | Owner | Default | Requires | Meaning |
|
|
246
|
+
| --- | --- | --- | --- | --- |
|
|
247
|
+
| `attachmentIdCodec` | service | not injected | `readAttachment` | Obfuscates attachment ids (use an instance separate from the user-id codec). Without the codec and the read port, the detail's `attachments` is always `[]` and the endpoint answers 404. |
|
|
248
|
+
| `readAttachment` | service | not injected | `attachmentIdCodec` | Reads the original bytes (`DailyReportReadAttachment`). |
|
|
249
|
+
| `attachmentThumbnailRenderer` | service | not injected | `readAttachment`, `attachmentIdCodec` | Renders the preview (`DailyReportAttachmentThumbnailRenderer`: `{ id, render }`; the package's sharp implementation is `createSharpThumbnailRenderer`). Validated when the service is created. Without it `hasThumbnail` is always `false` and every thumbnail request answers 404 (it never falls back to the original). |
|
|
250
|
+
| `attachmentMaxBytes` | service | 32 MiB | `readAttachment` | Upper bound for an original, for every delivery. Keep it below the read port's wire limit. Integer ≥ 1. |
|
|
251
|
+
| `attachmentRateLimitPerMinute` | handlers | 60 | `readAttachment` | Original downloads per user per minute; a token is spent before authorization. Integer ≥ 1. |
|
|
252
|
+
| `attachmentConcurrency` | handlers | 4 | `readAttachment` | Simultaneous original reads; the slot is held until the body is sent, and the request fails fast with 503 when none is free. Integer ≥ 1. |
|
|
253
|
+
| `attachmentThumbnailRateLimitPerMinute` | handlers | 120 | `attachmentThumbnailRenderer` | Thumbnail generations per user per minute, in a bucket separate from originals; revalidations (requests with `If-None-Match`) pay from a second bucket of ten times this value. Both admit before authorization, and an empty bucket answers 429 at once; a revalidation that does not end in a 304 also pays a generation token, right after authorization (see **Rate admission** below). Integer ≥ 1. |
|
|
254
|
+
| `attachmentThumbnailConcurrency` | handlers | 2 | `attachmentThumbnailRenderer` | Simultaneous thumbnail generations (read + render), with a wait queue. Integer ≥ 1. |
|
|
255
|
+
| `attachmentThumbnailCacheBytes` | handlers | 8 MiB | `attachmentThumbnailRenderer` | Total size of the thumbnail cache. `0` disables caching. Integer ≥ 0. |
|
|
256
|
+
|
|
257
|
+
- **The service is the only owner of the delivery configuration.** The four service keys go to `createDailyReportService` / `createDailyReportServer`; the service resolves them once into the frozen `service.attachmentDelivery` (`DailyReportAttachmentDelivery`: `{ idCodec, readAttachment, thumbnailRenderer, maxBytes }`, uninjected ports `undefined`, `maxBytes` resolved to its default), and the handlers read only that object.
|
|
258
|
+
The `hasThumbnail` flag in the lists and the endpoint's behaviour therefore always come from the same values. `createDailyReportHandlers` does not accept these keys; a hand-written service stand-in passed to it must provide `attachmentDelivery`.
|
|
259
|
+
- **Keys work only together with the keys in the Requires column, and a partial wiring fails at creation.** A key given without a key it requires throws a `TypeError` that names both, for example `[daily-report] readAttachment must be injected when attachmentIdCodec is given` or `[daily-report] attachmentThumbnailRenderer must be injected when attachmentThumbnailConcurrency is given`.
|
|
260
|
+
A key counts as given unless it is `undefined` (so `null` counts), and the first missing key is reported. So there are three valid wirings: no attachment port (no attachments; none of the other attachment keys either), the codec and the read port (originals only, with `attachmentMaxBytes` and the original tuning keys), or all three ports (originals and thumbnails, every key).
|
|
261
|
+
`createDailyReportService` checks the ports and `attachmentMaxBytes`; `createDailyReportHandlers` checks the tuning keys against the ports of `service.attachmentDelivery` (a hand-written service stand-in is checked too); `createDailyReportServer` runs both.
|
|
262
|
+
- **The six numeric settings are validated once, at creation.** Only an omitted key (`undefined`) takes the default. Any other value that is not a safe integer at or above the minimum — `null`, `NaN`, `1.5`, `Infinity`, `-1` or the string `"2"` — throws a `RangeError` that names the public key, for example `[daily-report] attachmentThumbnailCacheBytes must be an integer >= 0; got -1` or `[daily-report] attachmentConcurrency must be an integer >= 1; got "2"`.
|
|
263
|
+
`createDailyReportService` checks `attachmentMaxBytes`; the handler factory checks the other five (`createDailyReportServer` runs both). The wiring check runs first, so a numeric key given without its port throws the `TypeError`.
|
|
264
|
+
- **Handler tuning is per process.** Rate buckets, gates and the cache live in the handler factory's closure and are not shared across workers; the effective container-wide limit is the configured value times the number of workers. `createDailyReportServer` forwards the five tuning keys explicitly.
|
|
265
|
+
- **Heap estimate per process**: `attachmentConcurrency × attachmentMaxBytes` for originals, plus `attachmentThumbnailConcurrency × (attachmentMaxBytes + the renderer's decode memory)` for thumbnails, plus `attachmentThumbnailCacheBytes`. The package's sharp renderer decodes only a source whose predicted working set is at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes, server entry), and the prediction is built to stay above the measured one: read that constant in your own estimate instead of restating it (see **The sharp renderer** below).
|
|
266
|
+
|
|
267
|
+
**Read port**
|
|
268
|
+
|
|
269
|
+
The port type spelled out (the example file checks that it is identical to the exported `DailyReportReadAttachment`):
|
|
270
|
+
|
|
271
|
+
```ts examples/attachment-port-contracts.ts#read-attachment
|
|
272
|
+
type DailyReportReadAttachment = (
|
|
273
|
+
filePath: string,
|
|
274
|
+
options: { maxBytes: number; head: boolean; principal?: string; signal: AbortSignal },
|
|
275
|
+
) => Promise<
|
|
276
|
+
| { ok: true; bytes: Uint8Array<ArrayBuffer>; contentType?: string | null; size?: number }
|
|
277
|
+
| { ok: false; reason: "not_found" | "denied" | "too_large" | "invalid_path" | "unavailable"; code?: string }
|
|
278
|
+
>
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
- Never throw; return a typed failure. `filePath` stays on the server. With `head: true` read metadata only and declare the real `size` (the HEAD `Content-Length` must match GET).
|
|
282
|
+
- **`principal` is for attribution and audit only: the read result must not depend on it.** The SQL predicate (`getAttachmentForUser`) is the only authorization boundary; the port reads the `filePath` of a row that has already been authorized. Thumbnail generation relies on this: requests waiting for the same content share one read, made with the first requester's `principal`, and the outcome is cached for every authorized viewer of that attachment.
|
|
283
|
+
So a host whose storage enforces per-principal ACLs must not inject `attachmentThumbnailRenderer` (one requester's read and its cached outcome would reach authorized viewers outside that ACL), and `denied` is only for a refusal that is the same for every requester (a path outside the sandbox, a permission the service's own credentials lack).
|
|
284
|
+
- **The `bytes` of `ok: true` are the complete object, at most `maxBytes` long.** A read that ends early must fail (`unavailable`), and an object over `maxBytes` must fail as `too_large` without being read. Because an object can change between its size check and its read, the server checks what a read returns:
|
|
285
|
+
- bytes over `maxBytes` are a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=over_max_bytes`, and nothing is sent, rendered, cached or recorded;
|
|
286
|
+
- a thumbnail generation compares what it observed with the size the attachment row declares (`fileSize`, which the content identity names): the length it read, or for a `too_large` only that the object is over `maxBytes`. A row without a size has nothing to compare and is **verified**. When they differ — a truncated read, an object replaced under the same row, or a `too_large` for a row whose known size is within the limit (every eligible row's is) — the outcome is **unverified**:
|
|
287
|
+
that request is still answered (a thumbnail as 200 with `Cache-Control: no-store` and no `ETag`, a failure as the 404), nothing is cached, the response log line ends with `identity=unverified`, and `size_mismatch expected=<n> got=<m>` (`got=><maxBytes>` for a `too_large`) is logged at `warn`. A truncated read that the renderer rejects therefore never becomes a cached 404 for every viewer, and a browser never keeps a preview of the wrong bytes through 304s.
|
|
288
|
+
- **`signal` aborts when the package no longer needs the result.** An original download passes the request's own `request.signal`. A thumbnail generation passes a signal that aborts when every request waiting for that content has gone (one of them leaving is not enough) or when the read stage's deadline passes (`readMs`, see the time budget below).
|
|
289
|
+
- Forward it to your storage calls, combined with your own per-call deadline (for example `AbortSignal.any([options.signal, AbortSignal.timeout(ms)])`). An original download has no package deadline: its signal never aborts while the request is alive, so without your own deadline one hung call holds the handler indefinitely.
|
|
290
|
+
- Once aborted, settle promptly with a typed failure (normally `unavailable`) instead of throwing. A failure that settles after the abort is the abort's doing, not a verdict about the object: report it as `unavailable`, never `not_found` (`not_found` is a verdict that the object is gone, which the package records as a missing object).
|
|
291
|
+
- A read that has already been aborted when it settles is discarded whatever its kind, for an original download and a thumbnail generation alike: 503 (`reason=aborted`) and no missing- or present-object record, so an aborted `unavailable` is never logged as a storage outage (502) and a racing `not_found` never marks the object missing.
|
|
292
|
+
The original download logs `503 attachment=<id> viewer=<id> reason=aborted`; the thumbnail generation also caches nothing and does not render. No disconnected client receives either 503. An original request that is already aborted when it arrives gets the same 503 without reading.
|
|
293
|
+
- A thumbnail read still running at its deadline is answered with 502 (`reason=read_timeout`) without waiting for it; nothing is cached and the object is not marked missing. The generation slot stays held until the port actually settles (releasing it earlier would start the next read while this one still holds the original), and a settlement after the deadline is logged as `read_overrun ms=<elapsed>`.
|
|
294
|
+
A read that has still not settled at twice its budget is logged once, at that moment, as `read_stuck ms=<elapsed>`: such a port keeps its slot for the life of the worker, and once every slot is held every thumbnail request waits `queueWaitMs` and answers 503. A port that honours the signal settles early and hands the slot to the next queued generation. Code that calls a `DailyReportReadAttachment` directly (tests, for example) must pass `signal`.
|
|
295
|
+
|
|
296
|
+
Example — a read port over a host object store (the file is type-checked by `pnpm run typecheck:examples` and not shipped):
|
|
297
|
+
|
|
298
|
+
```ts examples/read-attachment-port.ts
|
|
299
|
+
/**
|
|
300
|
+
* Example read port over a host-provided object store (not shipped; type-checked by `pnpm run typecheck:examples`).
|
|
301
|
+
* ホストのオブジェクトストアの上に組む読み取りポートの例 (同梱しない。`pnpm run typecheck:examples` で型検査する)。
|
|
302
|
+
*
|
|
303
|
+
* It follows the port contract: typed failures instead of exceptions, the package's `signal` combined with a per-call deadline,
|
|
304
|
+
* the real size declared on HEAD, and `unavailable` (never `not_found`) for a failure that settles after an abort.
|
|
305
|
+
* ポート契約に従う: 例外ではなく種別付きの失敗で返し、パッケージの `signal` を呼び出しごとの締め切りと合成し、HEAD では実サイズを申告し、
|
|
306
|
+
* 中断の後に決着した失敗は `not_found` ではなく `unavailable` にする。
|
|
307
|
+
*/
|
|
308
|
+
import type { DailyReportAttachmentError, DailyReportReadAttachment } from "@aiquants/daily-report/server"
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The part of the host's storage client this port uses.
|
|
312
|
+
* この読み取りポートが使う、ホストのストレージクライアントの面。
|
|
313
|
+
*/
|
|
314
|
+
export type ObjectStore = {
|
|
315
|
+
/** Metadata of an object, or null when it does not exist; rejects on a storage error or an abort. 実体のメタデータ (無ければ null。ストレージの失敗と中断では拒否する)。 */
|
|
316
|
+
stat: (key: string, signal: AbortSignal) => Promise<{ size: number; contentType: string | null } | null>
|
|
317
|
+
/** Bytes of an object; rejects on a storage error or an abort. 実体のバイト列 (ストレージの失敗と中断では拒否する)。 */
|
|
318
|
+
read: (key: string, signal: AbortSignal) => Promise<Uint8Array<ArrayBuffer>>
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Settings of the read port, supplied by the host.
|
|
323
|
+
* 読み取りポートの設定 (ホストが渡す)。
|
|
324
|
+
*/
|
|
325
|
+
export type ObjectStoreReadSettings = {
|
|
326
|
+
/** Deadline of one storage call (ms). ストレージ呼び出し 1 回の締め切り (ms)。 */
|
|
327
|
+
callTimeoutMs: number
|
|
328
|
+
/** Maps a stored file path to an object key, or null for a path this store must not read. 保存されたファイルパスをオブジェクトキーへ写す (読んではならないパスは null)。 */
|
|
329
|
+
resolveKey: (filePath: string) => string | null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** 中断・締め切り・到達不能で決着した読み取り (実体についての判定ではない)。 */
|
|
333
|
+
const UNAVAILABLE: DailyReportAttachmentError = Object.freeze({ ok: false, reason: "unavailable" })
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Creates a read port that reads attachments from the object store.
|
|
337
|
+
* オブジェクトストアから添付を読む読み取りポートを作る処理。
|
|
338
|
+
*
|
|
339
|
+
* @param store Host storage client. ホストのストレージクライアント。
|
|
340
|
+
* @param settings Per-call deadline and the path-to-key mapping. 呼び出しごとの締め切りと、パスからキーへの写像。
|
|
341
|
+
* @returns The read port. 読み取りポート。
|
|
342
|
+
*/
|
|
343
|
+
export const createObjectStoreReadAttachment = (store: ObjectStore, settings: ObjectStoreReadSettings): DailyReportReadAttachment => {
|
|
344
|
+
/**
|
|
345
|
+
* Combines the package's signal with the deadline of one storage call.
|
|
346
|
+
* パッケージのシグナルと、ストレージ呼び出し 1 回の締め切りを合成する処理。
|
|
347
|
+
*
|
|
348
|
+
* @param signal Signal from the package. パッケージから渡されたシグナル。
|
|
349
|
+
* @returns A signal that aborts on either. どちらでも中断されるシグナル。
|
|
350
|
+
*/
|
|
351
|
+
const callSignal = (signal: AbortSignal): AbortSignal => AbortSignal.any([signal, AbortSignal.timeout(settings.callTimeoutMs)])
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Reads an attachment (metadata only on HEAD) and settles with the bytes or a typed failure; never throws.
|
|
355
|
+
* 添付を読み (HEAD ではメタデータだけ)、バイト列か種別付きの失敗で決着する処理。例外は投げない。
|
|
356
|
+
*
|
|
357
|
+
* @param filePath Stored file path (never leaves the server). 保存されたファイルパス (サーバーの外へ出ない)。
|
|
358
|
+
* @param options Size bound, HEAD flag and the package's signal. サイズの上限・HEAD か・パッケージのシグナル。
|
|
359
|
+
* @returns The bytes, or a typed failure. バイト列、または種別付きの失敗。
|
|
360
|
+
*/
|
|
361
|
+
const readAttachment: DailyReportReadAttachment = async (filePath, { maxBytes, head, signal }) => {
|
|
362
|
+
const key = settings.resolveKey(filePath)
|
|
363
|
+
if (key === null) return { ok: false, reason: "invalid_path" }
|
|
364
|
+
if (signal.aborted) return UNAVAILABLE
|
|
365
|
+
try {
|
|
366
|
+
const stat = await store.stat(key, callSignal(signal))
|
|
367
|
+
// 中断の後に届いた「無い」は打ち切りの結果であって実体の判定ではない (not_found はパッケージが実体消失として記録する)
|
|
368
|
+
if (stat === null) return signal.aborted ? UNAVAILABLE : { ok: false, reason: "not_found" }
|
|
369
|
+
if (stat.size > maxBytes) return { ok: false, reason: "too_large" }
|
|
370
|
+
// HEAD の Content-Length は GET と一致しなければならないので、読まずに実サイズを申告する
|
|
371
|
+
if (head) return { ok: true, bytes: new Uint8Array(0), contentType: stat.contentType, size: stat.size }
|
|
372
|
+
const bytes = await store.read(key, callSignal(signal))
|
|
373
|
+
// メタデータを読んだ後に実体が伸びていることがある
|
|
374
|
+
if (bytes.byteLength > maxBytes) return { ok: false, reason: "too_large" }
|
|
375
|
+
return { ok: true, bytes, contentType: stat.contentType, size: bytes.byteLength }
|
|
376
|
+
} catch (error) {
|
|
377
|
+
return { ok: false, reason: "unavailable", code: error instanceof Error ? error.name : undefined }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return readAttachment
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
**Thumbnail renderer port**
|
|
385
|
+
|
|
386
|
+
The port type spelled out (the example file checks that it is identical to the exported `DailyReportAttachmentThumbnailRenderer`):
|
|
387
|
+
|
|
388
|
+
```ts examples/attachment-port-contracts.ts#thumbnail-renderer
|
|
389
|
+
type DailyReportAttachmentThumbnailRenderer = {
|
|
390
|
+
readonly id: string
|
|
391
|
+
readonly render: (
|
|
392
|
+
source: Uint8Array<ArrayBuffer>,
|
|
393
|
+
options: { format: "png" | "jpeg" | "gif" | "webp"; maxWidth: number; maxHeight: number; signal: AbortSignal },
|
|
394
|
+
) => Promise<{ ok: true; bytes: Uint8Array<ArrayBuffer>; contentType: "image/webp" | "image/jpeg" | "image/png" } | { ok: false; reason: "unsupported" | "failed" }>
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
- `id` names the version of your rendering pipeline, for example `sharp-<version>/vips-<version>/webp-q75-e4/flatten-#ffffff/v1` (the id of the package's sharp renderer below, with the versions read from `sharp.versions` at run time), so a library update never leaves the id stale.
|
|
399
|
+
**Change it whenever the output bytes change** — output format, quality, effort, library version, or how you implement the contract. The id is part of the content identity and therefore of the ETag: a new id makes both the in-process cache and browser revalidation (304) stop reusing old previews. If you change the output without changing the id, browsers keep the old preview through 304s.
|
|
400
|
+
- `createDailyReportService` / `createDailyReportServer` check the port when they are created, in this order, by the convention under **Configuration errors**: a port that is not an object (`null` included)
|
|
401
|
+
throws `RangeError` (`[daily-report] attachmentThumbnailRenderer must be an object with id and render; got null`), a missing `render` throws `TypeError` (`[daily-report] attachmentThumbnailRenderer.render must be a function`), a `render` that is given but is not a function throws `RangeError` (`…render must be a function; got "sharp"`), and an `id` that is not a string with non-whitespace content throws `RangeError` (`[daily-report] attachmentThumbnailRenderer.id must be a non-blank string naming the rendering pipeline version; got ""`).
|
|
402
|
+
- `render`: fit the image inside `maxWidth` × `maxHeight` — the box of the requested variant — keeping the aspect ratio (never enlarge), apply the EXIF orientation, drop metadata, flatten transparency onto white, bound decode memory before decoding (a pixel count alone does not: a 16-bit sample takes twice the bytes of an 8-bit one), start no decoder other than the one for `format`, and never throw.
|
|
403
|
+
- `signal` aborts when every request waiting for that content has gone or when the render stage's deadline passes (`renderMs`). A renderer that can stop (a remote image service, for example) should forward it and settle with `{ ok: false, reason: "failed" }`.
|
|
404
|
+
A render still running at its deadline is answered with 502 (`reason=render_timeout`), and a result that settles after everyone has gone is discarded (503); neither is cached. A renderer that cannot stop (in-process libvips) is still valid: the response is bounded by the deadline, but the generation slot stays held until the render settles (`render_overrun ms=<elapsed>`; a render that has still not settled at twice its budget is logged once as `render_stuck ms=<elapsed>`).
|
|
405
|
+
- `format` is detected by the package from the leading bytes (PNG, JPEG, GIF87a / GIF89a, RIFF…WEBP), not taken from the declared type. Bytes with any other signature (SVG, TIFF, HEIF, BMP, ...) are never handed to the port.
|
|
406
|
+
- `unsupported` means the content itself cannot be downscaled (deterministic); `failed` means a transient failure (retry may succeed).
|
|
407
|
+
- The result's `contentType` is typed by the closed set (`DailyReportAttachmentThumbnailOutputMediaType`, derived from `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES`) and must have 1 byte to `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`. Both are also checked at run time, for hosts written in JavaScript.
|
|
408
|
+
The bytes are checked too, not only the label: their leading signature must be the format the `contentType` names, and the size in their header (PNG `IHDR`, WebP `VP8` / `VP8L` / `VP8X`, the first JPEG `SOFn`) must be readable and fit the requested box. Anything else — including a thrown exception or an unknown failure reason — is a contract violation answered with 500 and never cached, so an undecodable or oversized preview is never cached for every viewer and never pinned by 304s.
|
|
409
|
+
|
|
410
|
+
Values for implementing the port:
|
|
411
|
+
|
|
412
|
+
| Value | Entry | Value |
|
|
413
|
+
| --- | --- | --- |
|
|
414
|
+
| `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` | server | `image/webp`, `image/jpeg`, `image/png` — the types the port may return (a frozen tuple; mutating it does not change the output check) |
|
|
415
|
+
| `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES` | server | `1048576` (1 MiB) |
|
|
416
|
+
| `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` | root | `{ tile: { maxWidth: 480, maxHeight: 320 } }` (frozen) — the box handed to `render` |
|
|
417
|
+
|
|
418
|
+
**The sharp renderer (`createSharpThumbnailRenderer`)**: the server entry implements the port on sharp (libvips). The host passes its own `sharp` module, typed by `SharpLike` (the part the renderer uses), so the package never depends on sharp. The renderer:
|
|
419
|
+
|
|
420
|
+
- fits the image into the box without enlarging it, applies the EXIF orientation, flattens transparency onto white and encodes WebP at quality 75 and effort 4 (metadata is dropped). It reads only the first frame of an animation.
|
|
421
|
+
- reads the source's layout from its bytes before it starts sharp — for a JPEG its frame header (the first `SOFn` and `SOS`, read the way libjpeg reads them: one scan or several, and the sampling factors), for a PNG the interlace method in `IHDR`, for a WebP its chunks (lossy, lossy with an `ALPH` alpha plane, lossless or animated); a source whose layout cannot be read is `unsupported` without starting sharp.
|
|
422
|
+
It then reads the image header (`metadata()`, on the same pipeline with the same options) and decodes only an image within both bounds: at most 50,000,000 pixels (sharp's `limitInputPixels`) and a predicted working set of at most `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` (200,000,000 bytes). An image over either bound, a sample format outside the list below or a header without numeric dimensions is `unsupported` (content-determined, cached) without decoding.
|
|
423
|
+
- predicts the working set of one render with `predictSharpThumbnailWorkingSetBytes(source, format, header)` from one frozen table, `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` (both in the server entry): the buffer the layout's decoder holds for the whole image before it can shrink, plus `pipelineRows` = 2,048 full-width rows of decoded pixels (width × bands × bytes per sample; `uchar` / `char` 1, `ushort` / `short` 2, `uint` / `int` / `float` 4, `complex` / `double` 8) that libvips holds, plus `fixedBytes` = 4 MiB (the WebP encoder of the 480 × 320 output and libvips' own structures):
|
|
424
|
+
|
|
425
|
+
| Layout | Whole-image buffer | Largest square admitted |
|
|
426
|
+
| --- | --- | --- |
|
|
427
|
+
| Baseline (one-scan) JPEG, PNG without interlacing, lossy WebP | none: decoded row by row | 7,071² (the pixel limit; only very wide images reach the budget) |
|
|
428
|
+
| Multi-scan JPEG (progressive, or a first scan without every component) | the DCT coefficients: 64 coefficients of 2 bytes per 8 × 8 block of every component at its sampling factors, with libjpeg's rounding (outside libvips' memory accounting) | 5,218² at 4:4:4, 4,456² for CMYK, the pixel limit at 4:2:0 |
|
|
429
|
+
| Interlaced (Adam7) PNG | the decoded raster: width × height × bands × bytes per sample | 6,047² in 8-bit RGBA, 4,028² in 16-bit RGBA |
|
|
430
|
+
| GIF | 4 bytes per pixel | 6,047² with alpha, 6,270² without |
|
|
431
|
+
| Lossless WebP | 4 bytes per pixel | 6,047² with alpha, 6,270² without |
|
|
432
|
+
| Lossy WebP with an alpha plane | 5 bytes per pixel | 5,492² |
|
|
433
|
+
| Animated WebP | 9 bytes per pixel | 4,231² |
|
|
434
|
+
|
|
435
|
+
The table was fitted on sharp 0.35.5 / libvips 8.18.7 at one libvips thread over 107 layouts (every format and layout above, squares and aspect ratios up to 800:1, flat, noise and gradient content, the EXIF orientations), each rendered once in a fresh process, the working set measured as the peak resident set after the render minus the resident set before it. No render exceeded its prediction (the closest reached 93 % of it), the most full-width rows any render needed was 1,656 (2,048 is 1.24 times that), and every layout measured over the budget is refused.
|
|
436
|
+
The whole-image buffers of a lossy WebP with alpha and of an animated WebP are the most libwebp can allocate (measured: about 2.1 and 4.1 bytes per pixel). A host that checks its own decoders can assert measured ≤ `predictSharpThumbnailWorkingSetBytes(...)` ≤ the budget.
|
|
437
|
+
- changes sharp's settings for the whole process when it is created: libvips' operation cache is turned off (the package caches the outcomes), libvips uses one thread per image (`sharp.concurrency(1)`: the model's rows were measured at one thread, and libvips' line caches grow with the thread count — a 7,071² 8-bit RGBA PNG took 42.2 MB at 1 thread, 64.5 MB at 4 and 89.2 MB at 8; the package's generation gate decides how many renders run at once), and every loader is blocked except one per format the package hands over (PNG, JPEG, GIF, WebP). An Ultra HDR JPEG is a valid JPEG:
|
|
438
|
+
the JPEG loader decodes its SDR base image, and the 480 × 320 WebP is byte-identical to the one the Ultra HDR loader made. A host that uses sharp for anything else gets the same settings there, so create the renderer once.
|
|
439
|
+
- answers `failed` (transient, retried by the next request) only when the error message reports exhausted resources (memory, threads, open files, disk space; for example `webpsave: picture memory error` or `Error creating thread: Resource temporarily unavailable`). Every other failure is `unsupported` and cached: treating an unknown message as transient would read and decode a broken attachment again on every view.
|
|
440
|
+
- checks the abort signal before it starts, after the header read (before decoding) and when it settles (libvips cannot be stopped midway), and answers `failed` for an aborted render.
|
|
441
|
+
- is named `sharp-<sharp version>/vips-<libvips version>/webp-q75-e4/flatten-#ffffff/v1`, from `sharp.versions` at run time.
|
|
442
|
+
|
|
443
|
+
Example — the wiring; the host imports its own sharp (type-checked, not shipped):
|
|
444
|
+
|
|
445
|
+
```ts examples/sharp-thumbnail-renderer.ts
|
|
446
|
+
/**
|
|
447
|
+
* Example: the package's sharp renderer on the host's own sharp module (not shipped; type-checked by `pnpm run typecheck:examples`).
|
|
448
|
+
* パッケージの sharp の描画ポートを、ホスト自身の sharp モジュールで使う例 (同梱しない。`pnpm run typecheck:examples` で型検査する)。
|
|
449
|
+
*
|
|
450
|
+
* The package implements the renderer and never depends on sharp: the host imports its own module and passes it. Creating the renderer
|
|
451
|
+
* changes sharp's settings for the whole process (the libvips operation cache off, one libvips thread per image, every loader except the
|
|
452
|
+
* PNG, JPEG, GIF and WebP ones blocked), so create it once, in the module that owns sharp.
|
|
453
|
+
* 描画ポートはパッケージが実装し、パッケージは sharp に依存しない。ホストが自分の sharp を読み込んで渡す。描画ポートを作ると
|
|
454
|
+
* プロセス全体の sharp の設定が変わる (libvips の操作キャッシュを切り、1 枚の画像を 1 スレッドで扱い、PNG・JPEG・GIF・WebP の
|
|
455
|
+
* 読み込み器のほかを封じる) ので、sharp を持つモジュールで 1 回だけ作る。
|
|
456
|
+
*/
|
|
457
|
+
import { createSharpThumbnailRenderer } from "@aiquants/daily-report/server"
|
|
458
|
+
import sharp from "sharp"
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The process's thumbnail renderer port; its id is `sharp-<sharp version>/vips-<libvips version>/webp-q75-e4/flatten-#ffffff/v1`.
|
|
462
|
+
* プロセスのサムネイル描画ポート。id は `sharp-<sharp の版>/vips-<libvips の版>/webp-q75-e4/flatten-#ffffff/v1`。
|
|
463
|
+
*/
|
|
464
|
+
export const thumbnailRenderer = createSharpThumbnailRenderer(sharp)
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Reference values for sizing, not guarantees (one development host: sharp 0.35.5, libvips 8.18.7, Node 24, one libvips thread per image; p50 of warm runs): a 1920 × 1080 PNG screenshot about 35 ms, a 1280 × 800 PNG about 27 ms, a 12 MP JPEG about 79 ms, a 12 MP PNG about 200 ms with a process peak of about 194 MiB. A baseline JPEG is cheap because libvips shrinks it while decoding (a progressive one fills its coefficient buffer first, above); PNG cost grows with the pixel count.
|
|
468
|
+
|
|
469
|
+
Example — the wiring (the ports are service keys; the tuning keys stay in the host's base configuration):
|
|
470
|
+
|
|
471
|
+
```ts examples/attachment-wiring.ts
|
|
472
|
+
/**
|
|
473
|
+
* Example wiring of attachment delivery into `createDailyReportServer` (not shipped; type-checked by `pnpm run typecheck:examples`).
|
|
474
|
+
* 添付配信を `createDailyReportServer` へ結線する例 (同梱しない。`pnpm run typecheck:examples` で型検査する)。
|
|
475
|
+
*
|
|
476
|
+
* The three ports are service keys, like `attachmentMaxBytes`; the tuning keys (`attachmentThumbnailConcurrency`, ...) configure the
|
|
477
|
+
* handlers of one process and stay in the host's base configuration. Every attachment key works only together with the ports it
|
|
478
|
+
* needs, and injecting all three ports makes every attachment key valid. Mount `attachment.loader` of the result on the host's own
|
|
479
|
+
* byte-serving route (`{apiBasePath}/attachment/{token}`); thumbnails use the same route.
|
|
480
|
+
* 3 つのポートは `attachmentMaxBytes` と同じくサービスのキーで、調整値 (`attachmentThumbnailConcurrency` など) は 1 プロセスの
|
|
481
|
+
* ハンドラーの設定としてホストの基本設定に置く。添付のキーはどれも要るポートと組でしか効かず、3 つのポートをすべて注入すれば
|
|
482
|
+
* 添付のどのキーも有効になる。結果の `attachment.loader` はホスト自身のバイト列配信ルート (`{apiBasePath}/attachment/{token}`) に
|
|
483
|
+
* マウントし、サムネイルも同じルートを使う。
|
|
484
|
+
*/
|
|
485
|
+
import { createDailyReportServer, type DailyReportIdCodec, type DailyReportServerConfig } from "@aiquants/daily-report/server"
|
|
486
|
+
import { createObjectStoreReadAttachment, type ObjectStore, type ObjectStoreReadSettings } from "./read-attachment-port"
|
|
487
|
+
import { thumbnailRenderer } from "./sharp-thumbnail-renderer"
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* What the host supplies: its server configuration without the attachment ports, and what the read port is built from.
|
|
491
|
+
* ホストが渡すもの: 添付のポートを除いたサーバー設定と、読み取りポートの材料。
|
|
492
|
+
*/
|
|
493
|
+
export type AttachmentWiring = {
|
|
494
|
+
/** The rest of the server configuration, including the attachment size limit and tuning keys. 添付のサイズ上限と調整値を含む、サーバー設定の残り。 */
|
|
495
|
+
base: Omit<DailyReportServerConfig, "attachmentIdCodec" | "readAttachment" | "attachmentThumbnailRenderer">
|
|
496
|
+
/** Attachment id obfuscation (an instance separate from the user-id codec). 添付 ID の難読化 (ユーザー ID のコーデックとは別のインスタンス)。 */
|
|
497
|
+
idCodec: DailyReportIdCodec
|
|
498
|
+
/** Storage client and its read settings. ストレージクライアントとその読み取り設定。 */
|
|
499
|
+
storage: { store: ObjectStore; settings: ObjectStoreReadSettings }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Creates the daily-report server with attachment delivery and thumbnails enabled.
|
|
504
|
+
* 添付配信とサムネイルを有効にした日報サーバーを作る処理。
|
|
505
|
+
*
|
|
506
|
+
* @param wiring Base configuration and the attachment dependencies. 基本設定と添付の依存。
|
|
507
|
+
* @returns The server; mount its `attachment.loader` on the attachment route. サーバー (`attachment.loader` を添付のルートへマウントする)。
|
|
508
|
+
* @throws {RangeError} When a numeric attachment setting in `base` is not an integer in range. `base` の添付の数値設定が範囲内の整数でないとき。
|
|
509
|
+
*/
|
|
510
|
+
export const createDailyReportServerWithAttachments = ({ base, idCodec, storage }: AttachmentWiring): ReturnType<typeof createDailyReportServer> =>
|
|
511
|
+
createDailyReportServer({
|
|
512
|
+
...base,
|
|
513
|
+
attachmentIdCodec: idCodec,
|
|
514
|
+
readAttachment: createObjectStoreReadAttachment(storage.store, storage.settings),
|
|
515
|
+
attachmentThumbnailRenderer: thumbnailRenderer,
|
|
516
|
+
})
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
**`hasThumbnail`**: every `DailyReportAttachmentSummary` carries a required `hasThumbnail: boolean`. It is `true` only when both `attachmentThumbnailRenderer` and `readAttachment` are injected, the declared `fileType` is exactly one of `image/png`, `image/jpeg`, `image/gif` or `image/webp` (exact match: `IMAGE/PNG`, parameters and surrounding whitespace do not qualify), the known `fileSize` is at most `attachmentMaxBytes` (an unknown size qualifies; the read port's `too_large` enforces the bound), and `state` is not `"absent"`.
|
|
520
|
+
On the SSE wire an attachment entry without `hasThumbnail` (published before the field existed) is accepted as `false` (`.default(false)`); a non-boolean value is rejected.
|
|
521
|
+
The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
522
|
+
|
|
523
|
+
**Thumbnail endpoint (`?thumbnail=tile`)**
|
|
524
|
+
|
|
525
|
+
- **GET only**: `HEAD` and every other method answer 405 with `Allow: GET` (checked right after the query). A HEAD response would need a generated body to report the same `Content-Length` as GET.
|
|
526
|
+
- **Order**: authenticate → query (400) → method → ports → token → viewer → renderer port → rate admission (below; an empty bucket answers 429 without resolving visibility or running the authorization query)
|
|
527
|
+
→ authorization (`getAttachmentForUser`, the SQL predicate) → eligibility, identity and ETag → `If-None-Match` (304) → the generation token of a revalidation (below) → not visible / not eligible (404) → cache → generation. The cache, joining a generation and the 304 all come **after** authorization, so a cached preview is never returned to a viewer who cannot see the attachment.
|
|
528
|
+
- **Rate admission uses two buckets per user and process**. Every token is taken synchronously — before the first `await` on admission, right after authorization's last `await` otherwise — so concurrent requests can never spend one token twice. With L = `attachmentThumbnailRateLimitPerMinute`:
|
|
529
|
+
- The **generation bucket** holds L tokens and refills L per minute. Every answer except a matching 304 needs one of its tokens. A request without `If-None-Match` can never be a 304, so it spends its token on admission or is answered 429 (`reason=rate_limit`) before authorization.
|
|
530
|
+
A request that fails authorization (`not_visible`) or goes on to generate (a cache miss) keeps the whole token spent; a cache hit and a not-eligible 404 give back all but a tenth of it, the tenth paying for the authorization query. A refund refills the bucket first and never lifts it above L.
|
|
531
|
+
- The **revalidation bucket** holds 10 L tokens (L ÷ the tenth) and refills 10 L per minute. A request with `If-None-Match` spends one of them on admission or is answered 429 before authorization (`reason=revalidation_rate_limit`); it holds no generation token while it waits for authorization.
|
|
532
|
+
After authorization, a matching ETag answers 304 and takes nothing more (the revalidation bucket has paid for its query). Any other answer — not visible, not eligible or an ETag that does not match — spends a generation token at that point under the generation bucket's rules, or is answered 429 (`reason=rate_limit`) when none is left.
|
|
533
|
+
Invisible attachments and visible ones with another ETag pay alike, so that 429 does not reveal whether the attachment is visible; only a visible attachment's right ETag answers 304.
|
|
534
|
+
- Bounds per user and process, by construction: generations and `not_visible` answers at most L from a full bucket, then L per minute; authorization queries at most 11 L in flight at once (L requests holding a generation token, 10 L holding a revalidation token), and from full buckets at one instant, or per minute of refill, at most 10 L answered right after authorization plus 10 L revalidations (2,400 at the default 120).
|
|
535
|
+
- First views and revisits never refuse each other: a revisit waiting for authorization holds no generation token, so it cannot make a concurrent first view answer 429, and revisiting thumbnails the browser already holds is a 304 per tile while the revalidation bucket has tokens, also right after new tiles emptied the generation bucket. A run of refusals writes one log line per bucket, not one per request (see **Log levels** below).
|
|
536
|
+
- **Identity**: `JSON.stringify([rendererId, box.maxWidth, box.maxHeight, attachmentId, filePath, fileType, fileSize])`, with the renderer `id`, the box of the requested variant (not its name: the box decides the output bytes) and the current file path, type and size, so an attachment rewritten under the same id, a new rendering pipeline or a different box is regenerated. The cache, generation joining and the ETag all use it; the ETag is the strong SHA-256 of the key, so the path never leaves the server.
|
|
537
|
+
A generation's outcome carries this identity only when what it read matched the row's size (**verified**; see **Read port** above); an **unverified** outcome gets no validator and is never cached.
|
|
538
|
+
- **Revalidation**: a verified preview carries `Cache-Control: private, no-cache` and the ETag. `If-None-Match` is compared weakly (`W/` stripped, lists accepted); `*` never matches. A match answers 304 without touching the gate, storage or renderer. Re-mounted thumbnails cost one 304 round trip, and a logout or a visibility change takes effect on the next revalidation.
|
|
539
|
+
An unverified preview is sent with `Cache-Control: no-store` and no `ETag`: the browser neither keeps nor revalidates it, so a later `If-None-Match` can never pin it through 304s, and the next view generates again.
|
|
540
|
+
- **Generation**: a process-local gate with `attachmentThumbnailConcurrency` slots and a FIFO wait queue (at most 64 waiting, 8 per user, waiting at most `queueWaitMs`). A slot is held while the original is read and rendered. Concurrent requests for the same identity join one generation; the joined requests share one read made with the first requester's principal. A request that disconnects withdraws from the shared generation, but its handler still settles with the shared outcome (which no client receives).
|
|
541
|
+
The generation has its own abort signal, which fires only when every joined request has gone, and passes it to the gate wait and to both ports. An abandoned generation leaves the wait queue; a port that honours the signal settles early and hands the slot to the next queued generation; and a stage that settles after the abort is discarded before its result is looked at (see **Read port** above).
|
|
542
|
+
- **Time budget**: one budget bounds a cache miss from the generation gate onward — the wait for a slot, the read, the render and the transfer — so a request that has reached the gate leaves the server within the sum of those stages, and the client waits exactly that sum before counting a load as failed. Each port stage runs under its own deadline and answers 502 at the deadline even when the port ignores the signal; a deadline that passes after every requester has gone ends as 503 `aborted` instead, since nobody receives it.
|
|
543
|
+
Two things are outside the budget. Authentication and authorization run before the gate and are bounded only by the host's own timeouts; their time is taken out of the client's sum as well. And the budget cannot end a port that ignores its signal: after the 502 such a port keeps its generation slot, and the server logs `read_stuck` / `render_stuck` once when it has not settled at twice its stage budget.
|
|
544
|
+
|
|
545
|
+
| Stage | Budget | At the deadline |
|
|
546
|
+
| --- | --- | --- |
|
|
547
|
+
| `queueWaitMs` — waiting for a generation slot | 30 s | 503 `reason=queue` (`Retry-After: 5`) |
|
|
548
|
+
| `readMs` — reading the original | 30 s | 502 `reason=read_timeout` |
|
|
549
|
+
| `renderMs` — decoding, resizing, encoding | 10 s | 502 `reason=render_timeout` |
|
|
550
|
+
| `transferMarginMs` — sending the response (at most 1 MiB) | 5 s | (the client's margin) |
|
|
551
|
+
| Client load timeout | 75 s (the sum) | the load counts as one failure |
|
|
552
|
+
|
|
553
|
+
- **Cache**: an LRU bounded by `attachmentThumbnailCacheBytes` and 1,024 entries (each entry weighs its body plus 256 bytes). A preview is stored as an owned copy sized exactly to its body, so a view into a larger buffer never keeps that buffer alive, and later writes to the port's buffer do not change the cached preview.
|
|
554
|
+
It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch, the port's `unsupported`, and the read port's `too_large` for a row whose size is unknown — on a row with a known size, which eligibility keeps within `attachmentMaxBytes`, a `too_large` is always unverified). It never stores unverified outcomes, `failed`, timeouts, storage errors (including `not_found`), queue rejections, aborts or exceptions.
|
|
555
|
+
- **Presence records**: `not_found` sets the missing mark (`markAttachmentMissing`). A successful read does **not** call `markAttachmentPresent` (it would issue an unconditional UPDATE on every view, and `present` and `unknown` look the same on screen);
|
|
556
|
+
the client never requests a thumbnail from a summary that already says `absent` (`hasThumbnail: false`). The endpoint itself does not check `state`, though, so a request from an older summary (for example the automatic retry right after a `not_found`) still reads and renders, and a successful read leaves the mark unchanged. Recovery is left to the original download. Cache hits and 304s record nothing.
|
|
557
|
+
|
|
558
|
+
| Status | When |
|
|
559
|
+
| --- | --- |
|
|
560
|
+
| 200 | Preview. Headers: the port's `Content-Type`, `Content-Length`, `Cache-Control: private, no-cache` and `ETag` (an unverified preview: `Cache-Control: no-store` and no `ETag`), `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, `X-Frame-Options: SAMEORIGIN`, forwarded `Set-Cookie` |
|
|
561
|
+
| 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `Set-Cookie`) |
|
|
562
|
+
| 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
|
|
563
|
+
| 401 / 403 | Not authenticated / no internal user |
|
|
564
|
+
| 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
|
|
565
|
+
| 405 | Any method other than GET (`Allow: GET`) |
|
|
566
|
+
| 429 | An empty generation bucket (`reason=rate_limit`) or revalidation bucket (`reason=revalidation_rate_limit`), or a revalidation that is not a matching 304 when no generation token is left after authorization (`reason=rate_limit`) (`Retry-After: 60`) |
|
|
567
|
+
| 502 | Storage unreachable (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
|
|
568
|
+
| 503 | Wait queue full or wait timed out (`queue`), or the generation abandoned by every waiting request (`aborted`; no one receives it) (`Retry-After: 5`) |
|
|
569
|
+
| 500 | Unexpected exception, including one thrown by a port during generation (logged as `500 attachment=<id> viewer=<id> variant=thumbnail cache=miss reason=port_exception message=<msg>`, never cached), or a port contract violation (`port_contract`: a render result outside the contract, logged with `code=` `content_type`, `bytes`, `empty`, `too_large`, `signature` or `dimensions`, or a read over `maxBytes`, `code=over_max_bytes`), or a host route that passes no `token` parameter (a route misconfiguration, below) |
|
|
570
|
+
|
|
571
|
+
Error responses are JSON (`{ "error": { "message": "..." } }`) with `Cache-Control: no-store` (a 404 or 405 without freshness information may otherwise be cached heuristically, `Set-Cookie` included), and forward `Set-Cookie`. Log lines carry `attachment=<id> viewer=<id> variant=thumbnail`, plus `cache=hit|miss|revalidated` once the cache stage is reached; the 200 and 404 lines of an unverified outcome end with `identity=unverified`.
|
|
572
|
+
Unexpected exceptions outside generation go to the loader's shared catch and are logged as `500 attachment=? viewer=? reason=unexpected message=<msg>` (no `variant`).
|
|
573
|
+
A route that mounts `attachment.loader` without a `token` route parameter is the host's configuration error, on both deliveries: right after the port check the loader throws `[daily-report] params.token must be passed by the route that mounts attachment.loader; declare a "token" route parameter ({apiBasePath}/attachment/{token})`, which that catch logs and answers 500, without decoding an empty token or querying the database.
|
|
574
|
+
Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / 403) are not logged.
|
|
575
|
+
The package never writes the file path itself; a `port_exception` line includes the port's own error message verbatim, so keep paths out of your port's error messages.
|
|
576
|
+
|
|
577
|
+
**Log levels** (both deliveries; the line formats are fixed):
|
|
578
|
+
|
|
579
|
+
| Level | Outcomes |
|
|
580
|
+
| --- | --- |
|
|
581
|
+
| `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified) |
|
|
582
|
+
| `warn` | 404 `not_visible` (a sign of token enumeration), the storage 404s (`not_found`, `denied`, `invalid_path`), 413 (`db_size`, and the port's `too_large` on an original), the first 429 of a bucket's run of refusals and the run's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`), every 502, port settlements after a deadline (`read_overrun`, `render_overrun`), ports still unsettled at twice their stage budget (`read_stuck`, `render_stuck`) and thumbnail reads that do not match the row's size (`size_mismatch`: another length, or a `too_large` on a row whose size is known) |
|
|
583
|
+
| `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
|
|
584
|
+
|
|
585
|
+
`error` is left to failures of the server itself, so an alert on `error` does not fire on user traffic or on upstream storage states.
|
|
586
|
+
|
|
587
|
+
**429 lines**: each rate bucket of a user (the original's, and the thumbnail's generation and revalidation buckets) writes its `429 … reason=rate_limit` (`reason=revalidation_rate_limit`) line only for the first refusal after an admission.
|
|
588
|
+
The later refusals of the run are counted, and the bucket's next admission writes one `rate_limit_suppressed count=<N> viewer=<id>` (`revalidation_rate_limit_suppressed …`) line (with `variant=thumbnail` on the thumbnail path), N being the refusals that had no line of their own; the 429 of a revalidation refused after authorization counts in the generation bucket's run.
|
|
589
|
+
A client therefore cannot write log lines at its request rate.
|
|
590
|
+
|
|
189
591
|
### Transactions
|
|
190
592
|
|
|
191
593
|
日報の作成と編集はそれぞれ 1 トランザクションで書き切る。入口は `createDailyReportService` 内の `inTransaction` 1 つに限り、**デッドロックの犠牲になったトランザクションだけをやり直す** (判定は [`@aiquants/mssql`](../mssql/README.md) が持ち、閉包は `capture` で包んで ORM が原因を消す前に押さえる)。鍵の範囲が重なる並行操作はデッドロックしうるため、犠牲になった側は従来 500 になって書いた内容が消えていた。
|
|
@@ -196,7 +598,7 @@ resolveVisibleSourceTypes: async (request, { reason }) => {
|
|
|
196
598
|
|
|
197
599
|
## Client wiring
|
|
198
600
|
|
|
199
|
-
```tsx
|
|
601
|
+
```tsx illustrative
|
|
200
602
|
// daily_report._index/route.tsx
|
|
201
603
|
import { createDailyReportClientLoader, DailyReportPage, dailyReportShouldRevalidate } from "@aiquants/daily-report/client"
|
|
202
604
|
|
|
@@ -220,6 +622,7 @@ export default function Route() {
|
|
|
220
622
|
title: "Field Reports", content: "Body",
|
|
221
623
|
},
|
|
222
624
|
onError: (info) => yourToast(info.message), // Optional: route mutation failures to your own toast
|
|
625
|
+
headingLevel: 2, // Optional: level of each report's heading, 2–5 (default 3)
|
|
223
626
|
// apiBasePath: "/daily_report/api", ssePath: "/sse/daily_report/updates"
|
|
224
627
|
}}
|
|
225
628
|
/>
|
|
@@ -228,9 +631,14 @@ export default function Route() {
|
|
|
228
631
|
}
|
|
229
632
|
```
|
|
230
633
|
|
|
231
|
-
`config` is **required** and so is its `onSessionExpired` port (omitting it is a type error, TS2741; a
|
|
634
|
+
`config` is **required** and so is its `onSessionExpired` port (omitting it is a type error, TS2741; at render a missing port throws a `TypeError`, `[daily-report] config.onSessionExpired must be a function …`, and a value that is not a function a `RangeError` ending with `; got <value>`; every client configuration error follows the convention under **Configuration errors** in [DI ports](#di-ports)).
|
|
635
|
+
The package never navigates by itself: when the session is gone — the SSE stream ends with `auth-required`, or the ids stream answers 401 / 403 — it calls `onSessionExpired()` exactly once per occurrence and stops. A typical host reloads the route (`navigate(0)`) so its own loader redirects to the login page. `DailyReportConfigProvider` takes the same required `config`; outside a provider, the default configuration's `onSessionExpired` throws.
|
|
636
|
+
|
|
637
|
+
`config.headingLevel` is the level of each report's heading: an integer from 2 to 5 (default 3), so that the section headings inside a report, one level lower, stay within `h6`. Pick the level that continues your page's outline (see [Document structure](#keyboard-focus-and-selection-list--detaillist)); any other value (`1`, `6`, `3.5`, `"3"`, `null`) throws a `RangeError` at render (`[daily-report] config.headingLevel must be an integer from 2 to 5 …; got 6`).
|
|
232
638
|
|
|
233
639
|
`DailyReportPage` is layout-agnostic (header rendering via `renderHeader` slot, error boundaries/footer control managed by the app). For fine-grained usage, `DailyReportActionProvider`, `DailyReportResolvedContent`, `useDailyReportDetail`, etc. can be imported individually.
|
|
640
|
+
Every field `useDailyReportDetail(reportHubId, businessDate)` returns — `report`, `error`, `isLoading` and `isRefetching` — belongs to the id it is called with: the hook keeps them as one record keyed by the id. On the first render after the id changes it already returns that id's starting state: its cached report (one definition, also used by the loading effect: the report cache, expired entries included, then the business date's cached list), `isLoading` only when nothing is cached, no error and no refetch yet — never the previous id's report, error or loading flags.
|
|
641
|
+
An expired cached report is therefore drawn at once, never preceded by `null`, and refetched behind it; an answer that arrives for an earlier id is dropped. An id of 0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error).
|
|
234
642
|
|
|
235
643
|
Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject `config.onError(info: DailyReportErrorInfo)` to route them into your own toast/notification system, or omit it to use the package's built-in `role="alert"` banner (auto-dismiss + manual close). Failures on a continuation that resolves after a no-reload user switch are suppressed. If you compose `DailyReportActionProvider` yourself instead of using `DailyReportPage`, wrap it in `DailyReportErrorProvider` (both are exported from `@aiquants/daily-report/client`) so `onError` / the banner work.
|
|
236
644
|
|
|
@@ -240,6 +648,310 @@ If your app overrides `config.apiBasePath`, pass the same value to `createDailyR
|
|
|
240
648
|
`DailyReportPage` subscribes via `useDailyReportIdsStream`, rendering the list as soon as the first chunk arrives (on cache misses the server races a fast `TOP 200` first page against the cached full query, so first paint does not wait for the full id scan).
|
|
241
649
|
There is no `dailyReportIds` prop and no deferred `/ids` JSON fetch (the old `ids` endpoint was removed).
|
|
242
650
|
|
|
651
|
+
### View height (host layout)
|
|
652
|
+
|
|
653
|
+
Each view (List and DetailList) is as tall as the space the host's layout leaves it: the platform's layout sizes every box from the page down to the view, and no script computes or writes a height.
|
|
654
|
+
|
|
655
|
+
- **Host contract**: render `DailyReportPage` (or `DailyReportResolvedContent`) as the content of a column flex container (`display: flex; flex-direction: column`) whose height is bounded — a definite height, or the growing item of a column with a minimum height, such as a `min-height: 100dvh` shell whose footer follows the content. The header that `renderHeader` returns and the page box are items of that container, and the page box takes the rest of it. Nothing is bound and no host variable is read.
|
|
656
|
+
Outside such a container the view has no height to fill: its size is contained (below), so its content cannot size it, and it is 0 px tall.
|
|
657
|
+
- **The fill chain**: every box from the page box down to a view's root — the page box, its centred width-capped wrapper, the view-mode tabs and the active tab panel — fills the rest of its parent column (`flex: 1 1 0%; min-height: 0`, `FILL_COLUMN_CLASS_NAME`) and declares no height. The wrapper's padding is 4 px at the top and the sides and none at the bottom, so the view's box ends exactly at the page's bottom edge.
|
|
658
|
+
- **The view's root contains its size** (`VIEW_ROOT_CLASS_NAME`: the fill rule, `contain: size` and `overflow: clip`): its box is sized as if it were empty, so what the view renders — a `VirtualScroll` given the measured height — never feeds back into the root's size, into the intrinsic size of an ancestor (a host column with a minimum height does not grow) or into the document's scroll height. The clip keeps rows drawn for the previous height from painting outside the box during the one frame before the view renders the new height.
|
|
659
|
+
Layout and paint are not contained, so the mobile overlay, a fixed-position descendant, keeps the viewport as its containing block.
|
|
660
|
+
- **One measurement**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so the view's root reads its own border box once in a layout effect before the first paint (`useDynamicViewportHeight`; until then the view renders no body, so no row is ever drawn at a guessed height; the List also waits for its row slot, below) and then follows it with one `ResizeObserver` on the root alone (`box: "border-box"`), whose delivery only hands `borderBoxSize[0].blockSize` to React state and reads and writes nothing.
|
|
661
|
+
The height is the layout's own box, so the same layout gives the same height whatever the order of the measurements, every change is followed, a sub-pixel one included, and 0 stays 0. The value reaches only `viewportSize`: the list column, the panel group and the scroll container write no height.
|
|
662
|
+
- **G-symmetric frame**: the rows' gutter G is inside the view's box, so the last aligned surface — a List or DetailList row aligned to the bottom, or the List's side pane — sits exactly G = 8 px above the page's bottom edge (where a footer that follows the page starts), the distance from the view-mode toolbar to the first surface at the top.
|
|
663
|
+
- **Empty list**: `labels.listEmpty` is laid over the top of the list area (absolutely positioned inside the scroll container, out of the flow), so an empty view keeps the same box.
|
|
664
|
+
|
|
665
|
+
### Keyboard, focus and selection (List / DetailList)
|
|
666
|
+
|
|
667
|
+
Each view keeps one cursor: the selected report, the report that holds the view's single Tab stop, the row that owns focus, and at most one pending focus request, all keyed by report id.
|
|
668
|
+
The keys are delegated to each view's **list**: the element that holds the view's `VirtualScroll` (its rows and its scroll bar). In the List view the resize handle and the side pane lie outside the list.
|
|
669
|
+
|
|
670
|
+
| Key | List | DetailList | Default prevented |
|
|
671
|
+
| --- | --- | --- | --- |
|
|
672
|
+
| `ArrowUp` / `ArrowDown` | Selects the previous / next report and reveals it by the nearer edge. Focus moves to the card's primary button (to the row frame while the card is not shown). | Selects the previous / next report and aligns it to the top (the reading start); focus moves to the row. | Yes, also at the ends (no wrap-around) |
|
|
673
|
+
| `Home` / `End` | First / last report, aligned to the top / bottom | First / last report, aligned to the top / bottom (to the top when the last row is taller than the viewport) | Yes |
|
|
674
|
+
| `PageDown` / `PageUp` | Selects the farthest row that still fits in the viewport together with the focused row, revealed by the nearer edge | The same; a focused row taller than the viewport is first scrolled by min(overflow, ⌊0.875 × viewport⌋) without changing the selection | Yes |
|
|
675
|
+
| `Enter` / `Space` | The focused primary button's native activation: selects (on a mobile layout it also opens the detail overlay) | On the row element itself: selects without scrolling. On a control inside the row: that control's own action | Native / yes |
|
|
676
|
+
| `Ctrl+Home` / `Ctrl+End` | Leaves the list: focus moves to the last Tab stop before the list / the first one after it (below; on the desktop layout `Ctrl+End` reaches the resize handle), which the browser scrolls into view; the selection does not change | The same | Yes, when there is such a stop (otherwise not handled: the key keeps its browser meaning) |
|
|
677
|
+
| `Tab` / `Shift+Tab` | Moves through the Tab-stop row's controls only (below); Tab never selects | The same | No |
|
|
678
|
+
| Any other key, a modifier combination, IME | Not handled, and cancels a pending focus request | The same | No |
|
|
679
|
+
|
|
680
|
+
- **The move starts from the focused row**, not from the selection: Tab into a row, press `ArrowDown`, and the report after that row is selected.
|
|
681
|
+
- **Held keys move at once, then at most once per frame.** The first repeated keydown (`repeat`) of a key is checked like any key and starts a repeat stream from the focused row; it and every later repeat on the same axis have their default prevented.
|
|
682
|
+
A repeat that finds the stream's frame fence open — the first one, and any repeat after a frame in which nothing was pending — moves at once inside its own keydown (one selection, the scroll, one focus request; React commits the discrete event synchronously), so a key that repeats more slowly than the display refreshes moves once per keydown and never waits for a frame.
|
|
683
|
+
The repeats behind the fence only add their step: the arrows one row each (up and down cancel out), `PageUp` / `PageDown` one page each, and `Home`, `End` and `Enter` / `Space` on a DetailList row count once; they read neither the DOM nor the layout.
|
|
684
|
+
The frame right after an immediate move applies nothing (two moves never commit in one frame); every later frame applies what accumulated as one move with `flushSync` from the row that holds focus, so the next key already starts from the new row, and a frame with nothing pending opens the fence again. A key that repeats faster than the display therefore moves at most once per frame. A repeat on another axis (rows, pages, `Home`, `End`, activation) applies what is pending before it starts a new stream.
|
|
685
|
+
A key that is not a repeat first resolves its command, drops the pending focus request and then applies the pending repeats synchronously: the destination row renders and takes focus inside that call, however far the repeats went, so the key moves from there. Every `keyup` applies what is pending and ends the stream; a pointer press, a wheel or touch start in the view, focus leaving the view and unmounting end it without applying it.
|
|
686
|
+
On a slow device the keys therefore never queue up, and the selection stops when the key is released. `Ctrl+Home` / `Ctrl+End` are never accumulated. While a stream lasts, the desktop side pane keeps showing the report the stream started from (see **List side pane** below).
|
|
687
|
+
- **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone (so `Ctrl+PageDown` and `Shift+End` keep their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
|
|
688
|
+
keys from outside a row (the scroll bar, the side pane, the mobile overlay); keys from an input region — a `form` (every control of the in-row edit form and of the comment form, their buttons included) or an editable element (`input`, `textarea`, `select`, `contenteditable` other than `"false"`, `audio[controls]`, `video[controls]`) —
|
|
689
|
+
or from inside ARIA widgets that use these keys themselves (`combobox`, `listbox`, `menu`, `menubar`, `radiogroup`, `slider`, `spinbutton`, `textbox`, `tree`, `grid`, `scrollbar`, `tablist`, `toolbar`, `treegrid`, `separator`) between the key's origin and the view's scroll container, the exits included.
|
|
690
|
+
So no key pressed in an open in-row editor moves the list (moving it would scroll the editing row out of the rendered window and discard what was typed). Only elements **inside** the view count: wrapping the whole view in a host `form`, `role="grid"`, `role="listbox"` or `contenteditable` region does not disable the keys. While the viewport has no size yet, the movement keys are not handled.
|
|
691
|
+
- **One Tab order rule for both views: only the Tab-stop row's controls are in the Tab order.** The Tab-stop row is the row that owns focus (below) while it is inside the visible range, otherwise the selected row while it is visible, otherwise the first visible row (until the view has reported its visible range: the focus owner, then the selection).
|
|
692
|
+
Every focusable control the package draws inside a row reads the rule: the List card's primary button, ★ and 既読, the DetailList row's ★, 既読, 編集 (on the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment field and its send button), the edit form's fields and buttons, and the attachment links keep their natural order in the Tab-stop row and have `tabIndex=-1` in every other row. The stop row's own element is a stop too where it takes focus: a DetailList row (`tabIndex=0`) and a List row frame whose card is not shown.
|
|
693
|
+
So the Tab path through a view is one row's controls long, however many rows are rendered: crossing the List takes exactly 3 presses (the primary button, ★ and 既読). Because the focus owner is the stop, a control reached by pointer continues within its own row, and a row in edit mode keeps its form in the Tab order.
|
|
694
|
+
The scroll bar's two arrow buttons are not Tab stops in either view (`enableArrowButtonTabStops: false` of `@aiquants/virtualscroll`), because the keys above already scroll the list. The arrows still scroll on a press and while held and keep their names; they sit inside the scroll bar's `role="scrollbar"` element, whose children ARIA 1.2 makes presentational, so whether assistive technology presents them as separate buttons depends on the browser (Chromium does).
|
|
695
|
+
- **Leaving the list** (`Ctrl+Home` / `Ctrl+End`, as in the WAI-ARIA feed pattern): focus moves to the last Tab stop before the list or the first one after it — where real `Shift+Tab` from the list's first stop and real `Tab` from its last stop go — and the browser scrolls it into view as it does for Tab.
|
|
696
|
+
In the List view the exits are measured from the list like in the DetailList, so on the desktop layout `Ctrl+End` moves to the resize handle (then Tab goes on to the side pane), and `Ctrl+Home` to the last stop before the list (in `DailyReportPage`, the last control of the view-mode toolbar).
|
|
697
|
+
The stops come from the package's one model of sequential focus navigation (`readFocusCapability`, `listTabbables` and `partitionTabbables` in `src/client/keyboard/dom-node.ts`), which the mobile overlay's Tab wrap uses as well. The order is the flat tree's: open shadow roots at their hosts, slotted elements at their slots (an empty slot shows its own children), and a focusable ancestor before its descendants (the package writes no positive `tabindex`). The list's descendants do not count; a focusable ancestor of the list (a host tab panel around the view, for example)
|
|
698
|
+
counts before it, as real `Shift+Tab` returns there.
|
|
699
|
+
An element with a `tabindex` that parses as an integer is a stop when the value is 0 or more (a negative value makes it focusable but not a stop; an unparsable value counts as absent). Without one, its kind decides:
|
|
700
|
+
`a` / `area` with `href`, `button`, `input` other than `type="hidden"` (of a radio group only the checked radio, or the first enabled and rendered one when none is checked), `select`, `textarea`, the first `summary` of a `details`, an editing host (`contenteditable` true, empty or `plaintext-only` under a parent that is not editable), `audio` and `video` with `controls`, `iframe`, `embed`, an `object` that shows a document, and — as Chromium does — a keyboard-focusable scroll container:
|
|
701
|
+
an element that overflows on an axis whose `overflow` is `auto` or `scroll` and has no stop among its descendants (the document's own scroller excluded).
|
|
702
|
+
A stop must also be enabled (not `:disabled`), outside every `inert` subtree (shadow-root boundaries are crossed to the host) and visible to `checkVisibility({ visibilityProperty: true })` (not inside `display: none` or `content-visibility: hidden`, not `display: contents`, not `visibility: hidden`; an element that `content-visibility: auto` skips still counts, since it stays in the Tab order); an `area` counts when an image that uses its map is visible.
|
|
703
|
+
The stops of a closed shadow root cannot be seen and are not counted; an element outside the HTML and SVG namespaces is not counted even with a `tabindex` (Chromium stops there, but script cannot focus it), and an `audio` element with `controls` is one stop (Chromium also stops inside its built-in controls).
|
|
704
|
+
When there is no such element on that side, the key is not handled and keeps its browser meaning; for example, when nothing focusable follows the list on the page, `Ctrl+End` scrolls the document as usual. `labels.listKeyboardHelp` names both exits and says that they apply when there is a control before or after the list.
|
|
705
|
+
- **Focus follows the selection by report id, inside the key's commit.** A key move focuses its destination before its one synchronous commit ends, whether or not the row was rendered: a destination outside the rendered window gets the alignment scroll (`scrollToIndex`) in that same commit, which renders it, and a move without alignment lands on a fully visible row, which is rendered already (a key that only scrolls, such as `PageDown` inside a tall DetailList row, requests nothing).
|
|
706
|
+
So no key leaves a focus request behind: a wheel that follows in the same task finds the destination focused and changes nothing. Focus is set with `focus({ preventScroll: true })` (native focus scrolling would move the pane's `overflow: hidden` content element away from the virtual scroll position).
|
|
707
|
+
A request stays pending only on the paths that send focus to a row that may lie outside the rendered window: the focus owner's report leaving the list and the selected report deleted from the side pane or the mobile overlay (the table below), and the List's return to the card whose overlay closed (its scroll runs in an effect after the commit). Such a request settles when its row registers, however long that takes;
|
|
708
|
+
the next key, a `pointerdown`, `wheel` or `touchstart` in the view, and the destination leaving the list drop it, and a dropped request never settles later.
|
|
709
|
+
Focus is only ever taken from nowhere — no element, `body`, or an element inside an `inert` subtree such as the mobile overlay while it slides out (such an element cannot keep focus; `isFocusNowhere` in `src/client/keyboard/dom-node.ts`) — or from inside the view; focus that has meanwhile moved to the side pane, the open overlay or a host element stays there and the request is dropped.
|
|
710
|
+
- **A key move writes before focus and commits once.** The row cursor owns the `tabindex` of every registered focus target (0 on the Tab-stop row, −1 on every other row; the row components render none): it writes the attribute when a target registers and when the row's Tab stop changes, and never rewrites a value the element already has.
|
|
711
|
+
A move first makes the destination current and the Tab stop while focus is still on the origin (the cursor writes the destination's `tabindex="0"` then, without a React commit); then focus moves; then, once focus has left the origin, the selection and the Tab stop settle on the destination (the cursor writes the origin's `-1`). The rows' states, the host's selection and the scroll commit in one synchronous React commit.
|
|
712
|
+
No step writes the `tabindex` of the element that holds focus (Chromium recalculates the document's style and layout on the spot when that happens, to check that the element can still take focus), and none reads layout after a write, so every style recalculation a key forces comes from its one `focus()` call (which recomputes style to check that the element can take focus).
|
|
713
|
+
When the destination is not rendered yet, focus is still on the origin as the commit starts: the destination renders in that commit, registers and takes focus, and the commit's layout effect then settles the origin, which keeps the Tab stop until then.
|
|
714
|
+
The commit renders the destination's and the origin's row frames, the view and the host component that owns the controlled selection (the List's `selectedReportHubId`, the DetailList's `selectedItemId`), and `VirtualScroll` only when the key scrolled. Report rows, card bodies and the row renderer do not render, and no context value changes per key.
|
|
715
|
+
A row that the commit brings into the rendered window outside the rows the key shows — the rows of the viewport after the scroll, plus one on each side, computed from the same row heights `VirtualScroll` uses (overscan rows, in other words) — mounts its row element alone, with an empty surface that fills its slot and `aria-busy="true"` (a DetailList row without its name and description references, whose elements do not exist yet), and renders its body in a transition right after the commit; a held row that the next key's shown rows reach renders its body inside that key's commit.
|
|
716
|
+
While held, the row fills the box `VirtualScroll` gives it (the List's slot; the DetailList's measured or estimated height), so holding moves neither the other rows nor the scroll anchor.
|
|
717
|
+
- **The row that owns focus keeps it.** Each view has at most one focus owner, the row it gives focus back to. One rule decides it, a pure transition over these events (`nextFocusOwner` in `src/client/keyboard/focus-ownership.ts`; the view only translates DOM events into them): outside the list a pointer press ends ownership, and inside the list only focus transitions change it.
|
|
718
|
+
|
|
719
|
+
| Event | Focus owner after it |
|
|
720
|
+
| --- | --- |
|
|
721
|
+
| Focus enters an element of a row | That row |
|
|
722
|
+
| Focus enters an element of the list outside every row | None |
|
|
723
|
+
| Focus moves to an element outside the list (the resize handle and the side pane included) | None |
|
|
724
|
+
| Focus goes nowhere (`body`): the focused element was removed or scrolled out of the rendered window, the window lost focus, or a press inside the list landed on something that does not take focus | Unchanged: giving focus back is the owner's job |
|
|
725
|
+
| A pointer press outside the list, anywhere in the list's document, also while focus is already nowhere | None |
|
|
726
|
+
| A pointer press inside the list (a gutter, the focused card or row, another row) | Unchanged; a press that moves focus into a row changes the owner through that focus |
|
|
727
|
+
| The list changes and the owner's report is still listed | Unchanged |
|
|
728
|
+
| The list changes and the owner's report is gone (a delete, an SSE removal, a stale removal) | The report now at the owner's index (clamped to the new length), which takes focus and becomes the Tab stop; none in an empty list |
|
|
729
|
+
| The selected report is deleted from the side pane or the mobile overlay | The report now at the deleted report's index, which takes focus and becomes the Tab stop |
|
|
730
|
+
|
|
731
|
+
Wheel and touch scrolling change nothing. While a row owns focus and its focus target is replaced (the List frame ⇄ card while loading, a control that disappears, the DetailList body turning into an error, loading or edit form and back), remounted, or scrolled back into the rendered window while focus is nowhere (`body`), the row's registered target takes focus again; focus left on a List row frame moves to the card's primary button when the card arrives.
|
|
732
|
+
Focus is only ever taken from nowhere: focus that the user put elsewhere is never moved.
|
|
733
|
+
- **Keyboard focus is revealed**: when an element inside a row receives keyboard focus (`:focus-visible`), the view scrolls the row frame into the visible area by the nearer edge — or, for a row taller than the viewport, the focused element with 8 px around it. Focus from a pointer never scrolls.
|
|
734
|
+
- **Keyboard-focus attribute**: while keyboard focus is inside a view, the view root carries `data-daily-report-keyboard-focus` (the List's `[data-testid="daily-report-root"]`, the DetailList's scroll container). Every `focusin` and `keydown` inside the view sets it from the focused element (`:focus-visible`; a key press can turn pointer focus into keyboard focus without moving it), and `pointerdown` inside the view or focus leaving it removes it.
|
|
735
|
+
It is a plain DOM attribute toggled with `toggleAttribute`, so arrow keys moving between rows never rewrite it and nothing re-renders. The floating tap-scroll circle hides while it is present, because the circle is drawn over the rows.
|
|
736
|
+
- **Pointer selection**: in the List the card's primary `<button>` selects on `click` (a pointer click, Enter or Space; the click that ends a drag is swallowed by the scroll pane), and ★ / 既読 run only their own action. In the DetailList a click on a row selects it without scrolling, except clicks on controls inside the row (`button`, `a[href]`, `input`, `textarea`, `select`, `label`, `[role="button"]`, `contenteditable`). Without `onSelectItem` the DetailList handles neither clicks nor keys.
|
|
737
|
+
- **Host selections and list changes**: when the host changes the selection, the selection ring follows it and the DetailList scrolls that row to the top (when the selected report is not in the list yet, as soon as it arrives); focus does not move.
|
|
738
|
+
A change of the list alone (SSE inserts and deletes, a stale removal) keeps what is on screen in place. Rows are keyed by report id in both views, and both views anchor their scroll position on a report, the way CSS scroll anchoring does: each time the view reports its visible range, it records the first visible report and how many px of it are hidden above the viewport, and when the list changes it finds that report by id and restores the same offset with one scroll, in a layout effect before paint (only when that report or a row of the visible window changed).
|
|
739
|
+
An insert or a delete before or inside the rendered window therefore leaves the first visible report — and the focus inside the rows — where it was. At the start of the list (scroll position 0) no anchor is kept, so reports that arrive at the top are shown;
|
|
740
|
+
a pending selection reveal (a DetailList selection waiting for its report) takes precedence over the anchor (a key move needs no precedence: its scroll, its destination's render and the focus all end inside its own commit); and when the anchor report itself is removed, the first remaining report that followed it on screen is shown at its offset.
|
|
741
|
+
- **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
|
|
742
|
+
On the desktop layout the pane follows the selection in a deferred render: its frame (`[data-testid="daily-report-side-pane"]`) takes the new `data-report-id` in the key's own render and carries `aria-busy="true"` until the content catches up, and the content (`data-displayed-report-id`) renders from `useDeferredValue` of the selection, so React draws it when the main thread is free and drops an unfinished catch-up for a newer key.
|
|
743
|
+
While a held key repeats, the content keeps the report the repeat stream started from and does not render at all (the frame keeps following the selection, with `aria-busy="true"`); it catches up once, when the stream ends.
|
|
744
|
+
The content is keyed by the displayed report: each change of the displayed report mounts it exactly once, so nothing of one report (its loaded detail, an open editor, its comments, its thumbnails) renders under the next, and coming back to a report mounts its attachments once (one conditional request per loaded thumbnail). The chosen tab (article or relations) belongs to the pane, and to the mobile overlay, and is kept across reports.
|
|
745
|
+
Auto-read and the article tab's scroll to the top follow the displayed report. Auto-read marks an unread report read once the pane has dwelt on it for 500 ms: on the desktop layout while the pane has caught up with the selection (the displayed report is the selected one and the pane is not `aria-busy`), in the mobile overlay while the overlay is open. The clock stops when the pane stops dwelling (a newer key, a held key's stream, the overlay sliding out)
|
|
746
|
+
and starts over when it dwells again, so a report that was only selected — never displayed —, passed over with a held key, or opened in the overlay and closed within 500 ms stays unread. A report whose read state the viewer toggled is not marked again while it stays displayed.
|
|
747
|
+
The mobile overlay shows the report it opened, so its pane carries the same `data-displayed-report-id` and no `aria-busy`.
|
|
748
|
+
The article tab scrolls to its end (where the comments are) only after the viewer's own comment has been posted; a comment that arrives over SSE, from another user or another tab, never moves what the viewer is reading.
|
|
749
|
+
- **Screen readers**: each list references `labels.listKeyboardHelp` (visually hidden) through `aria-describedby` — the DetailList only when it handles keys (with `onSelectItem`); there is no live region, announcements come from focus.
|
|
750
|
+
The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and then by its preview, and carries `aria-current="true"` when selected. A DetailList row is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state and the markers its header paints.
|
|
751
|
+
The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one, otherwise its label); a description references only the markers that are painted. A row without content (loading, failed, missing, editing) is named by its ISO business date, with `aria-busy="true"` while loading. At most one element per view carries `aria-current`. ★, 既読, 編集 and 削除 carry `aria-label` equal to their `title` and no `aria-pressed`.
|
|
752
|
+
- **Document structure**: every report has a heading at `config.headingLevel` (an integer from 2 to 5, default 3; pick the level that continues the host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
|
|
753
|
+
The side pane and the mobile overlay start with two label / value pairs: `labels.businessDate`, whose value is the report heading (the business date alone), and `labels.author` with the author's name; each label and each value is read once, and the source badge sits beside the pair, outside it. The overlay names its dialog by the heading and the author's value together (`<date> <author>`, the name of the List card).
|
|
754
|
+
The sections inside a report — customer, subject, content, attendees and comments in the DetailList card; content and comments in the side pane; attachments in both — are headings one level lower.
|
|
755
|
+
Every label and its value is a pair of its own (`dt` / `dd` inside a `dl`, one `MetadataField` primitive), and no value repeats its label as text (no `区分: 日報`): the DetailList metadata column (author, created at, updated by, updated at, business date, and the visit time, the category and the creation category, each when present) and the side pane's subject, customer and visit time; both attachment lists are named by their section heading.
|
|
756
|
+
The DetailList header's chips are one `dl` too: ID, business date, author, visit time, updated at, and the category and the creation category when present, each label once, with the dates as `<time datetime>`; the header's markers and buttons sit outside the list.
|
|
757
|
+
Both tab lists are named by what they switch (`aria-label`): the view-mode tabs by `labels.viewTabList`, the side pane's tabs by `labels.reportTabList` (the report heading names only the business date, which reports of the same day share).
|
|
758
|
+
- **Editing**: on the viewer's own reports, an Edit button (`labels.edit`, `data-detail-edit-button`, a pencil icon) stands before Delete in the DetailList header and in the side pane's action row. It is the keyboard way into the editor; a double click is the pointer shortcut. Entering the editor this way moves focus to its title field, and Cancel or a successful Publish returns focus to the Edit button (when the button is not rendered, the DetailList row takes focus itself and the side pane focuses its report heading).
|
|
759
|
+
A draft that opens in the editor by itself does not move focus. The editor is a real `<form>` whose submission is cancelled: Save, Publish and Cancel are `type="button"`, and an implicit submission (Enter in the title) or a `requestSubmit()` neither navigates nor saves nor publishes, so what was typed stays. As an input region the form keeps the list keys out (above).
|
|
760
|
+
- **Deleting a comment**: the trash button of the viewer's own comment is a disclosure (`aria-expanded`; while open, `aria-controls` names the confirmation pill it shows under itself). The confirmation has no time limit (WCAG 2.2.1): it stays open until it is confirmed or cancelled — by pressing the trash button again, by Escape (the comment list takes it before the mobile overlay while the confirmation is open; an Escape that belongs to an IME composition is left alone), by focus leaving the trash button and the confirmation, or by a pointer press outside them.
|
|
761
|
+
A cancel by the trash button or by Escape returns focus to the trash button when focus was on the confirmation.
|
|
762
|
+
Confirming moves focus before the comment is hidden: to the next remaining trash button, else the previous one, else the comment section's heading (`tabIndex=-1`) — the same in the side pane, the mobile overlay and the DetailList row — so focus never falls to `body`.
|
|
763
|
+
- **Mobile detail overlay** (List, single-column layout): the platform's modal dialog, a `<dialog data-daily-report-mobile-overlay>` (implicit `dialog` role, no `aria-modal`) opened with `showModal()`. It is drawn in the top layer, above every host `z-index`, and everything outside it is inert while it is open, host chrome included: no focus, no pointer, nothing in the accessibility tree.
|
|
764
|
+
It is named by the report heading at its top and the author's value under it (`aria-labelledby` lists both: `<date> <author>`). That heading (`tabIndex=-1`) is the dialog's first focusable descendant, so `showModal()`'s own focusing steps move focus to it in the same commit (the overlay focuses it explicitly as well). Tab and Shift+Tab wrap inside it (its stops are counted on every press by the model of **Leaving the list**;
|
|
765
|
+
on Shift+Tab the container of a roving-focus widget around the origin, such as the tabs' `tablist`, is not counted before it, because real Shift+Tab leaves such a widget without stopping at it), and Escape closes it unless the key belongs to an IME composition.
|
|
766
|
+
Every close request goes through the overlay's own close path, which also pops the history entry the overlay pushed: the platform's `cancel` (an Escape the overlay's key handling did not take, Android's back gesture) is default-prevented and routed there, except one caused within 50 ms by an Escape the overlay declined during an IME composition; when the platform closes the dialog by itself (a `cancel` the page is not allowed to stop), the close path runs too, and the dialog opens modally again when the close is refused.
|
|
767
|
+
Closing it by any means (close button, Escape, backdrop, browser back, a switch to the desktop layout) first blurs the focus inside the dialog, then ends the modal state with `close()` (the platform returns focus, without scrolling, to the element that had it when the dialog opened, when that element is still in the document), and then the view hands focus to the report's card with `preventScroll`, the list scrolling only when that row is not fully visible.
|
|
768
|
+
The dialog is never opened again to show the slide-out: it stays rendered while closed (its layout does not depend on the `open` attribute) and `inert` — no focus, no pointer, nothing in the accessibility tree — until it has slid out, and the row cursor counts focus left inside it as focus that is nowhere. Opening the overlay again during the slide-out shows the same dialog modally again.
|
|
769
|
+
The dialog itself takes focus (`tabIndex=-1`), so pressing a non-focusable part of it never drops focus to `body`. The backdrop is a decorative layer (`data-daily-report-overlay-backdrop`, `aria-hidden`, without a role or a name, not focusable): pressing it closes the overlay, while keyboard and assistive-technology users close it with Escape or the close button. Its transitions run only under `prefers-reduced-motion: no-preference`.
|
|
770
|
+
- **Iframes and shadow roots**: the views never read the global `document`; focus checks, the handoff, the body scroll lock and the overlay use the view's own document or shadow root, the Tab-stop search walks open shadow roots, and script-driven scrolling reads the reduced-motion preference of the element's own window.
|
|
771
|
+
- `VirtualScroll`'s own `enableKeyboardNavigation` stays `false`; the keys are handled by the package.
|
|
772
|
+
|
|
773
|
+
### Selection and focus appearance
|
|
774
|
+
|
|
775
|
+
Selection and keyboard focus are two separate channels, identical in both views, so "selected and focused" never looks like "focused":
|
|
776
|
+
|
|
777
|
+
| State | Channel | Geometry | Light | Dark | Forced colours |
|
|
778
|
+
| --- | --- | --- | --- | --- | --- |
|
|
779
|
+
| Selected | box-shadow ring; the surface paints no shadow outside it | 2 px, 0–2 px outside the surface | blue-500 | blue-500 | 2 px `Highlight` outline of the row frame in the ring's band (below) |
|
|
780
|
+
| Keyboard focus on a row or card | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
|
|
781
|
+
| Keyboard focus on a control or link | outline | 2 px at offset 2 | blue-600 | blue-400 | kept |
|
|
782
|
+
| Pointer focus | none | — | — | — | — |
|
|
783
|
+
| Hover (cards) | blue-300 border, `shadow-lg` (a selected card paints no shadow), 2 px lift (motion-safe) | — | — | — | — |
|
|
784
|
+
|
|
785
|
+
The selected surface sets the shadow colour to transparent (`shadow-transparent`; the selection rules are the only ones that write a shadow colour), and every shadow size the surface can take — the resting `shadow-sm`, the hover `shadow-lg` and the mid-scroll reset `[[data-daily-report-scrolling]_&]:hover:shadow-sm`, whose specificity (0,3,0) beats the selection rules' (0,2,0) — reads its colour from that one variable (`--tw-shadow-color`).
|
|
786
|
+
Whichever size rule wins the cascade, a selected surface therefore paints nothing outside its ring, at rest, hovered or mid-scroll, and the 2 px separation band below the ring is page colour like the other three edges (any shadow there tints the band: the resting `shadow-sm` shifts its relative luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to 3.12:1 against it).
|
|
787
|
+
|
|
788
|
+
- **Row gutter G = 8 px** on all four sides of both row frames: ring 2 + separation 2 + outline 2 + hover lift 2. Every term is a CSS pixel quantity and is written in px (`p-[8px]`, the lift `-translate-y-[2px]`), because the ring and the outline do not scale with the root font size and a `rem` term would break the sum at any root other than 16 px; the lengths built on G — the focus reach, the toolbar insets, the frame radius — are px too. A row aligned to either edge of the viewport while hovered, selected and focused is therefore never clipped, at any root font size;
|
|
789
|
+
a DetailList row is its measured body plus 2G = 16.
|
|
790
|
+
- **List slot P**: the List card's content is sized in rem, so the slot follows the page's root font size R: P(R) = 2G + 2 × (1 + 15) (the card's border and padding) + 4 (spare) + 6.75R = 52 px + 6.75 rem, rounded up to a whole CSS pixel (`listRowSlotHeight`), so every row top stays on a whole pixel. R is read from the root element's computed style and read again whenever a hidden 1 rem probe inside the List (`data-daily-report-root-font-size-probe`) changes size, and the List renders its `VirtualScroll` only once P is known (measured before the first paint).
|
|
791
|
+
That one value sizes the row frames, `VirtualScroll`'s rows and the keyboard's row geometry, and when it changes the List keeps the first visible row in place by rescaling the scroll position. At the default 16 px root P = 160, the value of `DEFAULT_ITEM_HEIGHT` (exported as P(16); the List itself never reads it): the card is P − 2G = 144 px, cards are 16 px apart and an edge-aligned card sits exactly 8 px from the edge.
|
|
792
|
+
- **List card**: a wrapping row of the primary button and the action row (markers and toggles), with the preview always on the next line. The primary button takes the remaining width but never less than 96 px, enough for the business-date pill, and the action row keeps to the card's end; on a card too narrow for both (a phone with a pinned host menu), the action row wraps under the button instead of squeezing it to nothing, and the card clips the preview lines that no longer fit.
|
|
793
|
+
The content of a full card is 6.75 rem: the pills' line 1.5 rem + 0.5 + three one-line preview paragraphs of 1.25 rem, 0.5 rem apart (24 + 8 + 3 × 20 + 2 × 8 = 108 px at a 16 px root), inside a 1 + 15 inset at the top and the bottom (the inset counts the border, below), so 1 + 15 + 6.75R + 15 + 1 ≤ P − 2G with at least 4 px to spare at every root font size (140 ≤ 144 at 16 px).
|
|
794
|
+
- **Forced colours**: the ring (a box shadow) disappears, so the row frame draws the selection itself: a 2 px solid `Highlight` outline at offset −8 (−G), which puts the line in the ring's band, 0–2 px outside the surface. The frame's corner radius is the surface's radius plus G (`calc(var(--radius-2xl) + 8px)`, 24 at a 16 px root), so the line is concentric with the surface at every root font size.
|
|
795
|
+
A DetailList row and a List row without a card read their own `aria-current`; a List row with a card reads its card's primary button (`:has(> [data-daily-report-card] > [aria-current=true])`). The selected row differs by shape as well as by colour (WCAG 1.4.1); the surface border stays 1 px in every state, and the focus outline (4–6 px outside) sits beside the selection line without touching it.
|
|
796
|
+
- **State comes from the row itself**: a row frame styles its surface — the direct child that carries `data-daily-report-row-surface` in every load state — from its own `aria-current` / `:focus-visible`, and the List card surface styles itself from its direct-child primary button (`:has(> …)`). Conditions on an ancestor read only the attributes the package writes itself (`data-daily-report-scrolling`, `data-daily-report-keyboard-focus`), so an ancestor that carries shared attributes such as `aria-current` never lights up a row.
|
|
797
|
+
- **A key press restyles only what paints the change**: every selector that depends on another element's state ends in the styled element's own class or attribute, and `:has()` sits only on the styled element itself. A featureless subject (`*:`, `group-*`) or an ancestor's `:has(:focus-visible)` would make the browser restyle whole rows or the whole view on each key; `src/client/ui/tailwind-selector-scope.spec.ts` compiles the package's classes and fails on either form.
|
|
798
|
+
- **Containment keeps a row's work inside the row**: each List row frame is `contain: size layout style` (its height is the fixed slot) and each DetailList row frame `contain: layout style` (its height follows its content), so a change inside one row never lays out or restyles another. Neither contains paint, because the hover `shadow-lg` of an unselected surface (22 px below, 12 px to the sides) reaches beyond the 8 px gutter G that paint containment would clip (the resting `shadow-sm`, 4 px below, stays inside G; a selected surface paints no shadow).
|
|
799
|
+
Both side-pane tab panels (article and relations) are one scroll box, `contain: strict`: its size comes from the pane's column and, as a scroll container, it already clips, so its content never restyles or repaints the pane around it and cannot change the panel's size.
|
|
800
|
+
Neither the panel nor a DetailList row frame is a relayout boundary in Chromium (the panel is a flex item, and the row frame has no size containment), so a layout inside one still starts at the document root and walks the dirty chain of its ancestors; the tile frames inside them are boundaries of their own whose own style never changes, so an image's size arriving on load and the waiting pulse starting or stopping lay out inside the frame alone (**Frame** in [Attachment display](#attachment-display)).
|
|
801
|
+
The panel reserves its scroll bar's width whatever the content's height (`scrollbar-gutter: stable`), so the width its content gets — and with it the attachment grid's tracks — never depends on whether the article overflows, and it keeps the focus reach (below) inside its clipping edges.
|
|
802
|
+
The tokens are `LIST_ROW_CONTAINMENT_CLASS_NAME`, `DETAIL_ROW_CONTAINMENT_CLASS_NAME` and `SIDE_PANE_ARTICLE_CONTAINMENT_CLASS_NAME`; `src/client/ui/row-containment.spec.ts` compiles them and measures the two shadows against G.
|
|
803
|
+
Outside the rows, the side pane's tab panels are the package's own (`role="tabpanel"`, named by their trigger, hidden and empty while not selected) and read no computed style when they mount, and the scroll bar's business-day bubble reads its date and wheel state from a store of its own (`useSyncExternalStore`), so a change of the visible range or a wheel re-renders only the bubble, never the view or `VirtualScroll`.
|
|
804
|
+
- **Focus indicators are the package's own**: every focus indicator the package draws is one of the outlines above, including those of its tabs, buttons, switches, inputs and text areas (`src/client/ui`); none uses the host's `--ring` (measured at 2.43:1 in light and 1.22:1 in dark against the tab list), and no element that carries a focus indicator transitions its colours (in Tailwind 4 `transition-colors` also fades the outline in).
|
|
805
|
+
No element is scaled: a scaled element scales its outline with it. The switches are drawn at their real size — a 40 × 24 track whose transparent 4 px border (its style declared) frames a 16 px thumb that moves 16 px — so the 24 px track is its own target and its 2 px outline at offset 2 is drawn at full width. `src/client/ui/focus-indicator.spec.ts` checks the shipped CSS, including that no rule scales an element.
|
|
806
|
+
- **Focus reach**: a control's outline reaches 4 px outside it (offset 2 + width 2 = 4 = u; a row's or a card's outline stays inside the gutter G), and every box that clips its content and holds focusable content keeps that reach inside its clipping edges, so no outline is ever cut.
|
|
807
|
+
The rows' gutter holds it in both views; the side pane's tab panels — the pane's scroll box, also inside the mobile overlay — carry `FOCUS_RING_REACH_CLASS_NAME` (`-mx-[4px] px-[4px] pb-[4px] scroll-p-[4px]`, in px like the outline it holds): the negative inline margin and the equal padding move the clipping edges 4 px out on both sides without moving or narrowing the content, `pb-[4px]` keeps the last control's outline, `scroll-p-[4px]` keeps the outline of a control that Tab scrolls to an edge, and the panel's 8 px top padding holds the top.
|
|
808
|
+
The view-mode toolbar clips nothing (it wraps instead, below).
|
|
809
|
+
`src/client/ui/focus-indicator.spec.ts` reads the sources and fails on a class constant that scrolls or contains paint (`overflow-*-auto` / `-scroll`, `contain-strict` / `-content` / `-paint`) without the reach token after following the constants it is built from, and on such a class written outside a constant; its one allowed constant, the containment token, is only ever composed into the side pane's scroll box.
|
|
810
|
+
- **State colours are the package's own too**: the switches and the tabs take every state colour from package tokens (`SWITCH_TRACK_COLOR_CLASS_NAME`, `SWITCH_THUMB_COLOR_CLASS_NAME`, `SEGMENTED_TRIGGER_CLASS_NAME`), never from the host's `--input`, `--primary`, `--background` or `--muted`.
|
|
811
|
+
The switch track is slate-500 when off and blue-600 when on (dark: slate-400 / blue-400) under a white thumb (dark: slate-950). The selected tab is a white surface (dark: slate-950) with slate-900 text (dark: slate-100) and a 2 px blue-600 bottom line (dark: blue-400), the line being the cue that does not depend on colour; every tab reserves that line as a transparent border, so selecting one moves nothing. Unselected labels are slate-600 (dark: slate-300).
|
|
812
|
+
In forced colours the thumb is `ButtonText`, the track has a `ButtonText` border and the on track a `Highlight` fill, and the selected line is `Highlight`, so the state survives the colour override. `src/client/ui/ui-state-contrast.spec.ts` computes every pair below from the compiled CSS and the palette (the toolbar surface is slate-100 at 60 % over the slate-50 page, dark slate-900 at 60 % over slate-950):
|
|
813
|
+
|
|
814
|
+
| Pair | Light | Dark | Minimum |
|
|
815
|
+
| --- | --- | --- | --- |
|
|
816
|
+
| Switch thumb / off track | 4.76 | 7.66 | 3 |
|
|
817
|
+
| Switch thumb / on track | 5.25 | 7.64 | 3 |
|
|
818
|
+
| Off track / toolbar surface | 4.43 | 7.18 | 3 |
|
|
819
|
+
| On track / toolbar surface | 4.88 | 7.16 | 3 |
|
|
820
|
+
| Selected tab line / selected tab | 5.25 | 7.64 | 3 |
|
|
821
|
+
| Selected tab line / the side pane's tab track | 4.79 | 5.54 | 3 |
|
|
822
|
+
| Selected tab line / toolbar surface | 4.88 | 7.16 | 3 |
|
|
823
|
+
| Unselected label / the side pane's tab track | 6.90 | 9.83 | 4.5 |
|
|
824
|
+
| Unselected label / toolbar surface | 7.03 | 12.71 | 4.5 |
|
|
825
|
+
| Selected label / selected tab | 17.83 | 18.40 | 4.5 |
|
|
826
|
+
|
|
827
|
+
- **Borders declare their style**: every border the package draws sets `border-solid` itself, so a host whose base layer resets the border style of every element (for example `* { border: none }` in Tailwind v4, which turns `--tw-border-style` into `none`) cannot erase it; the hover border and the switch track depend on it.
|
|
828
|
+
The card-like surfaces — the List card and its skeleton, the desktop side pane, the DetailList card and its skeleton — share one token (`CARD_SURFACE_CLASS_NAME`): a 16 px corner, a 1 px slate-200 / dark slate-700 border, white / dark slate-900 at 70 %, a 16 px inset that counts the border (1 px border + 15 px padding, so the content edge is 16 px from the visible edge and its corner concentric with the surface's) and `shadow-sm`.
|
|
829
|
+
Two more surfaces have one token each, and no other place writes their classes: the placeholder panel (`PLACEHOLDER_PANEL_CLASS_NAME`: the side pane's selection prompt and its loading and empty tabs; a dashed 1 px border, the 12 px corner of an inner panel and the 12 px inset of every such panel (1 + 11), slate-50 / dark slate-900 at 40 %, slate-600 / dark slate-300 text) and the reading panel (`READING_PANEL_CLASS_NAME`: a report's content in the side pane and in the DetailList card;
|
|
830
|
+
a 12 px corner and a concentric 12 px inset, slate-50 / dark slate-800 at 60 %, slate-700 / dark slate-200 text on 24 px lines). The scroll bar's business-day bubble is the bordered pill (`BORDERED_PILL_CLASS_NAME`: 24 px tall, its text 12 px in = 1 px border + 11 px padding, the radius of its round ends). `src/client/ui/border-style.spec.ts` checks these rules, a source scan included.
|
|
831
|
+
- **Motion**: everything that moves animates only transform or opacity under `motion-safe` — the hover lift, the thumbnail fade, the loading pulse (on a thumbnail frame's skin, only while the frame may show its load activity, see [Attachment display](#attachment-display)), the switch's thumb, the entrance of the comment delete confirmation and of the error banner, the mobile overlay's slide and its backdrop.
|
|
832
|
+
The one exception is the mobile overlay layer: its right edge follows the host's pinned-chrome offset (`--aqdr-overlay-right`) over 0.5 s, also under `motion-safe`, in step with the host's own transitions.
|
|
833
|
+
State surfaces never animate their opacity: a row's surface (`data-daily-report-row-surface`, which paints the selection ring and the focus outline) and its ancestors up to the row frame stay opaque and still: the List's loading skeleton pulses an element inside its surface (the DetailList's skeleton does not pulse), so a selected or focused loading row keeps both indicators at 3:1 or more at the pulse's trough.
|
|
834
|
+
Selection, focus, colours and borders switch instantly, and with reduced motion nothing moves, script included: the side pane's scroll after the viewer posts a comment is smooth only while the pane's own window does not prefer reduced motion and instant under `prefers-reduced-motion: reduce` (`scrollBehaviorFor` in `src/client/ui/motion.ts` reads the preference on every scroll, because browsers do not apply it to a script's `behavior: "smooth"`).
|
|
835
|
+
A touch or pen flick starts no glide under `prefers-reduced-motion: reduce` either: `VirtualScroll` continues a flick with a scripted `requestAnimationFrame` glide (for up to 10 s with the views' fast-scroll options), so each view reads the preference of its own window (`useReducedMotion`, which follows a change from the next render and is `false` on the server) and passes inertia options whose start threshold no velocity reaches (`inertiaOptionsFor(true)`); the content still follows the finger 1:1 while it drags and stops when it is released.
|
|
836
|
+
Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
|
|
837
|
+
While the list scrolls (`data-daily-report-scrolling` on the view's scroll container) the hover lift and its shadow are held still. `src/client/ui/motion-policy.spec.ts` checks the shipped CSS, and scans the sources so that no smooth scroll behaviour, no `animate()` call and no inertia option other than one chosen by `inertiaOptionsFor` is written outside `src/client/ui/motion.ts`.
|
|
838
|
+
- **Spacing**: every spacing the package writes is 4 (within a group), 8 (between parts) or 16 (between items), and every box length it writes sits on the 4 px grid, line boxes included: one-line text such as each List preview line has a 20 px line box (`leading-5`, the preview's lines 8 px apart at a 28 px pitch) and the reading text of the side pane, the DetailList content and the comments a 24 px one (`leading-6`); no line height is proportional to the font size.
|
|
839
|
+
These are the values at the default 16 px root: lengths written in rem (type, line boxes, the card's content, most spacing) scale with the root font size, while the CSS-pixel quantities of the state indicators — G and the lengths built on it — are written in px and keep their values at every root (see **Row gutter G** and **List slot P**).
|
|
840
|
+
Two lengths follow the container instead, by design: the attachment grid's fluid track width t = (W − 16 (n − 1)) / n (horizontal) and the tile frame's height derived from it, h(t) = round(2t / 3) (see [Attachment display](#attachment-display)). A surface that holds k rows of tiles is therefore off the 4 px grid by exactly k · h(t) (mod 4), which the app's visual contract asserts.
|
|
841
|
+
The frame height is not snapped to 4 px: snapping moves the frame's shape more than 0.01 away from 3 : 2 (196 → 132 gives |t / h − 3 / 2| = 0.0152, 171 → 116 gives 0.026) and letterboxes a 3 : 2 image, which fills an unsnapped frame exactly.
|
|
842
|
+
A bordered box counts its border in its inset, so that its content edge sits on the grid measured from the visible edge: border + padding is on the ladder — 16 for the card surfaces, the error panel, the sides of the error banner and the mobile overlay's close button (1 + 15), 8 for the form fields, the comment input and the top and bottom of the error banner (1 + 7), 4 for the bordered tab bar, the development box and the top and bottom of the text input (1 + 3) — or equals a 12 px corner.
|
|
843
|
+
**The 12 px tier**: every panel with a 12 px corner has one inset, 12, equal to its corner, so its content box's corner is concentric with the panel's — the reading panel, the DetailList metadata column and the DetailList skeleton's inner panel (`p-3`, no border), the placeholder panel, a posted comment and the bordered pill (1 + 11); a 24 px pill has the same 12 px padding at its round ends. The tab bars are not in that tier: their 4 px inset makes them concentric with their 8 px tabs (12 − 4 = 8).
|
|
844
|
+
The only other exception is the visually hidden text. `src/client/ui/spacing-ladder.spec.ts` checks every class the package writes, that every bordered box's border + padding is on the ladder or equals its corner, and that every 12 px-corner panel's inset is 12.
|
|
845
|
+
- **Side pane** (List, desktop layout): the panel group fills the view's root, and the list and the side-pane panels both take its height ([View height](#view-height-host-layout)), so a long report scrolls only inside the pane's article tab and never lengthens the page. The pane sits G inside its panel on all four sides, so its top and bottom edges line up with the first and last cards, and its bottom edge sits exactly G above the page's bottom edge, as far as the toolbar is above the first surface.
|
|
846
|
+
- **Tab bars are one segmented control**: the side pane's tabs and the view-mode toolbar share one geometry. The bar has a 12 px corner (`rounded-xl`) and a total inset of 4 px that counts its border (4 px padding without a border, `SEGMENTED_LIST_CLASS_NAME`; 1 px border + 3 px padding with one, `SEGMENTED_BORDERED_LIST_CLASS_NAME`), the same at the top and at the sides.
|
|
847
|
+
The tabs are 24 px tall with 12 px labels and an 8 px corner (`rounded-lg`), so both bars are 4 + 24 + 4 = 32 px tall and every tab is concentric with its bar: 12 − 4 = 8. Because xl − lg = 4 equals the inset both in Tailwind's scale and in a host `--radius` scale (lg = `--radius`, xl = `--radius` + 4 px), the pair stays concentric for any host radius; the toolbar's children at its end corners (the create button, the development box) are `rounded-lg` too.
|
|
848
|
+
- **View-mode toolbar** (`DailyReportResolvedContent`, above every view): its start edge is inset by G and lines up with the cards; its end edge lines up with the active view's content: G in the desktop List, where the side pane ends the row, and G + the scroll-bar width (8 + 8 = 16) in the DetailList and in the single-column List, where the scroll bar ends it. Both insets are written in px (`ml-[8px]`, `mr-[8px]` / `mr-[16px]`), like G and the scroll bar, so they line up at every root font size.
|
|
849
|
+
It never scrolls sideways and hides nothing: when it is narrower than its one-row width its controls wrap onto a second row, every label kept. It clips nothing, so the tabs' outlines, which reach 4 px outside them into the bar's 4 px inset, are never cut (the **Focus reach** rule above). Its tab list is named by `labels.viewTabList`.
|
|
850
|
+
- **Scroll bars**: both views theme VirtualScroll's scroll bar through its root class (`VIEW_SCROLL_BAR_THEME_CLASS_NAME`): the track is slate-100 / dark slate-900 and the thumb slate-500 (slate-600 / 400 on hover, slate-700 / 300 while dragged), at least 3:1 against the track in both themes (lowest 4.35 / 3.74); the arrow glyphs reach 6.90 / 6.14 in light and 6.78 / 5.56 in dark on their resting and hover backgrounds.
|
|
851
|
+
- **Type and contrast**: no text is smaller than 12 px (`text-xs`); text reaches 4.5:1 and the indicators 3:1 in both themes (section labels slate-500 / slate-400: 4.77 / 7.09). The floating tap-scroll circle hides while the view root carries `data-daily-report-keyboard-focus` (`TAP_SCROLL_CIRCLE_OPTIONS.className`).
|
|
852
|
+
- **Action buttons and icons**: the List card's star and read toggles, the star, read, edit and delete buttons of the DetailList header and the side pane, and the trash button of the viewer's comments share one 24 px round target that never shrinks, with a 16 px SVG icon centred in it (4 px on every side), so the icons sit on the 4 px grid without depending on a font. In the List card the markers and toggles stand 4 px apart; the header pills and the source badge are 24 px tall like the targets.
|
|
853
|
+
The star is a regular five-pointed star, outlined when off and filled gold when on (with a darker gold edge in the light theme); read is a check, unread an 8 px dot, edit a pencil, delete a trash can. Every icon state reaches at least 3.59:1 against each background it sits on, the button's hover background included, in both themes; in forced colours the star is drawn in `CanvasText` (off) and `Highlight` (on). The side pane shows them in the order star, read, edit, delete, centred on the first line of the subject.
|
|
854
|
+
Unread is shown by the dot on the read toggle and by the row state in the description; no surface changes its border or its layout for it (a card's border is the same 1 px in every state).
|
|
855
|
+
The comment's trash button takes the 16 px icon's place in the 20 px line of the comment header (its 24 px target overhangs by 2 px, so the header keeps its height), turns red while its confirmation waits (at least 3.59:1 on the comment surfaces), and the confirmation pill carries the control focus outline.
|
|
856
|
+
|
|
857
|
+
### Attachment display
|
|
858
|
+
|
|
859
|
+
- Attachments with `hasThumbnail: true` are laid out as tiles in a grid; the others as rows. Each group keeps the original order.
|
|
860
|
+
- **Section heading**: the section is headed by `labels.attachments`, a heading one level below the report heading (`config.headingLevel` + 1), and both the grid and the rows list are named by it (`aria-labelledby`).
|
|
861
|
+
- **Grid**: the attachment section is a size container, and the number of columns follows its width W: n(W) = 1 + ⌊(W + 16) / 256⌋ up to 5 (breakpoints at 240, 496, 752 and 1,008 px; 256 = the largest frame width 240 + the 16 px gap).
|
|
862
|
+
The grid itself is at most 1,264 px wide, the width at which 5 tracks are exactly 240 px, so a track t is never wider than 240 px (t ≤ 240, equal only at the cap) inside any container, however wide, and at a device pixel ratio up to 2 a thumbnail is never upscaled.
|
|
863
|
+
Each track is t(W) = (W − 16 (n − 1)) / n. For example, at 1280 × 800 and at 1920 × 1080 the DetailList card's content column is W = 1,136 (the rows, beside the 8 px scroll bar) − 2 × 8 (G) − 2 × (1 + 15) (the card's border and padding) − 16 − 240 (the gap and the metadata column) = 832 px, which holds 4 tracks of 196 px.
|
|
864
|
+
The side pane's grid sits in the pane's scroll box, whose stable scroll-bar gutter g is taken whatever the content's height (the panel's classic scroll-bar width, 10 px for Chromium's thin scroll bar on Linux; 0 for overlay scroll bars), and whose focus reach (`-mx-[4px] px-[4px]`) leaves the content's width unchanged. In a 1,144 px content column with the List panel at its default 360 px the pane's content is 1,144 − 360 − 2 × 8 (the detail panel's G) − 2 × (1 + 15) (the pane's border and padding) = 736 px, so W = 736 − g:
|
|
865
|
+
n = 3 at any scroll-bar width (W stays between 496 and 752), with t = (736 − 32) / 3 = 234⅔ for overlay scroll bars and (726 − 32) / 3 = 231⅓ beside a 10 px gutter. The 390 px phone overlay's pane is 358 px wide, W = 358 − g: 2 tracks of 171 px (overlay scroll bars) or of 166 px (a 10 px gutter).
|
|
866
|
+
Every native scroll container that holds a grid reserves its scroll-bar gutter (the side pane's scroll box; the views' rows sit beside `VirtualScroll`'s own scroll bar of fixed width), so a grid's track width never depends on whether its container overflows.
|
|
867
|
+
Every length of the grid — the breakpoints, the cap and the gap — is written in px, because the bounds are CSS-pixel quantities: in rem they would grow with the root font size (a 20 px root would make the tracks 300 px wide, upscaled 1.25× at a device pixel ratio of 2).
|
|
868
|
+
- **Tile**: one link to the original wraps the thumbnail frame and the file name; below it one line holds the size and the download link. The frame is decorative (`aria-hidden="true"`, no link of its own), so the tile link is named by the file name and a tile has two Tab stops (the preview, then the download). The download link has a 24 px hit area, and its accessible name is the download label followed by the file name.
|
|
869
|
+
- **File name**: a long name is shortened in its stem and keeps its extension visible, on one line. In a tile grid the name is fitted exactly to the track: the longest start of the stem that still fits, an ellipsis that touches the extension, then the extension, drawn as one run clipped to the track (so the ellipsis never floats a glyph's width away from the extension, and a sub-pixel misfit is clipped instead of adding a second ellipsis).
|
|
870
|
+
The extension is kept whole up to half the track; a longer one keeps the start that fits in half the track plus an ellipsis. The names are cut between code points of the NFC-normalized name, not between graphemes, so a combining sequence or a joined emoji at the cut can be split (`Intl.Segmenter` is above the browser floor).
|
|
871
|
+
One `ResizeObserver` per document watches every grid in it (created from the document's own window, disconnected when its last grid stops) and supplies the track width, computed from the grid's content width with the column formula above, not by measuring tiles.
|
|
872
|
+
A notification only reads its records: it keeps each grid's track width and asks for one animation frame when a track width differs from the one last handed out. It never writes the DOM, never commits and never reads layout, so nothing done during a delivery can resize an element that any observer watches — the grid, or a virtual-scroll row around it — and the browser never reports a ResizeObserver loop.
|
|
873
|
+
In that frame, before its style and layout, every grid whose track width changed receives it and re-fits its names, all in one `flushSync` however many grids changed. A notification with an unchanged track width (a height-only change, or a new width with the same track, such as 150 px in one column and 316 px in two) or a width of 0 (an ancestor with `display: none`) hands out nothing, a width that changes back before the frame cancels its own hand-out, and a grid hidden and shown again at the same width does not re-render.
|
|
874
|
+
The frames never wait for this: CSS sizes them in the layout that sizes the tracks (**Frame**, below). Only the names do, so a grid's first frame draws its long names with the CSS truncation described below, and the next frame draws them fitted.
|
|
875
|
+
The text is measured with one canvas `measureText` per document in the names' computed font (no forced layout), with one measuring function per font string, so a fit whose width and measure are unchanged keeps its identity and no name re-renders. One `loadingdone` listener per document (on `document.fonts`, added after the first fit) re-fits only the grids whose names a loaded face can draw — the face's family is one of the names' families and its `unicode-range` covers a character of a name, of its NFC form or the ellipsis (an unreadable range counts as covering)
|
|
876
|
+
— after dropping that font's measure, since the glyph widths change while the font string stays the same; other font loads re-render nothing.
|
|
877
|
+
Outside a grid (the rows of non-image attachments) and until the grid's width is known, the name truncates with CSS: the stem ellipsizes and the extension, kept up to half the width, follows it.
|
|
878
|
+
The visible name is hidden from assistive technology either way, and a visually hidden copy of the whole name gives each link one unbroken name (no space before the extension); `title` holds the whole name too.
|
|
879
|
+
- **Tab order inside list rows**: the attachment links follow the row rule of [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist): inside a DetailList row they are Tab stops only in the view's Tab-stop row (`tabIndex=-1` in every other row); in the side pane and the mobile overlay they keep their natural order.
|
|
880
|
+
- **Marker**: the List card and the DetailList header show `DailyReportAttachmentIndicator` when a report has attachments: a 24 px tall box without padding or radius (its first ink starts at the content edge) holding a 16 px paperclip icon and, from 2 attachments on, the count in 12 px type on a 16 px line; it is named `<labels.attachments>: <count>` (`role="img"`).
|
|
881
|
+
Its required `id` prop lands on that `role="img"` element, so a row's description can reference the marker (pass an id unique in the document, for example from `useId()`); with no attachment nothing is rendered, and the description leaves it out.
|
|
882
|
+
- **Frame**: as wide as its track and h(t) = round(2t / 3) whole CSS px tall — the ratio of the variant box (480 × 320 for `tile`), rounded to the nearest pixel. CSS computes it, in the same layout that sizes the track: each frame sits in an inline-size container of its own (an `@container` wrapper exactly as wide as its track, so `100cqi` is t) and has the style `aspect-ratio: 480 / 320; height: round(nearest, 100cqi * 320 / 480, 1px); contain: strict`, the sizes built from the variant box (`ATTACHMENT_TILE_FRAME_STYLE`).
|
|
883
|
+
No script measures or writes a frame, so its height is right from the first frame, a width change resizes the frames in the layout that changes the width, and every row of a grid draws its frames with the same number of device pixels at an integer device pixel ratio (a fractional height such as 130.33 px would give rows of 130 and 131; at a fractional ratio such as 1.25 or 1.5 the product can still be fractional).
|
|
884
|
+
The rounding keeps the frame within |t / h − 3 / 2| ≤ 0.75 / h, under 0.01 for every track of a grid with two or more columns (t ≥ 112, h ≥ 75); for the widths above, 196 → 131, 234⅔ → 156, 231⅓ → 154, 171 → 114 and 166 → 111. On Chrome 121–124, which lack `round()`, the height declaration is dropped and the aspect ratio alone gives the fractional 2t / 3 (see the browser floor table).
|
|
885
|
+
The height depends only on the track and stays the same before, during and after loading and after a failure (virtual-scroll row heights do not shift).
|
|
886
|
+
The frame paints nothing itself: its only child, the **skin** (`data-testid="daily-report-attachment-thumbnail-skin"`), is an absolutely positioned box that fills it and paints every state — the background, the waiting pulse, the rounded clip, the inner rim — and holds the image or the failure message.
|
|
887
|
+
The image keeps its aspect ratio inside the skin and is not enlarged; it has no corner radius of its own, and the skin's rounded clip (`overflow: hidden`) alone makes the corners, so an image narrower or shorter than the frame shows no notches of the background where it meets the straight edges (the frame does not clip the corners as well: a second clip at the same edge anti-aliases the corner pixels twice and lightens them).
|
|
888
|
+
The skin's inner rim turns blue while the tile link (`data-daily-report-attachment-preview`) is hovered, on devices that can hover, and a deeper blue while it is pressed; the background never animates.
|
|
889
|
+
The frame is a relayout boundary: its strict containment (size, layout, paint, style) changes neither its size, which comes from its own style alone (the container's full width and the height above, or the aspect ratio), nor what is painted, since paint containment clips at the frame's edge and the skin lies inside it; and as a positioned box that is not a flex or grid item (its parent is the block `@container` wrapper), Chromium lays out what changes inside the frame — the image's intrinsic size arriving on load, a style change of the skin — inside the frame alone, not from the document root.
|
|
890
|
+
Only a change inside the frame stops there: a boundary whose own computed style changes is laid out by its parent, from the document root. So the frame's own style never changes after mount — its classes are its box alone (`relative block w-full`: no variant, no paint, no animation) and its inline style is `ATTACHMENT_TILE_FRAME_STYLE` — and every state is drawn inside it, by the skin and the image, which read the frame's attributes.
|
|
891
|
+
- **`data-thumbnail-state`** on the frame is `pending` (waiting or loading), `loaded` (the image fades in) or `unavailable` (an icon and `labels.attachmentThumbnailUnavailable` inside the skin; the icon reaches 4.35:1 / 5.56:1 and the label 6.90:1 / 5.58:1 in the light / dark theme). The loader's internal phases are not exposed.
|
|
892
|
+
- **The waiting pulse runs only where the tile can be seen**: a `pending` frame's skin pulses (under `prefers-reduced-motion: no-preference`) only while the frame also carries the boolean attribute `data-thumbnail-active`. The frame's load controller decides it (`showActivity`) and the attribute is toggled on the frame element, without a React render, so visibility changes and key moves add no commit; only the skin reads it, so a toggle restyles the skin, never the frame (see **Frame**):
|
|
893
|
+
an observed frame (waiting for its 250 ms or for a slot, below) carries the attribute exactly while it is visible, and before its first visibility record it carries none; a granted load sets it and stops observing, so the frame keeps it through the load and, after a failed attempt, through the retry wait — both bounded — until observation starts again (the attribute is removed then) or the frame is released (unmount, a URL change, hiding under `<Activity>`). Once the image has loaded the state is no longer `pending`, so a remaining attribute pulses nothing.
|
|
894
|
+
A frame rendered but never seen — a row in the overscan — therefore never pulses: an animation whose element is not visible is not composited, and Chromium would restyle it on the main thread on every frame of an otherwise idle page. A frame that starts from the page's memory (a loaded URL, a remembered failure) is not observed and is only told to hide its activity when it is released.
|
|
895
|
+
- **The original is never fetched automatically** — only a click on its link fetches it. A thumbnail `<img>` (`fetchpriority="low"`) gets its `src` (the `?thumbnail=tile` URL) only after its frame has been visible for 250 ms (`IntersectionObserver` with the implicit root, so clipping by an ancestor's `overflow` counts) **and** a page-wide load slot is free (2 slots shared by every thumbnail on the page). Attachments passed over by a held arrow key or a fast scroll send no request.
|
|
896
|
+
Every frame of a document shares one `IntersectionObserver`, created from that document's own window (a frame inside an iframe uses the iframe's) and disconnected when its last frame stops, so rows streaming past add and remove targets instead of creating observers.
|
|
897
|
+
- **Why 2 slots, counted page-wide**: browser connections are shared per origin by every tab of the browser profile, not per page. Under HTTP/1.1 an origin gets 6 connections, which Chromium shares across all tabs of a profile. This screen always holds one for SSE, plus a second one for the ids stream only while it loads the list. With 2 thumbnail slots the screen uses at most 4, which leaves 2 for detail fetches, navigation and the user's downloads (fewer when other tabs of the same origin hold resident streams); thumbnails that filled the rest would stall them.
|
|
898
|
+
Under HTTP/2 all tabs of a profile share one connection per origin, and the limit is the server's number of concurrent streams. The value stays 2 because it must be safe wherever HTTP/1.1 remains (development, E2E, TLS-inspecting proxies, deployments without HTTP/2), and it also bounds how many server-side renders one screen starts at once. Module state cannot see other tabs, so the page is the widest unit that can be counted; counting per component or per list would multiply the limit when the side pane and several DetailList cards show thumbnails at once.
|
|
899
|
+
- A frame waiting for a slot keeps being observed: if it leaves view it gives up its place in the queue, and when it comes back it waits the 250 ms again before queuing.
|
|
900
|
+
- A load ends on `load`, on `error`, or after 75 s without either, counted from the moment the slot was granted. 75 s is the sum of the server's time budget, which bounds a request from the generation gate onward, so the client does not give up on a response the server may still produce once the request has reached the gate; authentication and authorization come before the gate and their time is taken out of the same 75 s. The slot is returned then, and a timed-out load has its `src` removed first.
|
|
901
|
+
- A failed or timed-out load is retried once, 5 s later, through the same visible-settle-slot path (an `<img>` cannot see the status code, so a transient 503 / 502 looks the same as a permanent failure). Only when the retry also fails does the frame become `unavailable`.
|
|
902
|
+
The 5 s and the 60 s below are the server's own timing, read by both sides from one shared module (`src/shared/attachment-delivery-timing.ts`) because an `<img>` cannot follow a `Retry-After` it receives: 5 s is the `Retry-After` of the server's overload 503, and 60 s is the window of the per-minute rate buckets, the `Retry-After` of their 429.
|
|
903
|
+
- **Page-wide progress**: the page remembers up to 1,024 thumbnail URLs (the least recently used is forgotten first). A URL that loaded before shows its image in the first render of a remounted frame, with no observation, slot or timer — for example after the arrow keys moved away from a report and back.
|
|
904
|
+
Because thumbnails are `private, no-cache`, the browser revalidates such an image with one conditional request (answered 304 without a body when unchanged, after authorization); the revalidation waits for no settle time and takes no slot, and at most one runs per mounted frame. If it fails, the page forgets that the URL loaded, counts one failed attempt, and the frame goes on with the retry wait.
|
|
905
|
+
A URL whose attempts are all used up shows `unavailable` at once, without a request, for 60 s after its last failure (one rate-limit window); after that, one fresh attempt starts. A URL between attempts keeps its failure count and its retry time across remounts.
|
|
906
|
+
- **One writer of `src`**: React never renders the image's `src`. A layout effect projects it from the frame's state before paint on every commit (the URL while a load is granted and once it has loaded, none while waiting) and writes the attribute only when it differs, because rewriting the same value makes the browser drop the loaded image and fetch it again.
|
|
907
|
+
- **Unmounting, a URL change and hiding under `<Activity>`** release everything the frame holds: observation, the settle wait, the slot (a waiting frame leaves the queue), the deadline and the retry wait. An unfinished load also has its `src` attribute removed, so the browser aborts it: a granted load without `load` or `error` yet, and a loaded URL's revalidation before its `load` — frames that only scroll past leave no request behind. A granted first load also returns to `pending`; a revalidation keeps `loaded`.
|
|
908
|
+
Showing a hidden frame again, and React's StrictMode remounting a frame to check its effects, run the layout effect again on the same element before the load lifecycle restarts, so the projection writes back a `src` that the release removed: a frame hidden before its revalidation's `load` shows `loaded` again at once, without a slot or a timer, and revalidates once more.
|
|
909
|
+
A frame whose image has fired `load` keeps its `src` across any number of hide-and-show cycles and nothing is requested again; a first load aborted by a real unmount never gets its `src` back.
|
|
910
|
+
|
|
911
|
+
| `data-testid` | Element |
|
|
912
|
+
| --- | --- |
|
|
913
|
+
| `daily-report-attachment-section` | Attachment section (the size container) |
|
|
914
|
+
| `daily-report-attachment-grid` | `<ul>` of tiles (only when at least one attachment has a thumbnail) |
|
|
915
|
+
| `daily-report-attachment-rows` | `<ul>` of rows (only when at least one attachment has no thumbnail) |
|
|
916
|
+
| `daily-report-attachment-item` | One attachment (tile or row), with `data-attachment-state` = `present` / `absent` / `unknown` |
|
|
917
|
+
| `daily-report-attachment-link` | Link to the original: the tile link (wrapping the frame and the name) or a row's name link |
|
|
918
|
+
| `daily-report-attachment-name` | Struck-through name of a missing attachment (no link) |
|
|
919
|
+
| `daily-report-attachment-download` | Download link |
|
|
920
|
+
| `daily-report-attachment-thumbnail-frame` | Thumbnail frame, the relayout boundary whose own style never changes (`aria-hidden`, `data-thumbnail-state`, `data-thumbnail-active` while it may show its load activity) |
|
|
921
|
+
| `daily-report-attachment-thumbnail-skin` | The frame's only child, absolutely positioned over it: the background, the waiting pulse, the rounded clip and the rim, around the image or the failure message |
|
|
922
|
+
| `daily-report-attachment-thumbnail` | Thumbnail `<img>` (inside the skin) |
|
|
923
|
+
| `daily-report-attachment-thumbnail-unavailable` | Failure message inside the skin |
|
|
924
|
+
| `daily-report-attachment-indicator` | Attachment marker of a List card or a DetailList header (`data-attachment-count`) |
|
|
925
|
+
|
|
926
|
+
### Test hooks
|
|
927
|
+
|
|
928
|
+
DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling and never selectors.
|
|
929
|
+
|
|
930
|
+
| Hook | Element |
|
|
931
|
+
| --- | --- |
|
|
932
|
+
| `[data-daily-report-row="<id>"]` | Row frame of both views (`role="listitem"` with `aria-posinset` / `aria-setsize`), in every load state |
|
|
933
|
+
| `[data-daily-report-row-surface]` | Surface of a row frame: its direct child that paints the selection ring and the focus outline, in every load state (opaque and never animated; a loading pulse runs inside it) |
|
|
934
|
+
| `[data-daily-report-card]` | List card surface (present while the card is shown) |
|
|
935
|
+
| `button[data-daily-report-button="<id>"]` | List card primary button (selection, focus target, `aria-current`) |
|
|
936
|
+
| `[role="listitem"][data-daily-report-button="<id>"]` | DetailList row element (also its focus target) |
|
|
937
|
+
| `[data-list-star-button="<id>"]` / `[data-list-read-button="<id>"]` | List card toggles |
|
|
938
|
+
| `[data-detail-star-button="<id>"]` / `[data-detail-read-button="<id>"]` / `[data-detail-edit-button="<id>"]` / `[data-detail-delete-button="<id>"]` | Toggles, edit and delete in the DetailList header and the List side pane (edit and delete on the viewer's own reports only) |
|
|
939
|
+
| `[data-testid="daily-report-side-pane"]` | List side pane: `data-report-id` is the selected report, `data-displayed-report-id` the report its content shows; the desktop pane carries `aria-busy="true"` while the two differ (the overlay's pane shows the report it opened, so they are equal) |
|
|
940
|
+
| `[data-daily-report-keyboard-focus]` | View root while keyboard focus is inside the view (List: `daily-report-root`; DetailList: its scroll container) |
|
|
941
|
+
| `[data-daily-report-scrolling]` | View's scroll container while its list scrolls (until 150 ms after the last scroll event) |
|
|
942
|
+
| `dialog[data-daily-report-mobile-overlay]` | Mobile detail overlay: the `<dialog>` element itself (its implicit role is `dialog`; it carries `open` while open, and stays in the DOM without `open`, `inert`, while it slides out) |
|
|
943
|
+
| `[data-daily-report-overlay-panel]` | The overlay's sliding panel inside the dialog (the report content and the close button) |
|
|
944
|
+
| `[data-daily-report-overlay-backdrop]` | The overlay's backdrop inside the dialog (`aria-hidden`, not focusable; a press on it closes the overlay) |
|
|
945
|
+
| `[data-thumbnail-state]` | Thumbnail frame state (`pending` / `loaded` / `unavailable`) |
|
|
946
|
+
| `[data-thumbnail-active]` | Thumbnail frame that may show its load activity (visible while observed, or a granted load); a `pending` frame's skin pulses only with it |
|
|
947
|
+
| `[data-daily-report-root-font-size-probe]` | Hidden 1 rem probe inside the List's root whose size change re-reads the root font size (`aria-hidden`, fixed, out of the flow) |
|
|
948
|
+
| `[data-daily-report-attachment-preview]` | Tile link that wraps a thumbnail frame (the skin's hover and press rim read it) |
|
|
949
|
+
| `data-testid` | `daily-report-root`, `daily-report-list`, `daily-report-detail-list`, `daily-report-side-pane`, and the attachment ids above |
|
|
950
|
+
|
|
951
|
+
Only the package writes `data-daily-report-keyboard-focus` and `data-daily-report-scrolling`. Hosts may select on them — in tests, or in CSS to adapt their own content inside a view — but never set them. When the pane content matters, wait for `data-displayed-report-id`, not `data-report-id`: during a key's render the frame already names the new selection while the content still shows the previous report.
|
|
952
|
+
|
|
953
|
+
`[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a read-only `__virtualScroll` accessor for end-to-end tests, with the same shape in both views: the current `VirtualScroll` handle plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the view's committed list. It only observes: select through the UI (a click, the keys). Read it on every use; it is `undefined` while the list is not mounted.
|
|
954
|
+
|
|
243
955
|
### Localization (`locale` / `labels`)
|
|
244
956
|
|
|
245
957
|
Every string the package renders on its own comes from one built-in catalog per **UI chrome
|
|
@@ -247,7 +959,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
247
959
|
`config.locale` and override single keys with `config.labels`, which are laid over the catalog of
|
|
248
960
|
`locale`:
|
|
249
961
|
|
|
250
|
-
```tsx
|
|
962
|
+
```tsx illustrative
|
|
251
963
|
<DailyReportPage
|
|
252
964
|
config={{
|
|
253
965
|
locale: "ja",
|
|
@@ -257,38 +969,37 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
257
969
|
```
|
|
258
970
|
|
|
259
971
|
- **One surface for all wording.** The catalog covers the 7 engine chrome keys of
|
|
260
|
-
`@aiquants/virtualscroll` plus
|
|
261
|
-
`renderHeader` and used as the accessible name of both lists),
|
|
262
|
-
`confirm()` / `alert()` texts, stream badges, the load-error screen
|
|
263
|
-
message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
|
|
972
|
+
`@aiquants/virtualscroll` plus 84 own keys: field headings, the page title (`title`, passed to
|
|
973
|
+
`renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state read to
|
|
974
|
+
screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
|
|
975
|
+
and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
|
|
264
976
|
value, so the scroll arrows and the "No items" text always speak the same language as the rest.
|
|
265
977
|
- **Forwarded to every embedded `VirtualScroll`.** Both the List and the DetailList pass `locale`
|
|
266
978
|
and the 7 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
|
|
267
979
|
its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
|
|
268
980
|
re-render the memoized list subtree.
|
|
269
|
-
- **Formatter keys.**
|
|
981
|
+
- **Formatter keys.** Ten keys take arguments and are functions: `totalCount(count)`,
|
|
270
982
|
`debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
|
|
271
983
|
`streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
|
|
272
|
-
`interviewerWithAffiliation(name, affiliation)
|
|
984
|
+
`interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
|
|
273
985
|
`DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
|
|
274
|
-
`toggleStar` / `toggleRead`)
|
|
986
|
+
`toggleStar` / `toggleRead`) and `rowState({ isRead, isStarred })`. An override of such a key must be a function too.
|
|
275
987
|
- **Digit grouping follows the UI locale, not the runtime.** The built-in formatters group numbers with
|
|
276
988
|
the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
|
|
277
989
|
the raw number and formats it itself.
|
|
278
990
|
- **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
|
|
279
|
-
`RangeError` at render. So do an unknown `labels` key (
|
|
280
|
-
|
|
281
|
-
|
|
991
|
+
`RangeError` at render. So do an unknown `labels` key (a key error that lists the 91 keys), a string
|
|
992
|
+
key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
|
|
993
|
+
function. An `undefined` value keeps the catalog value.
|
|
282
994
|
- **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
|
|
283
|
-
`sourceTypeConfigs`, `renderHeader`, `showDevControls`, `onError`.
|
|
284
|
-
|
|
285
|
-
config
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
`useMemo` result, a variable) skips TypeScript's excess-property check.
|
|
995
|
+
`sourceTypeConfigs`, `renderHeader`, `showDevControls`, `headingLevel`, `onError`, `onSessionExpired`. Any other
|
|
996
|
+
own key — also one whose value is `undefined` — throws the key error of **Configuration errors**
|
|
997
|
+
(`[daily-report] config key must be one of "apiBasePath", …, "onSessionExpired"; got "<key>"`). This runs at
|
|
998
|
+
render because a config built outside a typed object literal (a `useMemo` result, a variable) skips
|
|
999
|
+
TypeScript's excess-property check.
|
|
289
1000
|
- **`defaultDailyReportClientConfig` is spreadable input.** Its type `DailyReportClientConfigDefaults`
|
|
290
1001
|
holds only the locale-independent defaults (`apiBasePath`, `ssePath`, `draftLabelName`,
|
|
291
|
-
`renderHeader`, `showDevControls`) and it is frozen. It carries no `locale`, `labels` or
|
|
1002
|
+
`renderHeader`, `showDevControls`, `headingLevel`) and it is frozen. It carries no `locale`, `labels` or
|
|
292
1003
|
`sourceTypeConfigs`, so `{ ...defaultDailyReportClientConfig, locale: "ja" }` renders Japanese. The
|
|
293
1004
|
resolved configuration from `useDailyReportConfig()` is output only: spreading it into `config`
|
|
294
1005
|
would pin its labels as overrides.
|
|
@@ -298,9 +1009,11 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
298
1009
|
without a display name is sent as `""`).
|
|
299
1010
|
- **`labels` must be a plain object** (prototype `Object.prototype` or `null`); arrays, class
|
|
300
1011
|
instances and objects created with `Object.create(proto)` throw a `RangeError`. Only own properties
|
|
301
|
-
are read, so a label key inherited from a polluted `Object.prototype` is never applied.
|
|
302
|
-
|
|
303
|
-
`
|
|
1012
|
+
are read, so a label key inherited from a polluted `Object.prototype` is never applied. Every error
|
|
1013
|
+
follows the package's convention (see **Configuration errors** in [DI ports](#di-ports)): a rejected
|
|
1014
|
+
value names its key (`[daily-report] labels.<key> must be …; got <value>`), a rejected object reads
|
|
1015
|
+
`[daily-report] labels must be a plain object (prototype Object.prototype or null); got array`, an unknown key
|
|
1016
|
+
`[daily-report] labels key must be one of "scrollUp", …; got "<key>"`, and describing the value never throws.
|
|
304
1017
|
- **No language negotiation**: the package never reads `navigator.language`; map it to a supported
|
|
305
1018
|
value yourself. No `lang` attribute is stamped either — the document language belongs to the host.
|
|
306
1019
|
- **Not tied to `locale`**: `draftLabelName` (a label name stored in the database) and
|
|
@@ -312,7 +1025,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
312
1025
|
grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
|
|
313
1026
|
that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
|
|
314
1027
|
|
|
315
|
-
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all
|
|
1028
|
+
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 91 keys, the 7 engine keys
|
|
316
1029
|
first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
|
|
317
1030
|
(the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
|
|
318
1031
|
`locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
|
|
@@ -334,9 +1047,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
334
1047
|
| `createdAt` | created-at heading | Created at | 作成日時 |
|
|
335
1048
|
| `updatedAt` | updated-at heading | Updated at | 更新日時 |
|
|
336
1049
|
| `updatedBy` | updated-by heading | Updated by | 更新者 |
|
|
337
|
-
| `category` | category chip
|
|
338
|
-
| `
|
|
339
|
-
| `creationCategory` | `creationCategory: value` prefix | Creation category | 作成区分 |
|
|
1050
|
+
| `category` | DetailList: label of the category chip and of the metadata column's category pair | Category | 区分 |
|
|
1051
|
+
| `creationCategory` | DetailList: label of the creation-category chip and of the metadata column's creation-category pair | Creation category | 作成区分 |
|
|
340
1052
|
| `customer` | customer heading | Customer | 顧客 |
|
|
341
1053
|
| `subject` | subject heading | Subject | 件名 |
|
|
342
1054
|
| `content` | content heading | Content | 内容 |
|
|
@@ -345,13 +1057,17 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
345
1057
|
| `attachments` | attachment section heading, attachment marker name | Attachments | 添付ファイル |
|
|
346
1058
|
| `attachmentMissing` | note on a missing attachment | File is missing | 実体が見つかりません |
|
|
347
1059
|
| `attachmentDownload` | attachment download link | Download | ダウンロード |
|
|
1060
|
+
| `attachmentThumbnailUnavailable` | inside a thumbnail frame whose preview failed to load | Preview unavailable | プレビューを表示できません |
|
|
348
1061
|
| `tabArticle` | side pane tab | Overview | 概要 |
|
|
349
1062
|
| `tabRelations` | side pane tab | Related | 関連 |
|
|
1063
|
+
| `reportTabList` | accessible name (`aria-label`) of the side pane's tab list | Report sections | 日報の表示の切り替え |
|
|
350
1064
|
| `relationsEmpty` | side pane related tab body | No related view configured yet. | 関連ビューはまだ構成されていません。 |
|
|
351
1065
|
| `title` | `renderHeader({ title })`, both lists' `aria-label` | Daily Reports | 日報 |
|
|
1066
|
+
| `listKeyboardHelp` | both lists' `aria-describedby`, visually hidden (the DetailList only with `onSelectItem`) | Up and Down arrows: previous or next report. Home and End: first or last report. Page Up and Page Down: move by a page. Enter or Space on a report row: make it current. Control+Home and Control+End: move to the nearest control before or after the list, when there is one. | ↑↓ で前後の日報、Home / End で先頭・末尾、PageUp / PageDown でページ移動、日報の行で Enter または Space を押すと選択、Ctrl+Home / Ctrl+End で一覧の直前・直後の操作部品があればそこへ移ります。 |
|
|
352
1067
|
| `tabList` | top-level tab | 📄 List | 📄 一覧 |
|
|
353
1068
|
| `tabTree` | top-level tab | 🌳 Tree | 🌳 ツリー |
|
|
354
1069
|
| `tabDetailList` | top-level tab | 📋 DetailList | 📋 詳細一覧 |
|
|
1070
|
+
| `viewTabList` | accessible name (`aria-label`) of the top-level tab list | Views | 表示の切り替え |
|
|
355
1071
|
| `treeComingSoon` | tree tab body | The tree view is coming soon | ツリーは現在準備中です |
|
|
356
1072
|
| `totalCountLoading` | header annotation while loading | Total: loading... | 総件数: 読み込み中... |
|
|
357
1073
|
| `createReport` | create button | New report | 日報作成 |
|
|
@@ -369,7 +1085,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
369
1085
|
| `streamFailed` | stream badge (failed) | Failed to load | 読み込みに失敗しました |
|
|
370
1086
|
| `streamRetry` | stream badge retry button | Retry | 再試行 |
|
|
371
1087
|
| `streamRevalidating` | stream badge (revalidating) | Refreshing | 最新化中 |
|
|
372
|
-
| `listEmpty` | package empty state
|
|
1088
|
+
| `listEmpty` | package empty state, laid over the top of an empty list | No data | データがありません |
|
|
373
1089
|
| `selectPrompt` | side pane without a selection | Select a report from the list on the left. | 左側の一覧から日報を選択してください。 |
|
|
374
1090
|
| `loading` | side pane loading (edit mode) | Loading... | 読み込み中... |
|
|
375
1091
|
| `reportNotFound` | list card of a vanished report | Report not found | 日報が見つかりません |
|
|
@@ -382,9 +1098,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
382
1098
|
| `markRead` | read toggle (unread) | Mark as read | 既読にする |
|
|
383
1099
|
| `markUnread` | read toggle (read) | Mark as unread | 未読にする |
|
|
384
1100
|
| `deleteReport` | report delete button | Delete | 削除する |
|
|
1101
|
+
| `edit` | edit button of the viewer's own report (`aria-label` / tooltip) | Edit | 編集 |
|
|
385
1102
|
| `resizeHandle` | list / detail resize handle `aria-label` | Resize the border between the report list and the detail pane | 日報一覧と詳細ペインの境界のサイズ変更ハンドル |
|
|
386
|
-
| `overlayBackdrop` | mobile overlay backdrop `aria-label` | Tap the background to close | 背景をタップして閉じる |
|
|
387
|
-
| `overlayDialog` | mobile detail dialog `aria-label` | Report details | 日報詳細 |
|
|
388
1103
|
| `close` | mobile overlay close button, error banner close `aria-label` | Close | 閉じる |
|
|
389
1104
|
| `formTitle` | edit form | Title | タイトル |
|
|
390
1105
|
| `formTitlePlaceholder` | edit form | Enter the report title | 日報のタイトルを入力 |
|
|
@@ -408,68 +1123,13 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
408
1123
|
| `reportSummaryLoadFailed` | list card load failure | `(12345)` → Failed to load the summary of report 12,345. | `(12345)` → 日報 12,345 の概要読み込みに失敗しました。 |
|
|
409
1124
|
| `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
|
|
410
1125
|
| `operationFailed` | error banner / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
|
|
411
|
-
|
|
412
|
-
#### Migrating from 0.20.x (breaking changes in 0.21.0)
|
|
413
|
-
|
|
414
|
-
0.21.0 is a clean cut-over: there are no aliases for the removed names. It needs the peer
|
|
415
|
-
`@aiquants/virtualscroll` 3.7.0 or later (the engine label catalog it reuses).
|
|
416
|
-
|
|
417
|
-
1. **The default UI language is English.** The Japanese wording that used to be hard-coded in the
|
|
418
|
-
components (buttons, placeholders, confirmation dialogs, status badges, loading / error screens,
|
|
419
|
-
the failure banner) now renders in English when `locale` is omitted. A host that relies on the
|
|
420
|
-
Japanese chrome must opt in with `config.locale: "ja"`. The ja catalog keeps the former Japanese
|
|
421
|
-
wording except four values aligned with the shared `@aiquants` vocabulary: `treeComingSoon`
|
|
422
|
-
(`ツリーは現在準備中です`), `resizeHandle` (`日報一覧と詳細ペインの境界のサイズ変更ハンドル`),
|
|
423
|
-
`unknownUser` (`不明なユーザー`, formerly `"Unknown"` / `不明`) and `interviewerWithAffiliation`
|
|
424
|
-
(half-width parentheses: `佐藤 (山田商事)`).
|
|
425
|
-
2. **`config.fieldLabels` → `config.labels`** (the same 21 keys). The type `DailyReportFieldLabels`
|
|
426
|
-
and the value `defaultDailyReportFieldLabels` are removed; use `DailyReportLabels` /
|
|
427
|
-
`DAILY_REPORT_LABEL_CATALOGS[locale]`. A leftover `fieldLabels` key throws a `RangeError` at
|
|
428
|
-
render, even inside a `useMemo` config that TypeScript does not flag.
|
|
429
|
-
3. **`config.title` → `config.labels.title`** (en `"Daily Reports"`, ja `"日報"`). It is still what
|
|
430
|
-
`renderHeader({ title })` receives and the accessible name of both lists. A leftover `title` key
|
|
431
|
-
throws a `RangeError` at render.
|
|
432
|
-
4. **Adding only `locale: "ja"` replaces your vocabulary with the catalog's.** The ja catalog carries
|
|
433
|
-
neutral headings (`顧客`, `内容`, `コメント`, `概要`, `関連`, ...). Move the domain wording you used to
|
|
434
|
-
pass in `fieldLabels` into `labels` in the same change, e.g.
|
|
435
|
-
`{ locale: "ja", labels: { customer: "顧客・面談先", content: "商談内容", comments: "コメント一覧", tabArticle: "記事", tabRelations: "相関" } }`.
|
|
436
|
-
5. **`locale: "ja"` also translates what used to stay English**: the top-level tabs (`📄 一覧` /
|
|
437
|
-
`🌳 ツリー` / `📋 詳細一覧`), the page title (`日報`), the load-error badge (`エラー`), the side-pane
|
|
438
|
-
tabs (`概要` / `関連`), the scroll arrows (`上へスクロール` / `下へスクロール`) and the empty state
|
|
439
|
-
(`項目がありません`). Keep the English form through `labels`, e.g.
|
|
440
|
-
`labels: { tabList: "📄 List", tabDetailList: "📋 DetailList" }`.
|
|
441
|
-
6. **Resolved config shape**: `DailyReportClientConfig` (the value of `useDailyReportConfig()`) loses
|
|
442
|
-
`title` / `fieldLabels`, gains the resolved `locale` / `labels`, and `sourceTypeConfigs` is always
|
|
443
|
-
present (the built-in default when omitted).
|
|
444
|
-
7. **`defaultDailyReportClientConfig`** is now the frozen, locale-independent input
|
|
445
|
-
`DailyReportClientConfigDefaults` (`apiBasePath`, `ssePath`, `draftLabelName`, `renderHeader`,
|
|
446
|
-
`showDevControls`). It no longer has `title`, `fieldLabels` or `sourceTypeConfigs`; read resolved
|
|
447
|
-
values through `useDailyReportConfig()`.
|
|
448
|
-
8. **Built-in `Internal` badge**: when `sourceTypeConfigs` is omitted, its label is
|
|
449
|
-
`labels.sourceInternal` — en `"Original"` (formerly always `オリジナル`), ja `"オリジナル"`.
|
|
450
|
-
9. **`DailyReportErrorInfo`**: `operation` is narrowed from `string` to `DailyReportOperation`, and
|
|
451
|
-
`message` is `labels.operationFailed(operation)` in the chosen locale (English by default).
|
|
452
|
-
10. **`mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`** (root
|
|
453
|
-
export): the fourth argument is new and required, `currentUserId` is no longer optional (pass
|
|
454
|
-
`undefined` to keep the server's `isMine`), and a blank or non-string `unknownAuthorName` throws a
|
|
455
|
-
`RangeError`. A comment without an author name (legacy `name` or relational `userName` that is
|
|
456
|
-
empty, `null` or missing) gets `unknownAuthorName`; the client passes `labels.unknownUser`.
|
|
457
|
-
11. **Server `addComment`**: the SSE `comment-add` payload sends `userName: ""` (formerly `"Unknown"`)
|
|
458
|
-
for an author without a display name, like the read paths; the client renders
|
|
459
|
-
`labels.unknownUser`. Comments with an empty author name show `labels.unknownUser` instead of `-`.
|
|
460
|
-
12. **List side-pane load error** renders `labels.reportLoadFailed(reportHubId)` instead of the
|
|
461
|
-
exception message (the same text as the DetailList).
|
|
462
|
-
13. **Explicit `undefined`** for `apiBasePath` / `ssePath` / `draftLabelName` / `showDevControls` /
|
|
463
|
-
`renderHeader` now resolves to the default (it used to override the default with `undefined`).
|
|
464
|
-
|
|
465
|
-
Unchanged: `draftLabelName` (label data stored in the database) does not follow `locale`, and dates
|
|
466
|
-
keep their ISO form.
|
|
1126
|
+
| `rowState` | visually hidden row state, the first description of the List card's primary button and of a DetailList row | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
|
|
467
1127
|
|
|
468
1128
|
### External Source Badge Configuration (`sourceTypeConfigs`)
|
|
469
1129
|
|
|
470
1130
|
Display parameters (label names and badge CSS classes) for reports ingested from external sources can be overridden using `sourceTypeConfigs`. This prevents the package from hardcoding specific third-party service names or designs, leaving display customization to the host application.
|
|
471
1131
|
|
|
472
|
-
```tsx
|
|
1132
|
+
```tsx illustrative
|
|
473
1133
|
sourceTypeConfigs: {
|
|
474
1134
|
Internal: {
|
|
475
1135
|
label: "Native",
|
|
@@ -480,19 +1140,25 @@ sourceTypeConfigs: {
|
|
|
480
1140
|
badgeClassName: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
|
|
481
1141
|
},
|
|
482
1142
|
LegacyPortal: {
|
|
483
|
-
label: "
|
|
1143
|
+
label: "LP",
|
|
1144
|
+
name: "Legacy Portal",
|
|
484
1145
|
badgeClassName: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
|
|
485
1146
|
},
|
|
486
1147
|
}
|
|
487
1148
|
```
|
|
488
1149
|
|
|
1150
|
+
- `label` is the text on the badge. When it is an abbreviation, give the full name in `name` (WCAG 3.1.4): the badge then renders the label as `<abbr title="<name>">` (pointer users see the expansion), hides the abbreviation from assistive technology and reads the name once instead. Without `name` the badge shows its label and nothing is substituted.
|
|
1151
|
+
A `name` that is given must be a non-blank string; anything else (`""`, `" "`, `null`, a number) throws a `RangeError` at render that names the source type.
|
|
1152
|
+
- The badge is part of the row description (see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)): the List card's primary button and the DetailList row are described by it, so screen reader users hear the name the badge stands for.
|
|
1153
|
+
- A report's source type is read from one field only, the loaded report's `DailyReportDetail.sourceType` (the server fills it, with `Internal` for reports the package creates), and each row, the side pane and the mobile overlay resolve its configuration once. That one value draws the badge, decides whether the row's description references it, and decides whether the comment form is shown: `allowComment` when the configuration sets it, otherwise only for `Internal` and for a report without a source type (the form is a display choice; the server decides whether a comment may be posted).
|
|
1154
|
+
|
|
489
1155
|
### Troubleshooting: Hook errors / Multiple React instances (Cannot read properties of null)
|
|
490
1156
|
|
|
491
1157
|
If you encounter `TypeError: Cannot read properties of null (reading 'useMemo')` or "Invalid hook call", it means multiple React instances are loaded in memory. This often happens because the bundler/framework (Vite/React Router v7) resolves separate React packages for your application and this dependency (especially when linked via monorepo or npm link).
|
|
492
1158
|
|
|
493
1159
|
To fix this, update your consumer application's `vite.config.ts` to deduplicate React:
|
|
494
1160
|
|
|
495
|
-
```typescript
|
|
1161
|
+
```typescript illustrative
|
|
496
1162
|
import path from "node:path"
|
|
497
1163
|
import { defineConfig } from "vite"
|
|
498
1164
|
|
|
@@ -524,6 +1190,10 @@ export default defineConfig({
|
|
|
524
1190
|
})
|
|
525
1191
|
```
|
|
526
1192
|
|
|
1193
|
+
## Breaking changes and migrations
|
|
1194
|
+
|
|
1195
|
+
Every breaking change and its migration is listed per version in `CHANGELOG.md`, which ships with the package.
|
|
1196
|
+
|
|
527
1197
|
## Realtime Architecture
|
|
528
1198
|
|
|
529
1199
|
1. Mutation services publish to Redis Stream (`daily-report:sse-stream`) in sequence: `invalidate -> epoch increment -> SSE publish`.
|
|
@@ -567,15 +1237,23 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
567
1237
|
|
|
568
1238
|
## API Surface (Summary)
|
|
569
1239
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
`
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
`
|
|
577
|
-
|
|
578
|
-
`
|
|
579
|
-
-
|
|
1240
|
+
Every entry exports by name (no `export *`), so a module-internal helper never becomes public by accident. `src/public-surface.spec.ts` pins the runtime and the type names of the three entries below and fails when a public name is missing from this section.
|
|
1241
|
+
|
|
1242
|
+
- **shared** (`@aiquants/daily-report`, isomorphic):
|
|
1243
|
+
- Values: `buildDailyReportAttachmentUrl` / `parseDailyReportAttachmentQuery` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_VARIANTS` / `isDailyReportAttachmentThumbnailVariant` (the attachment URL codec and the variant list), `DAILY_REPORT_SSE_TERMINAL_EVENTS` / `DAILY_REPORT_SSE_HEARTBEAT_MS` / `DAILY_REPORT_SSE_CURSOR_PARAM` / `isDailyReportSseStreamId` (the SSE wire constants),
|
|
1244
|
+
`dailyReportSseMessageSchema` (8 discriminated union types) with its members `connectedMessageSchema` / `reportCreateMessageSchema` / `reportUpdateMessageSchema` / `reportPublishMessageSchema` / `reportDeleteMessageSchema` / `statusUpdateMessageSchema` / `commentAddMessageSchema` / `commentDeleteMessageSchema` and the part schemas `dailyReportDetailSchema` / `dailyReportPostedCommentSchema` / `dailyReportExternalCommentSchema` / `dailyReportInterviewerSchema` / `dailyReportLabelDefSchema`,
|
|
1245
|
+
`isIdsStreamChunkLine`, `normalizeBusinessDateKey`, `mergeComments(legacyComments, modernComments, currentUserId, unknownAuthorName)`.
|
|
1246
|
+
- Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment` / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` / `DailyReportIdsStreamChunkLine`, `DailyReportAttachmentDeliveryRequest` / `DailyReportAttachmentInvalidQuery` / `DailyReportAttachmentThumbnailVariant` / `DailyReportAttachmentThumbnailBox`.
|
|
1247
|
+
- **client** (`@aiquants/daily-report/client`, React):
|
|
1248
|
+
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
|
|
1249
|
+
- Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`).
|
|
1250
|
+
- The ids stream: `DailyReportIdsStreamClient` (`resync()`) / `DailyReportIdsStreamStatus` / `primeDailyReportIdsStreamSession` / `bootstrapDailyReportIdsStreamSession` / `ensureDailyReportIdsStreamSession` / `resyncDailyReportIdsStream`; the route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
|
|
1251
|
+
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`; the layout values `DEFAULT_ITEM_HEIGHT` (the List slot P at a 16 px root) / `TAP_SCROLL_CIRCLE_OPTIONS`.
|
|
1252
|
+
- Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`.
|
|
1253
|
+
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1254
|
+
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1255
|
+
- Attachments: `createSharpThumbnailRenderer` / `predictSharpThumbnailWorkingSetBytes` / `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
|
|
1256
|
+
- SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
|
|
1257
|
+
- Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `StreamEntry` / `ExternalReportFields`, and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
|
|
580
1258
|
|
|
581
1259
|
MIT
|