@aiquants/daily-report 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,6 +56,7 @@ domain-specific knowledge. It is one module with three entries:
56
56
  - [Client wiring](#client-wiring)
57
57
  - [View height (host layout)](#view-height-host-layout)
58
58
  - [Keyboard, focus and selection (List / DetailList)](#keyboard-focus-and-selection-list--detaillist)
59
+ - [Assistive technology checklist](#assistive-technology-checklist)
59
60
  - [Selection and focus appearance](#selection-and-focus-appearance)
60
61
  - [Attachment display](#attachment-display)
61
62
  - [Test hooks](#test-hooks)
@@ -82,11 +83,16 @@ pnpm add @aiquants/daily-report @aiquants/virtualscroll @aiquants/swipe-overlay
82
83
  ```
83
84
 
84
85
  `@aiquants/virtualscroll` / `@aiquants/swipe-overlay` / `@aiquants/resize-panels` / `react` / `react-dom` / `react-router` / `drizzle-orm` / `zod` are
85
- **peer dependencies**. React must be **19 or later** (`react` / `react-dom` `>=19`): the rows register their keyboard focus targets with ref callbacks
86
- that return a cleanup.
86
+ **peer dependencies**. React must be **19 or later** (`react` / `react-dom` `>=19`): the views attach their `VirtualScroll` handle and observe the attachment
87
+ names with ref callbacks that return a cleanup.
87
88
  `zod` may be 3.25.76 or later, zod 4 included (`>=3.25.76 <5.0.0`): the package reads zod only through its `zod/v3` entry, which every version in that
88
89
  range ships, so the shipped declarations compile and a mismatch names the same issue codes whichever version the host installs.
89
- The peer floor of `@aiquants/virtualscroll` is **3.14.0**: install 3.14.0 or later.
90
+ The peer floor of `@aiquants/virtualscroll` is **3.17.0**: install 3.17.0 or later.
91
+ Both views name their active row through its `activeItemIndex`, which renders `aria-activedescendant` in the same render as the row and keeps that
92
+ row mounted, count that row as shown only beyond its `activeItemVisibleInset`, describe an empty composite by its empty text, and skip the rendering
93
+ of far rows through its `itemZones` and `useVirtualScrollItem`; they bind its focus-ring colour variable `--aqvs-focus-ring-color` to the row outline
94
+ (each since 3.17.0; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist) and
95
+ [Selection and focus appearance](#selection-and-focus-appearance)).
90
96
  Both views pass its scroll-bar option `enableArrowButtonTabStops: false`, since 3.10.0 its one signal that the host scrolls by keyboard itself, under
91
97
  which the scroll bar is pointer-only — hidden from assistive technology, out of the Tab order and never taking focus on a press (the views scroll by
92
98
  keyboard themselves; see [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)) —; its stylesheet stops the motion of its
@@ -116,7 +122,7 @@ of the floor, and the row says what those engines draw:
116
122
  | Feature | Supported from | Used for | Below the floor |
117
123
  | --- | --- | --- | --- |
118
124
  | `Element.checkVisibility({ visibilityProperty: true })` and its `opacityProperty` option | 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; the focus successors (with `opacityProperty: true` too; **Focus goes only to what shows it** in [Client wiring](#client-wiring)) | 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); a hand-over of focus throws the same error before it moves focus (a retry button's press then does not retry, the notice's close does not close, and a hand-over in a layout effect reaches the nearest error boundary). With the method but not the options (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, and an element at opacity 0 counts as a visible successor |
119
- | `: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) |
125
+ | `:has()` | Chrome 105, Firefox 121, Safari 15.4 | The keyboard-focus outline of a List card whose primary button holds keyboard focus (the card's surface reads its direct-child button). The selection ring, its forced-colours outline and the active row's outline come from the row frame's own `aria-selected` and `data-daily-report-active` and do not use `:has()` | A List card whose primary button holds keyboard focus (after a pointer hand-back, a key on the button) shows no focus outline; the selection ring and the active row's outline still show, and the DetailList is unaffected |
120
126
  | Container size queries and container query units (`cqi`) | Chrome 105, Firefox 110, Safari 16 | The attachment grid's column count and its whole-pixel width (both read from the grid's own size container); 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 |
121
127
  | CSS `round()` | Chrome 125, Firefox 118, Safari 15.4 | The attachment grid's whole-pixel width `calc(round(down, 100% − 16 (n − 1), n px) + 16 (n − 1))` per column count, which makes every track a whole pixel, the tile frame's whole-pixel height `round(nearest, 100cqi * 320 / 480, 1px)` and the tile's lattice pad `round(up, h, var(--aqdr-lattice-block)) − h` under its last line (see [Attachment display](#attachment-display)); the views' column origin on the lattice (see **Origin on the lattice**) | Chrome 121–124, inside the floor, drop those declarations: the grid fills its container with the fluid tracks (W − 16 (n − 1)) / n (every breakpoint keeps its column count, which no `round()` declaration sets), so tile edges can fall between pixels and the names are fitted less than 1 px narrower than the track; 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 |
122
128
  | CSS `calc-size()` | Chrome 129 | The DetailList row bodies', the error notice's and the view-mode toolbar band's height, their content height rounded up to the block quantum of the device-pixel lattice (`calc-size(auto, round(up, size, var(--aqdr-lattice-block)))`: 4 px, 8 px at the odd multiples of 1/8; see **Row slots on the lattice** and **Origin 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 4 px lattice at a 16 px root and can leave it at other roots and at the odd multiples of 1/8, so the DetailList rows below can start between device pixels; the toolbar's band keeps its content height, so at the odd multiples of 1/8 the views start half a device pixel off whenever that height is not a multiple of 8 px (the toolbar alone is 32) |
@@ -213,10 +219,17 @@ const tables = defineDailyReportSchema("dbo_app", { userTable: Users })
213
219
  ```
214
220
 
215
221
  `DailyReportHub.source_id_num` is a computed column (`TRY_CAST(source_id AS BIGINT)`), used to join external legacy sources.
222
+ `DailyReportHub.source_type` must be an `nvarchar(n)` column with a declared length from 1 to 4,000 (`defineDailyReportSchema` declares
223
+ `nvarchar(20)`): the source-type predicate compares the lower-cased stored value and the tokens as UTF-16 bytes of twice that length
224
+ ([Source-type visibility](#source-type-visibility)), and `createDailyReportService` stops with a `RangeError` for any other column
225
+ (`[daily-report] tables.hub.sourceType must be an nvarchar(n) column with a declared length (source types compare as UTF-16 bytes); got "<SQL type>"`).
226
+ Every write to an existing report starts its transaction with an update of the report's live hub row that changes no value and outputs the row, so
227
+ the hub's `update` must support drizzle's `output()` (a hand-written database stand-in answers it).
216
228
 
217
229
  The text search adds two optional tables in a schema of their own (see [Text search](#text-search)); `defineDailyReportSearchSchema(schemaName)`
218
230
  defines them, and a host may inject its own models of the same shape. The service also needs `execute` on `DailyReportDb` (drizzle's `db.execute`; the
219
- handle a host already passes has it).
231
+ handle a host already passes has it): the star and read toggles write the viewer's status row with one `MERGE … WITH (HOLDLOCK)` through it and read
232
+ the row it outputs, so two first toggles of the same report and viewer never collide on the primary key.
220
233
 
221
234
  ## Server wiring (DI)
222
235
 
@@ -231,6 +244,8 @@ export const dailyReportServer = createDailyReportServer({
231
244
  resolveUserId: async (externalId) => {/* External ID (e.g., OAuth sub) -> internal numeric ID */},
232
245
  encodeUserId: (id) => encodeId(id), // Internal ID obfuscation (e.g. sqids)
233
246
  authenticate: authenticateInLoader, // DailyReportAuthenticate (overloaded): string mode => { user, cookie? } / null mode => { user?, cookie? }
247
+ loginRedirectPath: SIGN_IN_PATH, // Required: the host's sign-in route, where the page loader sends a signed-out reader
248
+ resolveVisibleSourceTypes: visibilityPort, // Required: the source types each request may view (see Source-type visibility)
234
249
  redis: redisProvider as unknown as DailyReportRedisProvider, // Optional (disables SSE/epoch)
235
250
  externalSources: [ // Optional: ingest legacy daily report tables from external systems
236
251
  { sourceType: "legacy", table: LegacyReport, idColumn: LegacyReport.reportId, mapRow: mapLegacyRow },
@@ -390,6 +405,12 @@ the two routes need no guard of their own:
390
405
  `cache_miss_admission_suppressed count=<N> since=<ISO 8601 time of the window's first refusal> viewer=<internal id> endpoints=business-date:<n>,ids-columns:<n>,report:<n>`
391
406
  line written by the window's own timer at its end; the refusal of an aborted request writes nothing.
392
407
  The slots and the queues are per worker process, so the effective bounds across workers are the worker count times these.
408
+ - **Visibility unavailable**: every endpoint, the ids stream included, resolves the viewer's source-type visibility once per request, after the 405
409
+ and before its own checks and reads (the cache keys depend on the scope). When the visibility port throws or answers outside its contract
410
+ ([Source-type visibility](#source-type-visibility)), the request is answered 503 `{"error":{"message":"Report visibility is unavailable"}}` with
411
+ `Retry-After: 5` and `Cache-Control: no-store`, before any read, and no body starts: an empty list would read as "no visible report" and an ids
412
+ stream would hand out the live anchor with it. The action, the attachment route and the SSE route answer the same outcome in their own forms
413
+ (**Visibility, then the user** under **Request values** below; the `busy` 503 in [Attachments](#attachments); [SSE wire contract](#sse-wire-contract)).
393
414
  - **Security headers**: every response of the API, action and SSE routes, whatever its status — the JSON answers and refusals, the action's answers,
394
415
  the request isolation's refusals, the SSE stream, its terminal frames and its errors, the ids NDJSON — carries `X-Content-Type-Options: nosniff`,
395
416
  `X-Frame-Options: DENY` and `Content-Security-Policy: default-src 'none'`, set last, so no header of a response overrides them.
@@ -496,6 +517,12 @@ its payload — with one strict parser each, after authentication and before any
496
517
  - **`toggleStar` / `toggleRead`**: `isStarred` / `isRead` is exactly `true` or `false`. Anything else — `TRUE`, `1`, `yes`, the empty text — is
497
518
  `Invalid isStarred` / `Invalid isRead`, and a missing one is `isStarred required` / `isRead required`.
498
519
  - **`addComment`**: `content` is a non-empty text (`Content required` otherwise). **`deleteComment`**: `commentId` (**Ids** above).
520
+ - **Visibility, then the user**: once the form is read and checked, the action resolves the viewer's source-type visibility once
521
+ ([Source-type visibility](#source-type-visibility)). When the port throws or answers outside its contract, the action answers 503
522
+ `{"error":"Report visibility is unavailable"}` with `Retry-After: 5` and `Cache-Control: no-store`, before the internal user lookup and any intent.
523
+ Then the internal user (`User not found`, 404). A `create` whose scope does not show the package's own source type (`Internal`) is 403
524
+ `{"error":"Creating reports is restricted"}` before any query; the other intents answer a report the scope hides as 404 (**Where it applies** in
525
+ [Source-type visibility](#source-type-visibility)).
499
526
  - **One wire contract for the action and the JSON endpoints**, which the server, the package's client and a host that calls the routes itself all read
500
527
  from the shared entry (`@aiquants/daily-report`; neither side restates a name):
501
528
  - the intents (`DAILY_REPORT_ACTION_INTENTS` and their type `DailyReportActionIntent`, from which the server's form reading and the client's
@@ -568,11 +595,17 @@ its payload — with one strict parser each, after authentication and before any
568
595
  shared draft-label test, beside the hub row) or true when its creation category is one of the names; a row whose flag reads as anything but 1 or 0
569
596
  throws.
570
597
  The client compares no name: the views open the viewer's own draft in the editor by `isDraft` alone, so a draft named by any of the names, or by a
571
- variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:v3`),
598
+ variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:v4`; [Realtime architecture](#realtime-architecture)),
572
599
  so an entry written before `isDraft` existed is never read.
573
- - `resolveVisibleSourceTypes(request)` — **Optional row-level authorization port.** Returns the `Hub.source_type` values this request may view.
574
- Applies uniformly to every server data path: list (ids stream), business-date list, detail, comments, attachment bytes, and SSE. See
600
+ - `resolveVisibleSourceTypes(request, { reason })` — **Required row-level authorization port.** Returns the `Hub.source_type` values this request may
601
+ view, or the explicit opt-out `{ unrestricted: "<reason>" }` of a deployment that does not restrict by source type. Applies uniformly to every server
602
+ data path: list (ids stream), business-date list, detail, comments, attachment bytes, SSE, the create and every write to a report.
603
+ A configuration without it is a type error, and `createDailyReportServer` / `createDailyReportHandlers` stop with a `TypeError` when it is missing
604
+ (`[daily-report] resolveVisibleSourceTypes must be a function`), so no deployment can leave every category open by forgetting it. See
575
605
  [Source-type visibility](#source-type-visibility) below.
606
+ - `loginRedirectPath` — **Required**: the path of the host's sign-in route, where the page loader (`index.loader`) redirects a reader who is not
607
+ signed in. `createDailyReportServer` and `createDailyReportHandlers` stop with a `RangeError` for anything but a non-blank text
608
+ (`[daily-report] loginRedirectPath must be the non-blank path of the host's sign-in route; got <value>`): the package names no host route of its own.
576
609
  - `enableDevCacheClear` — Gates the dev-only `POST /action` `intent=clearCache` (flush every worker's cache). Default `false` → the action answers
577
610
  `400` (`Invalid intent`) and never calls `service.clearCache()`. Wire `import.meta.env.DEV` to enable it only in development (any authenticated user
578
611
  could otherwise flush all caches without limit).
@@ -590,7 +623,7 @@ its payload — with one strict parser each, after authentication and before any
590
623
  | Channel | Console from | Lines |
591
624
  | --- | --- | --- |
592
625
  | `[DailyReportAPI]` | `error` | the 500 of a JSON endpoint and of the action, the ids stream's emission failures, and the JSON answers' `debug` trace (below) |
593
- | `[DailyReportVisibility]` | `error` | a `resolveVisibleSourceTypes` that threw (the request fails closed) |
626
+ | `[DailyReportVisibility]` | `error` | a `resolveVisibleSourceTypes` that threw or answered outside its contract (the request is answered "not now": **Failure** in [Source-type visibility](#source-type-visibility)) |
594
627
  | `[DailyReportSSE]` | `info` | the SSE route: a malformed resume cursor, its producers' and the shared reader's errors, its 500 |
595
628
  | `[DailyReportAttachment]` | `info` | both attachment deliveries (**Log levels** under [Attachments](#attachments)) |
596
629
  | `[DailyReportIsolation]` | `warn` | the refusal record of [Request isolation](#request-isolation) |
@@ -606,7 +639,7 @@ its payload — with one strict parser each, after authentication and before any
606
639
 
607
640
  The client has no host logger port: its four channels of the same list write to the console from `info` — `[DailyReportActionProvider]` (the action
608
641
  provider: a failed action, the queue of SSE messages about uncached reports, a cache reload), `[DailyReportDetailLoader]` (the detail loading: a
609
- stale update of a report's cache, a failed prefetch of a business date),
642
+ stale update of a report's cache, a failed prefetch of a business date, a failed refetch that left the shown report in place),
610
643
  `[DailyReportIdsStream]` (the ids stream client: a revalidation that gave up, a rejected malformed line, which ends the attempt, a skipped line of
611
644
  an unknown kind, a malformed stream anchor) and `[DailyReportSseConnection]` (the SSE connection hook).
612
645
  Every line, the server's and the client's, carries exactly one channel prefix: no message spells a `[DailyReport…]` prefix of its own.
@@ -622,7 +655,7 @@ its payload — with one strict parser each, after authentication and before any
622
655
  and 405), so a client decides neither the number nor the size of the lines.
623
656
  - Primary tuning parameters: `idsTtlMs` (180 s) / `businessDateTtlMs` (300 s) / `cacheDefaultTtlMs` (60 s) / `cacheMaxBuckets` (4,096) /
624
657
  `cacheMaxBytes` (512 MiB per process; both **The SQL result cache** in [The API route](#the-api-route)) / `streamKey` / `streamMaxLen` (10,000
625
- entries) / `loginRedirectPath`; the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
658
+ entries); the attachment tuning lives in the `attachments` block ([Attachments](#attachments)).
626
659
  Every number among them is an integer of at least 1, and only an omitted one takes its default: `0`, a negative number, `NaN`, a fraction or 2^53
627
660
  stops `createDailyReportService` and `createDailyReportServer` with a `RangeError` that names the key
628
661
  (`[daily-report] idsTtlMs must be an integer >= 1; got 0`).
@@ -665,12 +698,15 @@ writes no line in the refusal record below (it is not a cross-site refusal). Eac
665
698
  Each route's decisions — the name its refusals are recorded under (`api`, `action`, `sse` or `attachment`), the navigations it serves from another
666
699
  origin, its answer to a data request and its 403 — come from one policy of that route, so they cannot be paired wrongly, and an endpoint added with a
667
700
  streaming body is refused from the start.
701
+ The cross-origin rule itself is `crossOriginRefusal` of `@aiquants/request-isolation` (a dependency of this package, so every package that adopts it
702
+ refuses the same requests): a route's policy extends that package's `CrossOriginPolicy` (which navigations from another origin it serves), and the
703
+ rule answers why it refuses (`cross-origin-fetch` or `foreign-origin`, below), which the refusal record writes.
668
704
 
669
705
  | Request | Answer |
670
706
  | --- | --- |
671
707
  | `Sec-Fetch-Site: same-origin` or `none` (a load the user started: the address bar, a bookmark) | Served: authentication and the route's own checks follow |
672
- | Any other `Sec-Fetch-Site` — `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or an unknown value | 403 before authentication, unless the request is a top-level document navigation that the route serves: method `GET`, `Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest` exactly `document`, and a route policy that accepts it. Only the attachment route accepts one, and only for an original (`inline` or `download`, read by the loader's own strict query parser): a link in another page opens or downloads the original. The API, SSE and thumbnail routes accept no navigation from another origin |
673
- | No `Sec-Fetch-Site` | A safe method (`GET`, `HEAD`, `OPTIONS`) is served. An unsafe method is decided by `Origin`: no `Origin` is served (not a browser page), an `Origin` whose host is the host the request was addressed to (`new URL(request.url).host`, which the adapter builds from `Host`) is served, and `null`, an unparsable value, another host or another port is 403 |
708
+ | Any other `Sec-Fetch-Site` — `same-site` (another origin of the same site, such as a sibling subdomain), `cross-site` or an unknown value | 403 (`cross-origin-fetch`) before authentication, unless the request is a top-level document navigation that the route serves: method `GET`, `Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest` exactly `document`, and a route policy that accepts it. Only the attachment route accepts one, and only for an original (`inline` or `download`, read by the loader's own strict query parser): a link in another page opens or downloads the original. The API, SSE and thumbnail routes accept no navigation from another origin |
709
+ | No `Sec-Fetch-Site` | A safe method (`GET`, `HEAD`, `OPTIONS`) is served. An unsafe method is decided by `Origin`: no `Origin` is served (not a browser page), an `Origin` whose host is the host the request was addressed to (`new URL(request.url).host`, which the adapter builds from `Host`) is served, and `null`, an unparsable value, another host or another port is 403 (`foreign-origin`) |
674
710
 
675
711
  - **Requests without Fetch Metadata**: a browser sends no `Sec-Fetch-*` to an origin that is not potentially trustworthy (plain HTTP other than
676
712
  `localhost`), so such a request comes either from a client that is not a browser or from a page of such an origin. A browser always sends `Origin`
@@ -688,7 +724,9 @@ streaming body is refused from the start.
688
724
  - **The refusal record**: each handler factory keeps one bounded record of its refusals (state per factory), written on the `[DailyReportIsolation]`
689
725
  channel: to the configured `logger` with that prefix, or by default to the console from the `warn` level.
690
726
  A refusal with no open window writes one warn line at once,
691
- `cross_site_rejected route=<api|action|sse|attachment> site=<value> mode=<value> dest=<value> method=<method>` (`-` for an absent header; a value
727
+ `cross_site_rejected route=<api|action|sse|attachment> reason=<cross-origin-fetch|foreign-origin> site=<value> mode=<value> dest=<value> method=<method>`
728
+ (the reason says which half of the rule refused: `cross-origin-fetch` when the Fetch Metadata names another origin, `foreign-origin` when a request
729
+ without Fetch Metadata uses an unsafe method and an `Origin` that does not name the request's host; it carries nothing the request sent; `-` for an absent header; a value
692
730
  outside the specification's shape — lower-case letters and hyphens, at most 32 characters — is written as the JSON string of its first 32
693
731
  characters, so a sender cannot forge the line's fields), and opens a window of 60 s that every route of the factory shares; later refusals in the
694
732
  window are only counted, per route.
@@ -711,59 +749,120 @@ streaming body is refused from the start.
711
749
  ### Source-type visibility
712
750
 
713
751
  Restrict which report categories a viewer may see, without the package depending on any authorization library. The port receives the request and
714
- returns plain strings; your app decides the policy.
752
+ returns plain strings; your app decides the policy. The port is required, so no deployment leaves every category open by omission: a deployment
753
+ that does not restrict by source type says so with the explicit opt-out and its reason.
715
754
 
716
755
  ```ts illustrative
756
+ import { createDailyReportVisibilityCache } from "@aiquants/daily-report/server"
757
+
717
758
  createDailyReportServer({
718
759
  // …existing config
719
- resolveVisibleSourceTypes: async (request, { reason }) => {
720
- // `reason === "refresh"` is the periodic re-check of a live SSE connection: bypass any
721
- // request-scoped cache there, or a revoked grant keeps streaming until the client reconnects.
722
- const grants = await yourAuthz.grantsFor(request, { skipCache: reason === "refresh" })
723
- return grants.canReadLegacy ? ["legacy"] : []
724
- },
760
+ resolveVisibleSourceTypes: createDailyReportVisibilityCache({
761
+ ttlMs: 30_000,
762
+ // `refresh` is the periodic re-check of a live SSE connection: read the viewer from the session afresh there,
763
+ // so a viewer who signed out reads as null and the stream ends.
764
+ readViewerKey: async (request, { reason }) => (reason === "initial" ? yourAuth.viewerIdOf(request) : yourSession.viewerIdOf(request)),
765
+ resolve: async (request, { reason }) => {
766
+ const grants = await yourAuthz.grantsFor(request, { skipCache: reason !== "initial" })
767
+ return grants.canReadLegacy ? ["legacy"] : []
768
+ },
769
+ }),
725
770
  })
726
771
  ```
727
772
 
773
+ A deployment that shows every category to every viewer answers `{ unrestricted: "<why this deployment needs no category restriction>" }`; the
774
+ reason is the audit trail, so `grep -rn "unrestricted:"` in the host lists every such answer.
775
+
728
776
  #### Contract
729
777
 
730
778
  | Return value | Meaning |
731
779
  | --- | --- |
732
- | `undefined` / `null` | Unrestricted — every source type (the default when the port is not injected). |
733
780
  | `["legacy", "Internal"]` | Only those source types are visible (bodies **and** comments). |
734
- | `[]` | Nothing is visible (zero rows) — **not** the same as `null`. |
781
+ | `[]` | Nothing is visible (zero rows): a revocation, which reads answer with no rows and SSE delivery with anchors only. |
735
782
  | `{ read: [...] }` | Same as returning the bare array — the comment dimension follows `read`. |
736
783
  | `{ read: [...], comment: [...] }` | Bodies and comments restricted independently. See [Comment visibility](#comment-visibility). |
737
- | throws | Treated as `[]` (deny all). The failure is logged; an authorization-store outage never falls open. |
784
+ | `{ unrestricted: "<reason>" }` | Every source type: the explicit opt-out of a deployment that does not restrict by source type, with a non-blank reason. |
785
+ | throws | The visibility of this request cannot be decided now: the request is answered "not now", never as `[]` (**Failure** below). |
786
+ | anything else — `null`, `undefined`, a string, an array with a non-string element, an object with another key (such as `comments`), a blank reason | Outside the contract: handled like a throw. Reading an unknown key as an omitted `comment` would silently let comments follow `read`. |
738
787
 
739
788
  - **Vocabulary**: the strings are `DailyReportHub.source_type` **values** — the `sourceType` of each entry you registered in `externalSources`, plus
740
- `"Internal"` which the package writes for reports it creates. They are *not* your authorization resource keys; mapping a resource key (e.g.
741
- `report_legacy`) onto a source type (e.g. `legacy`) is the consuming app's job. Read the canonical set at boot from
742
- `dailyReportServer.knownSourceTypes` (frozen: `Internal` first, then each adapter's in order) and assert your mapping against it — an unknown token
743
- silently matches zero rows.
789
+ `"Internal"`, the package's own source type for the reports it creates (`DAILY_REPORT_INTERNAL_SOURCE_TYPE` on the shared entry). They are *not* your
790
+ authorization resource keys; mapping a resource key (e.g. `report_legacy`) onto a source type (e.g. `legacy`) is the consuming app's job. Read the
791
+ canonical set at boot from `dailyReportServer.knownSourceTypes` (frozen: `Internal` first, then each adapter's in order) and assert your mapping
792
+ against it — an unknown token silently matches zero rows. Fold your tokens with `foldDailyReportSourceTypeToken` (the shared entry, and the client entry for route modules that ship
793
+ to the browser: the shared entry is not split, so importing the fold from it carries a second copy of shared modules into the route's chunk), the
794
+ fold the server applies to the tokens it reads, instead of a copy of it.
744
795
  Two adapters whose source types are equal ignoring case (an exact duplicate included), or an adapter whose source type is `Internal` in any case,
745
796
  stop `createDailyReportService` and `createDailyReportServer` with a `RangeError`
746
797
  (`[daily-report] externalSources[1].sourceType must be a source type that differs, ignoring case, from externalSources[0].sourceType "legacy"; got "Legacy"`):
747
798
  matching folds case, so two such source types could not be told apart.
748
- - **Matching** is case-insensitive on both the JS and SQL sides (both fold to lower case), so it does not depend on the database collation.
749
- Surrounding whitespace is trimmed from the *tokens you return* but never from the stored column value — the two sides fold identically, and a padded
750
- stored value is simply invisible (fail-closed) rather than visible on one path and hidden on the other.
799
+ - **Matching** is case-insensitive and exact, the same on the JS and SQL sides: a token is trimmed and lower-cased (`foldDailyReportSourceTypeToken`),
800
+ a stored value is lower-cased and never trimmed, and the two must be equal. In SQL the predicate compares bytes,
801
+ `CAST(LOWER(source_type) AS VARBINARY(2n)) IN (CAST(@token AS VARBINARY(2n)), …)` with n the column's declared length (**Required database schema**),
802
+ because SQL Server compares strings with trailing blanks padded and, under a width-insensitive collation, full-width and half-width forms alike: the
803
+ bytes do neither, so the predicate does not depend on the database collation.
804
+ A padded (`kintone␠`) or full-width stored value therefore matches no token and is invisible on every path — the SQL reads, SSE delivery, the write
805
+ gate and attachment bytes alike. A token longer than the column matches no stored value and is left out of the list (the cast would cut it, and its
806
+ cut head could match); a list left empty denies everything. The predicate cannot use an index, as `LOWER()` could not.
751
807
  - **Where it applies**: the SQL predicate is injected in the service layer, so it holds for every delivery path (including attachment bytes) rather
752
- than only the HTTP handlers. It also gates the mutations that act on someone else's report (`addComment`, `deleteComment`, star, read); a hidden
753
- report is reported as `404`, never `403`.
808
+ than only the HTTP handlers. It also gates every write:
809
+ - **Creating** needs the package's own source type: a scope that does not show `Internal` is refused (`Forbidden`, answered 403
810
+ `{"error":"Creating reports is restricted"}`) before any query. A create names no existing row, so there is nothing to hide, and a report its author
811
+ could not read back would be stranded; give every author the read of `Internal`. The create's answer is the report read again under the actor's
812
+ scope.
813
+ - **Every write to an existing report** — `addComment`, `deleteComment`, star and read, and the author's own `updateDailyReport`,
814
+ `publishDailyReport` and `deleteDailyReport` — first passes the same gate (`assertHubMutable`, the read rule's category and draft clauses on the
815
+ live row), before the owner or author is compared. The comment writes, the update and the publish start their transaction with a lock of the
816
+ report's live hub row (`lockLiveHub`: an update that changes no value, conditioned on the row being live and, for the author's writes, owned), and
817
+ the delete conditions its own update the same way, so a delete committed after the gate turns the write into `Not Found` instead of a write to a
818
+ deleted report (a draft's labels stripped, its search document rewritten).
819
+ - A hidden report — a category outside the scope, another user's draft, a missing or a deleted row — is reported as `404`, never `403`, after the
820
+ same single read, so the answer does not tell whether a hidden row exists (a comment of such a report is `404` `Comment not found` for
821
+ `deleteComment`); a visible report of another user is still `403` for the three author mutations, and so is another viewer's comment on a visible
822
+ report for `deleteComment`.
823
+ - The publish action answers with the report read again under the actor's scope, so the comment dimension applies to the answer (other users'
824
+ comments of a category the scope's `comment` excludes are dropped, the actor's own posted comments stay, and external comments are emptied); the
825
+ `report-publish` event carries the whole report, and SSE delivery filters it for each recipient.
754
826
  - **How it reaches the service**: the resolved set is wrapped in a `DailyReportViewerScope` and passed as the **first** argument of every gated
755
- service method.
827
+ service method, the create and the three author mutations included.
756
828
  The class is nominal (it holds a private field), so `{ visible: null }`, `{ ...scope, visible: null }` and `new DailyReportViewerScope(...)` are all
757
829
  type errors — the only ways to obtain one are `DailyReportViewerScope.restrictTo(tokens)` — which accepts `readonly string[]` only, so it cannot be
758
- handed a nullish value and quietly widen — and `DailyReportViewerScope.unrestricted(auditReason)`.
830
+ handed a nullish value and quietly widen — and `DailyReportViewerScope.unrestricted(auditReason)`, which the handlers call only for the port's
831
+ explicit opt-out, with its reason.
759
832
  Because a bare `null` is not spellable as a visibility value, `grep -rn "DailyReportViewerScope.unrestricted("` enumerates every unfiltered read in
760
833
  a codebase, each carrying a written reason.
761
- - **Performance**: the port is called once per request on the critical path (cache keys incorporate the resolved set), so keep it fast — cache grants
762
- per session/user with a short TTL (≤ 60s). A live SSE connection re-resolves on a dedicated 60 s timer (independent of the heartbeat) to bound how
834
+ - **Failure**: a port that throws — an authorization-store outage, or a viewer who is gone (signed out or disabled) — or answers outside the contract
835
+ leaves the request's visibility undecided, and the package answers "not now" rather than "nothing visible", on every path, before any read or write:
836
+ - every JSON endpoint and the ids stream: 503 `{"error":{"message":"Report visibility is unavailable"}}` with `Retry-After: 5` and
837
+ `Cache-Control: no-store` (**Visibility unavailable** in [The API route](#the-api-route));
838
+ - the action: 503 `{"error":"Report visibility is unavailable"}` with the same headers, before the user lookup;
839
+ - an original or a thumbnail: the `busy` 503 with `Retry-After: 5` ([Attachments](#attachments));
840
+ - an SSE connect: 503 with `Retry-After: 5`; an open stream whose periodic re-check fails ends without writing anchors for the entries it has not
841
+ delivered (**SSE latency** below).
842
+
843
+ An empty answer for a failure would show an empty list during a blip, hand the client the live stream anchor with it, and let SSE skip the events of
844
+ the outage for good. Each failure writes one `error` line on the `[DailyReportVisibility]` channel per cause (`port_failure`, `port_contract`) and
845
+ window of 60 s — `visibility unavailable cause=<cause> reason=<initial|refresh>`, with the error (a throw) or the answer's shape (`answer=<null|array|type>`,
846
+ never its value) —, and the window's own timer writes
847
+ `visibility_unavailable_suppressed count=<N> since=<ISO 8601 time of the window's first failure> cause=<cause> reasons=initial:<n>,refresh:<n>` at its
848
+ end when it counted more.
849
+ - **Performance**: the port is called once per request on the critical path (cache keys incorporate the resolved set), so keep it fast.
850
+ `createDailyReportVisibilityCache({ ttlMs, readViewerKey, resolve })` (server entry) is the package's memoizer of the port's answers, which a host
851
+ wraps around its own lookup instead of restating the cache rules:
852
+ - it keeps one answer per viewer key (`readViewerKey`) for `ttlMs`, an integer from 1 to `DAILY_REPORT_VISIBILITY_MAX_CACHE_TTL_MS` (60,000; a
853
+ `RangeError` otherwise), measured on the monotonic clock, and sweeps expired answers on every write;
854
+ - it reads its memory for `initial` only; `refresh` (and any other reason) looks up afresh and writes the answer back, so the next `initial` uses it;
855
+ - concurrent `initial` calls share the pending lookup (written when it starts, so a slower earlier lookup never overwrites a later answer);
856
+ - a failed lookup is forgotten while the viewer's entry still holds it, keeping an answer written after it;
857
+ - a request whose `readViewerKey` answers null — nobody signed in, or a viewer gone by the time of a `refresh` — throws before any lookup.
858
+
859
+ A live SSE connection re-resolves on a dedicated 60 s timer (`DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS`, independent of the heartbeat) to bound how
763
860
  long a revoked grant keeps streaming; that call passes `reason: "refresh"` and reuses the connection's original `Request`, so a cache keyed on
764
- request identity **must** be bypassed when `reason === "refresh"` or the re-check silently returns the stale set.
765
- - **SSE latency**: because a live connection re-resolves periodically, a grant change (in either direction) takes effect for the stream within ~60s.
766
- Events dropped before a widening are not replayed; the next read (which re-resolves per request) restores them.
861
+ request identity **must** be bypassed when `reason === "refresh"` or the re-check silently returns the stale set (the memoizer does so).
862
+ - **SSE latency**: because a live connection re-resolves periodically, a grant change (in either direction) takes effect for the stream within the
863
+ re-check period plus the cache's lifetime. Events dropped as outside the scope before a widening are not replayed (the next read restores the
864
+ reports). A re-check that fails ends the stream instead: the last `id:` on the wire is the last delivered entry, so the browser's reconnect catches up
865
+ from there once the lookup answers again, and a viewer who signed out meets the stream's sign-in end on the reconnect.
767
866
 
768
867
  ### Comment visibility
769
868
 
@@ -787,7 +886,8 @@ resolveVisibleSourceTypes: async (request, { reason }) => {
787
886
  | `["Internal"]` | Only those source types' comments are visible/writable. Narrowed to `read ∩ comment` at construction, since comments only ever ship inside an already-read-filtered report. |
788
887
 
789
888
  - **`null` is rejected by the type system** — a second spelling for "follow `read`" would make the dimension four-state and reintroduce exactly the
790
- unrestricted-vs-deny-all confusion the tri-state contract exists to prevent. A `null` that slips through at runtime fails **closed**.
889
+ unrestricted-vs-deny-all confusion the contract exists to prevent. A `null` (or any other value that is not a token array) that slips through at
890
+ runtime is outside the contract and answered like a port failure (**Failure** in [Source-type visibility](#source-type-visibility)).
791
891
  - **What it covers**: `DailyReportDetail` carries comments in **two** structurally different fields — `postedComments` (the package's own comment
792
892
  table) and `externalComments` (JSON supplied by an `externalSources` adapter). Both are gated. They are emptied, never omitted: the keys are
793
893
  required by the SSE schema, and dropping them would make a receiver discard the whole message.
@@ -798,7 +898,7 @@ resolveVisibleSourceTypes: async (request, { reason }) => {
798
898
  - **Writing to external sources remains impossible regardless** — `addComment` rejects any non-`Internal` source type outright, so for external
799
899
  categories the comment dimension effectively controls *reading*.
800
900
  - **Cache**: the dimension is folded into the cache-key digest, so revoking only the comment grant invalidates immediately. When `comment` follows
801
- `read` (the default) the digest is byte-identical to before, so existing deployments see no cache churn.
901
+ `read` (the default) the digest equals the single-dimension digest of `read`, so such a deployment's cache keys do not depend on the dimension.
802
902
 
803
903
  ### Attachments
804
904
 
@@ -960,6 +1060,9 @@ type DailyReportReadAttachment = (
960
1060
 
961
1061
  `busy`, `deadline` and `unavailable` are transient: a thumbnail generation concludes nothing from them, caches nothing and marks nothing missing.
962
1062
  The requests that joined that generation receive the same failure, and the next request reads again.
1063
+ A request whose viewer visibility cannot be decided (the visibility port failed; **Failure** in [Source-type visibility](#source-type-visibility))
1064
+ gets the same transient 503 with `Retry-After: 5` on both deliveries, at the authorization step, before any query or read: an undecided scope is
1065
+ not read as "not visible", whose 404 a thumbnail would conclude from.
963
1066
  - **`principal` is for attribution and audit only: the read result must not depend on it.** The SQL predicate (`getAttachmentForUser`) is the only
964
1067
  authorization boundary; the port reads the `filePath` of a row that has already been authorized. Thumbnail generation relies on this: requests
965
1068
  waiting for the same content share one read, made with the first requester's `principal`, and the outcome is cached for every authorized viewer of
@@ -1457,7 +1560,7 @@ The thumbnail delivery (`?thumbnail=tile`):
1457
1560
  | 405 | Any method other than GET (`Allow: GET`) |
1458
1561
  | 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`) |
1459
1562
  | 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or the render stage's deadline (`render_timeout`) |
1460
- | 503 | Wait queue full or wait timed out (`queue`), the storage busy (`busy`), or the generation abandoned by every waiting request (`aborted`; no one receives it) (`Retry-After: 5`); the message is `Thumbnail temporarily unavailable` for every cause |
1563
+ | 503 | Wait queue full or wait timed out (`queue`), the storage busy (`busy`), the generation abandoned by every waiting request (`aborted`; no one receives it), or the viewer's visibility undecided at authorization (the visibility port failed; **Failure** in [Source-type visibility](#source-type-visibility); logged on that channel, not here) (`Retry-After: 5`); the message is `Thumbnail temporarily unavailable` for every cause |
1461
1564
  | 504 | A storage read past a deadline: the read port's own (`deadline`) or the read stage's `readMs` (`read_timeout`); never cached |
1462
1565
  | 500 | Unexpected exception, including one thrown by a port during generation (logged as `500 attachment=<id> viewer=<id> variant=thumbnail cache=miss reason=port_exception message=<msg>`, never cached), or a port contract violation (`port_contract`: a render result outside the contract, logged with `code=result` for a value that is not an object, `code=failure_reason` for a failure whose reason is neither `unsupported` nor `failed`, and for a read failure whose reason is outside `DailyReportAttachmentFailure`, or the output check that failed, `code=` `content_type`, `bytes`, `empty`, `too_large`, `signature` or `dimensions`; or a read over `maxBytes`, `code=over_max_bytes`), or a host route that passes no `token` parameter (a route misconfiguration, below) |
1463
1566
 
@@ -1505,7 +1608,8 @@ also when the refusals stop, and a bucket the limiter evicts (it keeps 1,024 use
1505
1608
 
1506
1609
  ### Transactions
1507
1610
 
1508
- Creating and editing a report each write in one transaction. The service has one entry to its transactions, which retries **only a transaction the
1611
+ Creating and editing a report each write in one transaction; creating also writes the author's status row (read, not starred) in it, so the
1612
+ report reads back as read. The service has one entry to its transactions, which retries **only a transaction the
1509
1613
  database chose as a deadlock victim** (the decision is
1510
1614
  [`@aiquants/mssql`](../mssql/README.md)'s, and the closure is wrapped with its `capture`, so the cause is kept before the ORM drops it): concurrent
1511
1615
  operations whose lock ranges overlap can deadlock,
@@ -1519,8 +1623,8 @@ and without the retry the victim would answer 500 and lose what it wrote.
1519
1623
 
1520
1624
  ### Realtime architecture
1521
1625
 
1522
- 1. Mutation services publish to Redis Stream (`<streamKey>:v3`, `daily-report:sse-stream:v3` by default: the key carries the wire version of the
1523
- embedded report, so a deployment that changes a required key of it reads no entry an older version wrote, and the stream of the previous version
1626
+ 1. Mutation services publish to Redis Stream (`<streamKey>:v4`, `daily-report:sse-stream:v4` by default: the key carries the wire version of the
1627
+ stream's messages and their embedded report, so a deployment that changes a required key of them reads no entry an older version wrote, and the stream of the previous version
1524
1628
  stops growing and can be deleted by hand; a host that reads or writes the stream itself, such as an integration test, builds the key with the
1525
1629
  server entry's `buildDailyReportSseStreamKey(streamKey)` instead of restating the version) in sequence:
1526
1630
  `invalidate -> epoch increment -> SSE publish`.
@@ -1529,20 +1633,27 @@ and without the retry the victim would answer 500 and lose what it wrote.
1529
1633
  `DailyReportSseReaderPort` — `ready()` and `subscribe(onEntry, onError)`, with the contract under **`connected` frame** below — which is the type a
1530
1634
  host's own reader implements: a plain object with the two methods is accepted, while the class type, which has private fields, would refuse one.
1531
1635
  The reader reads each live entry once per process and hands every subscriber the same frozen value (`DailyReportSseStreamEntry`:
1532
- `{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by `dailyReportSseMessageSchema` — the validated message
1533
- (`parsed`), the JSON value as published (`published`, with the server-only fields) and its text (`text`) — or `null` when the field is absent, is
1534
- not JSON or fails the schema (the last two logged once at `error`; such an entry is written to no connection — fail-closed — while the reader's own
1535
- read position moves past it).
1536
- Each connection only filters that value (recipient, source-type visibility, comment redaction) and never changes it, and the frame texts of the
1537
- variants that differ from the published text — without `recipientRawUserId`, and with the embedded report's comments emptied — are built at most
1538
- once per message and shared by every connection that writes them, so a live entry costs one parse in proportion to its size plus a filter per
1539
- connection, not one parse per connection.
1636
+ `{ id, message }`): `message` is the entry's `data` field parsed as JSON and validated by the stream form `dailyReportSseStreamMessageSchema` (the
1637
+ only form that declares the server-only fields: the six types that may be addressed — status-update, comment-add, comment-delete, report-create,
1638
+ report-update and report-publish — carry the recipient's internal id `recipientRawUserId`, and the three that embed a report also the writer's,
1639
+ `actorRawUserId`) — the validated message (`parsed`) and the JSON value as published (`published`, which only the recipient filter reads) — or
1640
+ `null` when the field is absent, is not JSON or fails the schema (the last two logged once at `error`; such an entry is written to no connection —
1641
+ fail-closed — while the reader's own read position moves past it).
1642
+ Each connection only filters that value (recipient, source-type visibility, comment redaction, the actor) and never changes it. Every frame is
1643
+ built from the parsed message, never from the published value or text: the message without its server-only fields, typed as the delivered form,
1644
+ so a server-only field left in a frame fails the type check, and a key the stream schema does not know never reaches a viewer. A message that
1645
+ embeds no report has one frame for every viewer; one that embeds a report has at most four — with the viewer's `viewerIsActor`, its comments
1646
+ emptied for a viewer who may not read them — each built at most once per message and shared by every connection that writes it, so a live entry
1647
+ costs one parse in proportion to its size plus a filter per connection, not one parse per connection, and every frame is one line of JSON.
1540
1648
  A host's own reader keeps the same contract: it reads an entry once — `readDailyReportSseStreamEntry(raw, logger)` (server entry) reads it as the
1541
1649
  package's reader does — and hands the same value to every subscriber.
1542
1650
  3. `sse.loader` is built on `@aiquants/sse/server` (`createSseResponse`, `terminalStreamResponse`, `startSseHeartbeat`, `readLastEventId`). Its
1543
1651
  responses carry the SSE response headers of `@aiquants/sse`, the routes' security headers (**Security headers** in [The API route](#the-api-route))
1544
- and the forwarded `Set-Cookie`, and no `Connection` or other hop-by-hop header (which HTTP/2 forbids). `recipientRawUserId` is filtered server-side
1545
- and removed before transmission to prevent internal ID leaks. Catch-up and live entries pass through the same filters (recipient, source-type
1652
+ and the forwarded `Set-Cookie`, and no `Connection` or other hop-by-hop header (which HTTP/2 forbids). `recipientRawUserId` and `actorRawUserId` are read
1653
+ server-side and never written to a viewer, to prevent internal ID leaks; each connection writes `viewerIsActor` instead (whether its viewer made
1654
+ the write), so the delivered form (`dailyReportSseMessageSchema`, the root entry's `DailyReportSseMessage`) declares `viewerIsActor` and no internal id
1655
+ on any type. The delivered schema does not refuse a key it does not know (it drops it), so a long-lived tab keeps accepting a newer server's
1656
+ messages that carry additive fields; what a viewer receives is decided by the frame builder above. Catch-up and live entries pass through the same filters (recipient, source-type
1546
1657
  visibility, comment redaction).
1547
1658
  4. On the client, `useDailyReportSseConnection` runs on `useReopeningEventSource` (`@aiquants/sse/react`): full-jitter backoff from 2 s to 30 s, the
1548
1659
  failure count reset only after 60 s of healthy open (the 45 s stale window plus one heartbeat period), one native browser reconnect (which carries
@@ -1560,6 +1671,9 @@ and without the retry the victim would answer 500 and lose what it wrote.
1560
1671
  A comment that arrives by SSE is the viewer's own exactly when its author id equals the viewer's (`userId`, from the deterministic `encodeUserId`
1561
1672
  port), whichever tab or device posted it; a comment of another user with the same text as one being posted is never taken for it, and a posted
1562
1673
  comment's temporary id becomes its real id only through its own action's answer.
1674
+ A report message (create, update, publish) is the viewer's own write exactly when it carries `viewerIsActor: true`: the client then caches the
1675
+ payload's read and star state as its own write's answer, so the writer's other tabs and devices see their own create as read; otherwise it merges the
1676
+ shared fields and keeps the viewer's own read and star state (false for a report it has not cached), so no write leaks its writer's state.
1563
1677
  Each action holds its own lock on the report it changes (its release frees only that lock and only once), so an overlapping action, an SSE change
1564
1678
  (which records the change time without locking) and the 1 s lock after a creation (which a later hold only extends) never end one another's locks.
1565
1679
 
@@ -1567,7 +1681,7 @@ and without the retry the victim would answer 500 and lose what it wrote.
1567
1681
 
1568
1682
  Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000` before one named terminal event, so a client that does not listen
1569
1683
  for the event (an older bundle) reconnects at most once a day instead of in a loop. A non-200 response is reserved for a misconfigured endpoint, an
1570
- unexpected error and a request that is not the package's client (another origin's page).
1684
+ unexpected error, a request that is not the package's client (another origin's page) and the transient 503s the client retries on its own backoff.
1571
1685
 
1572
1686
  | Server-side situation | Response | Client (`useDailyReportSseConnection`) |
1573
1687
  | --- | --- | --- |
@@ -1579,6 +1693,8 @@ unexpected error and a request that is not the package's client (another origin'
1579
1693
  | A `HEAD` request | the status and headers a `GET` would get (one of the rows here), with no body work: no subscription, no heartbeat, no visibility refresh timer ([Streaming routes](#streaming-routes)) | never sent by the client |
1580
1694
  | Unexpected error before the stream starts (for example the internal-user lookup fails) | 500 with the body `Internal Server Error` (no error detail), `Set-Cookie` forwarded | treated as retryable; backoff up to 30 s |
1581
1695
  | The viewer already holds every stream slot of the worker (**Streams per viewer** in [The API route](#the-api-route): 32, SSE and ids streams together), after the resume cursor is read | 503 with the body `Too many streams` and `Retry-After: 5`, `Set-Cookie` forwarded; nothing is subscribed and the visibility is not resolved | treated as retryable; backoff up to 30 s |
1696
+ | The viewer's visibility cannot be decided at connect (the visibility port throws or answers outside its contract; **Failure** in [Source-type visibility](#source-type-visibility)), after the stream slot is taken | 503 with the body `Report visibility is unavailable` and `Retry-After: 5`, `Set-Cookie` forwarded; the slot is given back and nothing is subscribed | treated as retryable; backoff up to 30 s |
1697
+ | The periodic re-check of an open stream's visibility fails (the port throws, a viewer who signed out included, or answers outside its contract) | the body ends without writing an anchor for any entry not yet delivered; the last `id:` on the wire is the last delivered entry | the browser reopens with `Last-Event-ID`, and the catch-up delivers what the closed stream had not; a viewer who signed out meets `auth-required` |
1582
1698
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
1583
1699
  | 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 |
1584
1700
  | 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 |
@@ -1853,6 +1969,10 @@ released search or another view's search holds the viewer's slot until its SQL e
1853
1969
  Its order: 404 without the `search` block, the 400s below, then a viewer without an internal user answered with the plan's words and no hits, with no
1854
1970
  admission and no dataset query, then the admission with the request's signal, the search, the slot's release whatever the outcome, and the ETag'd
1855
1971
  JSON; a search whose client left gets the bodyless 503 every search route answers (under `search-marks` below).
1972
+ Every search route is a GET, so the typed text (`q`, which the `search-marks` URL carries too) and the filter values (author and customer names,
1973
+ source types) travel in the URL and may stay in the access logs of the host's server and of a reverse proxy in front of it. That is accepted: there is
1974
+ no POST route, and the ETag with the quiet resend's `If-None-Match` (a 304, which only a GET gets) relies on it. The JSON routes answer 405 to other
1975
+ methods.
1856
1976
  `words` is the plan's words, keyword by keyword: each word's alternatives `{ text, tier }` (`DailyReportSearchAlternative`), tier 0 the typed word's
1857
1977
  forms and tier 1 its romaji readings. It depends on `q` and `combine` alone, never on the viewer, and the client reads it strictly with the rest of
1858
1978
  the answer (at most 16 keywords, 16 words a keyword, 32 words, 6 alternatives a word of 1 to 100 units, the first alternative of tier 0, tiers that
@@ -2027,14 +2147,17 @@ and that a match found through a reading is underlined with dots.
2027
2147
  While a search request runs or after it failed, the views keep the list they showed; an answer replaces the list in its order (a report the provider
2028
2148
  marked removed is left out), and the header shows the hit count. While an answer shows, the rows read their details by id in batches (`reports?ids=`)
2029
2149
  instead of by business date, and an answer goes into the per-report cache only, never into a business date's cached list.
2030
- A row's id joins the waiting ids at the first animation frame after rows stop entering and leaving, so a fast scroll or a held key requests nothing
2031
- for the rows it passes, and a window that stops waits at most one frame.
2032
- The batches are decided when they are sent, by the scheduler the search marks use: from the rows still waiting, newest first, up to 100 ids a request
2150
+ Each view leads its rows' inputs: it keeps its rendered rows and 24–40 rows beyond each rendered edge subscribed to the details and the marks, in
2151
+ chunks of 8 rows (32 on each side after a jump; **Highlights** below), so the rows a held key mounts find their details already loaded.
2152
+ An id joins the waiting ids at the first animation frame after rows stop entering and leaving, so a fast scroll requests nothing for the rows it
2153
+ passes and the lead let go, and a window that stops waits at most one frame.
2154
+ The batches are decided when they are sent, by the scheduler the search marks use: from the ids still waiting, newest first, up to 100 ids a request
2033
2155
  and `DAILY_REPORT_SEARCH_BATCHES_IN_FLIGHT` (2) requests at once per API base path (an aborted request keeps its slot until its fetch settles), each
2034
2156
  sent `cache: "no-cache"` with a signal of its own.
2035
- A row that unmounts leaves its load, so an id no row waits for is not sent, a request none of whose rows waits any more is aborted, and a row that
2036
- comes back asks again. A report a 2xx answer lacks reads as `null` (not visible, or deleted), and another status, a body that does not match the
2037
- schema or a network failure is the rows' error (`[daily-report] reports answered HTTP <status>` for a status).
2157
+ A row that unmounts leaves its load, and the details of a report are loaded once for all of its readers (the rows, the lead, the side pane), so an
2158
+ id nothing waits for is not sent, a request none of whose ids is still waited for is aborted, and a row that comes back asks again. A report a 2xx answer lacks reads as `null` (not visible, or deleted), and another status, a body that does not match the
2159
+ schema or a network failure is the rows' error (`[daily-report] reports answered HTTP <status>` for a status), except over a report a row already
2160
+ shows: a failed refetch of an expired report leaves it in place (the shared detail record under `useDailyReportDetail` below).
2038
2161
  A 429 whose `Retry-After` is at most 5 s (it covers a full admission gate's `Retry-After: 1` and stays below the rate bucket's 60 s window) keeps the
2039
2162
  rows loading, with no error and no `null`, and asks once more after that delay for the rows still waiting; a second 429, or one without such a
2040
2163
  `Retry-After`, is the error.
@@ -2149,8 +2272,8 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2149
2272
  accessible description (`aria-description`: `labels.searchReadingMarkDescription`, en `Matched by its romaji reading`, ja `ローマ字の読みで一致`); a word mark
2150
2273
  carries none, so the common case adds no speech. The attribute's value and the description come from one table keyed by the tier; a tier outside it
2151
2274
  stops the render with an error naming the package.
2152
- Moving through the rows with the keys, a row is announced by its focus target, whose name and description flatten the marks (a List card's author
2153
- marks sit inside its primary button, whose children are presentational, and its preview reaches the button only as a description), so the row says
2275
+ Moving through the rows with the keys, a row is announced as the composite's active descendant, whose name and description flatten the marks (a
2276
+ List card's author marks sit inside its primary button, which names the row, and its preview reaches the row only as a description), so the row says
2154
2277
  in words what matched:
2155
2278
  one sentence per field heading the row's leaves draw a match under, in the order the view draws them — `labels.searchFieldMatched(heading)` (en
2156
2279
  `Author matched`, ja `作成者で一致`) when a typed word's mark or a dense field is among the heading's leaves,
@@ -2161,34 +2284,46 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2161
2284
  - **Arrival**: the screen holds one marks store per shown answer (inside the client bundle), only while a view draws an answer that has words: without
2162
2285
  `config.search`, before the first answer is drawn, for a filters-only answer (it has no words, so nothing is marked and no match indicator shows),
2163
2286
  after the release and for a view rendered on its own there is none, and the DOM is the plain one.
2164
- A row wraps its subtree in the package's marks scope, which subscribes to the report's marks while the row is mounted and hands them to its leaves
2165
- through one row context (the marks, their revalidation, the row's tally and the indicator's settings) that only the scope provides:
2287
+ A row hands its marks scope the marks of the unit that drew its card: the row reads the report's details and marks through its input source from
2288
+ the moment it mounts as a shell (the first subscriber of a report asks for its marks) and draws them with its units (**Rows mount in two passes**
2289
+ under [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist)), so a card whose marks are known is drawn with them in its first
2290
+ commit, never plain first, and marks that arrive later are drawn by one unit of the row, which keeps the row's box (the row stays skippable).
2291
+ The desktop side pane reads the marks with a deferred reader instead: its first render after a change of report draws the text plain, and the marks
2292
+ follow in a deferred render.
2293
+ The scope subscribes to nothing: it hands the marks to its leaves through one row context (the marks, their revalidation, the row's tally and the
2294
+ indicator's settings) that only the scope provides, and settles the row's tally in its own layout effect, after its leaves registered;
2166
2295
  a leaf, a match indicator or a List card's match description drawn outside a scope while the screen holds a store stops with an error naming the
2167
2296
  package, so no leaf draws marks that no tally counts.
2168
- The row body reads no marks, so an arrival re-renders only the scope, the marked leaves and the match indicator — never the row's body, header,
2169
- attachment list or comment section, and never another row; a newly mounted row draws its text plain first and its marks in a deferred render.
2297
+ The card body reads no marks, so an arrival re-renders only the row's container and frame once (one unit) or the side pane's marks scope, then the
2298
+ scope, the marked leaves and the match indicator — never the row's body, header, attachment list or comment section, and never another row.
2170
2299
  A leaf registers in its row's tally only once the row's marks have arrived (the indicator and the description say nothing about a row without
2171
- marks), so a row that mounts during a held key adds no tally work before its marks, and the tally builds its summary when it is read and tells its
2172
- readers once, at the end of the task that changed it, however many leaves registered in it.
2300
+ marks), in one layout effect that revalidates, registers and returns its unregistration (an opaque token that removes exactly that registration),
2301
+ so a row that mounts during a held key adds no tally work before its marks, and the tally builds its summary when it is read and tells its readers
2302
+ once, at the end of the task that changed it, however many leaves registered in it: a card's match description is complete by the end of the task
2303
+ that committed the card.
2304
+ While an answer is shown, each view also keeps the rows beyond its rendered range subscribed (the input lead: 24–40 rows beyond each rendered edge,
2305
+ 8 more on the side it travels towards whenever fewer than 24 remain, 32 on each side after a jump), so a row that mounts during a held key finds its
2306
+ marks and details in the stores.
2173
2307
  A report's first subscription reaches the requests through a batching window of 240 ms: outside a window it is handed on at once, together with the
2174
2308
  subscriptions of the same task, and opens a window; the subscriptions made inside the window are handed on together at its end, which opens the next
2175
- window while rows keep mounting, and a row that leaves inside the window asks for nothing.
2176
- A held key mounts one row per key at the overscan's edge, 15 rows and about 0.5 s of key repeats before the row shows, so a request at the window's
2177
- end is answered and drawn before its rows are visible, while a hold sends about one request per window (4–5 in each 1.2 s hold of the perf harness).
2309
+ window while first subscriptions keep coming, and a report whose subscribers all left inside the window asks for nothing.
2310
+ During a held key the windows are opened by the lead's chunks of 8 rows, one chunk every 8 keys (about 240 ms at a 30 ms repeat), so a chunk never
2311
+ waits for the window the previous chunk opened, and a 40-key hold sends 4–5 requests per store; a row is the first subscriber of its report only
2312
+ when the lead does not hold it yet (the view's first render, after a jump, or a scroll faster than the lead).
2178
2313
  The epoch's renewal, a field's revalidation, a 429's retry and the deferred ids do not wait for the window.
2179
2314
  The requests are decided at send time: a subscribed report waits in one queue, and each free request slot — two in flight at most — takes the newest
2180
- waiting reports that a row still subscribes to, at most 100 a request, never cached. A report whose rows left while it waited is dropped, so a
2181
- scroll or a held key sends nothing for the rows it passed, and the window it stops on goes out in the first free slot.
2182
- A request whose rows have all left is aborted (the server then stops computing its marks; **Requests** above), and a row that leaves before its
2315
+ waiting reports that are still subscribed (by a row or the lead), at most 100 a request, never cached. A report whose subscribers left while it waited
2316
+ is dropped, so a scroll sends nothing for the rows it passed and the lead let go, and the window it stops on goes out in the first free slot.
2317
+ A request none of whose reports is still subscribed is aborted (the server then stops computing its marks; **Requests** above), and a row that leaves before its
2183
2318
  marks were sent, or whose request was aborted, asks again when it returns.
2184
2319
  An aborted request keeps its slot until its fetch settles, so the requests in flight, aborted ones included, never exceed the two the server's
2185
2320
  per-viewer wait queue is sized for while it still winds the aborted ones down.
2186
2321
  The store keeps one record per report (its marks, the send order that brought them, the epoch it was asked in, its 429 retry and its revalidations)
2187
- and forgets the oldest records of reports no row subscribes to beyond 400 (2 × the two requests in flight × 100: the answers still in flight and as
2188
- many rows just left), so its memory follows the drawn window, not the distance scrolled; a forgotten report asks again when a row subscribes to it.
2322
+ and forgets the oldest records of reports nothing subscribes to beyond 400 (2 × the two requests in flight × 100: the answers still in flight and as
2323
+ many reports just let go), so its memory follows the drawn rows and their lead, not the distance scrolled; a forgotten report asks again when it is
2324
+ subscribed again.
2189
2325
  The ids an answer defers (`deferred`, past the server's budget) are asked again through the same scheduler, once the answer arrived in the epoch it
2190
- was sent and carried at least one report: an id a row still subscribes to is queued again, any other is given back, so a row that returns asks
2191
- again.
2326
+ was sent and carried at least one report: an id still subscribed is queued again, any other is given back, so a row that returns asks again.
2192
2327
  The server always answers a request's first report, so every re-ask makes progress and the re-asks end with the ids asked in the epoch; a deferral
2193
2328
  without any report breaks the contract and is not asked again (those rows stay plain for the epoch), and a previous epoch's deferral is not queued
2194
2329
  (the epoch's renewal asked again for every subscribed report).
@@ -2200,7 +2335,7 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2200
2335
  painted and the field asks once for fresh marks. An answer whose `wordsDigest` differs from the shown answer's words is dropped; a quiet re-query of
2201
2336
  the same search makes the marks stale and the mounted rows ask again;
2202
2337
  401 and 403 call `onSessionExpired`; a 429 whose `Retry-After` is at most 60 s (one window of the server's rate bucket) is asked again once, after
2203
- that delay, for the reports a row still subscribes to then (one whose rows left meanwhile asks again when a row returns), and a 429 without a
2338
+ that delay, for the reports still subscribed then (one whose subscribers left meanwhile asks again when it is subscribed again), and a 429 without a
2204
2339
  `Retry-After`, or with a longer one, sets no timer and leaves its reports plain for the answer's epoch (a delay of 2^31 ms or more would overflow
2205
2340
  the timer and ask again at once);
2206
2341
  any other failure of the request — a network failure, an abort, another status, a body that is not JSON — leaves those rows plain for as long as the
@@ -2240,10 +2375,14 @@ narrow for the author and the slot the slot drops to a 16 px line of its own ins
2240
2375
  `Matched in fields this view does not show, such as labels or details`), which shows only when no field is marked or dense.
2241
2376
  A DetailList row's description (`aria-describedby`) ends with that element while the screen holds a marks store, so moving through the rows with the
2242
2377
  arrow keys reads the sentences each row shows.
2243
- - A List card shows no slot. While the screen holds a marks store, its primary button's description ends, after the preview, with a `hidden` element
2244
- inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the marked fields' sentence alone
2245
- (empty while the card draws no match; the List does not announce a hidden match), so a card's focus announcement says what matched and whether only
2246
- a reading did. Without a store the card's description is unchanged.
2378
+ - A List card shows no slot. While the screen holds a marks store, the description of its row and of its primary button ends, after the preview,
2379
+ with a `hidden` element inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the same
2380
+ sentences the DetailList row's indicator holds for the same report under the same answer, built by one rule: the matched keywords, the marked fields
2381
+ and the hidden match (a card whose leaves draw none of the report's matches — a report matched only in the body beyond the preview, for example —
2382
+ says the hidden match in the List, while the DetailList, which draws the whole body, names the content field). The element has no box, so the
2383
+ sentences never change the row's height, and each description reads them once; they are empty until the marks arrive. So the active row's
2384
+ announcement says how many keywords matched, what matched, whether only a reading did and whether the match lies outside the card. Without a store
2385
+ the card's description is unchanged.
2247
2386
 
2248
2387
  ## Client wiring
2249
2388
 
@@ -2303,14 +2442,29 @@ render (`[daily-report] config.headingLevel must be an integer from 2 to 5 …;
2303
2442
  `DailyReportPage` is layout-agnostic (header rendering via `renderHeader` slot, error boundaries/footer control managed by the app). For fine-grained
2304
2443
  usage, `DailyReportActionProvider`, `DailyReportResolvedContent`, `useDailyReportDetail`, etc. can be imported individually.
2305
2444
  Every field `useDailyReportDetail(reportHubId, businessDate)` returns — `report`, `error`, `isLoading` and `isRefetching` — belongs to the id it is
2306
- 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:
2307
- its cached report (one definition, also used by the loading effect: the report cache, expired entries included, then the business date's cached list),
2308
- `isLoading` only when nothing is cached, no error and no refetch yet — never the previous id's report, error or loading flags.
2309
- 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
2310
- 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).
2311
- 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
2312
- 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
2313
- only scroll past. Loads of reports of one business date share one request.
2445
+ called with. The details of a report live in one record per report id that every reader of the screen shares — the views' rows, the views' input
2446
+ lead while a search answer is shown, the List's side pane and the hook — so one report has one record and one load whoever reads it: its first
2447
+ reader's subscription starts the load and its last reader's leaving stops it (an answer that arrives after that is dropped), and a cache write
2448
+ reaches the readers of its own report only, once per answer. The hook copies the record into state at default priority. On the first render after
2449
+ the id changes it already returns that id's starting state: its cached report (one definition, also used by the load: the report cache, expired
2450
+ entries included, then the business date's cached list), `isLoading` only when nothing is cached, no error and no refetch yet — never the previous
2451
+ id's report, error or loading flags. An expired cached report is therefore drawn at once, never preceded by `null`, and refetched behind it. An id of
2452
+ 0 or less fetches nothing and returns no report (0 also returns an `Invalid ID` error). A row and the side pane that show one report share its
2453
+ record, so they never run two loads of it at once, and readers waiting on one load of a report not shown yet receive its failure once each. A
2454
+ settled report is shared with every later reader while its cache entry is fresh. Once the entry has expired, a reader that subscribes to the record
2455
+ — the side pane, which is rebuilt for every report it shows, a row that mounts, an id that enters the input lead's band — re-validates it after
2456
+ 120 ms (the same wait as the refetch below), even while a row keeps the record open: the record keeps the expired report meanwhile (never a loading
2457
+ state), every reader that subscribes before the answer shares the one request, and the answer reaches every reader once; a report selected again
2458
+ inside its TTL is not fetched. A refetch of a shown report that fails (the re-validation, or the first reader's refetch of an expired cached report)
2459
+ leaves the report in place with no error and only logs a warning on `[DailyReportDetailLoader]`, so the next new reader re-validates again. A settled
2460
+ load error is not shared either: a reader that subscribes to a record holding an error — a row that mounts, an id that enters the input lead's band,
2461
+ the side pane — asks again, and the answer replaces the error. Only a new subscription asks again or re-validates (a re-render or a second
2462
+ subscription of the same listener does not, so a row that keeps showing a failure or an expired report never loops), and a refusal of the viewer's
2463
+ session (401 or 403, which cannot succeed before `onSessionExpired` has run) is shared like an answer: it becomes the record's error even over a
2464
+ shown report, and no new reader asks again or re-validates.
2465
+ The first load of a report that is not cached starts inside its first reader's subscription, with no task between the row's mount and its request,
2466
+ 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
2467
+ absorb rows that only scroll past. Loads of reports of one business date share one request.
2314
2468
 
2315
2469
  Mutation failures (save / publish / delete / comment) are surfaced through an error seam: inject `config.onError(info: DailyReportErrorInfo)` to route
2316
2470
  them into your own toast/notification system, or omit it to use the package's built-in notice. Failures on a continuation that resolves after a
@@ -2349,27 +2503,31 @@ package does nothing silently outside its provider.
2349
2503
  technology); otherwise it renders in the provider's own slot after the page, centred with the row gutter G around it
2350
2504
  (`data-daily-report-notice-slot`). Its block size is rounded up to the lattice like a DetailList body.
2351
2505
  - **Focus is handed back, never dropped**: when the notice leaves while it holds focus, focus goes to the first visible successor (below) of: where it
2352
- came from (when that element is still in the document), the view's cursor row (its one Tab stop), the dialog that hosts the notice, the selected
2353
- view tab (a list without rows has no cursor row) and the status panel (no view is mounted yet).
2506
+ came from (when that element is still in the document), the view (its composite, the one Tab stop, or after a pointer input the active row's card
2507
+ button; **Focus handed back to the view**), the dialog that hosts the notice, the selected view tab (no view is rendered) and the status panel (no
2508
+ view is mounted yet).
2354
2509
  - **Focus goes only to what shows it**: every control that removes itself, or empties, while it holds focus — the two retry buttons, the notice's
2355
2510
  close, the header badge's container, the status panel that the content replaces — hands focus on through one rule (`focusVisibleSuccessor` in
2356
2511
  `src/client/keyboard/focus-successor.ts`).
2357
2512
  A candidate takes focus only when it is in the document, focusable, has a box with an area and passes
2358
2513
  `checkVisibility({ visibilityProperty: true, opacityProperty: true })`, and the rule confirms where focus landed before it stops, so a visually
2359
2514
  hidden region, a 0 × 0 box and an element that is not rendered never receive it. When no candidate qualifies, the rule moves nothing.
2515
+ The one exception is a row's own focus target (`data-daily-report-row-focus`, the DetailList card's visually hidden name heading among them): it is
2516
+ checked without the opacity test, which its shared visually-hidden token would always fail, and with every other test (**Focus handed back to the
2517
+ view** below).
2360
2518
  - **The page's stream status**: until the first ids chunk arrives `DailyReportPage` shows one status panel (`data-testid="daily-report-status-panel"`,
2361
2519
  `data-phase` `loading` or `failed`) whose content switches between loading and the load error with its retry button, so the panel persists through a
2362
2520
  retry, and the header's progress badge is one persistent container whose content switches by phase.
2363
2521
  A retry button that its own press removes first hands focus to its container (the status panel, or the badge's container), which draws the control
2364
2522
  focus outline while it holds keyboard focus.
2365
- When the status panel leaves while it holds focus, the page keeps a pending hand-over (`FocusHandOverContext`): the content's view takes it in the
2366
- first commit that mounts its Tab-stop row and focuses that row (the selected view tab when the list completed empty), and a status screen that a
2367
- user switch rebuilds takes it as its new panel mounts and focuses that panel.
2523
+ When the status panel leaves while it holds focus, the page keeps a pending hand-over (`FocusHandOverContext`): the content's view takes it in its
2524
+ first commit and hands focus to its composite (also when the list completed empty: the composite is a Tab stop in every state), and a status screen
2525
+ that a user switch rebuilds takes it as its new panel mounts and focuses that panel.
2368
2526
  Either passes focus only while focus is still nowhere, so focus the user has put elsewhere in the meantime stays. Between the panel leaving and the
2369
2527
  view's first rows focus is on `body` for one commit: a view draws no rows before its first height arrives (**One measurement, in one unit** in
2370
2528
  [View height](#view-height-host-layout)).
2371
2529
  The badge's container, emptied by the stream's completion while it holds focus (an empty container is a 0 × 0 box), hands focus to the page's
2372
- successors: the view's Tab-stop row, else the selected view tab.
2530
+ successors: the view, else the selected view tab.
2373
2531
  One persistent, visually hidden region of the page (`data-testid="daily-report-stream-announcer"`, outside the screens the phase swaps, never
2374
2532
  focusable) announces the changes of the stream's lifecycle that happen after it mounts: loading after a retry (`labels.loadingIds`) and reconnecting
2375
2533
  (`labels.streamRetrying`) as a `role="status"`, a failure as a `role="alert"` (`labels.loadError` before any report arrived, `labels.streamFailed`
@@ -2381,8 +2539,13 @@ package does nothing silently outside its provider.
2381
2539
  (`data-testid="daily-report-ids-stream-status-announcer"`, visually hidden, never focusable).
2382
2540
  Its `role="status"` and `role="alert"` regions are mounted empty and only their texts change: reconnecting (`labels.streamRetrying`) is a status and
2383
2541
  the stream's failure (`labels.streamFailed`) an alert, and the text present at mount is not written; the badge's pills are no live regions.
2384
- The regions sit beside the container, not inside it: the empty container is not laid out (`display: none`), so regions inside it would leave the
2385
- accessibility tree while it is empty and come back already filled, which a screen reader may not announce.
2542
+ The badge holds one slot in the header's row: an invisible sizer (every phase's pill in its widest form at the current digit counts, built like the
2543
+ header's counters) and the container share the slot's one grid cell, so the slot has one size in every phase, also while it shows nothing, and the
2544
+ header's rows and the search band below never move when a pill appears, disappears or changes (the width changes only with the count's digits).
2545
+ The pill's text starts at the slot's start (`text-start`) whatever the host header's alignment, so a narrower phase's label stays beside its dot
2546
+ instead of floating to the slot's other end.
2547
+ The regions sit beside the slot, not inside the container: the empty container is not painted (`visibility: hidden`; it stays laid out), so
2548
+ regions inside it would leave the accessibility tree while it is empty and come back already filled, which a screen reader may not announce.
2386
2549
  Inside the package only that live announcer (`src/client/ui/live-announcer.tsx`) and three reviewed persistent regions — the notice's alert regions
2387
2550
  (**Every failure is announced, once** above), the search box's status line and the filter row's status line — write a live role (`status`, `alert`,
2388
2551
  `log`, `marquee`, `timer`), an `aria-live` or an `<output>`: `src/client/ui/live-regions.spec.ts` reads the shipped client's sources and fails on
@@ -2589,73 +2752,103 @@ view, and no script computes or writes a height.
2589
2752
  「項目がありません」), 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
2590
2753
  message of their own; they only theme the engine's (on `VirtualScroll`'s root: 16 px above and below, slate-500 in light and slate-400 in dark:
2591
2754
  4.55:1 and 7.66:1 against the page, where the engine's default grey reaches only 4.17:1 in dark).
2755
+ Because the views pass an active row (`activeItemIndex`, `null` while the list is empty), `VirtualScroll` draws the message outside the composite —
2756
+ in a layer over the composite's box, with the same pixels and the same `.aqvs-no-items-container` / `.aqvs-no-items-text` hooks — and names it first
2757
+ in the composite's `aria-describedby`, before the key help: the `treegrid` owns nothing but rows, and a screen reader entering an empty view (a
2758
+ viewer who may see no category, for example) hears the title, the empty state and the key help.
2592
2759
 
2593
2760
  ### Keyboard, focus and selection (List / DetailList)
2594
2761
 
2595
- Each view keeps one cursor: the selected report, the report that holds the view's single Tab stop, the row that owns focus, and at most one pending
2596
- focus request, all keyed by report id.
2597
- The keys are delegated to each view's **list**: the element that holds the view's `VirtualScroll` (its rows and its scroll bar). In the List view the
2598
- resize handle and the side pane lie outside the list.
2762
+ Each view is one composite widget: `VirtualScroll`'s content element carries `role="treegrid"`, the view's single Tab stop (`tabIndex=0`, also while
2763
+ the rows load and when the list is empty), and DOM focus stays on it while the keys move. What the keys move is the view's **active row** — the row
2764
+ assistive technology's focus is on — which the composite names through `aria-activedescendant`. `@aiquants/virtualscroll` writes that attribute
2765
+ itself, in the same render as the row it names (`activeItemIndex`), and keeps that row mounted even outside the rendered window, so the reference
2766
+ always resolves (the peer's README, "The active item"). The host never writes the attribute.
2767
+ Each view keeps one row cursor: the active row and the selected report, both keyed by report id; a key re-renders exactly the two rows whose state
2768
+ changes.
2769
+ The keys are delegated to each view's **list**: the element that holds the view's `VirtualScroll` (its composite and its scroll bar). In the List view
2770
+ the resize handle and the side pane lie outside the list.
2599
2771
 
2600
2772
  | Key | List | DetailList | Default prevented |
2601
2773
  | --- | --- | --- | --- |
2602
- | `ArrowUp` / `ArrowDown` | Selects the previous / next report and reveals it by the nearer edge. Focus moves to the card's primary button (to the row frame while the card is not shown). | Selects the previous / next report and aligns it to the top (the reading start); focus moves to the row. | Yes, also at the ends (no wrap-around) |
2774
+ | `ArrowUp` / `ArrowDown` | Makes the previous / next report active and selected, and reveals it by the nearer edge | Makes the previous / next report active and selected, and aligns it to the top (the reading start) | Yes, also at the ends (no wrap-around) |
2603
2775
  | `Home` / `End` | First / last report, aligned to the top / bottom | First / last report, aligned to the top / bottom (to the top when the last row is taller than the viewport) | Yes |
2604
- | `PageDown` / `PageUp` | Selects the farthest row that still fits in the viewport together with the focused row, revealed by the nearer edge | The same; a focused row taller than the viewport is first scrolled by min(overflow, ⌊0.875 × viewport⌋) without changing the selection | Yes |
2605
- | `Enter` / `Space` | The focused primary button's native activation: selects (on a mobile layout it also opens the detail overlay) | On the row element itself: selects without scrolling. On a control inside the row: that control's own action | Native / yes |
2776
+ | `PageDown` / `PageUp` | The farthest row that still fits in the viewport together with the active row, revealed by the nearer edge | The same; an active row taller than the viewport is first scrolled by min(overflow, ⌊0.875 × viewport⌋) without changing the selection | Yes |
2777
+ | `Enter` / `Space` on the composite | Activates the active row like a press of its card's primary button: selects it (on a mobile layout it also opens the detail overlay) | Selects the active row without scrolling | Yes, also without an active row (the page does not scroll) |
2778
+ | `Enter` / `Space` on a control of the active row | That control's own action | The same | Native |
2779
+ | `Escape` on a control of the active row, a field or button of an in-row form included | Focus returns to the composite; the active row does not change (below: where `Escape` keeps its own meaning) | The same | Yes |
2606
2780
  | `Ctrl+Home` / `Ctrl+End` | Leaves the list: focus moves to the last Tab stop before the list / the first one after it (below; on the desktop layout `Ctrl+End` reaches the resize handle), which the browser scrolls into view; the selection does not change | The same | Yes, when there is such a stop (otherwise not handled: the key keeps its browser meaning) |
2607
- | `Tab` / `Shift+Tab` | Moves through the Tab-stop row's controls only (below); Tab never selects | The same | No |
2608
- | Any other key, a modifier combination, IME | Not handled, and cancels a pending focus request | The same | No |
2609
-
2610
- - **The move starts from the focused row**, not from the selection: Tab into a row, press `ArrowDown`, and the report after that row is selected.
2611
- - **`Home` and `End` name a report**, the first or the last: pressed on a focused row that already is that report but is not selected — the last row
2612
- left focused after the selected report was deleted, for example — they select it in place, aligned like any `Home` / `End` (focus is already there,
2613
- so nothing requests it). The relative moves name a neighbour, so an arrow, `PageUp` or `PageDown` that cannot move at an end selects nothing; and
2614
- the frames of a held `Home` / `End` do not select again what its first press selected (or the host refused).
2781
+ | `Tab` / `Shift+Tab` | Native order: the composite, then the active row's controls (below), then out of the view; Tab never selects | The same | No |
2782
+ | Any other key, a modifier combination, IME | Not handled | The same | No |
2783
+
2784
+ - **The move starts from the active row**, never from `document.activeElement` and never from the layout: Tab into a view, press `ArrowDown`, and the
2785
+ report after the active row is selected. A movement key pressed on a control of the active row (★, 既読, 編集, 削除, an attachment link, a comment
2786
+ control outside its form) moves from that row as on the composite and then focuses the composite once (`focus({ preventScroll: true })`), so the
2787
+ repeats that follow arrive at the composite. `Escape` on such a control returns focus to the composite without changing the active row — from the
2788
+ fields and buttons of the in-row edit and comment forms too, whose typed text stays where it was typed (the comment in its field, the editor's
2789
+ title and body in the view's editing store), so nothing is lost and row navigation is one key away instead of a dozen `Shift+Tab` presses —,
2790
+ unless the event's default is already prevented, the key belongs to an IME
2791
+ composition, the target is an element where `Escape` has its own meaning (`select`, `input[type="search"]`, which `Escape` clears, an editing host,
2792
+ `audio` / `video` with `controls`), the target or an ancestor up to the row carries `aria-expanded="true"` (an open disclosure, which `Escape` closes
2793
+ first), or it lies inside an ARIA widget that uses the keys itself (below).
2794
+ - **`Home` and `End` name a report**, the first or the last: pressed on an active row that already is that report but is not selected — the row left
2795
+ active after the selected report was deleted, for example — they select it in place, aligned like any `Home` / `End`. The relative moves name a
2796
+ neighbour, so an arrow, `PageUp` or `PageDown` that cannot move at an end selects nothing; and the frames of a held `Home` / `End` do not select again
2797
+ what its first press selected (or the host refused).
2615
2798
  - **Held keys move at once, then at most once per frame.** The first repeated keydown (`repeat`) of a key is checked like any key and starts a repeat
2616
- stream from the focused row; it and every later repeat on the same axis have their default prevented.
2799
+ stream from the active row; it and every later repeat on the same axis have their default prevented.
2617
2800
  A repeat that finds the stream's frame fence open — the first one, and any repeat after a frame in which nothing was pending — moves at once inside
2618
- its own keydown (one selection, the scroll, one focus request; React commits the discrete event synchronously), so a key that repeats more slowly
2619
- than the display refreshes moves once per keydown and never waits for a frame.
2801
+ its own keydown (the active row, the selection and the scroll in one commit; React commits the discrete event synchronously), so a key that repeats
2802
+ more slowly than the display refreshes moves once per keydown and never waits for a frame.
2620
2803
  The repeats behind the fence only add their step: the arrows one row each (up and down cancel out), `PageUp` / `PageDown` one page each, and `Home`,
2621
- `End` and `Enter` / `Space` on a DetailList row count once; they read neither the DOM nor the layout.
2804
+ `End` and `Enter` / `Space` count once; they read neither the DOM nor the layout.
2622
2805
  The frame right after an immediate move applies nothing (two moves never commit in one frame); every later frame applies what accumulated as one
2623
- move with `flushSync` from the row that holds focus, so the next key already starts from the new row, and a frame with nothing pending opens the
2624
- fence again. A key that repeats faster than the display therefore moves at most once per frame. A repeat on another axis (rows, pages, `Home`,
2625
- `End`, activation) applies what is pending before it starts a new stream.
2626
- A key that is not a repeat first resolves its command, drops the pending focus request and then applies the pending repeats synchronously: the
2627
- destination row renders and takes focus inside that call, however far the repeats went, so the key moves from there. Every `keyup` applies what is
2628
- pending and ends the stream; a pointer press, a wheel or touch start in the view, focus leaving the view and unmounting end it without applying it.
2806
+ move with `flushSync` from the active row, so the next key already starts from the new row, and a frame with nothing pending opens the fence again.
2807
+ A key that repeats faster than the display therefore moves at most once per frame. A repeat on another axis (rows, pages, `Home`, `End`, activation)
2808
+ applies what is pending before it starts a new stream.
2809
+ A key that is not a repeat first resolves its command and then applies the pending repeats synchronously, so it moves from the active row they
2810
+ reached, however far they went. Every `keyup` applies what is pending and ends the stream; a pointer press, a wheel or touch start in the view, focus
2811
+ leaving the view and unmounting end it without applying it.
2629
2812
  On a slow device the keys therefore never queue up, and the selection stops when the key is released. `Ctrl+Home` / `Ctrl+End` are never
2630
- accumulated. While a stream lasts, the desktop side pane keeps showing the report the stream started from (see **List side pane** below).
2813
+ accumulated. While a stream lasts, the desktop side pane keeps showing the report the stream started from (see **List side pane** below), and rows
2814
+ get their bodies only in the frames without a key, far rows behind the travel direction after the key is released (see **Rows mount in two
2815
+ passes** below).
2631
2816
  - **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone (so `Ctrl+PageDown` and `Shift+End` keep
2632
2817
  their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
2633
- keys from outside a row (the scroll bar, the side pane, the mobile overlay); keys from an input region — a `form` (every control of the in-row edit
2634
- form and of the comment form, their buttons included) or an editable element (`input`, `textarea`, `select`, `contenteditable` other than `"false"`,
2635
- `audio[controls]`, `video[controls]`) —
2818
+ keys whose target is neither the view's composite nor a control inside one of its rows (the scroll bar, the side pane, the mobile overlay); the
2819
+ movement keys and the exits from an input region — a `form` (every control of the in-row edit form and of the comment form, their
2820
+ buttons included) or an editable element (`input`, `textarea`, `select`, `contenteditable` other than `"false"`, `audio[controls]`,
2821
+ `video[controls]`); `Escape` there follows its own rule above —
2636
2822
  or from inside ARIA widgets that use these keys themselves (`combobox`, `listbox`, `menu`, `menubar`, `radiogroup`, `slider`, `spinbutton`,
2637
2823
  `textbox`, `tree`, `grid`, `scrollbar`, `tablist`, `toolbar`, `treegrid`, `separator`) between the key's origin and the view's scroll container, the
2638
- exits included.
2824
+ exits included; the view's own composite is the one `treegrid` that is exempt, since it is the element the keys arrive at.
2639
2825
  So no key pressed in an open in-row editor moves the list: the keys keep their meaning in the field (the caret, the text selection, the form's
2640
- buttons), and the row being edited stays where the viewer types. Only elements **inside** the view count: wrapping the whole view in a host `form`,
2826
+ buttons), and the row being edited stays where the viewer types; `Escape` only returns focus to the composite, and the editor stays open with what
2827
+ was typed. Only elements **inside** the view count: wrapping the whole view in a host `form`,
2641
2828
  `role="grid"`, `role="listbox"` or `contenteditable` region does not disable the keys. While the viewport has no size yet, the movement keys are not
2642
2829
  handled.
2643
- - **One Tab order rule for both views: only the Tab-stop row's controls are in the Tab order.** The Tab-stop row is the row that owns focus (below)
2644
- while it is inside the visible range, otherwise the selected row while it is visible, otherwise the first visible row (until the view has reported
2645
- its visible range: the focus owner, then the selection).
2646
- Every focusable control the package draws inside a row reads the rule: the List card's primary button, ★ and 既読, the DetailList row's ★, 既読, 編集 (on
2647
- the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment field and its send button), the edit form's fields
2648
- and buttons, and the attachment links keep their natural order in the Tab-stop row and have `tabIndex=-1` in every other row. The stop row's own
2649
- element is a stop too where it takes focus: a DetailList row (`tabIndex=0`) and a List row frame whose card is not shown.
2650
- So the Tab path through a view is one row's controls long, however many rows are rendered: crossing the List takes exactly 3 presses (the primary
2651
- button, ★ and 既読). Because the focus owner is the stop, a control reached by pointer continues within its own row, and a row in edit mode keeps its
2652
- form in the Tab order.
2830
+ - **One Tab stop per view.** The composite is the view's only stop, and only the active row's controls follow it in the Tab order: the List card's ★
2831
+ and 既読, and the DetailList row's ★, 既読, 編集 (on the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment
2832
+ field and its send button), the edit form's fields and buttons, and the attachment links, in their natural order; every other row's controls have
2833
+ `tabIndex=-1`. The List card's primary button is never a stop (`tabIndex=-1`; it stays the click target and the pointer hand-back target, below),
2834
+ and no row frame carries a `tabindex`.
2835
+ So the Tab path through a view is the composite plus one row's controls, however many rows are rendered: crossing the List takes exactly 3 presses
2836
+ (the composite, ★ and 既読), and `Shift+Tab` from the active row's first control returns to the composite. A row in edit mode is the active row (the
2837
+ title field took focus inside it), so its form stays in the Tab order.
2838
+ When the active row is not visible and the key's default action lands inside it — `Tab` from the composite when the row has a Tab stop, `Tab`
2839
+ from a control of the row with another stop after it, `Shift+Tab` from a control with another stop before it, never `Shift+Tab` from the composite,
2840
+ which leaves the view — the keydown first reveals the row by the nearer edge, synchronously, and then leaves the default action to the browser,
2841
+ which focuses a control already in view. A `Tab` or `Shift+Tab` that leaves the view moves nothing, so the list stays where the viewer left it.
2842
+ The landing is read from the package's one tab-order model (`tabLandsInRow` over `listTabbables` and `partitionTabbables` in
2843
+ `src/client/keyboard/row-tab-order.ts` and `src/client/keyboard/dom-node.ts`), the same model the exits use.
2844
+ The reveal cannot wait for `focusin`: Chromium decides the scroll of a focus move before `focusin` runs, so a row pinned far outside the window would
2845
+ scroll the document instead (an off-screen row is revealed by its index, never by its rect).
2653
2846
  The view-mode tab panel that holds a view is no Tab stop: a panel is a stop when its first meaningful content is not focusable, or when it is a
2654
2847
  scroll container that Tab must reach so the keys can scroll it (WAI-ARIA APG Tabs; `TabsContent` takes a required `tabStop`), and a view's content
2655
- begins with its own stop, the cursor row.
2848
+ begins with its own stop, the composite.
2656
2849
  The panel carries no `tabindex` at all (not −1, which would let a pointer press on blank view space focus the panel, an ancestor of the list where
2657
- the view's keys never arrive), so one Tab from the last control before the view reaches the cursor row, and `Ctrl+Home` from a row returns to that
2658
- control.
2850
+ the view's keys never arrive), so one Tab from the last control before the view reaches the composite, and `Ctrl+Home` from the view returns to
2851
+ that control.
2659
2852
  The side pane's article and relations panels are stops of their own: each is the pane's scroll box, which Tab must reach so the keys scroll it,
2660
2853
  although the article holds buttons, the comment field and attachment links further down.
2661
2854
  The scroll bar is pointer-only in both views, like a native scroll bar, because the keys above already scroll the list: both views pass
@@ -2663,13 +2856,17 @@ resize handle and the side pane lie outside the list.
2663
2856
  accessibility and the Tab order").
2664
2857
  The bar carries `aria-hidden="true"`, so assistive technology sees no `scrollbar` and no `slider` in either view, named or not; its arrow buttons
2665
2858
  have `tabIndex=-1` and the bar and its thumb no `tabindex`, so the bar adds no Tab stop; and a press anywhere on the bar has its default prevented,
2666
- so it moves no focus, neither onto a part of the bar nor away from the row that holds it. Every pointer interaction still scrolls: the arrows on a
2667
- press and while held, the track, the thumb and the tap-scroll circle.
2859
+ so it moves no focus, neither onto a part of the bar nor away from the composite. Every pointer interaction still scrolls: the arrows on a press and
2860
+ while held, the track, the thumb and the tap-scroll circle.
2668
2861
  The bar and its thumb still carry their names (the engine keys `verticalScrollBar` and `verticalScrollThumb`, below), which the hidden bar does not
2669
2862
  expose.
2670
2863
  - **Leaving the list** (`Ctrl+Home` / `Ctrl+End`, as in the WAI-ARIA feed pattern): focus moves to the last Tab stop before the list or the first one
2671
2864
  after it — where real `Shift+Tab` from the list's first stop and real `Tab` from its last stop go — and the browser scrolls it into view as it does
2672
2865
  for Tab.
2866
+ This departs from the WAI-ARIA APG treegrid pattern, where `Ctrl+Home` / `Ctrl+End` on a row move to the first / last row: here `Home` / `End`
2867
+ already do that (as they do on a row in the APG pattern; the rows have no cells to cross), so the two chords keep the feed's exits, and a keyboard
2868
+ user leaves the view in one key instead of tabbing through the active row's controls (a DetailList row can hold a dozen or more). `labels.listKeyboardHelp` names both exits, so a screen reader
2869
+ announces them with the composite's description.
2673
2870
  In the List view the exits are measured from the list like in the DetailList, so on the desktop layout `Ctrl+End` moves to the resize handle (then
2674
2871
  Tab goes on to the side pane), and `Ctrl+Home` to the last stop before the list (in `DailyReportPage`, the search row's last control when the search
2675
2872
  is configured — its clear button while it shows, otherwise the field — and the view-mode toolbar's last control when it is not).
@@ -2696,83 +2893,121 @@ resize handle and the side pane lie outside the list.
2696
2893
  When there is no such element on that side, the key is not handled and keeps its browser meaning; for example, when nothing focusable follows the
2697
2894
  list on the page, `Ctrl+End` scrolls the document as usual. `labels.listKeyboardHelp` names both exits and says that they apply when there is a
2698
2895
  control before or after the list.
2699
- - **Focus follows the selection by report id, inside the key's commit.** A key move focuses its destination before its one synchronous commit ends,
2700
- whether or not the row was rendered: a destination outside the rendered window gets the alignment scroll (`scrollToIndex`) in that same commit,
2701
- which renders it, and a move without alignment lands on a fully visible row, which is rendered already (a key that only scrolls, such as `PageDown`
2702
- inside a tall DetailList row, requests nothing).
2703
- So no key leaves a focus request behind: a wheel that follows in the same task finds the destination focused and changes nothing. Focus is set with
2704
- `focus({ preventScroll: true })` (native focus scrolling would move the pane's `overflow: hidden` content element away from the virtual scroll
2705
- position).
2706
- A request stays pending only on the paths that send focus to a row that may lie outside the rendered window: the focus owner's report leaving the
2707
- list and the selected report deleted from the side pane or the mobile overlay (the table below), and the List's return to the card whose overlay
2708
- closed (its scroll runs in an effect after the commit). Such a request settles when its row registers, however long that takes;
2709
- the next key, a `pointerdown`, `wheel` or `touchstart` in the view, and the destination leaving the list drop it, and a dropped request never
2710
- settles later.
2711
- Focus is only ever taken from nowhere — no element, `body`, or an element inside an `inert` subtree such as the mobile overlay while it slides out
2712
- (such an element cannot keep focus; `isFocusNowhere` in `src/client/keyboard/dom-node.ts`) — or from inside the view; focus that has meanwhile moved
2713
- to the side pane, the open overlay or a host element stays there and the request is dropped.
2714
- - **A key move writes at the focus hand-over and commits once.** The row cursor owns the `tabindex` of every registered focus target (0 on the
2715
- Tab-stop row, −1 on every other row; the row components render none): it writes the attribute when a target registers and when the row's Tab stop
2716
- changes, and never rewrites a value the element already has.
2717
- A move first makes the destination current and the Tab stop while focus is still on the origin, without a React commit and without writing its
2718
- `tabindex` yet. The cursor writes the destination's `tabindex="0"` at the hand-over of its focus move: inside the origin's `focusout`, while no
2719
- element holds focus (before the move when no element held focus, right after it when the move hands nothing over, and before any later change of the
2720
- rows' states when no focus move comes first).
2721
- Then focus arrives on the destination, and the selection and the Tab stop settle on it (the cursor writes the origin's `-1`). The rows' states, the
2722
- host's selection and the scroll commit in one synchronous React commit.
2723
- Chromium's `focus()` recalculates the document's style twice whenever focus moves between two elements — right after the origin loses focus and
2724
- right after the destination gains it — and once more at its entry when a style change is still pending there (a `tabindex` written before the call
2725
- is one whenever the page's CSS has a `[tabindex]` selector). The hand-over write rides on the first of the two.
2726
- No step writes the `tabindex` of the element that holds focus (Chromium recalculates the document's style and layout on the spot when that happens,
2727
- to check that the element can still take focus), and none reads layout after a write,
2728
- so a key whose destination is rendered forces exactly the two recalculations of its one `focus()` call and no layout (a destination that the key's
2729
- own commit renders also has the entry recalculation of the rows that commit inserted): in Chromium 148 with a host `[tabindex]` selector, 8 key
2730
- moves force 16 recalculations (24 when the destination's `tabindex` is written before the call; 16 either way without such a selector).
2731
- The app's keyboard harness counts the style and layout work done inside keydown tasks and, in a run of its own, records the JavaScript stack of
2732
- each. The entries without a stack (at 4× CPU from row 200,000 in run `2026-10-05T16-45-08-477Z`: two recalculations and two layouts, DetailList
2733
- 16.00 ms and 23.00 ms, List 3.76 ms and 2.42 ms) are the frame's own rendering update, which Chromium ran in the same task as the keydown; the
2734
- keydown handler forces none of them, and each laid out from virtualscroll's items boundary (`.aqvs-items-boundary`), none from the document root.
2735
- 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
2736
- takes focus, and the commit's layout effect then settles the origin, which keeps the Tab stop until then.
2737
- 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
2738
- `selectedReportHubId`, the DetailList's `selectedItemId`), and `VirtualScroll` only when the key scrolled. Report rows, card bodies and the row
2896
+ - **A key commits once and forces no layout.** A key move sets the active row, the selection, the scroll and the remembered selection in one
2897
+ synchronous commit (`flushSync`) inside its keydown: a destination outside the rendered window gets the alignment scroll (`scrollToIndex`) in that
2898
+ same commit, which renders it, and `VirtualScroll` names it in the same render. No key calls `focus()` (DOM focus is already on the composite), and
2899
+ no key reads the layout, so a key task forces no style recalculation and no layout of its own.
2900
+ The commit renders the two row frames whose state changed (the old and the new active row, and the old and the new selection), `VirtualScroll` once
2901
+ (the active index and, when the key scrolled, the new window) through the view's `VirtualScroll` wrapper, and the host component that owns the
2902
+ controlled selection (the List's `selectedReportHubId`, the DetailList's `selectedItemId`); the view itself, report rows, card bodies and the row
2739
2903
  renderer do not render, and no context value changes per key.
2740
- 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
2741
- side, computed from the same row heights `VirtualScroll` uses (overscan rows, in other words) — mounts its row element alone, with an empty surface
2742
- that fills its slot and `aria-busy="true"` (a DetailList row without its name and description references, whose elements do not exist yet), and
2743
- 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.
2744
- A report that arrives later — a load's answer or an SSE update — is drawn the same way: the List card and the DetailList body draw the loaded report
2745
- through `useDeferredValue`, so its card mounts in a deferred render that a key press interrupts, never inside the update that brought it.
2746
- Until that render commits, a row that has no card yet keeps its skeleton and `aria-busy="true"` (a loaded report waiting for its render is not
2747
- missing). Only the drawing is deferred: every decision reads the loaded report itself, not the deferred one — the safety valve that removes a row
2748
- whose report no longer exists, and a DetailList body's draft and ownership, its opening of the viewer's own draft in the editor and its edit
2749
- button's action (which does nothing once the report is gone).
2750
- 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
2751
- surface is not measured, so holding moves neither the other rows nor the scroll anchor.
2752
- - **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
2753
- over these events (`nextFocusOwner` in `src/client/keyboard/focus-ownership.ts`; the view only translates DOM events into them): outside the list a
2754
- pointer press ends ownership, and inside the list only focus transitions change it.
2755
-
2756
- | Event | Focus owner after it |
2904
+ The app's keyboard harness counts the style and layout work that starts inside a keydown dispatch or inside a frame callback the key caused: 0 in
2905
+ every run of its nine conditions.
2906
+ - **Rows mount in two passes.** A row that a commit mounts outside the visible range first renders as a **shell**: its row frame, with its role,
2907
+ id, position, set size, level and selection, named by the report's ISO business date and `aria-busy="true"`, and an empty surface
2908
+ (`data-daily-report-row-shell`) that fills the box `VirtualScroll` gives the row, so nothing moves when the body arrives.
2909
+ One scheduler per view then advances the rows one at a time — a shell to its body, a body that still draws older inputs to the latest ones (a report
2910
+ that arrived by a load's answer or an SSE update, or the report's search marks) — each unit applied with `flushSync` as one commit of its own.
2911
+ A row reads its report's details and search marks through one input source from the moment it mounts, and a unit draws what the source holds when it
2912
+ is applied, so an arriving answer never re-renders a row by itself: a row renders only through its units and its own state (such as its edit form).
2913
+ - **Units commit only in key-free frames.** The scheduler applies units only inside its own frame callback, and only when nothing of a key happened
2914
+ since its previous callback: no `keydown` or `keyup` was dispatched in the view's window (two passive capture listeners that only count, so they
2915
+ read nothing), the held key's stream applied no step in a frame callback, and the stream holds no accumulated step. A unit the scheduler applies
2916
+ in its own frame callback therefore never renders in the frame that renders a key, a single press included; a unit commits elsewhere only in the
2917
+ two cases named below (a row that becomes visible, and a newer input of a visible row outside a key hold). The first callback after the scheduler
2918
+ wakes only calibrates. The scheduler arms itself
2919
+ with a `MessageChannel` message and calls `requestAnimationFrame` only from its own message and callbacks — never from a keydown, a commit or the
2920
+ held key's frame — so the harness never counts its work as a key's.
2921
+ - **A frame's share.** A key-free frame applies its first unit, then more units only while their measured costs (the p90 of the latest 32 per kind
2922
+ of unit, known after 8; kinds are a box sizing and a change: a body over a shell, a change that can change the box, the marks alone) fit half the
2923
+ frame interval and the time left in the frame. At today's costs a DetailList card takes one frame, and the List packs 1–3 rows. A frame whose
2924
+ callbacks started late (a GC or a long task ran before them) skips its first unit when that unit no longer fits (a 2 ms margin); a unit of a
2925
+ visible or reach row is skipped by at most one late frame in a row.
2926
+ - **Order.** By zone — visible, then the active row's reach, then near, then far (**Far rows skip rendering** below) — and within a zone by the
2927
+ distance from the active row, the side the keys travel towards first; equal ranks keep the order the units were added in. While a key is held, far
2928
+ rows ahead in the travel direction are drawn after the near ones, so the rows a hold brings into the reach already have their bodies; far rows
2929
+ behind wait for the key's release.
2930
+ - **Visible rows are never stale.** A row that becomes visible with a pending unit renders its body inside the commit that made it visible, so a
2931
+ visible row is never a shell and never shows outdated data or marks (counted). Outside a key hold, a newer input of a row that is already visible
2932
+ is drawn at once: in the commit that brings it, or, when it arrives as a store notification, in one commit at the end of the notifying task.
2933
+ - **Search answers lead the rows' inputs.** While a search answer is shown, each view keeps its rendered rows and 24–40 rows beyond each rendered
2934
+ edge subscribed to the details and the marks (8 more rows on the side it travels towards whenever fewer than 24 remain, 32 on each side after a
2935
+ jump), from its range report, which `VirtualScroll` delivers in a frame callback, never inside a keydown. A row that mounts during a held key finds
2936
+ its details and marks already loaded, so every unit of a hold draws one complete card with its marks, and the requests leave in chunks (4–5 per
2937
+ store in a 40-key hold; after a jump one request per store carries the rendered window and 2 × 32 rows, at most 96 ids). The lead holds its
2938
+ subscriptions per store and releases only the swapped store's: a new answer (a new marks store) re-leases the marks and keeps the details and their
2939
+ request in flight, so it never asks for the same ids twice; a new details store also forgets the edges and leases as after a jump. The by-date
2940
+ views keep the by-date prefetch.
2941
+ - **Heights settle in the frame that lays the body out.** The DetailList's row-height delivery — one `ResizeObserver` per view, made from the
2942
+ view's own window where its scroll container attaches, beside the scheduler, and disconnected when the container detaches — commits each delivery
2943
+ in one synchronous commit:
2944
+ the row's height, `VirtualScroll`'s positions (with the compensation above the visible start) and the row's settled state land in the frame that
2945
+ laid the body out. A row that this commit reveals, or a body whose box it changes, is measured before the delivery ends and observed at the
2946
+ view's next frame callback, so no observation starts inside a delivery and the browser never reports "ResizeObserver loop completed with
2947
+ undelivered notifications".
2948
+ With a 30 ms key repeat at 60 Hz, a hold has about 0.8 key-free frames per key and brings about one row per key into the reach, so a 40-key hold
2949
+ keeps several bodied rows ahead of the active row; holds longer than about 60 keys draw the bodies they reveal inside the key's own commit (one
2950
+ complete card each, counted).
2951
+ On a slow device (the CPU slowed 4×) a held key renders about 9 frames a second — the next frame's p95 is about 109 ms against the 33 ms target —
2952
+ because each key frame's own style, layout and paint take that long; every row that enters the visible range is still complete, with its card and
2953
+ marks, at its entry.
2954
+ Decisions never wait for the drawing: the card and the error surface are both drawn from the unit's snapshot, and only the safety valve that
2955
+ removes a row whose report no longer exists, the busy state of a row whose card or error waits for its unit (a load error that arrived but is not
2956
+ drawn yet keeps the row's skeleton, never a "not found" surface), and a DetailList body's draft and ownership, its opening of the viewer's own draft
2957
+ in the editor and its edit button's action read the row's current details.
2958
+ - **Far rows skip rendering.** Every mounted row lies in one zone, computed in the render that positions the rows from that render's own scroll
2959
+ position (half-open intervals in content pixels, V the viewport's size, s the scroll position): **visible** when it intersects [s, s + V);
2960
+ **reach** when it intersects the active row's reach, from V above the row before the active one to V below the row after it — every row that a
2961
+ step of one or two rows can reveal, with any alignment, intersects the reach before the step, whatever the rows' heights; **near** when it
2962
+ intersects [s − V, s + 2V), the visible range widened by one viewport on each side; and **far** otherwise.
2963
+ A far row whose size is settled skips its rendering: `@aiquants/virtualscroll` writes `content-visibility: hidden` and `aria-hidden="true"` on its
2964
+ item container (`itemZones`; the peer's README, "Item zones and far-item skipping"). The container keeps its explicit height, so skipping and
2965
+ showing a row change no box.
2966
+ A List row is settled when it mounts (its slot is fixed); a DetailList row is settled once the view's size observer has measured its body after
2967
+ its last change, and becomes unsettled again when its body is replaced or its comment list changes, until it is measured again.
2968
+ **Consequences:** a far row is absent from the accessibility tree, from a screen reader's browse mode, from a touch screen reader's swipe order and
2969
+ from the browser's find-in-page, until it comes near; the active row, its reach and the rows within one viewport of the visible range never are.
2970
+ An engine without `content-visibility` (Safari before 18) skips nothing and hides nothing.
2971
+ - **The active row.** It is the row the composite names while focus is in the view, and the row focus lands on when it enters. One pure
2972
+ transition decides it (`nextActiveRow` in `src/client/keyboard/focus-ownership.ts`; the view only translates DOM events into it):
2973
+
2974
+ | Event | Active row after it |
2757
2975
  | --- | --- |
2758
- | Focus enters an element of a row | That row |
2759
- | Focus enters an element of the list outside every row | None |
2760
- | Focus moves to an element outside the list (the resize handle and the side pane included) | None |
2761
- | Focus goes nowhere (`body`): the focused element was removed or scrolled out of the rendered window, the window lost focus, or a press inside the list landed on something that does not take focus | Unchanged: giving focus back is the owner's job |
2762
- | A pointer press outside the list, anywhere in the list's document, also while focus is already nowhere | None |
2763
- | A pointer press inside the list (a gutter, the focused card or row, another row) | Unchanged; a press that moves focus into a row changes the owner through that focus |
2764
- | The list changes and the owner's report is still listed | Unchanged |
2765
- | The list changes and the owner's report is gone (a delete, an SSE removal, a stale removal) | The report now at the owner's index (clamped to the new length), which takes focus and becomes the Tab stop; none in an empty list |
2766
- | The selected report is deleted from the side pane or the mobile overlay | The report now at the deleted report's index, which takes focus and becomes the Tab stop |
2767
-
2768
- Wheel and touch scrolling change nothing. While a row owns focus and its focus target is replaced (the List frame ⇄ card while loading, a control
2769
- that disappears, the DetailList body turning into an error, loading or edit form and back), remounted, or scrolled back into the rendered window
2770
- while focus is nowhere (`body`), the row's registered target takes focus again; focus left on a List row frame moves to the card's primary button
2771
- when the card arrives.
2772
- Focus is only ever taken from nowhere: focus that the user put elsewhere is never moved.
2976
+ | A handled key | The key's destination; the selection follows |
2977
+ | Activation: a click on the List card's primary button or on a DetailList row body, `Enter` / `Space` on the composite | The activated row |
2978
+ | Focus enters a control inside a row (Tab, a click, a script) | That row, so focus on a control always lies in the active row |
2979
+ | Focus enters the composite from outside the view | The row under the mouse or pen press that caused it, which is not revealed (a press never moves what the viewer pressed); otherwise (a Tab) the **Tab-stop rule**: the active row if it is visible, else the selected report if it is visible, else the first visible row, and a row that is only partly shown is then revealed by the nearer edge |
2980
+ | Focus enters the composite from inside the view (`Escape`, `Shift+Tab`, a hand-back), or by a focus move the view makes itself (the return to the composite after a key on a control, `Escape`, every hand-back) | Unchanged |
2981
+ | Focus leaves the view for an element outside it (`Tab` or `Shift+Tab` out of the view, the `Ctrl+Home` / `Ctrl+End` exits, a press outside the view) | The Tab-stop rule, so while focus is outside, the active row is a shown row, and any later entry — a reverse Tab included — lands on a shown row |
2982
+ | The visible range changes while focus is outside the view | The Tab-stop rule, for the same reason |
2983
+ | The visible range changes while focus is in the view | Unchanged: the row stays mounted and is never skipped |
2984
+ | The active report leaves the list (a delete, an SSE removal, a stale removal, a search answer that replaces the list) | The report now at its index, clamped to the new length; none in an empty list |
2985
+ | The selected report is deleted from the side pane or the mobile overlay | The report now at the deleted report's index |
2986
+
2987
+ The row under a press is recorded at the press (mouse and pen) and dropped by every `focusin` in the view and every handled key, so a press whose
2988
+ focus never entered the composite — a List card's primary button takes the click's focus — cannot decide a later entry: a far key after a card
2989
+ click moves from the clicked row and stays on the key's destination.
2990
+ A press in the List's row gutter moves no focus, so it changes nothing; a touch scroll fires no click and no focus, so it changes nothing either.
2991
+ When the active row is removed while the composite holds focus, only the active row moves (nothing scrolls); when focus was inside the UI that
2992
+ left, the view hands it back (below). An empty list has no active row: the composite keeps focus and shows its pane ring.
2993
+ - **Focus handed back to the view.** One rule serves every path that returns focus to the view: the mobile overlay closing, the active row or its
2994
+ comment section leaving while focus was inside it, a focused control that leaves its row for another reason (the row's body turned into an error,
2995
+ an edit closed with no edit button to return to: while a control of a row holds focus, the view watches that row for the control's removal, since
2996
+ browsers fire no `focusout`, or fire it while the control is still in the document), a report deleted from the side pane or the overlay, and the
2997
+ page's hand-over from its status screen to the views. It makes the row active, reveals it by the nearer edge, and focuses by the last input's
2998
+ modality: after a key (or with no input recorded) the composite, with `preventScroll`; after a pointer or a touch the row's own focus target
2999
+ (`data-daily-report-row-focus`; `findRowFocusTarget`) — the List card's primary button, or the DetailList card's visually hidden name heading
3000
+ (`tabIndex=-1`, "date author subject"), so a touch reader lands on the row and hears its name. The heading adds no Tab stop, and a key pressed on it
3001
+ acts as on any control of the row. A row that renders no card (a shell, loading, an error, the editor), or a target that does not show, hands
3002
+ focus to the composite instead. iOS VoiceOver, TalkBack and Switch Access follow DOM focus rather than the active descendant, so a touch hand-back
3003
+ lands on a real element of the row.
3004
+ Focus is only ever taken from nowhere — no element, `body`, or an element inside an `inert` subtree such as the mobile overlay while it slides out
3005
+ (such an element cannot keep focus; `isFocusNowhere` in `src/client/keyboard/dom-node.ts`) — or, for the overlay's close and for a leaving UI, from
3006
+ inside the view (a leaving comment section hands back just before it detaches; focus left on the composite stays there); focus that has moved to
3007
+ the side pane, the open overlay or a host element stays where it is.
2773
3008
  - **Keyboard focus**: one predicate decides it for the whole view (`isKeyboardFocused` in `src/client/keyboard/keyboard-focus.ts`): the focused
2774
3009
  element matches `:focus-visible` and the last input of its document was not a pointer press. `:focus-visible` alone is not enough, because browsers
2775
- match it on a text field (`input`, `textarea`, an editing host) that a click or a tap focused.
3010
+ match it on a text field (`input`, `textarea`, an editing host) that a click or a tap focused, and on a composite that a press inside it focused.
2776
3011
  The last input is recorded per document (the element's `ownerDocument`, so a view in an iframe or a pop-out window reads its own), while a view of
2777
3012
  that document is mounted (`observeInputModality` in `src/client/keyboard/input-modality.ts`, reference-counted across the views):
2778
3013
  a capture-phase `pointerdown` records the pointer, and a capture-phase `keydown` records the keyboard unless the key is a modifier alone (`Shift`,
@@ -2782,21 +3017,25 @@ resize handle and the side pane lie outside the list.
2782
3017
  alone) turns the focus into keyboard focus without moving it. The row and card focus outlines follow this predicate through the attribute below, not
2783
3018
  `:focus-visible` alone, so they never show while the view treats the focus as a pointer's (also where Chromium matches `:focus-visible` after a bare
2784
3019
  `Shift` or `CapsLock` that follows a pointer press).
2785
- - **Keyboard focus is revealed**: when an element inside a row receives keyboard focus, the view scrolls the row frame into the visible area by the
2786
- nearer edge — or, for a row taller than the viewport, the focused element with 8 px around it. Focus from a pointer never scrolls.
3020
+ - **Keyboard focus is revealed**: focus entering the composite from outside by `Tab` reveals the active row by the nearer edge when it is only
3021
+ partly shown (above; an entry by a press reveals nothing), and `Tab` reveals an active row that is not shown at all before focus moves into it
3022
+ (**One Tab stop per view**). When a control inside a row
3023
+ receives keyboard focus, the view scrolls the row frame into the visible area by the nearer edge — or, for a row taller than the viewport, the
3024
+ focused element with 8 px around it — measured against the composite's box. Focus from a pointer never scrolls.
2787
3025
  - **Keyboard-focus attribute**: while keyboard focus is inside a view, the view root carries `data-daily-report-keyboard-focus` (the List's
2788
3026
  `[data-testid="daily-report-root"]`, the DetailList's scroll container). Every `focusin` and `keydown` inside the view sets it from the focused
2789
3027
  element by the same predicate (a key press can turn pointer focus into keyboard focus without moving it), and `pointerdown` inside the view or focus
2790
3028
  leaving it removes it; a click into a text field of a row therefore leaves it off, and the tap-scroll circle stays pressable.
2791
- It is a plain DOM attribute toggled with `toggleAttribute`, so arrow keys moving between rows never rewrite it and nothing re-renders. The floating
2792
- tap-scroll circle hides while it is present, because the circle is drawn over the rows.
2793
- The keyboard-focus outlines of the row frames and the List cards paint only under it (**Selection and focus appearance**), so an outline is drawn
3029
+ It is a plain DOM attribute toggled with `toggleAttribute`, so arrow keys moving the active row never rewrite it and nothing re-renders. The
3030
+ floating tap-scroll circle hides while it is present, because the circle is drawn over the rows.
3031
+ The keyboard-focus outlines of the active row and of the List cards paint only under it (**Selection and focus appearance**), so an outline is drawn
2794
3032
  only while the circle is hidden and the two never show together; the package's controls (buttons, links, tabs, switches, fields) keep their plain
2795
3033
  `:focus-visible` outlines.
2796
- - **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
2797
- 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
2798
- on controls inside the row (`button`, `a[href]`, `input`, `textarea`, `select`, `label`, `[role="button"]`, `contenteditable`). Without
2799
- `onSelectItem` the DetailList handles neither clicks nor keys.
3034
+ - **Pointer selection**: in the List the card's primary `<button>` selects on `click` and makes its row active (a pointer click, or Enter or Space
3035
+ on the button after a pointer hand-back; the click that ends a drag is swallowed by the scroll pane); the button keeps the focus the click gives it,
3036
+ and the next key moves the active row from there and focuses the composite. ★ / 既読 run only their own action. In the DetailList a click on a row
3037
+ body selects it without scrolling and makes it active, except clicks on controls inside the row (`button`, `a[href]`, `input`, `textarea`,
3038
+ `select`, `label`, `[role="button"]`, `contenteditable`). Without `onSelectItem` the DetailList handles neither clicks nor keys.
2800
3039
  - **Host selections and list changes**: when the host changes the selection, the selection ring follows it and the DetailList scrolls that row to the
2801
3040
  top (when the selected report is not in the list yet, as soon as it arrives); focus does not move.
2802
3041
  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,
@@ -2815,7 +3054,7 @@ resize handle and the side pane lie outside the list.
2815
3054
  first visible row (its layout-shift compensation); the update, the compensation and the position are all complete when the call returns, so the
2816
3055
  record reads the compensated state and never mistakes the compensation for a scroll of the user.
2817
3056
  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
2818
- never reverted, focus stays on the key's destination, and a re-measured row shifts no report.
3057
+ never reverted, the active row stays the key's destination, and a re-measured row shifts no report.
2819
3058
  The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read),
2820
3059
  so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled.
2821
3060
  Only the anchor's scroller holds the full handle (the end-to-end test handle gets the read-only part and the scroller, so its one position change,
@@ -2834,15 +3073,18 @@ resize handle and the side pane lie outside the list.
2834
3073
  (re-recording there would read the clamped position against the previous list's rows, name another report and leave the view displaced by up to
2835
3074
  the last row's height).
2836
3075
  - **Scrolls `VirtualScroll` makes for the user** (wheel, drag, the scroll bar, inertia) are recorded each time the view reports its visible range,
2837
- 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
3076
+ once per frame. A fling far beyond the DetailList's rendered window is not symmetric: the rows it passes as shells are drawn above the pane
3077
+ afterwards and grow from the unmeasured estimate (352 px, below every measured row) to their heights, and `VirtualScroll`'s compensation keeps the
3078
+ visible rows in place, so wheeling back by the same distance lands several hundred px (up to about 1,100) away from where the fling started; the
3079
+ visible rows never jump. A list change in the frame before that report first advances the anchor by the distance scrolled since it was recorded and
2838
3080
  restores from there: it neither undoes the user's scroll nor shifts the content by a row.
2839
3081
  Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the
2840
3082
  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
2841
3083
  scroll), whatever the list's length or the position.
2842
- 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.
3084
+ An insert or a delete before or inside the rendered window therefore leaves the first visible report — and the active row — where it was.
2843
3085
  At the start of the list (scroll position 0) no anchor is kept, so reports that arrive at the top are shown;
2844
3086
  the anchor yields only to a pending selection reveal (a DetailList selection waiting for its report; a key move needs no precedence, since its
2845
- 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
3087
+ scroll, its destination's render and the active row all end inside its own commit); and when the anchor report itself is removed, the first remaining
2846
3088
  report that followed it on screen is shown at its offset.
2847
3089
  - **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
2848
3090
  On the desktop layout the pane follows the selection in a deferred render: its frame (`[data-testid="daily-report-side-pane"]`) takes the new
@@ -2866,27 +3108,43 @@ resize handle and the side pane lie outside the list.
2866
3108
  The mobile overlay shows the report it opened, so its pane carries the same `data-displayed-report-id` and no `aria-busy`.
2867
3109
  The article tab scrolls to its end (where the comments are) only after the viewer's own comment has been posted; a comment that arrives over SSE,
2868
3110
  from another user or another tab, never moves what the viewer is reading.
2869
- - **Screen readers**: each list references `labels.listKeyboardHelp` (visually hidden) through `aria-describedby` — the DetailList only when it
2870
- handles keys (with `onSelectItem`); there is no live region, announcements come from focus.
2871
- The List card's primary button is named by its content ("YYYY-MM-DD creator"), described first by its position in the list
2872
- (`labels.listRowPosition`, a visually hidden text: en "3 of 40", ja 「全 40 件中 3 件目」; the row frame carries `aria-posinset` / `aria-setsize`, which
2873
- the focused button cannot carry), then by the row state (`labels.rowState`: read or unread, and starred), then by the markers the card paints and
2874
- then by its preview, and carries `aria-current="true"` when selected.
2875
- The position text reads the row's place from the same context as the row frame, so an insert or a delete before the rendered window re-renders the
2876
- frames and these texts, never a card's body.
2877
- The total the position names is the size of the set the rows belong to — every row frame's `aria-setsize` and the header's total count read the same
2878
- value, which `DailyReportResolvedContent` decides once for both views (`resolveRowSetSize`):
3111
+ - **Screen readers**: the composite is a `treegrid` named by `labels.title`; it references `labels.listKeyboardHelp` (visually hidden, outside the
3112
+ composite) through `aria-describedby` — the DetailList only when it handles keys (with `onSelectItem`) — and carries `aria-rowcount`, the size of
3113
+ the set its rows belong to. There is no live region: announcements come from the active descendant, which the composite names while it holds focus.
3114
+ Every row frame is a `row` with an `id` derived from the composite's id and the report id (`virtualScrollItemElementId`), `aria-rowindex` and
3115
+ `aria-posinset` (its place in the list, from 1), `aria-setsize`, `aria-level="1"` and an explicit `aria-selected` — `"true"` on the selected row,
3116
+ `"false"` on every other, so Chromium adds no implicit selection. Its surface is the row's one `gridcell`, named by the row's name element, so a
3117
+ cell is never named by the whole card.
3118
+ A List row with a card is named by its card's primary button, whose content is "YYYY-MM-DD creator" (`aria-labelledby`), and described by the row
3119
+ state (`labels.rowState`: read or unread, and starred), the markers the card paints, the preview and, while the screen holds a search marks store,
3120
+ the match description; the button itself is described first by its position in the list (`labels.listRowPosition`, a visually hidden text: en
3121
+ "3 of 40", ja 「全 40 件中 3 件目」), because touch screen readers land on the button, which carries no position attribute.
3122
+ A DetailList row with a body is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state, the
3123
+ markers its header paints and, while the screen holds a search marks store, the match indicator's hidden sentences last (**Match indicator** in
3124
+ [Text search](#text-search)).
3125
+ While the set's size is unknown (`aria-setsize="-1"`, whose support in assistive technology is unproven), a card row's description ends with its
3126
+ position text as well (`labels.listRowPositionInUnknownTotal`: en "Item 3", ja 「3 件目」); once the size is known the attributes alone carry the
3127
+ position, so it is not read twice. The position reads the row's place from the same context as the row frame, so an insert or a delete before the
3128
+ rendered window re-renders the frames and these texts, never a card's body.
3129
+ The total is the size of the set the rows belong to — every row frame's `aria-setsize`, the composite's `aria-rowcount` and the header's total count
3130
+ read the same value, which `DailyReportResolvedContent` decides once for both views (`resolveRowSetSize`):
2879
3131
  while the ids stream delivers, the total the server declared on the stream's first line (never fewer than the rows shown, and unchanged as chunks
2880
- arrive); before the server has declared it, unknown — `aria-setsize="-1"`, the header shows `labels.totalCountLoading`, and the position reads
2881
- `labels.listRowPositionInUnknownTotal` (en "Item 3", ja 「3 件目」); once the scan has completed, the rows shown.
3132
+ arrive); before the server has declared it, unknown — `-1`, and the header shows `labels.totalCountLoading`; once the scan has completed, the rows
3133
+ shown.
2882
3134
  A host that renders `DailyReportList` or `DailyReportDetailList` itself passes that size as `rowSetSize` (−1 while unknown), or omits it when its
2883
- rows are the whole set; the views' `VirtualScroll` always counts the rows given. A DetailList row is named by its report heading (date, author and
2884
- subject; `aria-labelledby`) and described by the row state and the markers its header paints, and, while the screen holds a search marks store, by
2885
- the match indicator's hidden sentences last (**Match indicator** in [Text search](#text-search)).
3135
+ rows are the whole set; the views' `VirtualScroll` always counts the rows given.
2886
3136
  The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one,
2887
- otherwise its label); a description references only the markers that are painted. A row without content (loading, failed, missing, editing) is named
2888
- by its ISO business date, with `aria-busy="true"` while loading. At most one element per view carries `aria-current`. ★, 既読, 編集 and 削除 carry
2889
- `aria-label` equal to their `title` and no `aria-pressed`.
3137
+ otherwise its label); a description references only the markers that are painted. A shell, and a row without content (loading, failed, missing,
3138
+ editing), is named by its ISO business date, with `aria-busy="true"` while its report or its body is still to come, and is described by the state
3139
+ it shows, so the active descendant's announcement never reduces a visible state to a date: a failed row by its error message
3140
+ (`labels.reportSummaryLoadFailed` in the List, `labels.reportLoadFailed` in the DetailList), a List row the server did not find by
3141
+ `labels.reportNotFound`, and a DetailList row that shows its edit form — the viewer's own draft opens there by itself — by a visually hidden
3142
+ `labels.rowEditingState({ isDraft })` (en "Editing draft" / "Editing", ja 「下書きを編集中」 / 「編集中」). A shell and a loading skeleton have no
3143
+ description. ★, 既読, 編集 and 削除 carry
3144
+ `aria-label` equal to their `title` and no `aria-pressed`; no element of a view carries `aria-current`.
3145
+ The active row is never skipped, so its name and description are always complete; a far row that skips rendering is absent from the accessibility
3146
+ tree (**Far rows skip rendering** above). The package's tests compute the active row's name and description in both views; how real screen readers
3147
+ announce the treegrid rows is the manual checklist in [Assistive technology checklist](#assistive-technology-checklist).
2890
3148
  - **Document structure**: every report has a heading at `config.headingLevel` (an integer from 2 to 5, default 3; pick the level that continues the
2891
3149
  host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
2892
3150
  The side pane and the mobile overlay start with two label / value pairs: `labels.businessDate`, whose value is the report heading (the business date
@@ -2912,8 +3170,8 @@ resize handle and the side pane lie outside the list.
2912
3170
  `labels.reportTabList` (the report heading names only the business date, which reports of the same day share).
2913
3171
  - **Editing**: on the viewer's own reports, an Edit button (`labels.edit`, `data-detail-edit-button`, a pencil icon) stands before Delete in the
2914
3172
  DetailList header and in the side pane's action row. It is the keyboard way into the editor; a double click is the pointer shortcut. Entering the
2915
- editor this way moves focus to its title field, and Cancel or a successful Publish returns focus to the Edit button (when the button is not
2916
- rendered, the DetailList row takes focus itself and the side pane focuses its report heading).
3173
+ editor this way moves focus to its title field, and Cancel or a successful Publish returns focus to the Edit button (the side pane focuses its
3174
+ report heading when the button is not rendered).
2917
3175
  A draft of the viewer's own opens in the editor by itself, in a DetailList row and in the side pane alike (another user's draft never does), without
2918
3176
  moving focus; it closes by itself only when the report turns published, and an editor the viewer opened stays open then.
2919
3177
  The editor's state lives in one editing store per action provider, by report (`src/client/contexts/daily-report-draft-store.ts`), not in the row or
@@ -2935,11 +3193,14 @@ resize handle and the side pane lie outside the list.
2935
3193
  belongs to an IME composition is left alone), by focus leaving the trash button and the confirmation, or by a pointer press outside them.
2936
3194
  A cancel by the trash button or by Escape returns focus to the trash button when focus was on the confirmation.
2937
3195
  Confirming moves focus before the comment is hidden, to a destination computed from what remains: the next remaining trash button, else the previous
2938
- 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
2939
- the view's focus request), the pane's report heading in the side pane and the mobile overlay — so focus never falls to `body`.
3196
+ one, else the comment section's heading (`tabIndex=-1`) when the section stays, else the report's own anchor — in the DetailList the view, through
3197
+ its hand-back just before the section detaches (**Focus handed back to the view**: the composite after a key, the row's name heading after a
3198
+ pointer), and the pane's report heading in the side pane and the mobile overlay — so focus never falls to `body`.
2940
3199
  - **One comment section rule**: the DetailList row, the side pane and the mobile overlay (which shows the side pane's content) render one comment
2941
3200
  section, its `labels.comments` heading included, only while the report has comments or the viewer may comment on it (the same decision that shows
2942
3201
  the comment form: see [External Source Badge Configuration](#external-source-badge-configuration-sourcetypeconfigs)).
3202
+ The comment field is named by that heading (`aria-labelledby`; `labels.comments`), which renders whenever the form does, so the field has a name of
3203
+ its own in the DetailList row, the side pane and the mobile overlay; its placeholder (`labels.commentPlaceholder`) stays a hint, not its name.
2943
3204
  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,
2944
3205
  so focus goes to the report's anchor above.
2945
3206
  - **Mobile detail overlay** (List, single-column layout): the platform's modal dialog, a `<dialog data-daily-report-mobile-overlay>` (implicit
@@ -2956,10 +3217,11 @@ resize handle and the side pane lie outside the list.
2956
3217
  the close path runs too, and the dialog opens modally again when the close is refused.
2957
3218
  Closing it by any means (close button, Escape, backdrop, browser back, a switch to the desktop layout) first blurs the focus inside the dialog, then
2958
3219
  ends the modal state with `close()` (the platform returns focus, without scrolling, to the element that had it when the dialog opened, when that
2959
- element is still in the document), and then the view hands focus to the report's card with `preventScroll`, the list scrolling only when that row is
2960
- not fully visible.
3220
+ element is still in the document), and then the view hands focus back to the report's row (**Focus handed back to the view**): the row becomes
3221
+ active and is revealed by the nearer edge only when it is not fully visible, and focus lands on the composite after a keyboard close (Escape) and on
3222
+ the card's primary button after a tap or a click, with `preventScroll`.
2961
3223
  The dialog is never opened again to show the slide-out: it stays rendered while closed (its layout does not depend on the `open` attribute) and
2962
- `inert` — no focus, no pointer, nothing in the accessibility tree — until it has slid out, and the row cursor counts focus left inside it as focus
3224
+ `inert` — no focus, no pointer, nothing in the accessibility tree — until it has slid out, and the view's hand-back counts focus left inside it as focus
2963
3225
  that is nowhere. Opening the overlay again during the slide-out shows the same dialog modally again.
2964
3226
  The dialog itself takes focus (`tabIndex=-1`), so pressing a non-focusable part of it never drops focus to `body`. The backdrop is a decorative
2965
3227
  layer (`data-daily-report-overlay-backdrop`, `aria-hidden`, without a role or a name, not focusable): pressing it closes the overlay, while keyboard
@@ -2969,6 +3231,38 @@ resize handle and the side pane lie outside the list.
2969
3231
  the element's own window.
2970
3232
  - `VirtualScroll`'s own `enableKeyboardNavigation` stays `false`; the keys are handled by the package.
2971
3233
 
3234
+ #### Assistive technology checklist
3235
+
3236
+ The package's tests prove the treegrid semantics in jsdom and Chromium's accessibility tree; how screen readers announce a treegrid whose rows are
3237
+ active descendants is checked by hand, with this checklist, which the package delivers and does not run. Run the steps below with each of six pairs —
3238
+ NVDA with Chrome, JAWS with Chrome and Narrator with Edge on Windows, VoiceOver with Safari on macOS, VoiceOver on iOS (Safari) and TalkBack on Android
3239
+ (Chrome) — in a List and in a DetailList that show at least 20 reports, and once more while the ids stream still delivers and has not declared its
3240
+ total (the set size unknown); run the Switch Access, forced-colours and reduced-motion steps once each. Desktop readers stay in their focus (forms)
3241
+ mode on the composite; touch readers navigate by swiping, since they follow DOM focus rather than the active descendant. Record the reader, the browser
3242
+ and their versions, and per step either a pass or the text announced.
3243
+
3244
+ | Step | Do | Expect |
3245
+ | --- | --- | --- |
3246
+ | Entry | Desktop: Tab from the last control before the view. Touch: swipe into the view | Desktop: the composite is announced as a tree grid named by the title, described by the keyboard help, and its active row (the selected report, else the first visible row) with its name, its description, its position (row M of N, or "Item M" while the total is unknown) and as selected. Touch: the first visible row's card button or row is reached, announced by its name and description |
3247
+ | ↑ / ↓ | Desktop: press ArrowDown three times, then ArrowUp once | Each key announces the new active row once — name, position, selection — and nothing else; if a reader repeats "level 1" or the row count on every key, note it (the alternative is the composite role `grid`) |
3248
+ | Home / End, PageUp / PageDown | Desktop: press End, then Home, then PageDown twice and PageUp once; repeat End right after opening the view, so that it lands on a row whose body has not loaded yet | Each key announces the row it lands on once, with its position. A row that lands before its body is announced by its ISO business date and as busy; note whether the reader announces the row's full name (date, author, subject) once the body arrives, or only on the next key |
3249
+ | Enter / Space | Desktop: on the composite press Enter, then move with ArrowDown and press Space; in the List on the phone layout, press Enter | The active row is selected in both views and announced as selected (the DetailList does not scroll); on the phone layout Enter opens the report's overlay, whose dialog is announced by the report heading and the author |
3250
+ | Tab to ★ | From the composite press Tab (the List: ★ of the active row; the DetailList: the row's first control) | Focus moves to that control of the active row and its label is announced; the next Tab reaches the row's next control; Shift+Tab from the first control returns to the composite, which announces the active row again |
3251
+ | Escape | On ★ press Escape | Focus returns to the composite, the same active row is announced, and the selection does not change |
3252
+ | Escape from the comment field | Desktop, DetailList: Tab to the active row's comment field, type a few characters, press Escape, then Tab back to the field | The field is announced by its name (the comment section's heading, 「コメント」), not only by its placeholder. Escape returns focus to the composite and announces the same active row; the typed characters are still in the field |
3253
+ | Exits | Desktop: on the composite press Ctrl+End, then Shift+Tab back into the view; press Ctrl+Home | Ctrl+End moves focus to the first control after the list (on the desktop List the resize handle) and Ctrl+Home to the last control before it, each announced by its own name; the keyboard help names both exits. These chords leave the view instead of moving to the first / last row as in the APG treegrid pattern (Home / End do that) |
3254
+ | Reverse entry | Desktop: from the first control after the view press Shift+Tab | Focus lands on the active row's last control, which is in view (the page does not scroll), and its label is announced; further Shift+Tab presses walk the row's controls back to the composite |
3255
+ | Tab into a far row | Desktop: with focus on the composite, wheel the list until the active row is far out of view (thousands of px), then press Tab | The active row is scrolled into view before focus lands on its first control, which is announced; the page does not scroll |
3256
+ | Removal of the active report | Desktop: with focus on the composite, delete the active report from another tab or window (an SSE removal) | Focus stays on the composite, nothing scrolls, and the report now at the removed report's position is announced as the active row (or nothing, when the list is empty) |
3257
+ | A search answer | Desktop: run a search from the search box and Tab into the view; then clear the search and Tab into the view again | The composite announces its active row, a shown row of the answer (the Tab-stop rule of **The active row**), with the answer's count as its set size; after the search is cleared, a shown row of the whole list, with the list's total |
3258
+ | Empty and no-category views | A search that matches nothing, and a viewer who may see no category: Tab into the view | The composite is announced by its title, described by the empty text (「項目がありません」) and the keyboard help, with no row; for the viewer without a category, the page's notice (「閲覧できる日報の区分がありません。…」) is reached in browse mode before the view |
3259
+ | Overlay close | Phone layout, List: open a report's overlay with Enter on the composite and close it with Escape; open it with a tap and close it with the close button | After the keyboard close, focus is on the composite and the same row is announced as active; after the tap close, focus is on the card's primary button, announced with its position first |
3260
+ | Touch hand-back in the DetailList | Touch reader, DetailList: in the active row of your own report from a source that takes no comments, delete its last comment (the trash button, then the confirmation) | The comment section leaves and focus lands on the row's name heading, announced as the report's date, author and subject (not on the list as a whole) |
3261
+ | Far rows | Browse mode (NVDA, JAWS, Narrator) or swiping (VoiceOver on iOS, TalkBack) past the visible rows | The rows within one viewport of the visible range are reached; rows farther away are reached only once the list has scrolled near them (**Far rows skip rendering**); the active row is always reached |
3262
+ | Switch Access | Android, Switch Access with Chrome, List on the phone layout: scan to a card and select it, then close the overlay with its close button | The card is reachable by scanning and opens the overlay; after the close, focus (which Switch Access follows) is on that card's primary button, or on the composite when the page recorded no pointer input for the action (a hand-back after no recorded input lands on the composite): record which, and that scanning continues from there |
3263
+ | Forced colours | Windows contrast theme (forced colours) with the keyboard: move the active row with the arrows, wheel it out of view, then press a key | The selected row shows the system's `Highlight` outline, the active row's keyboard outline shows in the system colour while it is visible, and the composite's 2 px pane ring shows while the active row is out of view |
3264
+ | Reduced motion | `prefers-reduced-motion: reduce`: move with the keys, hover a card, open and close the phone overlay | No transition or animation runs; every state change (selection, outline, hover lift, the overlay's opening) shows at once |
3265
+
2972
3266
  ### Selection and focus appearance
2973
3267
 
2974
3268
  Selection and keyboard focus are two separate channels, identical in both views, so "selected and focused" never looks like "focused":
@@ -2976,7 +3270,8 @@ Selection and keyboard focus are two separate channels, identical in both views,
2976
3270
  | State | Channel | Geometry | Light | Dark | Forced colours |
2977
3271
  | --- | --- | --- | --- | --- | --- |
2978
3272
  | Selected | box-shadow ring; the surface paints no shadow outside it | 2 px, 0–2 px outside the surface | blue-500 | blue-500 | 2 px `Highlight` outline of the row frame in the ring's band (below) |
2979
- | Keyboard focus on a row or card (only while the view root carries `data-daily-report-keyboard-focus`) | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
3273
+ | Keyboard focus on the active row (the composite holds keyboard focus) or on a List card's primary button (only while the view root carries `data-daily-report-keyboard-focus`) | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
3274
+ | Keyboard focus on the composite with no active row, or with the active row not shown beyond 4 px (below) | `@aiquants/virtualscroll`'s pane ring, coloured through its `--aqvs-focus-ring-color` | 2 px inset of the pane | blue-600 | blue-400 | kept (system colour) |
2980
3275
  | Keyboard focus on a control or link | outline | 2 px at offset 2 | blue-600 | blue-400 | kept |
2981
3276
  | Pointer focus | none | — | — | — | — |
2982
3277
  | Hover (cards) | blue-300 border, `shadow-lg` (a selected card paints no shadow), a lift L of at most 2 px on whole device pixels (motion-safe; **Row gutter G** below) | — | — | — | — |
@@ -3052,18 +3347,31 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3052
3347
  - **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
3053
3348
  (−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
3054
3349
  (`calc(var(--radius-2xl) + 8px)`, 24 at a 16 px root), so the line is concentric with the surface at every root font size.
3055
- 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
3056
- (`: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
3350
+ Every row reads its own `aria-selected="true"`, in every load state. The selected row differs by shape as well as by colour (WCAG 1.4.1); the surface border
3057
3351
  stays 1 px in every state, and the focus outline (4–6 px outside) sits beside the selection line without touching it.
3058
3352
  - **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
3059
- state — from its own `aria-current` / `:focus-visible`, and the List card surface styles itself from its direct-child primary button (`:has(> …)`).
3060
- Conditions on an ancestor read only the attributes the package writes itself (`data-daily-report-scrolling`, `data-daily-report-keyboard-focus`), so
3061
- an ancestor that carries shared attributes such as `aria-current` never lights up a row.
3062
- The focus outline of both surfaces also requires the view root's `data-daily-report-keyboard-focus`
3063
- (`:where([data-daily-report-keyboard-focus]) <frame>:focus-visible > :where(<surface>)` and
3064
- `:where([data-daily-report-keyboard-focus]) <card surface>:has(> <primary button>:focus-visible)`): the ancestor sits in `:where()`, so the rules
3065
- keep their specificity of (0,2,0) and (0,3,0), and the attribute changes only when the input modality does, never per arrow key, so it restyles the
3066
- surfaces once per change of modality.
3353
+ state — from its own `aria-selected` (the selection ring) and its own `data-daily-report-active` (the focus outline), and the List card surface
3354
+ styles itself from its direct-child primary button (`:has(> …)`).
3355
+ Conditions on an ancestor read only the attributes the package writes itself (`data-daily-report-scrolling`, `data-daily-report-keyboard-focus`)
3356
+ and the composite's `:focus-visible`, so an ancestor that carries shared attributes such as `aria-current` or `aria-selected` never lights up a row.
3357
+ The active row's outline requires the view root's `data-daily-report-keyboard-focus` and a `:focus-visible` ancestor — the composite, the only
3358
+ focusable ancestor of a row — (`:where([data-daily-report-keyboard-focus]) :where(:focus-visible) <frame>[data-daily-report-active] > :where(<surface>)`),
3359
+ and the List card's outline requires the same root attribute
3360
+ (`:where([data-daily-report-keyboard-focus]) <card surface>:has(> <primary button>:focus-visible)`): the ancestors sit in `:where()`, so the rules
3361
+ keep their specificity of (0,2,0) and (0,3,0). The root attribute is load-bearing: a press inside a keyboard-focused composite leaves the composite
3362
+ `:focus-visible`, and the attribute, removed by the press, keeps the outline off. It changes only when the input modality does, never per arrow key,
3363
+ so it restyles the surfaces once per change of modality.
3364
+ No selector reads `aria-activedescendant`: it changes on every key and would invalidate the composite's subtree; a key restyles the two row frames
3365
+ whose `data-daily-report-active` and `aria-selected` change. `@aiquants/virtualscroll` draws its pane ring on the composite only while the active
3366
+ row is not shown (`data-aqvs-active-item` is not `visible`), and both views count an active row that a viewport edge clips as shown only while more
3367
+ than 4 px of it show at that edge (`activeItemVisibleInset` = G − the outline's offset = 8 − 4: the distance from the row frame's edge to the inner
3368
+ edge of the surface's 2 px outline). The ring therefore yields only once the outline's whole width shows at the
3369
+ clipped edge, and a keyboard-focused composite always shows a whole indicator: the active row's outline, or the ring.
3370
+ The ring wears the outline's colour: both views bind the peer's neutral variable `--aqvs-focus-ring-color` to blue-600 (dark: blue-400) on
3371
+ `VirtualScroll`'s root, so one focus shows one colour in each scheme (the peer's default `#2563eb` stays just
3372
+ above 3:1 on the dark page, 3.90:1, against the outline's 7.63:1). The peer's scroll bar and edge pills read the same variable for their rings,
3373
+ but never take focus in these views (`enableArrowButtonTabStops: false`), and the arrow buttons keep their own fill, whose glyph contrast depends
3374
+ on it.
3067
3375
  - **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
3068
3376
  or attribute, and `:has()` sits only on the styled element itself. A featureless subject (`*:`, `group-*`) or an ancestor's `:has(:focus-visible)`
3069
3377
  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
@@ -3084,13 +3392,13 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3084
3392
  held.
3085
3393
  Inside the box the rows' overflow is ink overflow, so a scroll the browser makes on its own to reveal an overscan row (a find-in-page match there)
3086
3394
  cannot move the list and moves the nearest outer scroller instead when the row's box lies outside its view; the views' own reveals do not depend on
3087
- it (one Tab stop per view, rows focused with `preventScroll`, keyboard focus revealed by the scroller).
3395
+ it (one Tab stop per view, `Tab` revealing the active row in its keydown, keyboard focus revealed by the scroller).
3088
3396
  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
3089
3397
  the List writes on the frame or, in the DetailList, that row box itself (`h-full`), whose height `VirtualScroll` takes from the measured row.
3090
3398
  The DetailList therefore measures each row's body — the frame's direct child, as tall as its content rounded up to the block quantum q (**Row slots
3091
3399
  on the lattice**) — and adds 2G, handing the height to `VirtualScroll` through the scroller's `resizeRow` (see **Host selections and list
3092
- 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
3093
- released hold) is observed in its place.
3400
+ changes**); measuring the frame would read back the box it fills. A shell is not measured, and a body that is replaced (another load state, a
3401
+ shell's first body, a newer report's card) is observed in its place.
3094
3402
  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,
3095
3403
  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
3096
3404
  would clip (the resting `shadow-sm`, 4 px below, stays inside G; a selected surface paints no shadow).
@@ -3556,7 +3864,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3556
3864
  The visually hidden copy, `title` and `data-attachment-file-name` stay plain.
3557
3865
  - **Tab order inside list rows**: the attachment links follow the row rule of
3558
3866
  [Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist): inside a DetailList row they are Tab stops only in the view's
3559
- Tab-stop row (`tabIndex=-1` in every other row); in the side pane and the mobile overlay they keep their natural order.
3867
+ active row (`tabIndex=-1` in every other row); in the side pane and the mobile overlay they keep their natural order.
3560
3868
  - **Marker**: the List card and the DetailList header show `DailyReportAttachmentIndicator` when a report has attachments: a 24 px tall box without
3561
3869
  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
3562
3870
  16 px line; it is named `<labels.attachments>: <count>` (`role="img"`).
@@ -3675,11 +3983,14 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
3675
3983
 
3676
3984
  | Hook | Element |
3677
3985
  | --- | --- |
3678
- | `[data-daily-report-row="<id>"]` | Row frame of both views (`role="listitem"` with `aria-posinset` / `aria-setsize`; the set size is the declared total while the ids stream delivers and `-1` before it is declared, see **Screen readers**), in every load state |
3679
- | `[data-daily-report-row-surface]` | Surface of a row frame: its direct child that paints the selection ring and the focus outline, in every load state (opaque and never animated; a loading pulse runs inside it) |
3986
+ | `[role="treegrid"]` | Each view's composite: `VirtualScroll`'s content element, the view's one Tab stop and the element that holds DOM focus while the keys move; its `id` prefixes the row ids, `aria-activedescendant` names the active row's `id` (written by `@aiquants/virtualscroll`), `aria-rowcount` is the set's size, and `data-aqvs-active-item` is `visible` or `outside` while there is an active row |
3987
+ | `[data-daily-report-row="<id>"]` | Row frame of both views (`role="row"` with `id`, `aria-rowindex`, `aria-posinset`, `aria-setsize`, `aria-level` and an explicit `aria-selected`; the set size is the declared total while the ids stream delivers and `-1` before it is declared, see **Screen readers**), in every load state |
3988
+ | `[data-daily-report-active]` | The active row's frame (the row the composite's `aria-activedescendant` names) |
3989
+ | `[data-daily-report-row-surface]` | Surface of a row frame (`role="gridcell"`): its direct child that paints the selection ring and the focus outline, in every load state (opaque and never animated; a loading pulse runs inside it) |
3990
+ | `[data-daily-report-row-shell]` | The empty surface of a shell: a row mounted outside the visible range whose body is still to come (**Rows mount in two passes**) |
3680
3991
  | `[data-daily-report-card]` | List card surface (present while the card is shown) |
3681
- | `button[data-daily-report-button="<id>"]` | List card primary button (selection, focus target, `aria-current`) |
3682
- | `[role="listitem"][data-daily-report-button="<id>"]` | DetailList row element (also its focus target) |
3992
+ | `button[data-daily-report-button="<id>"]` | List card primary button (the click target that selects, and the pointer hand-back target; never a Tab stop, `tabIndex=-1`) |
3993
+ | `[data-daily-report-row-focus]` | A row's own focus target, where a pointer or touch hand-back lands: the List card's primary button, or the DetailList card's visually hidden name heading (`tabIndex=-1`; never a Tab stop) |
3683
3994
  | `[data-list-star-button="<id>"]` / `[data-list-read-button="<id>"]` | List card toggles |
3684
3995
  | `[data-detail-star-button="<id>"]` / `[data-detail-read-button="<id>"]` / `[data-detail-edit-button="<id>"]` / `[data-detail-delete-button="<id>"]` | Toggles, edit and delete in the DetailList header and the List side pane (edit and delete on the viewer's own reports only) |
3685
3996
  | `[data-testid="daily-report-side-pane"]` | List side pane: `data-report-id` is the selected report, `data-displayed-report-id` the report its content shows; the desktop pane carries `aria-busy="true"` while the two differ (the overlay's pane shows the report it opened, so they are equal) |
@@ -3699,14 +4010,18 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
3699
4010
  | `[data-daily-report-view-tabs]` | The view-mode tab list (its selected tab is the focus successor of a list without rows) |
3700
4011
  | `data-testid` | `daily-report-root`, `daily-report-list`, `daily-report-detail-list`, `daily-report-side-pane`, `daily-report-error-banner` (the error notice), `daily-report-status-panel` (the status screen's panel, `data-phase` `loading` / `failed`), `daily-report-load-error-retry`, `daily-report-stream-announcer` (the page's status region, never focusable), `daily-report-ids-stream-status` and `daily-report-ids-stream-retry` (the badge's content and its retry button), `daily-report-ids-stream-status-announcer` (the badge's own live regions, rendered only while it announces its phases), and the attachment ids above |
3701
4012
 
3702
- Only the package writes `data-daily-report-keyboard-focus` and `data-daily-report-scrolling`. Hosts may select on them — in tests, or in CSS to adapt
4013
+ Only the package writes `data-daily-report-keyboard-focus`, `data-daily-report-scrolling`, `data-daily-report-active`, `data-daily-report-row-shell` and `data-daily-report-row-focus`. Hosts may select on them — in tests, or in CSS to adapt
3703
4014
  their own content inside a view — but never set them. When the pane content matters, wait for `data-displayed-report-id`, not `data-report-id`: during
3704
4015
  a key's render the frame already names the new selection while the content still shows the previous report.
3705
4016
 
3706
4017
  `[data-testid="daily-report-list"]` and `[data-testid="daily-report-detail-list"]` carry a `__virtualScroll` accessor for end-to-end tests, with the
3707
4018
  same shape in both views. It is the read-only part of the current `VirtualScroll` handle — `getViewportSize()`, `getScrollPosition()`,
3708
4019
  `getScrollAnchor()`, `getRange()` and `getFenwickSize()` — plus `findReportIndex(id)`, `getReportItem(index)` and `getReportIds(limit = 20)` over the
3709
- view's committed list, and one way to move the position, `revealIndex(index, options)`.
4020
+ view's committed list, one way to move the position, `revealIndex(index, options)`, and one read-only view of the view's row pass,
4021
+ `rowPass.counters()`: the counts since the view mounted, read when it is called — `syncReleases` (units drawn synchronously, in a commit or a
4022
+ notification's microtask commit), `oversizeFrames` (key-free frames whose first unit alone exceeded the frame's share), `lateFrames` (late key-free
4023
+ frames the pass skipped) and `lateForcedFrames` (late key-free frames it applied a unit in regardless). A performance probe reads them before and
4024
+ after a key hold and reports the differences, so it copies none of the pass's constants or rules (**Rows mount in two passes**).
3710
4025
  The client entry exports the contract as types, so a host's tests import it instead of restating it (type-only, no bundle bytes):
3711
4026
  `DailyReportViewTestHandle` is the accessor, `DailyReportViewTestReadHandle` its read-only part, and `DailyReportRevealOptions` the options of
3712
4027
  `revealIndex`, which are the second argument of `VirtualScroll`'s `scrollToIndex` (its alignment and offset, as the peer defines them).
@@ -3731,9 +4046,9 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
3731
4046
  />
3732
4047
  ```
3733
4048
 
3734
- - **One surface for all wording.** The catalog covers the 11 engine chrome keys of `@aiquants/virtualscroll` plus 110 own keys: field headings, the
3735
- page title (`title`, passed to `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row state and the List
3736
- card's position read to screen readers, tabs, buttons, placeholders, the `confirm()` text, stream badges, the search box, its filter row and the
4049
+ - **One surface for all wording.** The catalog covers the 11 engine chrome keys of `@aiquants/virtualscroll` plus 111 own keys: field headings, the
4050
+ page title (`title`, passed to `renderHeader` and used as the accessible name of both lists), the lists' keyboard help, the row states (read and
4051
+ starred, editing) and the List card's position read to screen readers, tabs, buttons, placeholders, the `confirm()` text, stream badges, the search box, its filter row and the
3737
4052
  search's match indicator, the status screen and the page's status region and the mutation-failure message with its repeat count. The engine part of
3738
4053
  each catalog is `@aiquants/virtualscroll`'s own catalog of the same locale, reused by value, so the scroll arrows, the names of the scroll bar and
3739
4054
  its thumb, and the "No items" text always speak the same language as the rest.
@@ -3741,12 +4056,12 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
3741
4056
  and the 11 engine keys of the resolved labels to their `VirtualScroll`. The engine label object keeps
3742
4057
  its identity while its values are unchanged, so rebuilding `config.labels` on every render does not
3743
4058
  re-render the memoized list subtree.
3744
- - **Formatter keys.** Twenty-one keys take arguments and are functions: `totalCount(count)`,
4059
+ - **Formatter keys.** Twenty-two keys take arguments and are functions: `totalCount(count)`,
3745
4060
  `debounce(milliseconds)`, `streamProgressCount(loaded)`, `streamProgressRatio(loaded, total, percent)`,
3746
4061
  `streamRetrying(loaded)`, `reportLoadFailed(reportHubId)`, `reportSummaryLoadFailed(reportHubId)`,
3747
4062
  `interviewerWithAffiliation(name, affiliation)`, `operationFailed(operation)` (`operation` is a
3748
4063
  `DailyReportOperation`: `create` / `update` / `publish` / `delete` / `addComment` / `deleteComment` /
3749
- `toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `listRowPosition(position, total)`,
4064
+ `toggleStar` / `toggleRead`), `noticeRepeated(count)`, `rowState({ isRead, isStarred })`, `rowEditingState({ isDraft })`, `listRowPosition(position, total)`,
3750
4065
  `listRowPositionInUnknownTotal(position)`,
3751
4066
  `searchHitCount(count)`, `searchFailed(failure)` (`failure` is `invalid` / `busy` / `unavailable` / `failed`),
3752
4067
  `searchMatchedKeywords(matched, total)`, `searchMatchedKeywordsDescription(matched, total)`, `searchFieldMatched(field)` and
@@ -3757,7 +4072,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
3757
4072
  locale built once (the row positions and counts re-render on every stream publish); a host override receives
3758
4073
  the raw number and formats it itself.
3759
4074
  - **Fail-fast**: an unsupported `locale` (`"EN"`, `"ja-JP"`, `"fr"`, `""`, `null`, ...) throws a
3760
- `RangeError` at render. So do an unknown `labels` key (a key error that lists the 121 keys), a string
4075
+ `RangeError` at render. So do an unknown `labels` key (a key error that lists the 122 keys), a string
3761
4076
  key whose value is not a string with non-whitespace content, and a formatter key whose value is not a
3762
4077
  function. An `undefined` value keeps the catalog value.
3763
4078
  - **`config` has a closed key set**: `apiBasePath`, `ssePath`, `locale`, `labels`,
@@ -3795,7 +4110,7 @@ locale**: English (`"en"`, the default) and Japanese (`"ja"`). Pick the language
3795
4110
  grouping follows `locale` (`en` → `"en-US"`, `ja` → `"ja-JP"`) and dates stay in ISO form. Packages
3796
4111
  that do format dates (for example `@aiquants/period-slider`) name that separate axis `formatLocale`.
3797
4112
 
3798
- Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 121 keys, the 11 engine keys
4113
+ Exported API (`@aiquants/daily-report/client`): `DAILY_REPORT_LABEL_KEYS` (all 122 keys, the 11 engine keys
3799
4114
  first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReportLabels(locale, labels)`
3800
4115
  (the effective frozen labels — the catalog object itself when `labels` is `undefined`), the resolved
3801
4116
  `locale` / `labels` on `useDailyReportConfig()`, and the types `DailyReportLocale` (= `VirtualScrollLocale`),
@@ -3836,8 +4151,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
3836
4151
  | `tabRelations` | side pane tab | Related | 関連 |
3837
4152
  | `reportTabList` | accessible name (`aria-label`) of the side pane's tab list | Report sections | 日報の表示の切り替え |
3838
4153
  | `relationsEmpty` | side pane related tab body | No related view configured yet. | 関連ビューはまだ構成されていません。 |
3839
- | `title` | `renderHeader({ title })`, both lists' `aria-label` | Daily Reports | 日報 |
3840
- | `listKeyboardHelp` | both lists' `aria-describedby`, visually hidden (the DetailList only with `onSelectItem`) | Up and Down arrows: previous or next report. Home and End: first or last report. Page Up and Page Down: move by a page. Enter or Space on a report row: make it current. Control+Home and Control+End: move to the nearest control before or after the list, when there is one. | ↑↓ で前後の日報、Home / End で先頭・末尾、PageUp / PageDown でページ移動、日報の行で Enter または Space を押すと選択、Ctrl+Home / Ctrl+End で一覧の直前・直後の操作部品があればそこへ移ります。 |
4154
+ | `title` | `renderHeader({ title })`, both composites' `aria-label` | Daily Reports | 日報 |
4155
+ | `listKeyboardHelp` | both composites' `aria-describedby`, visually hidden and outside the composite (the DetailList only with `onSelectItem`) | Up and Down arrows: previous or next report. Home and End: first or last report. Page Up and Page Down: move by a page. Enter or Space: select. Tab: the report's controls. Escape: back to the report row. Control+Home and Control+End: move to the nearest control before or after the list, when there is one. | ↑↓ で前後の日報、Home / End で先頭・末尾、PageUp / PageDown でページ移動、Enter または Space で選択、Tab でその日報の操作部品へ、Escape で日報の行へ戻ります。Ctrl+Home / Ctrl+End で一覧の直前・直後の操作部品があればそこへ移ります。 |
3841
4156
  | `tabList` | top-level tab | 📄 List | 📄 一覧 |
3842
4157
  | `tabTree` | top-level tab | 🌳 Tree | 🌳 ツリー |
3843
4158
  | `tabDetailList` | top-level tab | 📋 DetailList | 📋 詳細一覧 |
@@ -3900,9 +4215,10 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
3900
4215
  | `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
3901
4216
  | `operationFailed` | the error notice / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
3902
4217
  | `noticeRepeated` | the error notice, beside its message when failures with the same message follow one another (`count` is how many came in a row, from 2; part of the notice's name) | `(2)` → (2 times) | `(2)` → (2 回目) |
3903
- | `rowState` | visually hidden row state, the first description of a DetailList row and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
4218
+ | `rowState` | visually hidden row state, the first description of a card row in both views and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
4219
+ | `rowEditingState` | visually hidden state of a DetailList row that shows its edit form in place of the card (the viewer's own draft opens there by itself), the row's description | `({ isDraft: true })` → Editing draft; `({ isDraft: false })` → Editing | `({ isDraft: true })` → 下書きを編集中; `({ isDraft: false })` → 編集中 |
3904
4220
  | `listRowPosition` | visually hidden position of a List card in the list, the first description of its primary button (`position` is 1-based; `total` is the size of the views' set) | `(3, 1234)` → 3 of 1,234 | `(3, 1234)` → 全 1,234 件中 3 件目 |
3905
- | `listRowPositionInUnknownTotal` | the same position while the size of the views' set is unknown (the ids stream has not declared its total yet), in place of `listRowPosition` | `(3)` → Item 3 | `(3)` → 3 件目 |
4221
+ | `listRowPositionInUnknownTotal` | the same position while the size of the views' set is unknown (the ids stream has not declared its total yet), in place of `listRowPosition`; it also ends the description of a card row in both views while the size is unknown | `(3)` → Item 3 | `(3)` → 3 件目 |
3906
4222
  | `searchFieldMatched` | match sentences of a row (the DetailList row's and the side pane's hidden indicator sentences, the List card's hidden match description): a field heading the row's leaves marked with a typed word's mark (`field` is the heading's resolved label) | `("Author")` → Author matched | `("作成者")` → 作成者で一致 |
3907
4223
  | `searchFieldMatchedByReading` | the same, for a heading whose marks matched only through a romaji reading | `("Author")` → Author matched by its romaji reading | `("作成者")` → 作成者がローマ字の読みで一致 |
3908
4224
  | `searchFiltersToggle` | search box: the filter toggle, with the number of selected filter values (no number at 0; digit grouping) | `(0)` → Filters; `(3)` → Filters (3) | `(0)` → 絞り込み; `(3)` → 絞り込み (3) |
@@ -4028,7 +4344,10 @@ Documenting a name here is no reason to export it, and the values that tune the
4028
4344
  routes itself: `DAILY_REPORT_ACTION_INTENTS` / `DAILY_REPORT_ACTION_FIELDS` (the intents and the form's field names),
4029
4345
  `encodeDailyReportActionCommand(command)` (a command as its `FormData`), `parseDailyReportActionResult(intent, json)` (the strict reading of a 200
4030
4346
  answer: `{ result }` or `{ mismatch }`), `DAILY_REPORT_API_ENDPOINTS` / `DAILY_REPORT_API_QUERY_PARAMS` (the endpoint and GET query-parameter
4031
- names) and `DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` (the echo id's two canonical forms).
4347
+ names) and `DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` (the echo id's two canonical forms),
4348
+ and the source-type vocabulary a host builds its visibility port with: `DAILY_REPORT_INTERNAL_SOURCE_TYPE` (`"Internal"`, the source type of the
4349
+ reports the package creates) and `foldDailyReportSourceTypeToken(token)` (the one fold the server applies to the tokens the port returns:
4350
+ surrounding blanks removed, then lower case; [Source-type visibility](#source-type-visibility)).
4032
4351
  - Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment`
4033
4352
  / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` /
4034
4353
  `DailyReportIdsStreamChunkLine` / `DailyReportIdsStreamChunk` (what `readIdsStreamChunkLine` answers), `DailyReportAttachmentDeliveryRequest` /
@@ -4075,6 +4394,8 @@ Documenting a name here is no reason to export it, and the values that tune the
4075
4394
  session's client is not public. The route helpers `createDailyReportClientLoader` / `dailyReportShouldRevalidate`.
4076
4395
  - Configuration and wording: `defaultDailyReportClientConfig` / `resolveDailyReportLabels` / `DAILY_REPORT_LABEL_CATALOGS` /
4077
4396
  `DAILY_REPORT_LABEL_KEYS` / `resolveSourceTypeConfig`.
4397
+ - The source-type fold `foldDailyReportSourceTypeToken`, the same function as the shared entry's, for the host's route modules that ship to the
4398
+ browser (the shared entry is not split, so a route module that imports the fold from it carries a second copy of shared modules into its chunk).
4078
4399
  - The search's filter controls: `DAILY_REPORT_SEARCH_FILTER_FACETS` with its type `DailyReportSearchFilterFacet` (the filters in the filter row's
4079
4400
  order, `author`, `sourceType` and `customer`), and the contract of the control a host renders in each filter cell:
4080
4401
  `DailyReportSearchFilterSelectRenderer` (what `config.search.renderFilterSelect` is) /
@@ -4112,7 +4433,7 @@ Documenting a name here is no reason to export it, and the values that tune the
4112
4433
  decode-work and walk bounds, for a host's conformance checks) / `DAILY_REPORT_ATTACHMENT_THUMBNAIL_OUTPUT_MEDIA_TYPES` /
4113
4434
  `DAILY_REPORT_ATTACHMENT_THUMBNAIL_MAX_OUTPUT_BYTES`.
4114
4435
  - SSE and caching: `buildDailyReportSseStreamKey(streamKey)` (the Redis stream key the service writes and reads the SSE entries of, the base key
4115
- with the wire version appended, `<streamKey>:v3`, for a host that reads or writes the stream itself) / `DailyReportSseReader` /
4436
+ with the wire version appended, `<streamKey>:v4`, for a host that reads or writes the stream itself) / `DailyReportSseReader` /
4116
4437
  `readDailyReportSseStreamEntry(raw, logger)` (reads one Redis stream entry, `{ id, message }` as the client returns it, once: `{ entry, json }`,
4117
4438
  the frozen `StreamEntry` the shared reader hands every subscriber and the entry's parsed JSON value; for a host whose own reader keeps the same
4118
4439
  contract) /
@@ -4125,15 +4446,22 @@ Documenting a name here is no reason to export it, and the values that tune the
4125
4446
  server entry alone, so client bundles do not carry it).
4126
4447
  - Request values: `parseClientTempId` and its branded result type `DailyReportClientTempId` (the action's echo id in its two canonical forms, the
4127
4448
  only id the service's write methods take; **The echo id** in [Request values](#request-values)).
4449
+ - Visibility: `createDailyReportVisibilityCache({ ttlMs, readViewerKey, resolve })` (the package's memoizer of the visibility port's answers, which
4450
+ returns a `DailyReportResolveVisibleSourceTypes`; its settings type `DailyReportVisibilityCacheConfig`) and
4451
+ `DAILY_REPORT_VISIBILITY_MAX_CACHE_TTL_MS` (60,000, the longest `ttlMs` it takes; **Performance** in
4452
+ [Source-type visibility](#source-type-visibility)).
4128
4453
  - Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` /
4129
4454
  `DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` /
4130
4455
  `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared
4131
4456
  reader `createDailyReportHandlers` takes) / `DailyReportSseStreamEntry` (an entry as the reader hands it to every subscriber: its id and its
4132
4457
  message read once, or `null`) / `DailyReportExternalReportFields` (what an `externalSources` adapter's `mapRow` returns),
4133
4458
  the ports' own types `DailyReportResolveUserId` / `DailyReportEncodeUserId` / `DailyReportResolveVisibleSourceTypes` (with
4134
- `DailyReportVisibleSourceTypes`, `DailyReportVisibilityDimensions`, `DailyReportVisibilityResolveContext` and
4135
- `DailyReportVisibilityResolveReason`) / `DailyReportRedisClient` / `DailyReportRedisBlockingClient` / `DailyReportRedisStreamMessage` /
4136
- `DailyReportSseEntryMessage` (an entry's message read once) / `DailyReportExternalSource` (an `externalSources` adapter),
4459
+ `DailyReportVisibleSourceTypes`, `DailyReportVisibilityDimensions`, `DailyReportVisibilityOptOut` (the explicit opt-out
4460
+ `{ unrestricted: <reason> }`), `DailyReportVisibilityResolveContext` and `DailyReportVisibilityResolveReason`) / `DailyReportRedisClient` / `DailyReportRedisBlockingClient` / `DailyReportRedisStreamMessage` /
4461
+ `DailyReportSseEntryMessage` (an entry's message read once: `parsed`, a `DailyReportSseStreamMessage` — the message as the service writes it to
4462
+ the stream, read by `dailyReportSseStreamMessageSchema`: the types that may be addressed carry the server-only `recipientRawUserId`, and the types
4463
+ that embed a report also the server-only `actorRawUserId`, which the delivery replaces with each viewer's `viewerIsActor` — and `published`, the JSON
4464
+ value as written, which only the recipient filter reads; every frame is built from `parsed`) / `DailyReportExternalSource` (an `externalSources` adapter),
4137
4465
  the injected tables `DailyReportHubTable` / `DailyReportInternalTable` / `DailyReportCommentTable` / `DailyReportLabelTable` /
4138
4466
  `DailyReportHubLabelTable` / `DailyReportUserStatusTable` / `DailyReportUserTable` / `DailyReportAttachmentTable` (the members of
4139
4467
  `DailyReportTables`), the service's rows `DailyReportUserStatusRow` / `DailyReportIdsSource`,