@aiquants/daily-report 0.28.0 → 0.29.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 +47 -1
- package/README.md +119 -50
- package/dist/client.d.mts +2 -2
- package/dist/client.d.ts +2 -2
- package/dist/client.js +5 -5
- package/dist/client.mjs +5 -5
- package/dist/{ids-stream-BR5RSj5u.d.ts → comment-adapter-5bAtR4qb.d.ts} +2 -22
- package/dist/{ids-stream-DvB2h1dj.d.mts → comment-adapter-lV2ZB_vH.d.mts} +2 -22
- package/dist/{types-DkERw8NE.d.mts → ids-stream-CWuIDrIO.d.mts} +22 -1
- package/dist/{types-DkERw8NE.d.ts → ids-stream-CWuIDrIO.d.ts} +22 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/server.d.mts +3 -2
- package/dist/server.d.ts +3 -2
- package/dist/server.js +4 -4
- package/dist/server.mjs +4 -4
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,10 +26,12 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
|
|
|
26
26
|
```
|
|
27
27
|
|
|
28
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`
|
|
29
|
+
The peer floor of `@aiquants/virtualscroll` is **3.11.0**: install 3.11.0 or later.
|
|
30
|
+
Both views pass its scroll-bar option `enableArrowButtonTabStops: false`, since 3.10.0 its one signal that the host scrolls by keyboard itself, under which the scroll bar is pointer-only — hidden from assistive technology, out of the Tab order and never taking focus on a press (the views scroll by keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)) —; its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
|
|
30
31
|
see [Selection and focus appearance](#selection-and-focus-appearance)); and both views re-record their scroll anchor from its `onScrollAdjust` notification and rely on its edge-keeping snap (3.9.0; see **Host selections and list changes** and [View height](#view-height-host-layout)). The label catalog reuses its 11 engine keys, the names of the scroll bar and its thumb among them (3.10.0; see [Localization](#localization-locale--labels)).
|
|
31
|
-
|
|
32
|
-
That floor is declared once, in `peer-floors.json` (`{ "<peer name>": "<X.Y.Z>" }`)
|
|
32
|
+
`DailyReportRevealOptions`, the options of the test handle's `revealIndex`, is `@aiquants/virtualscroll`'s `scrollToIndex` options type, which takes `align: "nearest"` since 3.11.0, and DetailList rows stay at the tree's tops and heights after a batch of re-measurements whose deltas cancel only because its row memo follows the tree revision (3.11.0).
|
|
33
|
+
That floor is declared once, in `peer-floors.json` (`{ "<peer name>": "<X.Y.Z>" }`); the documents attribute each feature to its own version and restate the floor only as the declared one (`src/docs-examples.spec.ts` fails when a sentence of this README or of `docs/specs/` that names the peer floor states another version, on every run of the unit tests).
|
|
34
|
+
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).
|
|
33
35
|
`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).
|
|
34
36
|
`@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.
|
|
35
37
|
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.
|
|
@@ -62,6 +64,7 @@ Every TypeScript or JavaScript code block of this README names its source in its
|
|
|
62
64
|
| `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 |
|
|
63
65
|
| `text-wrap: balance` | Chrome 114, Firefox 121, Safari 17.5 | The short centred labels that can wrap (`WRAPPING_LABEL_CLASS_NAME`: the side pane's selection prompt, an attachment tile's unavailable label) keep their lines about equally long | Safari 17.4, inside the floor, drops the value and wraps them greedily, so a wrapped label can end with a short last line |
|
|
64
66
|
| `word-break: auto-phrase` | Chrome 119 (not in Firefox or Safari) | The same labels break Japanese at phrase boundaries where the host document's `lang` is `ja` | Firefox and Safari drop the declaration and break Japanese between any two characters (the default), so a narrow label can break inside a word |
|
|
67
|
+
| `overflow-wrap: anywhere` | Chrome 80, Firefox 65, Safari 15.4 | The same labels break inside a phrase or a word that is wider than their box instead of crossing their frame, also as flex items, whose automatic minimum width would otherwise hold them at their longest phrase | An engine without it drops the declaration: a phrase wider than the label's box crosses its frame |
|
|
65
68
|
| `<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 |
|
|
66
69
|
|
|
67
70
|
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)).
|
|
@@ -149,12 +152,30 @@ export const loader = async (args) => {
|
|
|
149
152
|
return data(r.data, { headers: r.headers })
|
|
150
153
|
}
|
|
151
154
|
// daily_report.api.$endpoint/route.tsx
|
|
152
|
-
export const loader = (args) =>
|
|
155
|
+
export const loader = (args) => {
|
|
156
|
+
// only the ids stream: the JSON endpoints answer single fetch as before (Streaming routes, below)
|
|
157
|
+
if (args.params.endpoint === DAILY_REPORT_IDS_STREAM_ENDPOINT) refuseSingleFetchDataRequest(args.request)
|
|
158
|
+
return dailyReportServer.api.loader(args)
|
|
159
|
+
}
|
|
153
160
|
export const action = (args) => dailyReportServer.api.action(args)
|
|
154
161
|
// sse.daily_report.$endpoint/route.tsx
|
|
155
|
-
export const loader = (args) =>
|
|
162
|
+
export const loader = (args) => {
|
|
163
|
+
refuseSingleFetchDataRequest(args.request)
|
|
164
|
+
return dailyReportServer.sse.loader(args)
|
|
165
|
+
}
|
|
166
|
+
// refuseSingleFetchDataRequest is the host's own guard: it throws a 404 when new URL(request.url).pathname ends in ".data"
|
|
156
167
|
```
|
|
157
168
|
|
|
169
|
+
**Streaming routes**: the SSE route and the API route's ids stream (`GET {apiBasePath}/ids-stream`; the endpoint's name is exported as `DAILY_REPORT_IDS_STREAM_ENDPOINT` from the server entry) answer with a body that streams until the client leaves.
|
|
170
|
+
|
|
171
|
+
- **`HEAD`**: both answer the status and headers a `GET` would get, and start no work for the body.
|
|
172
|
+
Everything that decides the status runs as for `GET` — the request isolation, authentication, the internal user, the endpoint, the resume cursor (a malformed ids cursor is still 400, a malformed SSE cursor still the `resync-required` frame) and the viewer's visibility —
|
|
173
|
+
but the SSE route subscribes to nothing and starts no heartbeat and no visibility refresh timer, and the ids stream runs neither its full query nor its first-page query (so a resumed `GET`, which waits for the full query, can still end in a 500 that a `HEAD` does not see: it answers 200).
|
|
174
|
+
React Router runs a resource route's loader for `HEAD` and drops the body without reading or cancelling it, and an adapter need not abort the request's signal once the response is sent, so body work started for a `HEAD` would run until the process ends. A `GET` whose signal is already aborted starts no body work either.
|
|
175
|
+
- **Single fetch**: React Router answers a single-fetch data request (`<path>.data`) by reading the loader's whole body before it answers, so a `.data` request to either stream would hold every event, or the whole NDJSON, in server memory until the client leaves. The host refuses those requests in the two routes before it calls the package (above): the SSE route every one, the API route only the ids stream's, whose name it compares with `DAILY_REPORT_IDS_STREAM_ENDPOINT` instead of a copied literal.
|
|
176
|
+
The attachment route needs no such guard: the package refuses `<token>.data` itself ([Request isolation](#request-isolation)).
|
|
177
|
+
- **The API route's methods**: authentication comes first (401), then the endpoint — an unknown name is 404, an inherited name such as `toString` included — and then the method: one the endpoint does not answer is 405 with `Allow`, `GET` for the JSON endpoints (their ETag is computed from the body, so a `HEAD` would build the body only to discard it) and `GET, HEAD` for the ids stream.
|
|
178
|
+
|
|
158
179
|
### DI ports
|
|
159
180
|
|
|
160
181
|
- `authenticate(request, { failureRedirect })` — Session verification, declared as the overloaded `DailyReportAuthenticate`. With `failureRedirect: string` the implementation must throw a redirect on unauthenticated requests, so a normal return always carries `user` (typed as required — leaving it optional would force callers to write an unreachable `!user` guard). With `failureRedirect: null` it must not redirect and resolves without `user` instead; forward the returned `cookie` on unauthenticated responses too, otherwise a destroyed session lingers in the browser.
|
|
@@ -178,6 +199,9 @@ The three helpers of `src/shared/config-errors.ts` (`configValueError`, `configK
|
|
|
178
199
|
|
|
179
200
|
Every resource route the handlers serve — `api.loader`, `api.action`, `sse.loader` and `attachment.loader` — is wrapped once, where `createDailyReportHandlers` returns it, in the request isolation, which runs first: before authentication, any rate charge, authorization or service call (`isCrossSiteRequest(request, policy)` in `src/server/request-isolation.ts`). `index.loader`, the document route, is not wrapped.
|
|
180
201
|
The check is an allow-list: it names what a route serves from another origin, so everything else — frames (`iframe`, `frame`), `fencedframe`, `object`, `embed`, a navigation without `Sec-Fetch-Dest`, and any destination a later specification adds — is refused by construction.
|
|
202
|
+
Before that check the same wrapper asks the route's policy about React Router's single-fetch data requests (`isFrameworkDataRequest`: the URL's path ends in `.data`, the test React Router dispatches on; a percent-encoded `%2Edata` and a `.data` in the query are not one). React Router answers such a request by running the route's loader, reading the loader's whole body into memory and re-encoding it, and keeps none of the loader's headers but `Set-Cookie`.
|
|
203
|
+
The attachment route refuses them (`frameworkDataRefusal` of its policy): a `<token>.data` request — `GET` or `HEAD`, inline, download or thumbnail, from the same origin or from another origin's navigation alike — is answered with the attachment route's 404 (`Attachment not found`, thrown the way a loader answers early), before authentication, any rate charge, authorization or storage read, and it writes no line in the refusal record below (it is not a cross-site refusal).
|
|
204
|
+
The 404 carries no attachment content, which is why it is safe after React Router has replaced its headers; a caller of the loader itself still sees the attachment security headers. The API, the action and SSE serve such requests like any other (`NOT_NAVIGABLE`): the host guards its two streams (**Streaming routes** in [Server wiring](#server-wiring-di)).
|
|
181
205
|
|
|
182
206
|
| Request | Answer |
|
|
183
207
|
| --- | --- |
|
|
@@ -191,8 +215,9 @@ The check is an allow-list: it names what a route serves from another origin, so
|
|
|
191
215
|
- **The refusal**: on the API and SSE routes, JSON `{"error":{"message":"Cross-site request rejected"}}` with `Cache-Control: no-store` and `X-Content-Type-Options: nosniff` (`crossSiteRequestRejection`); on the attachment route, the same body through the attachment failure builder, so it carries every attachment security header (**Same-origin loads only** in [Attachments](#attachments)). Nothing has authenticated, so no `Set-Cookie` is forwarded.
|
|
192
216
|
On the attachment route the refusal comes before the query's 400 and the method's 405: a malformed query, a `HEAD` navigation (browsers never navigate with `HEAD`) or a form `POST` from another origin answers 403.
|
|
193
217
|
- **The refusal record**: each handler factory keeps one bounded record of its refusals (`createCrossSiteRejectionRecorder`; state per factory, not per module), written to the configured `logger`, or by default to a console logger at the `warn` level prefixed `[DailyReportIsolation]`.
|
|
194
|
-
|
|
195
|
-
The
|
|
218
|
+
A refusal with no open window writes one warn line at once, `cross_site_rejected route=<api|action|sse|attachment> site=<value> mode=<value> dest=<value> method=<method>` (`-` for an absent header; a value outside the specification's shape — lower-case letters and hyphens, at most 32 characters — is written as the JSON string of its first 32 characters, so a sender cannot forge the line's fields), and opens a window of `CROSS_SITE_REJECTION_LOG_WINDOW_MS` (60 s) that every route of the factory shares; later refusals in the window are only counted, per route.
|
|
219
|
+
The window's own timer ends it (one unref'd timer per window, which never keeps the process alive): when it counted refusals, it writes `cross_site_rejected_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> routes=api:<n>,action:<n>,sse:<n>,attachment:<n>` (N and the per-route counts are the refusals after the first one), and the next refusal opens a new window with its own line.
|
|
220
|
+
No clock comparison decides a window — the wall clock only stamps `since=` — and served requests neither open nor close one. So the record writes at most two lines per window, two a minute per factory however fast the refusals come: an active probe or a CSRF attempt shows in the log with its first refusal at once, and how many followed shows at the window's end, at most 60 s later, also when the refusals stop.
|
|
196
221
|
- **Why the request side**: the host's session cookie (`SameSite=Lax` in the usual setting) also accompanies requests from other origins of the same site, form submissions (`application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`) and no-cors requests need no preflight, so CORS does not stop them, and React Router checks CSRF only for document requests and single fetch, not for resource routes.
|
|
197
222
|
A response policy such as `Cross-Origin-Resource-Policy` or `X-Frame-Options` decides only whether a response may be read or drawn; it does not stop a state change or the authentication, rate and authorization work a request starts. With the check, a page of another origin can neither post to the API (create, update, publish or delete a report, comment, star, mark read), nor open any of these routes in a frame, nor probe attachment tokens: a visible and a hidden attachment get the same 403 at the same cost, and the viewer's rate buckets are not spent.
|
|
198
223
|
- **Same origin only**: the package's client calls its endpoints from the page's own origin. A host that serves the API from another origin than the page is refused by this check.
|
|
@@ -277,8 +302,10 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
|
|
|
277
302
|
- **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.
|
|
278
303
|
- **Same-origin loads only**: a request from another origin's page — an `<img>`, a no-cors `fetch`, a `HEAD` probe, an `<iframe>` or `<frame>` from a sibling subdomain, any request for a thumbnail — is refused with 403 before authentication ([Request isolation](#request-isolation);
|
|
279
304
|
on a host served from a potentially trustworthy origin such as HTTPS, where the browser sends Fetch Metadata), so it learns nothing about a token: a visible and a hidden attachment get the same 403 at the same cost, with no authentication, rate token, visibility resolution or query. The one exception is a top-level `GET` document navigation to an original (a link to an attachment in another page), which is served after authentication and authorization as usual.
|
|
280
|
-
As defence in depth, every attachment response, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the route answers (400, 401, 403, 404, 405, 413, 429, 500, 502 and
|
|
281
|
-
|
|
305
|
+
As defence in depth, every attachment response the route answers itself, success or failure — the original's 200 (inline and download, GET and HEAD), the thumbnail's 200 and 304, and every error status the route answers (400, 401, 403, 404, 405, 413, 429, 500, 502, 503 and 504, for GET and HEAD alike, the isolation's 403 included) —
|
|
306
|
+
carries `Cross-Origin-Resource-Policy: same-origin`, `X-Content-Type-Options: nosniff` and `X-Frame-Options: SAMEORIGIN`, so the browser lets only pages of the host's own origin read it or draw it in a frame (a frame of another origin could otherwise tell a blocked 200 from a drawn 404).
|
|
307
|
+
The three headers come from one set (`ATTACHMENT_RESPONSE_SECURITY_HEADERS`) that every path building an attachment response spreads last — the failure builder `attachmentFailureResponse`, which every JSON failure goes through, and the 200 and 304 of both deliveries — so no status the route answers can lose them. `Content-Security-Policy: default-src 'none'; sandbox` stays on the two content 200s.
|
|
308
|
+
The one request whose answer the route does not control is React Router's single-fetch data request (`<token>.data`): React Router would read the original into memory, re-encode it and keep only `Set-Cookie` of its headers (no `Cache-Control`, Content Security Policy, `Content-Disposition` or any of the three), so the route refuses it before anything runs, with a 404 that carries no attachment content ([Request isolation](#request-isolation)).
|
|
282
309
|
|
|
283
310
|
**Configuration**
|
|
284
311
|
|
|
@@ -314,19 +341,35 @@ type DailyReportReadAttachment = (
|
|
|
314
341
|
options: { maxBytes: number; head: boolean; principal?: string; signal: AbortSignal },
|
|
315
342
|
) => Promise<
|
|
316
343
|
| { ok: true; bytes: Uint8Array<ArrayBuffer>; contentType?: string | null; size?: number }
|
|
317
|
-
| { ok: false; reason: "not_found" | "denied" | "too_large" | "invalid_path" | "unavailable"; code?: string }
|
|
344
|
+
| { ok: false; reason: "not_found" | "denied" | "too_large" | "invalid_path" | "unavailable" | "busy" | "deadline"; code?: string }
|
|
318
345
|
>
|
|
319
346
|
```
|
|
320
347
|
|
|
321
348
|
- 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).
|
|
349
|
+
- **Failure reasons** form one closed union (`DailyReportAttachmentFailure`), and each has the status the handlers answer with; `code` is the raw storage code for the log line and never decides a status. A reason outside the union — which a host outside the type check can return — is a port contract violation on either delivery: 500, logged at `error` as `reason=port_contract code=failure_reason`, never guessed into another status.
|
|
350
|
+
|
|
351
|
+
| Reason | When | Original | Thumbnail |
|
|
352
|
+
| --- | --- | --- | --- |
|
|
353
|
+
| `not_found` | The object does not exist | 404, and the object is marked missing | 404, and the object is marked missing |
|
|
354
|
+
| `denied` | The storage refuses the read the same way for every requester (a path outside its sandbox, a permission the service's own credentials lack) | 404 | 404 |
|
|
355
|
+
| `invalid_path` | A scheme or a path the port does not accept | 404 | 404 |
|
|
356
|
+
| `too_large` | The object is over `maxBytes`; only the object's size may decide it, never a transient refusal | 413 | 404, cached only when the row's recorded size backs it (below) |
|
|
357
|
+
| `unavailable` | The storage cannot be reached, a read ended early, or the package's `signal` aborted the read | 502 | 502 |
|
|
358
|
+
| `busy` | The storage refuses for now (a load limit, such as a per-connection stream limit that refuses at once) | 503 with `Retry-After: 5` | 503 with `Retry-After: 5` |
|
|
359
|
+
| `deadline` | A storage call passed the port's own deadline (a per-call deadline, a stalled stream) | 504 | 504 |
|
|
360
|
+
|
|
361
|
+
`busy`, `deadline` and `unavailable` are transient: a thumbnail generation concludes nothing from them, caches nothing and marks nothing missing. The requests that joined that generation receive the same failure, and the next request reads again.
|
|
322
362
|
- **`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.
|
|
323
363
|
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).
|
|
324
364
|
- **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:
|
|
325
365
|
- 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;
|
|
326
|
-
- 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`.
|
|
366
|
+
- 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`.
|
|
367
|
+
A read of a row without a size has nothing to compare, so the bytes read are the answer and the outcome is **verified**; a `too_large` is verified only when the row records a size over `maxBytes`. 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**:
|
|
327
368
|
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.
|
|
369
|
+
- a `too_large` for a row without a recorded size has nothing to back it (**unrecorded**: a host that mistakes a transient refusal for `too_large` looks the same): that request is answered with the 404 (`Cache-Control: no-store`, no validator), nothing is cached, the response line ends with `size=unrecorded` (at `info`; no `size_mismatch`, since nothing was mismatched), and the next view asks the port again.
|
|
370
|
+
A view of an oversized file without a recorded size therefore costs one port call (a port that checks the size first answers it from metadata), and no port classification can leave a 404 in the cache for every viewer.
|
|
328
371
|
- **`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).
|
|
329
|
-
- 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.
|
|
372
|
+
- 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. A call that only your own deadline stopped is `deadline` (the example below tells the two apart by the deadline's own signal).
|
|
330
373
|
- 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).
|
|
331
374
|
- 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.
|
|
332
375
|
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.
|
|
@@ -341,9 +384,10 @@ Example — a read port over a host object store (the file is type-checked by `p
|
|
|
341
384
|
* ホストのオブジェクトストアの上に組む読み取りポートの例 (同梱しない。`pnpm run typecheck:examples` で型検査する)。
|
|
342
385
|
*
|
|
343
386
|
* It follows the port contract: typed failures instead of exceptions, the package's `signal` combined with a per-call deadline,
|
|
344
|
-
* the real size declared on HEAD, and `unavailable` (never `not_found`)
|
|
387
|
+
* the real size declared on HEAD, `deadline` for a call that only its own deadline stopped, and `unavailable` (never `not_found`)
|
|
388
|
+
* for a failure that settles after an abort.
|
|
345
389
|
* ポート契約に従う: 例外ではなく種別付きの失敗で返し、パッケージの `signal` を呼び出しごとの締め切りと合成し、HEAD では実サイズを申告し、
|
|
346
|
-
*
|
|
390
|
+
* 自前の締め切りだけが止めた呼び出しは `deadline`、中断の後に決着した失敗は `not_found` ではなく `unavailable` にする。
|
|
347
391
|
*/
|
|
348
392
|
import type { DailyReportAttachmentError, DailyReportReadAttachment } from "@aiquants/daily-report/server"
|
|
349
393
|
|
|
@@ -369,7 +413,7 @@ export type ObjectStoreReadSettings = {
|
|
|
369
413
|
resolveKey: (filePath: string) => string | null
|
|
370
414
|
}
|
|
371
415
|
|
|
372
|
-
/**
|
|
416
|
+
/** 中断の後や到達不能で決着した読み取り (実体についての判定ではない)。 */
|
|
373
417
|
const UNAVAILABLE: DailyReportAttachmentError = Object.freeze({ ok: false, reason: "unavailable" })
|
|
374
418
|
|
|
375
419
|
/**
|
|
@@ -382,13 +426,16 @@ const UNAVAILABLE: DailyReportAttachmentError = Object.freeze({ ok: false, reaso
|
|
|
382
426
|
*/
|
|
383
427
|
export const createObjectStoreReadAttachment = (store: ObjectStore, settings: ObjectStoreReadSettings): DailyReportReadAttachment => {
|
|
384
428
|
/**
|
|
385
|
-
*
|
|
386
|
-
*
|
|
429
|
+
* Starts the deadline of one storage call and combines it with the package's signal.
|
|
430
|
+
* ストレージ呼び出し 1 回の締め切りを始め、パッケージのシグナルと合成する処理。
|
|
387
431
|
*
|
|
388
432
|
* @param signal Signal from the package. パッケージから渡されたシグナル。
|
|
389
|
-
* @returns
|
|
433
|
+
* @returns The call's own deadline, and the signal to pass to the call, which aborts on either. 呼び出し自身の締め切りと、どちらでも中断される呼び出しへ渡すシグナル。
|
|
390
434
|
*/
|
|
391
|
-
const
|
|
435
|
+
const startCall = (signal: AbortSignal): { deadline: AbortSignal; callSignal: AbortSignal } => {
|
|
436
|
+
const deadline = AbortSignal.timeout(settings.callTimeoutMs)
|
|
437
|
+
return { deadline, callSignal: AbortSignal.any([signal, deadline]) }
|
|
438
|
+
}
|
|
392
439
|
|
|
393
440
|
/**
|
|
394
441
|
* Reads an attachment (metadata only on HEAD) and settles with the bytes or a typed failure; never throws.
|
|
@@ -402,19 +449,22 @@ export const createObjectStoreReadAttachment = (store: ObjectStore, settings: Ob
|
|
|
402
449
|
const key = settings.resolveKey(filePath)
|
|
403
450
|
if (key === null) return { ok: false, reason: "invalid_path" }
|
|
404
451
|
if (signal.aborted) return UNAVAILABLE
|
|
452
|
+
let call = startCall(signal)
|
|
405
453
|
try {
|
|
406
|
-
const stat = await store.stat(key, callSignal
|
|
454
|
+
const stat = await store.stat(key, call.callSignal)
|
|
407
455
|
// 中断の後に届いた「無い」は打ち切りの結果であって実体の判定ではない (not_found はパッケージが実体消失として記録する)
|
|
408
456
|
if (stat === null) return signal.aborted ? UNAVAILABLE : { ok: false, reason: "not_found" }
|
|
409
457
|
if (stat.size > maxBytes) return { ok: false, reason: "too_large" }
|
|
410
458
|
// HEAD の Content-Length は GET と一致しなければならないので、読まずに実サイズを申告する
|
|
411
459
|
if (head) return { ok: true, bytes: new Uint8Array(0), contentType: stat.contentType, size: stat.size }
|
|
412
|
-
|
|
460
|
+
call = startCall(signal)
|
|
461
|
+
const bytes = await store.read(key, call.callSignal)
|
|
413
462
|
// メタデータを読んだ後に実体が伸びていることがある
|
|
414
463
|
if (bytes.byteLength > maxBytes) return { ok: false, reason: "too_large" }
|
|
415
464
|
return { ok: true, bytes, contentType: stat.contentType, size: bytes.byteLength }
|
|
416
465
|
} catch (error) {
|
|
417
|
-
|
|
466
|
+
// パッケージの中断の後の失敗は打ち切りの結果。自前の締め切りだけが過ぎた呼び出しは、締め切りの失敗 (504) として区別する
|
|
467
|
+
return { ok: false, reason: !signal.aborted && call.deadline.aborted ? "deadline" : "unavailable", code: error instanceof Error ? error.name : undefined }
|
|
418
468
|
}
|
|
419
469
|
}
|
|
420
470
|
return readAttachment
|
|
@@ -575,7 +625,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
575
625
|
**Thumbnail endpoint (`?thumbnail=tile`)**
|
|
576
626
|
|
|
577
627
|
- **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.
|
|
578
|
-
- **Order**: request isolation (403 for a request from another origin's page, a navigation included
|
|
628
|
+
- **Order**: request isolation (a React Router single-fetch data request, `<token>.data`, is the 404 before it; 403 for a request from another origin's page, a navigation included; [Request isolation](#request-isolation)) → 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)
|
|
579
629
|
→ 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.
|
|
580
630
|
- **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`:
|
|
581
631
|
- 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.
|
|
@@ -584,7 +634,7 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
584
634
|
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.
|
|
585
635
|
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.
|
|
586
636
|
- 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).
|
|
587
|
-
- 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.
|
|
637
|
+
- 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. Refusals write at most two log lines per viewer, bucket and 60-s window, not one per request (**429 lines** below).
|
|
588
638
|
- **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.
|
|
589
639
|
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.
|
|
590
640
|
- **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.
|
|
@@ -605,8 +655,9 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
605
655
|
| Client load timeout | 75 s (the sum) | the load counts as one failure |
|
|
606
656
|
|
|
607
657
|
- **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.
|
|
608
|
-
It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch
|
|
609
|
-
|
|
658
|
+
It stores only verified outcomes: successful previews and the content-determined failures (signature mismatch and the port's `unsupported`), including those a render delivers after its generation stopped waiting for it, past its deadline or after every requester left (see **Thumbnail renderer port** above).
|
|
659
|
+
A `too_large` conclusion is cached only when the row's recorded size is over the limit, which eligibility already answers with the not-eligible 404 before any read; so the endpoint never caches a `too_large`: on a row whose known size is within the limit it is unverified, and on a row without a recorded size it is unrecorded (answered with the 404 with `Cache-Control: no-store`, and read again on the next view; see **Read port** above).
|
|
660
|
+
It never stores unverified or unrecorded outcomes, `failed`, the 502 of a timeout, the 503 of an abort, storage errors and transient refusals (`not_found`, `unavailable`, `busy`, `deadline`), queue rejections or exceptions.
|
|
610
661
|
- **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);
|
|
611
662
|
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.
|
|
612
663
|
|
|
@@ -616,33 +667,34 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
616
667
|
| 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `X-Frame-Options: SAMEORIGIN`, `Set-Cookie`) |
|
|
617
668
|
| 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
|
|
618
669
|
| 401 / 403 | Not authenticated / no internal user, or (403, before authentication, the query and the method) a request from another origin's page that the isolation refuses ([Request isolation](#request-isolation): it serves no thumbnail to another origin, not even to a navigation) |
|
|
619
|
-
| 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
|
|
670
|
+
| 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path`, and (before authentication) a React Router single-fetch data request (`<token>.data`) — one identical body for all |
|
|
620
671
|
| 405 | Any method other than GET (`Allow: GET`) |
|
|
621
672
|
| 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`) |
|
|
622
|
-
| 502 | Storage unreachable (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
|
|
623
|
-
| 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`) |
|
|
624
|
-
|
|
|
673
|
+
| 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
|
|
674
|
+
| 503 | Wait queue full or wait timed out (`queue`), the storage busy (`busy`), or the generation abandoned by every waiting request (`aborted`; no one receives it) (`Retry-After: 5`); the message is `Thumbnail temporarily unavailable` for every cause |
|
|
675
|
+
| 504 | A storage call past the read port's own deadline (`deadline`); never cached |
|
|
676
|
+
| 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=result` for a value that is not an object, `code=failure_reason` for a failure whose reason is neither `unsupported` nor `failed`, and for a read failure whose reason is outside `DailyReportAttachmentFailure`, or the output check that failed, `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) |
|
|
625
677
|
|
|
626
678
|
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), carry `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin` and `X-Frame-Options: SAMEORIGIN` like every attachment response (**Same-origin loads only** above), and forward `Set-Cookie` (except the isolation's 403, which comes before authentication).
|
|
627
|
-
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`.
|
|
679
|
+
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`, and the 404 line of an unrecorded `too_large` with `size=unrecorded`.
|
|
628
680
|
Unexpected exceptions outside generation go to the loader's shared catch and are logged as `500 attachment=? viewer=? reason=unexpected message=<msg>` (no `variant`).
|
|
629
681
|
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.
|
|
630
|
-
Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / the 403 of a viewer without an internal user) are not logged by the loader; the request isolation's 403 goes to the handlers' bounded refusal record instead (at most two lines
|
|
682
|
+
Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / the 403 of a viewer without an internal user) are not logged by the loader; the request isolation's 403 goes to the handlers' bounded refusal record instead (at most two lines per 60-s window, [Request isolation](#request-isolation)), and the isolation's 404 of a `.data` request is not logged.
|
|
631
683
|
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.
|
|
632
684
|
|
|
633
685
|
**Log levels** (both deliveries; the line formats are fixed):
|
|
634
686
|
|
|
635
687
|
| Level | Outcomes |
|
|
636
688
|
| --- | --- |
|
|
637
|
-
| `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified) |
|
|
638
|
-
| `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
|
|
639
|
-
| `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 |
|
|
689
|
+
| `info` | 200, 304, the 503 nobody receives (`reason=aborted`), 404 `not_eligible`, and the content-determined 404s (`signature`, `unsupported`, `too_large`; cached when verified, `too_large size=unrecorded` on a row without a recorded size) |
|
|
690
|
+
| `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 viewer's window per bucket and the window's `rate_limit_suppressed` / `revalidation_rate_limit_suppressed` line, the busy 503s (`queue`, `concurrency`, the storage's `busy`), every 502, the storage deadline's 504 (`deadline`), 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) |
|
|
691
|
+
| `error` | 500 (`port_exception`, `port_contract` including a read over `maxBytes` (`code=over_max_bytes`) and a read failure reason outside the union (`code=failure_reason`), the loader's `unexpected`, a route without the `token` parameter among them) and failed `markAttachmentMissing` / `markAttachmentPresent` writes |
|
|
640
692
|
|
|
641
693
|
`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.
|
|
642
694
|
|
|
643
|
-
**429 lines**: each rate bucket
|
|
644
|
-
|
|
645
|
-
A
|
|
695
|
+
**429 lines**: each rate bucket (the original's, and the thumbnail's generation and revalidation buckets) records its refusals in windows per viewer of `RATE_LIMIT_LOG_WINDOW_MS` (60 s, the rate window and the 429's `Retry-After`), kept apart from the buckets themselves.
|
|
696
|
+
A viewer's refusal with no open window writes its `429 … reason=rate_limit` (`reason=revalidation_rate_limit`) line at once and opens the window; later refusals in the window are only counted, and the window's own timer writes, when it counted any, one `rate_limit_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<id>` (`revalidation_rate_limit_suppressed …`) line (with `variant=thumbnail` on the thumbnail path) and closes the window. The 429 of a revalidation refused after authorization counts in the generation bucket's window.
|
|
697
|
+
A viewer therefore writes at most two lines per bucket and window, whatever its request rate or the refill; the count is written at the window's end, also when the refusals stop, and a bucket the limiter evicts (it keeps 1,024 users) loses none of it.
|
|
646
698
|
|
|
647
699
|
### Transactions
|
|
648
700
|
|
|
@@ -699,7 +751,7 @@ The first load of a report that is not cached starts inside the hook's effect, w
|
|
|
699
751
|
|
|
700
752
|
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.
|
|
701
753
|
|
|
702
|
-
The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, resilient client with cursor resume + exponential backoff) supplies it.
|
|
754
|
+
The report id list is **not** part of the loader data: a module-resident NDJSON stream session (`GET {apiBasePath}/ids-stream`, the endpoint `DAILY_REPORT_IDS_STREAM_ENDPOINT` of the server entry; resilient client with cursor resume + exponential backoff) supplies it.
|
|
703
755
|
The client splits the stream into lines as the chunks arrive, examining each decoded character once (a chunk of a 4,000-item line of about 300 KB does not re-read the part of the line already received), and reads a final line without a newline at the end of the stream; a line cut short there is not valid JSON, so it is skipped with one warning and the attempt resumes from its cursor, like any interrupted stream.
|
|
704
756
|
`createDailyReportClientLoader` warms an existing session (`primeDailyReportIdsStreamSession`) and then bootstraps (`bootstrapDailyReportIdsStreamSession`): when no session exists it is **created at loader time** so the stream fetch runs in parallel with hydration (the dominant cold-load optimization); when one exists, the obfuscated user id is reconciled (a different user destroys and recreates the session before render).
|
|
705
757
|
If your app overrides `config.apiBasePath`, pass the same value to `createDailyReportClientLoader({ apiBasePath })` — forgetting it costs one wrong-path request on the very first load, self-healed by the page-mount `ensureDailyReportIdsStreamSession`.
|
|
@@ -723,8 +775,10 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
|
|
|
723
775
|
Every change is followed, a sub-pixel one included. A root without a box (not rendered, or detached) reads 0 in Chromium, whose first delivery reports 0 for it; an engine that follows the specification's 0 × 0 starting size reports nothing for such a root, and the value stays `null` until the root has a box. A view whose document has no window throws. The value reaches only `viewportSize`: the list column, the panel group and the scroll container write no height.
|
|
724
776
|
- **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 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.
|
|
725
777
|
`VirtualScroll` snaps the layer that moves the rows to whole device pixels away from the edge a row is aligned to (the start at position 0 and after an alignment to the top, the end at the maximum position and after an alignment to the bottom; 3.9.0, "Device-pixel snapping" in its README), so an aligned row's surface never comes closer than G to that edge: what the snap adds is less than one device pixel.
|
|
726
|
-
It adds nothing — the surface sits exactly G from the edge — when the view's
|
|
727
|
-
|
|
778
|
+
It adds nothing — the surface sits exactly G from the edge — when the view's edge and the row slots are whole numbers of device pixels. The slots are multiples of the 4 px lattice (**Row slots on the lattice**), a whole number of device pixels at the ratios that are multiples of 1/4 (1, 1.25, 1.5, 1.75, 2, 3): at the integer ratios a view's end on a whole CSS pixel is on a device pixel too, so the bottom-aligned surface sits exactly G there, and at 1.25, 1.5 and 1.75 it does when the view's end lies on the lattice.
|
|
779
|
+
Anywhere else — a view's end off the lattice at those ratios, or browser zoom such as 0.9, 1.1 or 1.33 — the snap keeps the surface at least G and less than G + 1 device pixel from the end (measured in Chromium: 8.2 px at 1.25 and 8.333 px at 1.5 for a view's end 1 and 3 px off the lattice, 8.091 px at 1.1, 8.052–8.173 px at 1.33, 8.778 px at 0.9).
|
|
780
|
+
The host's part is its bars, not the window: it sizes the bars around the views — a header and a footer — on the lattice unit, exported as `DAILY_REPORT_LAYOUT_LATTICE_PX` (4 CSS px, client entry; the package's own unit, not a copy), so the views' top edge lies on the lattice. It cannot size the window, so the remainder of the window height modulo the unit stays inside the view, whose end lies on the lattice only when the window height is a multiple of the unit.
|
|
781
|
+
A host that computes its styles in script builds its lengths from the value (the `4px` of `height: calc-size(auto, round(up, size, 4px))` for a bar whose content decides its height); a Tailwind host, whose scanner reads class names only as they are written, writes such a class as a literal and ties it to `DAILY_REPORT_LAYOUT_LATTICE_PX` with a unit test that reads both.
|
|
728
782
|
- **Empty list**: an empty view shows exactly one message, `VirtualScroll`'s own empty state with the engine label `labels.noItems` (en "No items", ja 「項目がありません」), which `VirtualScroll` draws over the top of its content, out of the flow, so an empty view keeps the same box. The views render no message of their own; they only theme the engine's (`VIEW_EMPTY_TEXT_THEME_CLASS_NAME` on `VirtualScroll`'s root: 16 px above and below, slate-500 in light and slate-400 in dark: 4.55:1 and 7.66:1 against the page, where the engine's default grey reaches only 4.17:1 in dark).
|
|
729
783
|
|
|
730
784
|
### Keyboard, focus and selection (List / DetailList)
|
|
@@ -777,6 +831,8 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
777
831
|
- **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.
|
|
778
832
|
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.
|
|
779
833
|
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).
|
|
834
|
+
The app's keyboard harness counts the style and layout work done inside keydown tasks and, in a run of its own, records the JavaScript stack of each (run `2026-10-05T16-45-08-477Z`, keys held): every recalculation with a stack comes from that `focus()` call, made by the key's focus request before the commit — the DetailList from row 110 at 1× CPU ran 63 recalculations in 21 of its 22 keydown tasks (8.48 ms) and no layout.
|
|
835
|
+
At 4× CPU from row 200,000 the keydown tasks also ran two recalculations and two layouts without a stack (DetailList 16.00 ms and 23.00 ms, List 3.76 ms and 2.42 ms), each laid out from virtualscroll's items boundary (`.aqvs-items-boundary`), none from the document root.
|
|
780
836
|
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.
|
|
781
837
|
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.
|
|
782
838
|
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.
|
|
@@ -797,8 +853,12 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
797
853
|
|
|
798
854
|
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.
|
|
799
855
|
Focus is only ever taken from nowhere: focus that the user put elsewhere is never moved.
|
|
800
|
-
- **Keyboard focus
|
|
801
|
-
|
|
856
|
+
- **Keyboard focus**: one predicate decides it for the whole view (`isKeyboardFocused` in `src/client/keyboard/keyboard-focus.ts`): the focused element matches `:focus-visible` and the last input of its document was not a pointer press. `:focus-visible` alone is not enough, because browsers match it on a text field (`input`, `textarea`, an editing host) that a click or a tap focused.
|
|
857
|
+
The last input is recorded per document (the element's `ownerDocument`, so a view in an iframe or a pop-out window reads its own), while a view of that document is mounted (`observeInputModality` in `src/client/keyboard/input-modality.ts`, reference-counted across the views):
|
|
858
|
+
a capture-phase `pointerdown` records the pointer, and a capture-phase `keydown` records the keyboard unless the key is a modifier alone (`Shift`, `Control`, `Alt`, `Meta` and the other modifier keys of UI Events), so a modifier held during a pointer gesture does not turn it into keyboard input. A `Tab` pressed outside the view counts too.
|
|
859
|
+
So a click or a tap into any element of a row, a text field included, is never keyboard focus; a key press inside the view (other than a modifier alone) turns the focus into keyboard focus without moving it. Chromium also matches `:focus-visible` after a bare `Shift` or `CapsLock`, so there the focus outline can show while the view still treats the focus as a pointer's.
|
|
860
|
+
- **Keyboard focus is revealed**: when an element inside a row receives keyboard focus, 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.
|
|
861
|
+
- **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 by the same predicate (a key press can turn pointer focus into keyboard focus without moving it), and `pointerdown` inside the view or focus leaving it removes it; a click into a text field of a row therefore leaves it off, and the tap-scroll circle stays pressable.
|
|
802
862
|
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.
|
|
803
863
|
- **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.
|
|
804
864
|
- **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.
|
|
@@ -831,7 +891,8 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
831
891
|
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).
|
|
832
892
|
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.
|
|
833
893
|
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.
|
|
834
|
-
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
|
|
894
|
+
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; the header's markers and buttons sit outside the list.
|
|
895
|
+
Every ISO date of a DetailList card — the header chips' business date and updated at, the metadata column's created at, updated at and business date — is a `<time datetime>` carrying that date, through one component (`DateText` in `src/client/components/detail-list/date-text.tsx`), so a row never exposes the same date with two semantics; a missing date's `-` stays text.
|
|
835
896
|
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).
|
|
836
897
|
- **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).
|
|
837
898
|
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).
|
|
@@ -882,7 +943,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
882
943
|
- **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.
|
|
883
944
|
- **Every row is a relayout boundary**: the row frames of both views are `contain: size layout style` (one token, `ROW_CONTAINMENT_CLASS_NAME`), so a change inside one row — a held body rendered after the key's commit, a load that completes, the selection and focus indicators — lays out that row alone and restyles no other.
|
|
884
945
|
A key move that shifts `VirtualScroll`'s rendering window (it mounts and unmounts rows) is laid out from the items wrapper's containing block. In `@aiquants/virtualscroll` 3.9 that block is a flex item, which Chromium does not make a relayout boundary, so such a shift lays out from the document root: in the app's keyboard harness (run `2026-10-03T23-04-07-660Z`, 4× CPU) a key's layout CPU p50 equals its document-rooted layout CPU p50, 3.99 ms in the List and 8.12 ms in the DetailList.
|
|
885
|
-
3.10.0
|
|
946
|
+
3.10.0 puts the wrapper in a relayout boundary of its own (`.aqvs-items-boundary`, a zero-height box with `contain: size layout style`; its README "What a scroll step paints"), from which the same shift is laid out inside the list: in virtualscroll's own probe, 60 one-row shifts in a page of 2,231 layout objects ran 60 partial layouts of 218 objects instead of 60 layouts from the document root, with identical pixels.
|
|
886
947
|
In the views the box removes every document-rooted layout of a key's own rendering: in the app's keyboard harness with 3.10.0 no key's rendering lays out from `#document` in any condition, and a key's layout CPU p50 is about 0.2–0.4 ms at 1× CPU on an uncontended host and, at 4× CPU, about 1.1 ms in the List and 6.8–6.9 ms in the DetailList, all of it inside the list (3.99 and 8.12 ms from the document root before). What still lays out from the document root is the List side pane's deferred catch-up to the selection: about once per spaced key, and once per hold when the key is held.
|
|
887
948
|
Inside the box the rows' overflow is ink overflow, so a scroll the browser makes on its own to reveal an overscan row (a find-in-page match there) cannot move the list and moves the nearest outer scroller instead when the row's box lies outside its view; the views' own reveals do not depend on it (one Tab stop per view, rows focused with `preventScroll`, keyboard focus revealed by the scroller).
|
|
888
949
|
Size containment makes the frame's size independent of its content: its width is the row box `VirtualScroll` lays out, and its height is the slot P the List writes on the frame or, in the DetailList, that row box itself (`h-full`), whose height `VirtualScroll` takes from the measured row.
|
|
@@ -936,7 +997,7 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
936
997
|
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"`).
|
|
937
998
|
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.
|
|
938
999
|
Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
|
|
939
|
-
The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2
|
|
1000
|
+
The floating tap-scroll circle and its visual, the scroll bar's thumb and arrow buttons and the scroll-to-edge buttons are `VirtualScroll`'s own: one rule of its stylesheet (since 3.8.2) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
|
|
940
1001
|
(the circle and every element inside it, overriding the visual's inline transitions; the bar's circle wrapper; the arrow buttons; the thumb; the scroll-to-edge buttons and their overlay), so the circle follows a drag without easing and the other parts change state at once.
|
|
941
1002
|
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`.
|
|
942
1003
|
That check is lexical — the stylesheets and the literals of the scripts. What actually moves is checked in the browser by the host app's reduced-motion end-to-end guard: under `prefers-reduced-motion: reduce`, after each step of a pointer and a touch tour of both views (the tap-scroll circle pressed and dragged, a card hovered, a flick, the mobile overlay opened and closed, a thumbnail loaded), no animation or transition of a motion property runs in the view, including what `VirtualScroll` renders.
|
|
@@ -953,9 +1014,13 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
953
1014
|
Plain centring puts the column on a half pixel whenever the space beside it is odd (x = 56.5 in a 1,265 px area), and at a fractional ratio each 2 px stroke then blends into its neighbours.
|
|
954
1015
|
On the lattice, each 2 px stroke of the two-channel indicator — the ring, the separation band and the outline — paints ⌊2 × ratio⌋ full device pixels on the left and right edges (2, 3 and 3 at 1.25, 1.5 and 1.75) with no blended pixel on its inner side, and the 1 px border paints its own colour (measured in Chromium at those ratios, light and dark). An engine without CSS `round()` drops the declaration and keeps the `mx-auto` centre.
|
|
955
1016
|
The block-axis origin and the view's height are the host's: the views start and end where the host's layout puts them.
|
|
956
|
-
A host that wants the same whole-pixel strokes on the top and bottom edges at
|
|
1017
|
+
A host that wants the same whole-pixel strokes on the top and bottom edges at the quarter ratios 1.25, 1.5 and 1.75 puts the edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to the lattice unit `DAILY_REPORT_LAYOUT_LATTICE_PX` (`height: calc-size(auto, round(up, size, 4px))`), since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px).
|
|
1018
|
+
The top edge then lies on the lattice, and the bottom edge too when the window height is a multiple of the unit; the bottom-aligned surface then sits exactly G from the view's end.
|
|
1019
|
+
In any other window, and at other ratios (browser zoom such as 0.9, 1.1 or 1.33), it sits at least G and less than G + 1 device pixel from the end (**G-symmetric frame** in [View height](#view-height-host-layout)).
|
|
957
1020
|
- **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.
|
|
958
1021
|
The List panel ends G after its scroll bar (`LIST_PANEL_END_GUTTER_CLASS_NAME`, `pr-[8px]`), so a 2G = 16 px channel lies between the scroll bar and the pane, and the resize handle that `@aiquants/resize-panels` centres on the panel boundary (a 10 px hit area around a 6 px grip) covers neither the scroll bar nor the pane's edge.
|
|
1022
|
+
The side-pane panel is at least 200 px wide (`SIDE_PANE_PANEL_MIN_WIDTH_PX`): the frame around its centred selection prompt — 2G = 16 (the pane sits G inside its panel), the card surface's insets 2 × 16 and the placeholder panel's insets 2 × 12 (`PLACEHOLDER_PANEL_INSET_PX`), 72 in all — plus the prompt's longest phrase at the default 16 px root, 「選択してください。」 (9 full-width glyphs of `text-sm`, 9 × 0.875 rem = 126 px), is 198, rounded up to the 4 px lattice.
|
|
1023
|
+
At the default root the ja prompt therefore breaks only between phrases and leaves no one-glyph line; at a larger root its phrases widen and the prompt breaks inside one without crossing its frame (**Line breaks of wrapping labels** below). The List panel keeps its own minimum of 160 px, and the two minimums and the 10 px handle (370 px) fit in the List's two-column minimum of 460 px.
|
|
959
1024
|
The pane's header and its scrolling body end on one edge: the date and author column and the tab list sit in boxes that reserve the same scroll-bar gutter as the article and relations tab panel (`SIDE_PANE_HEADER_BOX_CLASS_NAME` and the panel both compose `SCROLLBAR_GUTTER_CLASS_NAME`, `scrollbar-thin` with `scrollbar-gutter: stable`, which an `overflow: hidden` box reserves too), so with a classic thin scroll bar, a wider one or an overlay one of no width, the header's end and the body's end share one x, in the desktop pane and in the mobile overlay alike.
|
|
960
1025
|
- **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.
|
|
961
1026
|
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.
|
|
@@ -964,9 +1029,11 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
964
1029
|
- **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.
|
|
965
1030
|
In forced colours the browser replaces every background that is not a system colour with `Canvas`, which would leave the thumb invisible on its track, so the token restates the thumb in system colours with the same selector as each state — `CanvasText` at rest, `Highlight` hovered and dragged, `GrayText` disabled — after the light and dark rules, and gives the track a 1 px inset `CanvasText` outline (an outline: the layout does not change).
|
|
966
1031
|
These rules win over virtualscroll's own forced-colours rules (`@aiquants/virtualscroll` 3.10.0, its README "Forced colours") the same way the token wins over its default colours, so the views' thumb is drawn by this token in every mode.
|
|
967
|
-
- **Line breaks of wrapping labels**: a short centred label that can wrap — the side pane's selection prompt, whose panel narrows to
|
|
968
|
-
|
|
969
|
-
|
|
1032
|
+
- **Line breaks of wrapping labels**: a short centred label that can wrap — the side pane's selection prompt, whose panel narrows to its minimum (**Side pane** above), and an attachment tile's unavailable label, as wide as its track — breaks its lines through one token, `WRAPPING_LABEL_CLASS_NAME` (`text-balance wrap-anywhere [word-break:auto-phrase]`), because a plain break leaves a narrow centred label with a stray glyph or two on its last line, or breaks a Japanese word in the middle:
|
|
1033
|
+
`text-wrap: balance` keeps the lines about equally long, so no one-glyph last line is left under the others (engines balance only blocks of a few lines, so the token is for labels, not body text);
|
|
1034
|
+
`word-break: auto-phrase` breaks Japanese at phrase boundaries where the host document's `lang` is `ja` (ja 「プレビューを / 表示できません」 instead of 「プレビューを表示できませ / ん」 in a 171 px track) and behaves as `normal` in other languages;
|
|
1035
|
+
and `overflow-wrap: anywhere` breaks inside a phrase or a word only when it does not fit on a line by itself, so a label never crosses its frame at any width, root font size or font. It also counts those breaks in the label's min-content width, so a flex item's automatic minimum width no longer holds the label at its longest phrase (the prompt is a flex item, and the tile label's text is the anonymous flex item of its box: the property is inherited, which is how it reaches that item, where a `min-width` on the label's box would not). Where a phrase fits, the lines are the same as without it.
|
|
1036
|
+
The token sets nothing else, and the labels that use it declare no other line-breaking property, so nothing cancels it: `src/client/ui/style-tokens.spec.ts` pins the token's three declarations, the side pane's spec the prompt's classes and the panel minimum derived from the compiled insets and the prompt's last phrase, and the tile's spec compiles the label's classes and checks that its line-breaking declarations are exactly the token's.
|
|
970
1037
|
- **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` (through the class both views pass in `VirtualScroll`'s tap-scroll circle options).
|
|
971
1038
|
- **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.
|
|
972
1039
|
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.
|
|
@@ -1346,6 +1413,7 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1346
1413
|
| Authenticated but no internal user | 200, `retry: 86400000` → `event: forbidden` | terminated; no navigation (a reload cannot fix it); visible through `sseStatus` |
|
|
1347
1414
|
| A request from another origin's page that the isolation refuses ([Request isolation](#request-isolation)) | 403 JSON before authentication (`Cache-Control: no-store`, no `Set-Cookie`), counted in the bounded refusal record | never met: the package's client connects from the page's own origin |
|
|
1348
1415
|
| Unknown endpoint | 404 | treated as retryable; backoff up to 30 s |
|
|
1416
|
+
| A `HEAD` request | the status and headers a `GET` would get (one of the rows here), with no body work: no subscription, no heartbeat, no visibility refresh timer (**Streaming routes** in [Server wiring](#server-wiring-di)) | never sent by the client |
|
|
1349
1417
|
| Unexpected error before the stream starts (for example the internal-user lookup fails) | 500 with the body `Internal Server Error` (no error detail), `Set-Cookie` forwarded | treated as retryable; backoff up to 30 s |
|
|
1350
1418
|
| Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
|
|
1351
1419
|
| Cursor older than the oldest retained entry (the stream is trimmed with `MAXLEN ~ streamMaxLen`) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
|
|
@@ -1393,6 +1461,7 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
|
|
|
1393
1461
|
- Factories: `createDailyReportServer` / `createDailyReportService` (`getDailyReportIdsByExternalId` returns `{ items, streamAnchor }`; `readSseStreamAnchor`; `attachmentDelivery`) / `createDailyReportHandlers` / `defineDailyReportSchema` / `createEpochStore`, the viewer scope `DailyReportViewerScope`.
|
|
1394
1462
|
- Attachments: `createSharpThumbnailRenderer` / `predictSharpThumbnailWorkingSetBytes` / `DAILY_REPORT_SHARP_THUMBNAIL_WORKING_SET_MODEL` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_BUDGET_BYTES` / `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (the render deadline `renderMs` and the decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
|
|
1395
1463
|
- SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
|
|
1464
|
+
- Routes: `DAILY_REPORT_IDS_STREAM_ENDPOINT` (`"ids-stream"`), the name of the API endpoint that streams the ids NDJSON, which the client's URL and the server's endpoint table both use; a host compares a route's `endpoint` parameter with it to single out that stream (**Streaming routes** in [Server wiring](#server-wiring-di)).
|
|
1396
1465
|
- Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared reader `createDailyReportHandlers` takes) / `StreamEntry` / `ExternalReportFields`,
|
|
1397
1466
|
and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
|
|
1398
1467
|
|
package/dist/client.d.mts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as react from "react";
|
|
2
2
|
import { RefObject, ReactNode, Context } from "react";
|
|
3
|
-
import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail } from "./
|
|
3
|
+
import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail, c as DailyReportSseStreamAnchor } from "./ids-stream-CWuIDrIO.mjs";
|
|
4
4
|
import { VirtualScrollLocale, VirtualScrollLabels, VirtualScrollHandle } from "@aiquants/virtualscroll";
|
|
5
5
|
import { UseReopeningEventSourceResult } from "@aiquants/sse/react";
|
|
6
|
-
import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment
|
|
6
|
+
import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment } from "./comment-adapter-lV2ZB_vH.mjs";
|
|
7
7
|
import { ShouldRevalidateFunction } from "react-router";
|
|
8
8
|
import "zod";
|
|
9
9
|
type DailyReportAttachmentIndicatorProps = {
|
package/dist/client.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as react from "react";
|
|
2
2
|
import { RefObject, ReactNode, Context } from "react";
|
|
3
|
-
import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail } from "./
|
|
3
|
+
import { D as DailyReportItem, a as DailyReportUser, b as DailyReportDetail, c as DailyReportSseStreamAnchor } from "./ids-stream-CWuIDrIO.js";
|
|
4
4
|
import { VirtualScrollLocale, VirtualScrollLabels, VirtualScrollHandle } from "@aiquants/virtualscroll";
|
|
5
5
|
import { UseReopeningEventSourceResult } from "@aiquants/sse/react";
|
|
6
|
-
import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment
|
|
6
|
+
import { D as DailyReportSseTerminalEvent, a as DailyReportSseMessage, U as UIComment } from "./comment-adapter-5bAtR4qb.js";
|
|
7
7
|
import { ShouldRevalidateFunction } from "react-router";
|
|
8
8
|
import "zod";
|
|
9
9
|
type DailyReportAttachmentIndicatorProps = {
|