@aiquants/daily-report 0.24.0 → 0.26.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 +94 -0
- package/README.md +126 -48
- package/dist/client.d.mts +2 -6
- package/dist/client.d.ts +2 -6
- package/dist/client.js +4 -4
- package/dist/client.mjs +4 -4
- package/dist/server.d.mts +12 -3
- package/dist/server.d.ts +12 -3
- package/dist/server.js +4 -4
- package/dist/server.mjs +4 -4
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +7 -12
package/README.md
CHANGED
|
@@ -26,12 +26,18 @@ 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` must be **3.
|
|
30
|
-
|
|
29
|
+
`@aiquants/virtualscroll` must be **3.9.0 or later**: both views pass its scroll-bar option `enableArrowButtonTabStops: false` (3.8.0), 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)), its stylesheet stops the motion of its own parts under `prefers-reduced-motion: reduce` (3.8.2;
|
|
30
|
+
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 7 engine keys (see [Localization](#localization-locale--labels)).
|
|
31
|
+
That floor is declared once, in `peer-floors.json` (`{ "<peer name>": "<X.Y.Z>" }`), 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
32
|
`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
33
|
`@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
34
|
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)
|
|
35
|
+
Build / test: `pnpm run build` (tsup → dist) / `pnpm run test` (vitest) / `pnpm run typecheck:examples` (the host examples under `examples/`, which are not shipped).
|
|
36
|
+
`pnpm run check:bundle` checks 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 a budget that is derived, never written: `bundle-baseline.json` at the package root records each file's gzip-9 size and one `headroomPercent` (3), and each budget is ⌈baseline × (100 + headroomPercent) ÷ 100⌉ in integer arithmetic.
|
|
37
|
+
An `.mjs` or `.css` target of `exports` without an entry, an entry for a file that is no longer a target and a hand-written `bundleBudget` in `package.json` fail the check.
|
|
38
|
+
`pnpm run bundle:ratchet` (`--write`) records the sizes of a fresh build: an entry goes down freely and a stale one is removed, while a larger size or a new target is recorded only with `--write --accept` (a reviewed growth; without `--accept` the entry keeps its size and the growth is judged against the old budget). `headroomPercent` is policy and is never written by the script.
|
|
39
|
+
Every `publish:*` script runs `node scripts/check-bundle-size.mjs --exact` right after `pnpm run verify` (which ends with the build and the bundle check), before the leak check and the version bump: besides the budgets, it fails when any measured size differs from its baseline entry in either direction and prints the `node scripts/check-bundle-size.mjs --write --accept` command that records that build.
|
|
40
|
+
So a release always ships the baseline of its own build: an entry left high after a shrink would loosen every budget derived from it, and one left low would judge the next change against a build nobody shipped. `--exact` cannot be combined with `--write` (exit 2).
|
|
35
41
|
`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
42
|
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
43
|
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).
|
|
@@ -41,14 +47,15 @@ The guards are the violations of the workspace's docstring and comment language
|
|
|
41
47
|
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
48
|
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
49
|
|
|
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.
|
|
50
|
+
**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. Three CSS features of the table are newer than parts of the floor, and the row says what those engines draw:
|
|
45
51
|
|
|
46
52
|
| Feature | Supported from | Used for | Below the floor |
|
|
47
53
|
| --- | --- | --- | --- |
|
|
48
54
|
| `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
55
|
| `: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
|
|
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
|
|
56
|
+
| 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 and the tile's lattice pad (`100cqi` of the tile root, an inline-size container as wide as its track); the view-mode toolbar's end inset in the single-column List | One attachment column at every width; frames sized by `aspect-ratio` alone and tiles without the pad (below); the toolbar's end edge overhangs the List's scroll bar by 8 px |
|
|
57
|
+
| CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, 4px) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the 4 px lattice (see **Origin on the 4 px lattice**) | Chrome 121–124, inside the floor, drop those declarations: the frame's `aspect-ratio: 480 / 320`, always declared beside its height, sizes the frame at the exact fractional 2t / 3, so the frames of one grid can paint one device pixel apart; a tile stays its frame's fractional height + 48 px tall, so the rows below an image grid can start between device pixels; and the column keeps the `mx-auto` centre |
|
|
58
|
+
| CSS `calc-size()` | Chrome 129 | The DetailList row bodies' height, their content height rounded up to the 4 px lattice (`calc-size(auto, round(up, size, 4px))`, see **Row slots on the lattice**) | Chrome 121–128, and any engine that does not implement it, drop the declaration: a body keeps its content height, which is on the lattice at a 16 px root and can leave it at other roots, so the DetailList rows below can start between device pixels |
|
|
52
59
|
| `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
60
|
| `<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
61
|
|
|
@@ -239,6 +246,8 @@ Attachment bytes are served by `dailyReportServer.attachment.loader` at `GET {ap
|
|
|
239
246
|
- **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
247
|
- **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
248
|
- **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.
|
|
249
|
+
- **Same-origin loads only**: 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 loader answers (400, 401, 403, 404, 405, 413, 429, 500, 502 and 503, for GET and HEAD alike) — carries `Cross-Origin-Resource-Policy: same-origin` and `X-Content-Type-Options: nosniff`, so the browser lets only pages of the host's own origin load it. Both headers come from one set that every path building an attachment response spreads last, so no status can lose them.
|
|
250
|
+
The session cookie also accompanies requests from other origins of the same site, so a response without the policy would let a same-site page learn, token by token, whether the viewer can see each attachment: an image load succeeds or fails, and a no-cors `fetch` resolves with an opaque response for a response without the policy while the browser blocks one that carries it. With the policy on every status, every cross-origin load fails alike.
|
|
242
251
|
|
|
243
252
|
**Configuration**
|
|
244
253
|
|
|
@@ -400,8 +409,11 @@ type DailyReportAttachmentThumbnailRenderer = {
|
|
|
400
409
|
- `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
410
|
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
411
|
- `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`)
|
|
412
|
+
- `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" }`. A render cut short by the signal must never answer `unsupported`: a late `unsupported` is recorded as a content-determined outcome (below).
|
|
413
|
+
A render still running at its deadline is answered with 502 (`reason=render_timeout`) without waiting for it. 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>`).
|
|
414
|
+
What such a render has paid for is kept: when it settles after the deadline with a thumbnail that passes the output checks below, or with `unsupported`, and the generation's read matched the row's size (**verified**, see **Read port** above), the outcome is cached before the slot is released. A late result is classified by the same rule as one that settles in time, so only those conclusions are kept: a late `failed`, a late exception, a late output that fails the checks and a late value outside the contract are not cached.
|
|
415
|
+
Until such a render settles, a new generation of the same content waits for it instead of reading and decoding the same source again beside it (**Generation** below), so a content key has at most one decode at a time: a slow but legitimate source costs one overrun, and the next view is answered from the cache.
|
|
416
|
+
A generation that every waiting request left before its render settled or reached the deadline ends as 503 (`reason=aborted`) and keeps nothing, whatever the render's result.
|
|
405
417
|
- `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
418
|
- `unsupported` means the content itself cannot be downscaled (deterministic); `failed` means a transient failure (retry may succeed).
|
|
407
419
|
- 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.
|
|
@@ -420,6 +432,15 @@ Values for implementing the port:
|
|
|
420
432
|
- 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
433
|
- 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
434
|
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.
|
|
435
|
+
- bounds the decode **work** of a JPEG too, in the same pass over the bytes that reads its frame header, before sharp starts. A progressive JPEG visits every block once per scan, so a small file with a valid progression of thousands of scans passes the pixel and memory bounds and still decodes for many seconds (a 0.2 MB, 4,096² file with 2,647 scans took 13–25 s of CPU).
|
|
436
|
+
The work is counted in block visits: the sum, over the scans up to EOI, of the 8 × 8 blocks of every component a scan codes (counted as libjpeg lays them out, rounded up to whole MCUs). The walk skips each scan's coded data without decoding it and stops at EOI or as soon as the sum passes the bound; a single-scan (baseline) file stops at its first scan, which is all libjpeg decodes.
|
|
437
|
+
The bound comes from the render deadline: ⌊`renderMs` × 10⁶ ÷ (125 ns per visit × a margin of 4)⌋ = 20,000,000 visits, so a source at the bound decodes in about a quarter of the 10 s deadline on the host it was measured on (sharp 0.35.5 / libvips 8.18.7 at one thread; 125 ns is a visit of the costliest scan, a refinement of the whole 1–63 band; single-coefficient scans cost 13–27 ns).
|
|
438
|
+
A JPEG over the bound is `unsupported` without starting sharp, and so is one whose work cannot be predicted: more scans than the JPEG standard allows (one per component in a sequential frame, 896 per component in a progressive one), an arithmetic-coded (SOF9–SOF15) or hierarchical (SOF5–SOF7) frame, or a malformed marker stream. The default progressions libjpeg writes stay well inside the bound even at the 7,071² pixel limit (about 6.3 M visits at 4:2:0, 10.9 M at 4:4:4 and 18.8 M for CMYK), and lossless (SOF3) frames, one scan per component like a sequential frame, are admitted.
|
|
439
|
+
- bounds the reading itself: the walks that read a source before sharp starts (the JPEG marker walk with its skip over each scan's coded data, and the WebP chunk walk) are synchronous and block the worker's event loop, so they count steps — one per 0xFF byte the walk visits (a marker, a fill byte before it, a stuffed `FF 00`, a restart marker or a fill byte inside coded data) and one per WebP chunk; coded data between them is skipped by the native byte search.
|
|
440
|
+
A walk that needs more than `maxWalkSteps` = ⌊`walkBudgetMs` 16 ms × 10⁶ ÷ (50 ns per step × a margin of 2)⌋ = 160,000 steps stops there, and the source is `unsupported` (content-determined, cached) without starting sharp, so neither a hostile file nor an abort during the walk can make the stall repeat. The walk's time is then bounded whatever the content: at most 160,000 steps plus the byte search, which grows with the file (2.4–2.8 ms for 32 MiB).
|
|
441
|
+
Measured on Node 24 over 32 MiB sources built to stall the walk (a run of 0xFF, `FF 00` pairs, restart markers inside a scan or between segments, fill bytes, only comment segments, a WebP of zero-size chunks): 1–2.4 ms each, and 5.0–7.0 ms for the costliest shape, `FF 00` pairs spread every 3 to 209 bytes so that every visit restarts the search, within the 8 ms the margin leaves.
|
|
442
|
+
Legitimate sources stay far inside the bound: a baseline JPEG stops at its first scan, the coded data of a noisy photo that sharp encoded at quality 98 holds about 2,800 0xFF bytes per MiB (about 90,000 steps at 32 MiB), and noisy progressive photos above the size limit took 147,010 steps (64 MB) and 155,365 steps (58 MB).
|
|
443
|
+
The model is the frozen `DAILY_REPORT_SHARP_THUMBNAIL_DECODE_WORK_MODEL` (server entry): `renderMs` (the render stage's deadline, 10,000 ms), `nanosecondsPerBlockVisit`, `margin` and `maxBlockVisits` above, and `walkBudgetMs`, `nanosecondsPerWalkStep`, `walkMargin` and `maxWalkSteps`. A host that proves its own decoders against the bound reads `renderMs` from it instead of restating the deadline.
|
|
423
444
|
- 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
445
|
|
|
425
446
|
| Layout | Whole-image buffer | Largest square admitted |
|
|
@@ -437,7 +458,7 @@ Values for implementing the port:
|
|
|
437
458
|
- 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
459
|
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
460
|
- 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
|
|
461
|
+
- checks the abort signal before it starts and after the header read, before decoding, and answers `failed` when it is aborted by then. libvips cannot be stopped midway, so a decode that has started returns its own result (the thumbnail, `unsupported` or a resource `failed`) even when the signal aborts meanwhile; the package decides what to keep (a late verified result is cached, see **Thumbnail renderer port** above).
|
|
441
462
|
- is named `sharp-<sharp version>/vips-<libvips version>/webp-q75-e4/flatten-#ffffff/v1`, from `sharp.versions` at run time.
|
|
442
463
|
|
|
443
464
|
Example — the wiring; the host imports its own sharp (type-checked, not shipped):
|
|
@@ -538,6 +559,8 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
538
559
|
- **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
560
|
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
561
|
- **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).
|
|
562
|
+
**One decode per content key at a time**: a render that passed its deadline keeps its slot until it settles, and while it runs, a new generation of the same identity does not queue for a slot: it first waits for that render to settle and its result to be recorded or dropped, then reads the cache, and only then queues at the gate.
|
|
563
|
+
The wait for the late render and the wait for a slot share one `queueWaitMs` budget, and the generation's abort signal ends both; when the budget runs out first, the request is answered 503 `reason=queue` without a second read or decode. A generation that receives its slot also reads the cache again before it reads the original, so a request that waited at the gate behind an overrunning render of the same content is answered from that render's late result.
|
|
541
564
|
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
565
|
- **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
566
|
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.
|
|
@@ -551,14 +574,14 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
551
574
|
| Client load timeout | 75 s (the sum) | the load counts as one failure |
|
|
552
575
|
|
|
553
576
|
- **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`,
|
|
577
|
+
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), including those a render delivers after its deadline (see **Thumbnail renderer port** above). It never stores unverified outcomes, `failed`, the 502 of a timeout, storage errors (including `not_found`), queue rejections, aborts or exceptions.
|
|
555
578
|
- **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
579
|
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
580
|
|
|
558
581
|
| Status | When |
|
|
559
582
|
| --- | --- |
|
|
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`) |
|
|
583
|
+
| 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`, `Cross-Origin-Resource-Policy: same-origin`, forwarded `Set-Cookie` |
|
|
584
|
+
| 304 | `If-None-Match` matches (carries `Cache-Control`, `ETag`, `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin`, `Set-Cookie`) |
|
|
562
585
|
| 400 | A query that names no delivery (unknown or repeated variant such as `?thumbnail=1`, combined with `download`), or a malformed token |
|
|
563
586
|
| 401 / 403 | Not authenticated / no internal user |
|
|
564
587
|
| 404 | Ports not injected, not visible, not eligible, signature mismatch, `unsupported`, `too_large`, `not_found`, `denied`, `invalid_path` — one identical body for all |
|
|
@@ -566,9 +589,9 @@ The client places a thumbnail on this flag alone; it has no switch of its own.
|
|
|
566
589
|
| 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
590
|
| 502 | Storage unreachable (`unavailable`), the port's `failed` (`render_failed`), or a stage deadline (`read_timeout`, `render_timeout`) |
|
|
568
591
|
| 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
|
|
592
|
+
| 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`, 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) |
|
|
570
593
|
|
|
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`.
|
|
594
|
+
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` and `Cross-Origin-Resource-Policy: same-origin` like every attachment response (**Same-origin loads only** above), 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
595
|
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
596
|
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
597
|
Rejections up to the renderer-port check (401 / 400 / 405 / 404 for a missing port / 403) are not logged.
|
|
@@ -638,7 +661,8 @@ The package never navigates by itself: when the session is gone — the SSE stre
|
|
|
638
661
|
|
|
639
662
|
`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
663
|
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).
|
|
664
|
+
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, or after the component unmounted, is dropped. An id of 0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error).
|
|
665
|
+
The first load of a report that is not cached starts inside the hook's effect, with no task between the row's mount and its request, so a held arrow key's stream of keydowns cannot postpone it until the key is released; only the refetch of an expired cached report waits 120 ms, to absorb rows that only scroll past. Loads of reports of one business date share one request.
|
|
642
666
|
|
|
643
667
|
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.
|
|
644
668
|
|
|
@@ -655,12 +679,18 @@ Each view (List and DetailList) is as tall as the space the host's layout leaves
|
|
|
655
679
|
- **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
680
|
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
681
|
- **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.
|
|
682
|
+
- **One page frame and one column for every screen**: the loading screen, the load-error screen and the loaded screen share the page box (`VIEW_PAGE_FRAME_CLASS_NAME`: the fill rule and the page surface, slate-50 / dark slate-950) and its centred column (`VIEW_COLUMN_CLASS_NAME`: the fill rule, at most `max-w-6xl`, 4 px from the frame's top and side edges, starting on the 4 px lattice). So the page keeps its colour in both schemes while loading ends (a dark page never shows the light surface first) and the content does not move sideways between the screens.
|
|
658
683
|
- **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
684
|
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
|
|
661
|
-
The
|
|
662
|
-
|
|
663
|
-
|
|
685
|
+
- **One measurement, in one unit**: `VirtualScroll` needs its viewport size as a number (`viewportSize`), so each view takes its root's border-box block size in layout px from one `ResizeObserver` of the root's own window, observing the root alone (`box: "border-box"`; `useViewBoxHeight`). Every value, the first included, is the delivery's `borderBoxSize[0].blockSize`: the layout effect only starts the observation, and no code reads a size from the DOM.
|
|
686
|
+
The value is `null` until the first delivery, and the view renders no body until then, so no row is ever drawn at a guessed height (the List also waits for its row slot, below). The platform delivers the first observation in the rendering update after the observation starts, after layout and before paint, and that one delivery is committed at once (`flushSync`), so the view's content is drawn before the same frame paints; later deliveries only set React state, which React renders after the delivery.
|
|
687
|
+
A delivery reads and writes nothing in the DOM, and a value equal to the last one written is not written again.
|
|
688
|
+
The observer reports the layout px value itself — the exact `LayoutUnit`, before any ancestor transform or zoom — so the same layout gives the same height whatever the order of the measurements, and each height commits once (the computed `block-size` would serialize a fractional height to 6 significant digits, such as 743.656 for 743.65625, and the observer's value would then commit a second time).
|
|
689
|
+
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.
|
|
690
|
+
- **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.
|
|
691
|
+
`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.
|
|
692
|
+
It adds nothing — the surface sits exactly G from the edge — when the view's height and the row slots are whole numbers of device pixels: the slots are multiples of 4 px (**Row slots on the lattice**), and a host gives the view a height on the 4 px lattice by sizing its own bars on that lattice (for example a window height that is a multiple of 4 under a header and a footer whose heights are rounded up to 4 px).
|
|
693
|
+
- **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).
|
|
664
694
|
|
|
665
695
|
### Keyboard, focus and selection (List / DetailList)
|
|
666
696
|
|
|
@@ -713,7 +743,7 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
713
743
|
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
744
|
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
745
|
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
|
-
|
|
746
|
+
A row frame always fills the box `VirtualScroll` gives it (the List's slot; the DetailList's measured or estimated height), held or not, and a held surface is not measured, so holding moves neither the other rows nor the scroll anchor.
|
|
717
747
|
- **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
748
|
|
|
719
749
|
| Event | Focus owner after it |
|
|
@@ -735,9 +765,19 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
735
765
|
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
766
|
- **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
767
|
- **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:
|
|
768
|
+
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: the anchor is the first visible report, how many px of it are hidden above the viewport, the last visible row and the scroll position it was taken at, all read from `VirtualScroll`'s handle, whose position is current right after a scroll call.
|
|
769
|
+
When the list changes, the view 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).
|
|
770
|
+
The anchor is recorded after every change of the position, so it describes the committed position, not the one before the last change:
|
|
771
|
+
- **Position changes the view makes itself** — key moves (each frame of a held key included), the reveal of keyboard focus, the List's alignment of the card whose mobile overlay closed and its rescale when the slot P changes, the DetailList's selection alignment and its reveal after the viewer's comment, and the DetailList's row re-measurements — all go through one scroller (`ListViewScroller`: `toIndex`, `by` and `resizeRow`) that records the anchor right after the call, without waiting for the next visible-range report.
|
|
772
|
+
A re-measurement (`resizeRow`) is `VirtualScroll`'s `updateItemSize`, which moves the position by the height change when the row lies above the first visible row (its layout-shift compensation); the update, the compensation and the position are all complete when the call returns, so the record reads the compensated state and never mistakes the compensation for a scroll of the user.
|
|
773
|
+
An insert or a delete that commits between such a change and the next frame therefore keeps the position: `End`, `PageDown` and a held key are never reverted, focus stays on the key's destination, and a re-measured row shifts no report.
|
|
774
|
+
The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read), so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled; only the anchor's scroller and the end-to-end test handle hold the full handle, and `src/client/components/view-scroller.spec.ts` also scans the sources for such a call outside the scroller.
|
|
775
|
+
- **Position changes `VirtualScroll` makes on its own** — the layout-shift compensation of a row re-measured above the first visible row, the reconciliation with a new row height after a render, the re-pinning of a pending alignment after a size change, and the second stage of a compensation the pane had clamped — are reported synchronously, once each change is complete, through its `onScrollAdjust` (3.9.0, the peer floor).
|
|
776
|
+
Both views pass the anchor's `handleScrollAdjust` there, which records the anchor from the handle at once, so every position change `VirtualScroll` makes is in the anchor before a list change can commit.
|
|
777
|
+
- **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range, once per frame. A list change in the frame before that report first advances the anchor by the distance scrolled since it was recorded and restores from there: it neither undoes the user's scroll nor shifts the content by a row.
|
|
778
|
+
Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the previous list's row heights from the anchor to the report now at the top, so the walk is bounded by the rows passed since the record (one frame's scroll), whatever the list's length or the position.
|
|
739
779
|
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
|
|
780
|
+
the anchor yields only to a pending selection reveal (a DetailList selection waiting for its report; a key move needs no precedence, since 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
781
|
- **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
|
|
742
782
|
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
783
|
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.
|
|
@@ -759,7 +799,9 @@ The keys are delegated to each view's **list**: the element that holds the view'
|
|
|
759
799
|
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
800
|
- **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
801
|
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
|
|
802
|
+
Confirming moves focus before the comment is hidden, to a destination computed from what remains: the next remaining trash button, else the previous one, else the comment section's heading (`tabIndex=-1`) when the section stays, else the report's own anchor — the row in the DetailList (through the view's focus request), the pane's report heading in the side pane and the mobile overlay — so focus never falls to `body`.
|
|
803
|
+
- **One comment section rule**: the DetailList row, the side pane and the mobile overlay (which shows the side pane's content) render one comment section, its `labels.comments` heading included, only while the report has comments or the viewer may comment on it (the same decision that shows the comment form: see [External Source Badge Configuration](#external-source-badge-configuration-sourcetypeconfigs)).
|
|
804
|
+
A report of a source that takes no comments and has none shows no empty section, and deleting the last comment of such a report removes the section, so focus goes to the report's anchor above.
|
|
763
805
|
- **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
806
|
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
807
|
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.
|
|
@@ -787,29 +829,44 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
787
829
|
|
|
788
830
|
- **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
831
|
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
|
|
791
|
-
|
|
832
|
+
- **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) = 4 · ⌈(2G + 2 × (1 + 15) (the card's border and padding) + 4 (spare) + 6.75R) / 4⌉ = 4 · ⌈(52 px + 6.75 rem) / 4⌉, the sum rounded up to the 4 px layout lattice (`listRowSlotHeight`, `LAYOUT_LATTICE_PX`), so every row top stays on the lattice.
|
|
833
|
+
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).
|
|
834
|
+
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 the sum is exactly 160, so P = 160: the card is P − 2G = 144 px, cards are 16 px apart and an edge-aligned card sits 8 px from the edge (exactly 8 when the view's height is a whole number of device pixels, see **G-symmetric frame**). Roots of 12, 20 and 24 px give 136, 188 and 216 (the sums 133, 187 and 214, rounded up), so the card's spare space grows by less than 4 px.
|
|
835
|
+
P is not a host contract: it follows the host's root font size, so a host or a test locates a row by `[data-daily-report-row="<id>"]` or through the test handle (below), never by its index times a slot height.
|
|
836
|
+
- **Row slots on the lattice**: every row slot of both views is a multiple of the 4 px layout lattice u, which is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every row top sits on a device-pixel boundary wherever the list is scrolled (`VirtualScroll` snaps only the layer that moves the rows; a row starts on a device pixel when the rows above it add up to whole device pixels).
|
|
837
|
+
The List slot is P above. A DetailList row is its measured body plus 2G, and the body — the frame's direct child in every state: the card, the skeleton, the edit form and the error — rounds its content height up to the lattice (`LATTICE_BLOCK_SIZE_CLASS_NAME`: `height: calc-size(auto, round(up, size, 4px))`), so rem line boxes at a root other than 16 px (a 25 px line at a 20 px root) leave the body on the lattice; at a 16 px root the content is already on it. Engines without `calc-size()` drop the declaration and keep the content height (see the browser floor table).
|
|
838
|
+
A row that has not been measured yet is laid out at an estimate of 352 px (`UNMEASURED_ROW_HEIGHT_ESTIMATE_PX`), also a multiple of 4, so the unmeasured rows above the window move the rows below by whole lattice steps. An image attachment tile is padded to the lattice as well (**Tile** in [Attachment display](#attachment-display)), so the rows below an image grid stay on it.
|
|
792
839
|
- **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
840
|
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
841
|
- **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
842
|
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
843
|
- **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
844
|
- **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
|
-
- **
|
|
845
|
+
- **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; the layout of a key move stays inside the rows it changes, wherever the list is scrolled.
|
|
846
|
+
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.
|
|
847
|
+
The DetailList therefore measures each row's body — the frame's direct child, as tall as its content rounded up to the 4 px lattice (**Row slots on the lattice**) — and adds 2G, handing the height to `VirtualScroll` through the scroller's `resizeRow` (see **Host selections and list changes**); measuring the frame would read back the box it fills. A held body is not measured, and a body that is replaced (another load state, a released hold) is observed in its place.
|
|
848
|
+
A frame with layout and size containment that is neither a flex nor a grid item is a relayout boundary in Chromium. Neither frame 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).
|
|
849
|
+
Scrolling repaints rows only where `VirtualScroll` shifts its rendering window. A scroll step that mounts no row writes only the items wrapper's `transform` and repaints no row; a step that mounts one makes Chromium re-centre the area it paints the wrapper's layer in (`will-change: transform`), and every kept row whose content clips its own overflow repaints, which the rows of both views do. `@aiquants/virtualscroll`'s README ("What a scroll step paints") gives the cost of a one-row shift in Chromium 148: 36 layers with 160 px rows and 26 with 448 px rows, Paint 0.99 and 0.66 ms.
|
|
850
|
+
Containing paint would not avoid it (an overflow clip inside a row still marks the row as clipped by that area) and would clip the hover shadow; a `perspective` on the wrapper would avoid it, but it composites the rows under a non-2D transform that Chromium resamples at some device-pixel ratios (at 1.25 the band between the ring and the outline blends). Neither is used, so the strokes below stay whole device pixels.
|
|
799
851
|
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
|
-
|
|
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 `
|
|
852
|
+
The panel is not a relayout boundary in Chromium (it is a flex item), so a layout inside it still starts at the document root and walks the dirty chain of its ancestors; the tile frames inside the panel and the rows 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)).
|
|
853
|
+
The panel reserves its scroll bar's width whatever the content's height (`scrollbar-gutter: stable`, the token `SCROLLBAR_GUTTER_CLASS_NAME` that the pane's header shares, see **Side pane** below), 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.
|
|
854
|
+
The tokens are `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, and `src/client/components/report-views.spec.tsx` checks that both views' frames carry the boundary and are neither flex nor grid items.
|
|
803
855
|
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`.
|
|
856
|
+
The bubble sits at a constant offset from the thumb overlay's box, 16 px beside the bar, and follows the thumb's centre by `transform` alone, with no transition, and the thumb itself moves by a translate snapped to device pixels (`@aiquants/virtualscroll` 3.9.0); so a scroll step that keeps the rendering window writes no `top` or `left` and adds no layout from the document root while the bubble shows.
|
|
804
857
|
- **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
858
|
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
859
|
- **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
860
|
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.
|
|
861
|
+
The side pane's header boxes (the date and author column and the tab list, which clip to reserve the scroll-bar gutter; **Side pane** below) move their clipping edges 4 px out on all four sides the same way (`-m-[4px] p-[4px]` in `SIDE_PANE_HEADER_BOX_CLASS_NAME`), so the heading's and the tabs' outlines stay whole.
|
|
808
862
|
The view-mode toolbar clips nothing (it wraps instead, below).
|
|
809
863
|
`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
864
|
- **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
|
|
812
|
-
|
|
865
|
+
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 straight 2 px blue-600 bar (dark: blue-400) along the straight part of its bottom edge, the bar being the cue that does not depend on colour. Unselected labels are slate-600 (dark: slate-300).
|
|
866
|
+
The bar is an `::after` box at the tab's bottom edge, inset on each side by the tab's own corner radius (`--radius-lg`), so it never runs into the rounded corners: it is 2 px thick along its whole length at every device pixel ratio (a bottom border on a rounded box thins and rises along both corners), and it stays on the straight part for any host `--radius`. It is out of the flow and takes no space, so selecting a tab moves nothing and every label sits in the centre of its 24 px tab.
|
|
867
|
+
In forced colours the thumb is `ButtonText`, the track has a `ButtonText` border and the on track a `Highlight` fill, and the selected bar is `Highlight`, painted as declared (`forced-color-adjust: none` on the bar), so the state survives the colour override.
|
|
868
|
+
The package's error messages — the load-error screen's message and its badge in the host's header, and the side pane's report-load error — take their colour from one token (`ERROR_TEXT_CLASS_NAME`: red-700, dark red-400); the badge sits on the host's header, whose colour is the host's, so it has no pair below.
|
|
869
|
+
`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; the card surface is white, dark slate-900 at 70 % over the page; the overlay panel is white, dark slate-900):
|
|
813
870
|
|
|
814
871
|
| Pair | Light | Dark | Minimum |
|
|
815
872
|
| --- | --- | --- | --- |
|
|
@@ -817,12 +874,15 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
817
874
|
| Switch thumb / on track | 5.25 | 7.64 | 3 |
|
|
818
875
|
| Off track / toolbar surface | 4.43 | 7.18 | 3 |
|
|
819
876
|
| On track / toolbar surface | 4.88 | 7.16 | 3 |
|
|
820
|
-
| Selected tab
|
|
821
|
-
| Selected tab
|
|
822
|
-
| Selected tab
|
|
877
|
+
| Selected tab bar / selected tab | 5.25 | 7.64 | 3 |
|
|
878
|
+
| Selected tab bar / the side pane's tab track | 4.79 | 5.54 | 3 |
|
|
879
|
+
| Selected tab bar / toolbar surface | 4.88 | 7.16 | 3 |
|
|
823
880
|
| Unselected label / the side pane's tab track | 6.90 | 9.83 | 4.5 |
|
|
824
881
|
| Unselected label / toolbar surface | 7.03 | 12.71 | 4.5 |
|
|
825
882
|
| Selected label / selected tab | 17.83 | 18.40 | 4.5 |
|
|
883
|
+
| Error text / card surface (the desktop side pane) | 6.42 | 6.45 | 4.5 |
|
|
884
|
+
| Error text / overlay panel (the side pane's content on a phone) | 6.42 | 6.17 | 4.5 |
|
|
885
|
+
| Error text / the load-error panel (the card surface over the page frame) | 6.42 | 6.45 | 4.5 |
|
|
826
886
|
|
|
827
887
|
- **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
888
|
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`.
|
|
@@ -834,21 +894,33 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
834
894
|
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
895
|
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
896
|
Both option sets are module constants, so the view's props to `VirtualScroll` stay equal from render to render.
|
|
897
|
+
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; the peer floor is 3.9.0) stops every transition and animation of every part it animates under `prefers-reduced-motion: reduce`
|
|
898
|
+
(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.
|
|
837
899
|
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`.
|
|
900
|
+
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.
|
|
838
901
|
- **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
902
|
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)).
|
|
903
|
+
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)).
|
|
841
904
|
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.
|
|
905
|
+
The tile around the frame is on the lattice anyway: its last line carries the pad δ(t) = ⌈h / 4⌉ · 4 − h (0–3 px) as a bottom margin, so every tile is ⌈h / 4⌉ · 4 + 48 px tall, every tile row top and every grid's height are multiples of 4 px, and what follows a grid stays on the lattice (**Tile** in [Attachment display](#attachment-display)).
|
|
842
906
|
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
907
|
**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
908
|
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.
|
|
909
|
+
- **Origin on the 4 px lattice**: the views' centred column (at most 72 rem, `max-w-6xl`, centred with `mx-auto`) starts at the centring offset rounded down to a multiple of 4 px (`VIEW_COLUMN_ORIGIN_CLASS_NAME`: `ml-[max(0px,round(down,calc((100%-72rem)/2),4px))]`, part of `VIEW_COLUMN_CLASS_NAME`, so the loading and load-error screens start there too), less than 4 px left of the exact centre.
|
|
910
|
+
4 CSS px is a whole number of device pixels at the ratios 1, 1.25, 1.5, 1.75 and 2 (4, 5, 6, 7 and 8), so every inline edge built on the origin — G, the ladder steps, the card, the row frame — starts on a whole device pixel.
|
|
911
|
+
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.
|
|
912
|
+
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.
|
|
913
|
+
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.
|
|
914
|
+
A host that wants the same whole-pixel strokes on the top and bottom edges at fractional ratios puts both edges on the lattice too — for example with a header and a footer whose heights are their content rounded up to 4 px (`height: calc-size(auto, round(up, size, 4px))`) in a window whose height is a multiple of 4, since a bar sized by its font metrics alone ends on a fraction (such as 30.4375 px); the bottom-aligned surface then sits exactly G from the view's end (**G-symmetric frame** in [View height](#view-height-host-layout)).
|
|
845
915
|
- **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.
|
|
916
|
+
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.
|
|
917
|
+
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.
|
|
846
918
|
- **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
919
|
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
920
|
- **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
921
|
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
922
|
- **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` (`
|
|
923
|
+
- **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).
|
|
852
924
|
- **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
925
|
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
926
|
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).
|
|
@@ -866,6 +938,9 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
866
938
|
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
939
|
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
940
|
- **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.
|
|
941
|
+
The tile root (the grid item) is the tile's one inline-size container, exactly as wide as its track, so `100cqi` inside the tile is t. A tile is the frame (h, below), the 8 px gap, the 20 px name, the 4 px gap and the 16 px last line, h + 48 px in all, and h need not be a multiple of 4.
|
|
942
|
+
The last line therefore carries a bottom margin, the lattice pad δ(t) = ⌈h / 4⌉ · 4 − h (`ATTACHMENT_TILE_LATTICE_PAD_STYLE`: `margin-block-end: calc(round(up, h, 4px) - h)`, where h is the frame height's own expression, resolved against the same container), which puts the remainder of 0–3 px under the last line and keeps the gaps between the frame, the name and the last line.
|
|
943
|
+
Every tile is then ⌈h / 4⌉ · 4 + 48 px tall, so every tile row top and the grid's height stay on the 4 px lattice, and so does what follows the grid (the DetailList rows below an image grid start on device pixels); for the tracks below, t = 196 gives h = 131 and δ = 1, 171 gives 114 and 2, 231⅓ gives 154 and 2, and 234⅔ gives 156 and 0. On Chrome 121–124, which lack `round()`, the margin declaration is dropped with the frame height, and a tile stays its frame's height + 48 px tall.
|
|
869
944
|
- **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
945
|
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
946
|
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.
|
|
@@ -879,14 +954,15 @@ Whichever size rule wins the cascade, a selected surface therefore paints nothin
|
|
|
879
954
|
- **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
955
|
- **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
956
|
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
|
|
957
|
+
- **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 its tile, whose root is the inline-size container exactly as wide as its track (so `100cqi` is t; the frame's parent is a plain block wrapper, not a container), 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
958
|
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).
|
|
959
|
+
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 (each tile adds the lattice pad under its last line, **Tile** above). 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
960
|
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
961
|
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
962
|
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
963
|
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;
|
|
964
|
+
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;
|
|
965
|
+
and as a positioned box that is not a flex or grid item (its parent is a block wrapper inside the tile link), 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
966
|
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
967
|
- **`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
968
|
- **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**):
|
|
@@ -969,7 +1045,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
969
1045
|
```
|
|
970
1046
|
|
|
971
1047
|
- **One surface for all wording.** The catalog covers the 7 engine chrome keys of
|
|
972
|
-
`@aiquants/virtualscroll` plus
|
|
1048
|
+
`@aiquants/virtualscroll` plus 83 own keys: field headings, the page title (`title`, passed to
|
|
973
1049
|
`renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state read to
|
|
974
1050
|
screen readers, tabs, buttons, placeholders, `confirm()` / `alert()` texts, stream badges, the load-error screen
|
|
975
1051
|
and the mutation-failure message. The engine part of each catalog is `VIRTUAL_SCROLL_LABEL_CATALOGS[locale]`, reused by
|
|
@@ -988,7 +1064,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
988
1064
|
the BCP 47 tag bound to their locale (`en` → `"en-US"`, `ja` → `"ja-JP"`); a host override receives
|
|
989
1065
|
the raw number and formats it itself.
|
|
990
1066
|
- **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
|
|
991
|
-
`RangeError` at render. So do an unknown `labels` key (a key error that lists the
|
|
1067
|
+
`RangeError` at render. So do an unknown `labels` key (a key error that lists the 90 keys), a string
|
|
992
1068
|
key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
|
|
993
1069
|
function. An `undefined` value keeps the catalog value.
|
|
994
1070
|
- **`config` has a closed key set**: `apiBasePath`, `ssePath`, `draftLabelName`, `locale`, `labels`,
|
|
@@ -1025,7 +1101,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
|
|
|
1025
1101
|
grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
|
|
1026
1102
|
that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
|
|
1027
1103
|
|
|
1028
|
-
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all
|
|
1104
|
+
Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 90 keys, the 7 engine keys
|
|
1029
1105
|
first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
|
|
1030
1106
|
(the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
|
|
1031
1107
|
`locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
|
|
@@ -1039,7 +1115,7 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
1039
1115
|
| `scrollRight` | engine: horizontal arrow (not rendered by this package) | Scroll right | 右へスクロール |
|
|
1040
1116
|
| `scrollToTop` | engine: top pill (not enabled by this package) | Top | 先頭へ |
|
|
1041
1117
|
| `scrollToBottom` | engine: bottom pill (not enabled by this package) | Bottom | 末尾へ |
|
|
1042
|
-
| `noItems` | engine: empty list
|
|
1118
|
+
| `noItems` | engine: the one message of an empty List or DetailList, over the top of the list | No items | 項目がありません |
|
|
1043
1119
|
| `id` | DetailList card: id chip | ID | ID |
|
|
1044
1120
|
| `businessDate` | business date heading | Business date | 営業日 |
|
|
1045
1121
|
| `author` | author heading | Author | 作成者 |
|
|
@@ -1085,7 +1161,6 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
1085
1161
|
| `streamFailed` | stream badge (failed) | Failed to load | 読み込みに失敗しました |
|
|
1086
1162
|
| `streamRetry` | stream badge retry button | Retry | 再試行 |
|
|
1087
1163
|
| `streamRevalidating` | stream badge (revalidating) | Refreshing | 最新化中 |
|
|
1088
|
-
| `listEmpty` | package empty state, laid over the top of an empty list | No data | データがありません |
|
|
1089
1164
|
| `selectPrompt` | side pane without a selection | Select a report from the list on the left. | 左側の一覧から日報を選択してください。 |
|
|
1090
1165
|
| `loading` | side pane loading (edit mode) | Loading... | 読み込み中... |
|
|
1091
1166
|
| `reportNotFound` | list card of a vanished report | Report not found | 日報が見つかりません |
|
|
@@ -1216,10 +1291,13 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1216
1291
|
| 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 |
|
|
1217
1292
|
| The start of a catch-up page is trimmed away before the page is read | 200, the entries delivered so far → `retry: 86400000` → `event: resync-required` → end | same as above |
|
|
1218
1293
|
| Empty stream, or a cursor inside the retained window or in the future | 200, `connected` → heartbeat → catch-up (every entry) → live | continues |
|
|
1219
|
-
| Redis failure during the catch-up | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
|
|
1294
|
+
| Redis failure during the catch-up, or the stream tail cannot be read while the shared reader fixes its read position (`ready()` rejects; on the path without a cursor before `connected` is written) | the body ends (`producer-failed`) | one native reconnect with `Last-Event-ID`, then the utility's own backoff; the cursor is kept |
|
|
1220
1295
|
|
|
1221
1296
|
- **Resume cursor**: `readLastEventId` reads the `Last-Event-ID` header first, then the `lastEventId` query parameter (`DAILY_REPORT_SSE_CURSOR_PARAM`). The browser's own reconnect reuses the URL it opened with (a stale query) but sends a fresh header, so the header is always the newer value. A cursor must be a Redis stream id of the form `<ms>-<seq>` with 1–15 digits per part (`isDailyReportSseStreamId`); anything else is answered with `resync-required`. The client URL-encodes the cursor.
|
|
1222
1297
|
- **`connected` frame**: `data: {"type":"connected"}` with `id:` set to the resume position (the cursor, or the newest entry at connect time when there is no cursor). On the cursor path it is written before any Redis round trip.
|
|
1298
|
+
Without a cursor it is written only after the shared reader has fixed its read position (`DailyReportSseReader.ready()`), so every entry appended after a client sees `connected` reaches that client; on the cursor path the catch-up is read after that point instead, so nothing appended between the catch-up and the position fix is lost.
|
|
1299
|
+
The read position is always a concrete stream id: the shared reader fixes it by reading the stream tail (XREVRANGE) before its first run starts reading, and it never issues an XREAD from `$` (which the server resolves to the tail again at every read, so entries appended between two blocking reads would reach nobody).
|
|
1300
|
+
When the tail cannot be read, `ready()` rejects and the failed read is not kept, so the next call reads again: the connection that waited for it ends (`producer-failed`, the row above) and the client reopens it, with its cursor when it has one, so the catch-up covers everything since; and a run that cannot fix its position fails like any other failing run (every subscriber's `onError`). A host with its own SSE handler awaits `ready()` the same way and closes the connection when it rejects.
|
|
1223
1301
|
The same frame is used as an anchor-only frame for entries the viewer's filters drop (addressed to another user, or a source type the viewer cannot see), so the client's cursor keeps advancing without receiving their content.
|
|
1224
1302
|
Without it, a tab whose visible traffic is quiet while other users' read / star updates flow would keep an old cursor, and its next reconnect would fall outside the retained window (`resync-required`, then an ids rescan that bypasses the server cache).
|
|
1225
1303
|
- **Catch-up**: entries after the cursor are read in pages of 1,000 (`xRange` is inclusive, so each page skips its start id) until the stream end; every entry is delivered. When a page does not start with its start id, the oldest retained entry is read again: if it is newer than the start id, the range after it may have been trimmed, so the connection ends with `resync-required` instead of silently skipping it (a cursor that is simply not an entry id inside the retained window continues).
|
|
@@ -1237,7 +1315,7 @@ Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000
|
|
|
1237
1315
|
|
|
1238
1316
|
## API Surface (Summary)
|
|
1239
1317
|
|
|
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.
|
|
1318
|
+
Every entry exports by name (no `export *`), so a module-internal helper never becomes public by accident. A name is added to an entry only when a consumer — a host app or the examples — imports it: documenting a name here is no reason to export it, and the values that tune the package itself (its row geometry, its scroll settings) stay inside it. `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
1319
|
|
|
1242
1320
|
- **shared** (`@aiquants/daily-report`, isomorphic):
|
|
1243
1321
|
- 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),
|
|
@@ -1248,11 +1326,11 @@ Every entry exports by name (no `export *`), so a module-internal helper never b
|
|
|
1248
1326
|
- Components: `DailyReportPage` / `DailyReportResolvedContent` / `DailyReportList` / `DailyReportDetailList` / `DailyReportAttachmentIndicator`, the providers `DailyReportConfigProvider` / `DailyReportActionProvider` / `DailyReportErrorProvider`.
|
|
1249
1327
|
- Hooks: `useDailyReportConfig` / `useDailyReportActionContext` (`sseStatus`) / `useDailyReportDetail` / `useDailyReportPrefetch` / `useDailyReportComments` / `useDailyReportSseConnection` (returns `DailyReportSseConnectionStatus`) / `useDailyReportIdsStream` (state carries `streamAnchor`).
|
|
1250
1328
|
- 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
|
|
1329
|
+
- Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` / `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`. No layout value is public: the row geometry follows the host's root font size (see **List slot P**), so rows are located by `[data-daily-report-row]` or the test handle.
|
|
1252
1330
|
- Types: `DailyReportClientConfigInput` / `DailyReportClientConfigDefaults` / `SourceTypeConfig` / `DailyReportLocale` / `DailyReportLabels` / `DailyReportLabelOverrides` / `DailyReportOperation` / `DailyReportErrorInfo` / `DailyReportSseConnectionStatus`.
|
|
1253
1331
|
- **server** (`@aiquants/daily-report/server`, Node.js):
|
|
1254
1332
|
- 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`.
|
|
1333
|
+
- 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`.
|
|
1256
1334
|
- SSE and caching: `DailyReportSseReader` / `DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS` / `DAILY_REPORT_SSE_CATCH_UP_BATCH` / `isStreamIdLte` / `SqlResultCache` / `transformJsonArray` / `jsonResponseWithETag` / `generateETag`.
|
|
1257
1335
|
- Types: `DailyReportServerConfig` / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` / `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `StreamEntry` / `ExternalReportFields`, and for attachments `DailyReportAttachmentDelivery` / `DailyReportReadAttachment` / `DailyReportAttachmentError` / `DailyReportAttachmentFailure` / `DailyReportAttachmentThumbnailRenderer` / `DailyReportAttachmentThumbnail` / `DailyReportAttachmentThumbnailSourceFormat` / `DailyReportAttachmentThumbnailOutputMediaType` / `SharpLike`.
|
|
1258
1336
|
|