@aiquants/daily-report 0.41.0 → 0.42.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
@@ -570,9 +597,15 @@ its payload — with one strict parser each, after authentication and before any
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
598
  variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:v3`),
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) |
@@ -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`).
@@ -711,59 +744,119 @@ streaming body is refused from the start.
711
744
  ### Source-type visibility
712
745
 
713
746
  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.
747
+ returns plain strings; your app decides the policy. The port is required, so no deployment leaves every category open by omission: a deployment
748
+ that does not restrict by source type says so with the explicit opt-out and its reason.
715
749
 
716
750
  ```ts illustrative
751
+ import { createDailyReportVisibilityCache } from "@aiquants/daily-report/server"
752
+
717
753
  createDailyReportServer({
718
754
  // …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
- },
755
+ resolveVisibleSourceTypes: createDailyReportVisibilityCache({
756
+ ttlMs: 30_000,
757
+ // `refresh` is the periodic re-check of a live SSE connection: read the viewer from the session afresh there,
758
+ // so a viewer who signed out reads as null and the stream ends.
759
+ readViewerKey: async (request, { reason }) => (reason === "initial" ? yourAuth.viewerIdOf(request) : yourSession.viewerIdOf(request)),
760
+ resolve: async (request, { reason }) => {
761
+ const grants = await yourAuthz.grantsFor(request, { skipCache: reason !== "initial" })
762
+ return grants.canReadLegacy ? ["legacy"] : []
763
+ },
764
+ }),
725
765
  })
726
766
  ```
727
767
 
768
+ A deployment that shows every category to every viewer answers `{ unrestricted: "<why this deployment needs no category restriction>" }`; the
769
+ reason is the audit trail, so `grep -rn "unrestricted:"` in the host lists every such answer.
770
+
728
771
  #### Contract
729
772
 
730
773
  | Return value | Meaning |
731
774
  | --- | --- |
732
- | `undefined` / `null` | Unrestricted — every source type (the default when the port is not injected). |
733
775
  | `["legacy", "Internal"]` | Only those source types are visible (bodies **and** comments). |
734
- | `[]` | Nothing is visible (zero rows) — **not** the same as `null`. |
776
+ | `[]` | Nothing is visible (zero rows): a revocation, which reads answer with no rows and SSE delivery with anchors only. |
735
777
  | `{ read: [...] }` | Same as returning the bare array — the comment dimension follows `read`. |
736
778
  | `{ 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. |
779
+ | `{ unrestricted: "<reason>" }` | Every source type: the explicit opt-out of a deployment that does not restrict by source type, with a non-blank reason. |
780
+ | throws | The visibility of this request cannot be decided now: the request is answered "not now", never as `[]` (**Failure** below). |
781
+ | 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
782
 
739
783
  - **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.
784
+ `"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
785
+ 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
786
+ canonical set at boot from `dailyReportServer.knownSourceTypes` (frozen: `Internal` first, then each adapter's in order) and assert your mapping
787
+ against it — an unknown token silently matches zero rows. Fold your tokens with `foldDailyReportSourceTypeToken` (shared entry), the fold the server
788
+ applies to the tokens it reads, instead of a copy of it.
744
789
  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
790
  stop `createDailyReportService` and `createDailyReportServer` with a `RangeError`
746
791
  (`[daily-report] externalSources[1].sourceType must be a source type that differs, ignoring case, from externalSources[0].sourceType "legacy"; got "Legacy"`):
747
792
  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.
793
+ - **Matching** is case-insensitive and exact, the same on the JS and SQL sides: a token is trimmed and lower-cased (`foldDailyReportSourceTypeToken`),
794
+ a stored value is lower-cased and never trimmed, and the two must be equal. In SQL the predicate compares bytes,
795
+ `CAST(LOWER(source_type) AS VARBINARY(2n)) IN (CAST(@token AS VARBINARY(2n)), …)` with n the column's declared length (**Required database schema**),
796
+ because SQL Server compares strings with trailing blanks padded and, under a width-insensitive collation, full-width and half-width forms alike: the
797
+ bytes do neither, so the predicate does not depend on the database collation.
798
+ 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
799
+ 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
800
+ cut head could match); a list left empty denies everything. The predicate cannot use an index, as `LOWER()` could not.
751
801
  - **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`.
802
+ than only the HTTP handlers. It also gates every write:
803
+ - **Creating** needs the package's own source type: a scope that does not show `Internal` is refused (`Forbidden`, answered 403
804
+ `{"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
805
+ 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
806
+ scope.
807
+ - **Every write to an existing report** — `addComment`, `deleteComment`, star and read, and the author's own `updateDailyReport`,
808
+ `publishDailyReport` and `deleteDailyReport` — first passes the same gate (`assertHubMutable`, the read rule's category and draft clauses on the
809
+ 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
810
+ 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
811
+ 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
812
+ deleted report (a draft's labels stripped, its search document rewritten).
813
+ - 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
814
+ 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
815
+ `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
816
+ report for `deleteComment`.
817
+ - The publish action answers with the report read again under the actor's scope, so the comment dimension applies to the answer (other users'
818
+ comments of a category the scope's `comment` excludes are dropped, the actor's own posted comments stay, and external comments are emptied); the
819
+ `report-publish` event carries the whole report, and SSE delivery filters it for each recipient.
754
820
  - **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.
821
+ service method, the create and the three author mutations included.
756
822
  The class is nominal (it holds a private field), so `{ visible: null }`, `{ ...scope, visible: null }` and `new DailyReportViewerScope(...)` are all
757
823
  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)`.
824
+ handed a nullish value and quietly widen — and `DailyReportViewerScope.unrestricted(auditReason)`, which the handlers call only for the port's
825
+ explicit opt-out, with its reason.
759
826
  Because a bare `null` is not spellable as a visibility value, `grep -rn "DailyReportViewerScope.unrestricted("` enumerates every unfiltered read in
760
827
  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
828
+ - **Failure**: a port that throws — an authorization-store outage, or a viewer who is gone (signed out or disabled) — or answers outside the contract
829
+ leaves the request's visibility undecided, and the package answers "not now" rather than "nothing visible", on every path, before any read or write:
830
+ - every JSON endpoint and the ids stream: 503 `{"error":{"message":"Report visibility is unavailable"}}` with `Retry-After: 5` and
831
+ `Cache-Control: no-store` (**Visibility unavailable** in [The API route](#the-api-route));
832
+ - the action: 503 `{"error":"Report visibility is unavailable"}` with the same headers, before the user lookup;
833
+ - an original or a thumbnail: the `busy` 503 with `Retry-After: 5` ([Attachments](#attachments));
834
+ - 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
835
+ delivered (**SSE latency** below).
836
+
837
+ 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
838
+ the outage for good. Each failure writes one `error` line on the `[DailyReportVisibility]` channel per cause (`port_failure`, `port_contract`) and
839
+ 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>`,
840
+ never its value) —, and the window's own timer writes
841
+ `visibility_unavailable_suppressed count=<N> since=<ISO 8601 time of the window's first failure> cause=<cause> reasons=initial:<n>,refresh:<n>` at its
842
+ end when it counted more.
843
+ - **Performance**: the port is called once per request on the critical path (cache keys incorporate the resolved set), so keep it fast.
844
+ `createDailyReportVisibilityCache({ ttlMs, readViewerKey, resolve })` (server entry) is the package's memoizer of the port's answers, which a host
845
+ wraps around its own lookup instead of restating the cache rules:
846
+ - 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
847
+ `RangeError` otherwise), measured on the monotonic clock, and sweeps expired answers on every write;
848
+ - 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;
849
+ - concurrent `initial` calls share the pending lookup (written when it starts, so a slower earlier lookup never overwrites a later answer);
850
+ - a failed lookup is forgotten while the viewer's entry still holds it, keeping an answer written after it;
851
+ - a request whose `readViewerKey` answers null — nobody signed in, or a viewer gone by the time of a `refresh` — throws before any lookup.
852
+
853
+ 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
854
  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.
855
+ request identity **must** be bypassed when `reason === "refresh"` or the re-check silently returns the stale set (the memoizer does so).
856
+ - **SSE latency**: because a live connection re-resolves periodically, a grant change (in either direction) takes effect for the stream within the
857
+ 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
858
+ 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
859
+ from there once the lookup answers again, and a viewer who signed out meets the stream's sign-in end on the reconnect.
767
860
 
768
861
  ### Comment visibility
769
862
 
@@ -787,7 +880,8 @@ resolveVisibleSourceTypes: async (request, { reason }) => {
787
880
  | `["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
881
 
789
882
  - **`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**.
883
+ 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
884
+ runtime is outside the contract and answered like a port failure (**Failure** in [Source-type visibility](#source-type-visibility)).
791
885
  - **What it covers**: `DailyReportDetail` carries comments in **two** structurally different fields — `postedComments` (the package's own comment
792
886
  table) and `externalComments` (JSON supplied by an `externalSources` adapter). Both are gated. They are emptied, never omitted: the keys are
793
887
  required by the SSE schema, and dropping them would make a receiver discard the whole message.
@@ -798,7 +892,7 @@ resolveVisibleSourceTypes: async (request, { reason }) => {
798
892
  - **Writing to external sources remains impossible regardless** — `addComment` rejects any non-`Internal` source type outright, so for external
799
893
  categories the comment dimension effectively controls *reading*.
800
894
  - **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.
895
+ `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
896
 
803
897
  ### Attachments
804
898
 
@@ -960,6 +1054,9 @@ type DailyReportReadAttachment = (
960
1054
 
961
1055
  `busy`, `deadline` and `unavailable` are transient: a thumbnail generation concludes nothing from them, caches nothing and marks nothing missing.
962
1056
  The requests that joined that generation receive the same failure, and the next request reads again.
1057
+ A request whose viewer visibility cannot be decided (the visibility port failed; **Failure** in [Source-type visibility](#source-type-visibility))
1058
+ 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
1059
+ not read as "not visible", whose 404 a thumbnail would conclude from.
963
1060
  - **`principal` is for attribution and audit only: the read result must not depend on it.** The SQL predicate (`getAttachmentForUser`) is the only
964
1061
  authorization boundary; the port reads the `filePath` of a row that has already been authorized. Thumbnail generation relies on this: requests
965
1062
  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 +1554,7 @@ The thumbnail delivery (`?thumbnail=tile`):
1457
1554
  | 405 | Any method other than GET (`Allow: GET`) |
1458
1555
  | 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
1556
  | 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 |
1557
+ | 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
1558
  | 504 | A storage read past a deadline: the read port's own (`deadline`) or the read stage's `readMs` (`read_timeout`); never cached |
1462
1559
  | 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
1560
 
@@ -1505,7 +1602,8 @@ also when the refusals stop, and a bucket the limiter evicts (it keeps 1,024 use
1505
1602
 
1506
1603
  ### Transactions
1507
1604
 
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
1605
+ 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
1606
+ report reads back as read. The service has one entry to its transactions, which retries **only a transaction the
1509
1607
  database chose as a deadlock victim** (the decision is
1510
1608
  [`@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
1609
  operations whose lock ranges overlap can deadlock,
@@ -1567,7 +1665,7 @@ and without the retry the victim would answer 500 and lose what it wrote.
1567
1665
 
1568
1666
  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
1667
  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).
1668
+ 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
1669
 
1572
1670
  | Server-side situation | Response | Client (`useDailyReportSseConnection`) |
1573
1671
  | --- | --- | --- |
@@ -1579,6 +1677,8 @@ unexpected error and a request that is not the package's client (another origin'
1579
1677
  | 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
1678
  | 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
1679
  | 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 |
1680
+ | 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 |
1681
+ | 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
1682
  | Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
1583
1683
  | 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
1684
  | 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 +1953,10 @@ released search or another view's search holds the viewer's slot until its SQL e
1853
1953
  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
1954
  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
1955
  JSON; a search whose client left gets the bodyless 503 every search route answers (under `search-marks` below).
1956
+ 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,
1957
+ 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
1958
+ 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
1959
+ methods.
1856
1960
  `words` is the plan's words, keyword by keyword: each word's alternatives `{ text, tier }` (`DailyReportSearchAlternative`), tier 0 the typed word's
1857
1961
  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
1962
  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
@@ -2149,8 +2253,8 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
2149
2253
  accessible description (`aria-description`: `labels.searchReadingMarkDescription`, en `Matched by its romaji reading`, ja `ローマ字の読みで一致`); a word mark
2150
2254
  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
2255
  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
2256
+ 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
2257
+ 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
2258
  in words what matched:
2155
2259
  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
2260
  `Author matched`, ja `作成者で一致`) when a typed word's mark or a dense field is among the heading's leaves,
@@ -2240,10 +2344,10 @@ narrow for the author and the slot the slot drops to a 16 px line of its own ins
2240
2344
  `Matched in fields this view does not show, such as labels or details`), which shows only when no field is marked or dense.
2241
2345
  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
2346
  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.
2347
+ - 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,
2348
+ with a `hidden` element inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the marked
2349
+ fields' sentence alone (empty while the card draws no match; the List does not announce a hidden match), so the active row's announcement says what
2350
+ matched and whether only a reading did. Without a store the card's description is unchanged.
2247
2351
 
2248
2352
  ## Client wiring
2249
2353
 
@@ -2349,27 +2453,31 @@ package does nothing silently outside its provider.
2349
2453
  technology); otherwise it renders in the provider's own slot after the page, centred with the row gutter G around it
2350
2454
  (`data-daily-report-notice-slot`). Its block size is rounded up to the lattice like a DetailList body.
2351
2455
  - **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).
2456
+ 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
2457
+ 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
2458
+ view is mounted yet).
2354
2459
  - **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
2460
  close, the header badge's container, the status panel that the content replaces — hands focus on through one rule (`focusVisibleSuccessor` in
2356
2461
  `src/client/keyboard/focus-successor.ts`).
2357
2462
  A candidate takes focus only when it is in the document, focusable, has a box with an area and passes
2358
2463
  `checkVisibility({ visibilityProperty: true, opacityProperty: true })`, and the rule confirms where focus landed before it stops, so a visually
2359
2464
  hidden region, a 0 × 0 box and an element that is not rendered never receive it. When no candidate qualifies, the rule moves nothing.
2465
+ 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
2466
+ checked without the opacity test, which its shared visually-hidden token would always fail, and with every other test (**Focus handed back to the
2467
+ view** below).
2360
2468
  - **The page's stream status**: until the first ids chunk arrives `DailyReportPage` shows one status panel (`data-testid="daily-report-status-panel"`,
2361
2469
  `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
2470
  retry, and the header's progress badge is one persistent container whose content switches by phase.
2363
2471
  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
2472
  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.
2473
+ 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
2474
+ 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
2475
+ that a user switch rebuilds takes it as its new panel mounts and focuses that panel.
2368
2476
  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
2477
  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
2478
  [View height](#view-height-host-layout)).
2371
2479
  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.
2480
+ successors: the view, else the selected view tab.
2373
2481
  One persistent, visually hidden region of the page (`data-testid="daily-report-stream-announcer"`, outside the screens the phase swaps, never
2374
2482
  focusable) announces the changes of the stream's lifecycle that happen after it mounts: loading after a retry (`labels.loadingIds`) and reconnecting
2375
2483
  (`labels.streamRetrying`) as a `role="status"`, a failure as a `role="alert"` (`labels.loadError` before any report arrived, `labels.streamFailed`
@@ -2381,8 +2489,13 @@ package does nothing silently outside its provider.
2381
2489
  (`data-testid="daily-report-ids-stream-status-announcer"`, visually hidden, never focusable).
2382
2490
  Its `role="status"` and `role="alert"` regions are mounted empty and only their texts change: reconnecting (`labels.streamRetrying`) is a status and
2383
2491
  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.
2492
+ 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
2493
+ 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
2494
+ 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).
2495
+ 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
2496
+ instead of floating to the slot's other end.
2497
+ The regions sit beside the slot, not inside the container: the empty container is not painted (`visibility: hidden`; it stays laid out), so
2498
+ 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
2499
  Inside the package only that live announcer (`src/client/ui/live-announcer.tsx`) and three reviewed persistent regions — the notice's alert regions
2387
2500
  (**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
2501
  `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 +2702,102 @@ view, and no script computes or writes a height.
2589
2702
  「項目がありません」), 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
2703
  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
2704
  4.55:1 and 7.66:1 against the page, where the engine's default grey reaches only 4.17:1 in dark).
2705
+ Because the views pass an active row (`activeItemIndex`, `null` while the list is empty), `VirtualScroll` draws the message outside the composite —
2706
+ 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
2707
+ 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
2708
+ viewer who may see no category, for example) hears the title, the empty state and the key help.
2592
2709
 
2593
2710
  ### Keyboard, focus and selection (List / DetailList)
2594
2711
 
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.
2712
+ Each view is one composite widget: `VirtualScroll`'s content element carries `role="treegrid"`, the view's single Tab stop (`tabIndex=0`, also while
2713
+ 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
2714
+ assistive technology's focus is on — which the composite names through `aria-activedescendant`. `@aiquants/virtualscroll` writes that attribute
2715
+ itself, in the same render as the row it names (`activeItemIndex`), and keeps that row mounted even outside the rendered window, so the reference
2716
+ always resolves (the peer's README, "The active item"). The host never writes the attribute.
2717
+ 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
2718
+ changes.
2719
+ 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
2720
+ the resize handle and the side pane lie outside the list.
2599
2721
 
2600
2722
  | Key | List | DetailList | Default prevented |
2601
2723
  | --- | --- | --- | --- |
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) |
2724
+ | `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
2725
  | `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 |
2726
+ | `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 |
2727
+ | `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) |
2728
+ | `Enter` / `Space` on a control of the active row | That control's own action | The same | Native |
2729
+ | `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
2730
  | `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).
2731
+ | `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 |
2732
+ | Any other key, a modifier combination, IME | Not handled | The same | No |
2733
+
2734
+ - **The move starts from the active row**, never from `document.activeElement` and never from the layout: Tab into a view, press `ArrowDown`, and the
2735
+ report after the active row is selected. A movement key pressed on a control of the active row (★, 既読, 編集, 削除, an attachment link, a comment
2736
+ control outside its form) moves from that row as on the composite and then focuses the composite once (`focus({ preventScroll: true })`), so the
2737
+ repeats that follow arrive at the composite. `Escape` on such a control returns focus to the composite without changing the active row — from the
2738
+ 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
2739
+ 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 —,
2740
+ unless the event's default is already prevented, the key belongs to an IME
2741
+ composition, the target is an element where `Escape` has its own meaning (`select`, `input[type="search"]`, which `Escape` clears, an editing host,
2742
+ `audio` / `video` with `controls`), the target or an ancestor up to the row carries `aria-expanded="true"` (an open disclosure, which `Escape` closes
2743
+ first), or it lies inside an ARIA widget that uses the keys itself (below).
2744
+ - **`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
2745
+ active after the selected report was deleted, for example — they select it in place, aligned like any `Home` / `End`. The relative moves name a
2746
+ 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
2747
+ what its first press selected (or the host refused).
2615
2748
  - **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.
2749
+ stream from the active row; it and every later repeat on the same axis have their default prevented.
2617
2750
  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.
2751
+ 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
2752
+ more slowly than the display refreshes moves once per keydown and never waits for a frame.
2620
2753
  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.
2754
+ `End` and `Enter` / `Space` count once; they read neither the DOM nor the layout.
2622
2755
  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.
2756
+ 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.
2757
+ 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)
2758
+ applies what is pending before it starts a new stream.
2759
+ 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
2760
+ 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
2761
+ leaving the view and unmounting end it without applying it.
2629
2762
  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).
2763
+ accumulated. While a stream lasts, the desktop side pane keeps showing the report the stream started from (see **List side pane** below), and rows
2764
+ far from the active row stay shells (see **Rows mount in two passes** below).
2631
2765
  - **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone (so `Ctrl+PageDown` and `Shift+End` keep
2632
2766
  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]`) —
2767
+ 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
2768
+ 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
2769
+ buttons included) or an editable element (`input`, `textarea`, `select`, `contenteditable` other than `"false"`, `audio[controls]`,
2770
+ `video[controls]`); `Escape` there follows its own rule above —
2636
2771
  or from inside ARIA widgets that use these keys themselves (`combobox`, `listbox`, `menu`, `menubar`, `radiogroup`, `slider`, `spinbutton`,
2637
2772
  `textbox`, `tree`, `grid`, `scrollbar`, `tablist`, `toolbar`, `treegrid`, `separator`) between the key's origin and the view's scroll container, the
2638
- exits included.
2773
+ exits included; the view's own composite is the one `treegrid` that is exempt, since it is the element the keys arrive at.
2639
2774
  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`,
2775
+ 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
2776
+ was typed. Only elements **inside** the view count: wrapping the whole view in a host `form`,
2641
2777
  `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
2778
  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.
2779
+ - **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 ★
2780
+ and 既読, and the DetailList row's ★, 既読, 編集 (on the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment
2781
+ 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
2782
+ `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),
2783
+ and no row frame carries a `tabindex`.
2784
+ 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
2785
+ (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
2786
+ title field took focus inside it), so its form stays in the Tab order.
2787
+ 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`
2788
+ 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,
2789
+ which leaves the view — the keydown first reveals the row by the nearer edge, synchronously, and then leaves the default action to the browser,
2790
+ 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.
2791
+ The landing is read from the package's one tab-order model (`tabLandsInRow` over `listTabbables` and `partitionTabbables` in
2792
+ `src/client/keyboard/row-tab-order.ts` and `src/client/keyboard/dom-node.ts`), the same model the exits use.
2793
+ 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
2794
+ scroll the document instead (an off-screen row is revealed by its index, never by its rect).
2653
2795
  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
2796
  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.
2797
+ begins with its own stop, the composite.
2656
2798
  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.
2799
+ 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
2800
+ that control.
2659
2801
  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
2802
  although the article holds buttons, the comment field and attachment links further down.
2661
2803
  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 +2805,17 @@ resize handle and the side pane lie outside the list.
2663
2805
  accessibility and the Tab order").
2664
2806
  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
2807
  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.
2808
+ 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
2809
+ while held, the track, the thumb and the tap-scroll circle.
2668
2810
  The bar and its thumb still carry their names (the engine keys `verticalScrollBar` and `verticalScrollThumb`, below), which the hidden bar does not
2669
2811
  expose.
2670
2812
  - **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
2813
  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
2814
  for Tab.
2815
+ 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`
2816
+ 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
2817
+ 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
2818
+ announces them with the composite's description.
2673
2819
  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
2820
  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
2821
  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 +2842,98 @@ resize handle and the side pane lie outside the list.
2696
2842
  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
2843
  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
2844
  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
2845
+ - **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
2846
+ synchronous commit (`flushSync`) inside its keydown: a destination outside the rendered window gets the alignment scroll (`scrollToIndex`) in that
2847
+ 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
2848
+ no key reads the layout, so a key task forces no style recalculation and no layout of its own.
2849
+ 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
2850
+ (the active index and, when the key scrolled, the new window) through the view's `VirtualScroll` wrapper, and the host component that owns the
2851
+ controlled selection (the List's `selectedReportHubId`, the DetailList's `selectedItemId`); the view itself, report rows, card bodies and the row
2739
2852
  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 |
2853
+ 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
2854
+ every run of its nine conditions.
2855
+ - **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,
2856
+ id, position, set size, level and selection, named by the report's ISO business date and `aria-busy="true"`, and an empty surface
2857
+ (`data-daily-report-row-shell`) that fills the box `VirtualScroll` gives the row, so nothing moves when the body arrives.
2858
+ One scheduler per view then advances the rows one at a time — a shell to its body, a body that still draws an older report to the latest loaded one
2859
+ (a report that arrived by a load's answer or an SSE update) — each unit applied with `flushSync` as one commit of its own, in a slot that runs right
2860
+ after a frame's rendering (a `MessageChannel` task armed from the scheduler's own task by `requestAnimationFrame`, never from a keydown, a commit or a
2861
+ held key's frame).
2862
+ The order is by zone — visible, then the active row's reach, then near, then far (**Far rows skip rendering** below) — and within a zone by the
2863
+ distance from the active row, the side the keys travel towards first. A slot applies one unit, and a second when the next pending unit is visible or
2864
+ in reach.
2865
+ A row that becomes visible with a pending unit renders its body inside the commit that made it visible, so a visible row is never a shell. A newer
2866
+ loaded report for a row that is already visible is applied inside the commit that brings it, outside a key hold, so such a row never shows an outdated
2867
+ rendering of a loaded report while no key is held. A commit that brings a pending row into the reach or the near band wakes a sleeping scheduler, so
2868
+ the rows a key can reveal get their bodies in a slot before that key.
2869
+ While a key is held, the scheduler keeps its slots out of the frames that render the keys:
2870
+ - far units wait: far rows stay shells and take no arriving card until the key is released;
2871
+ - a slot applies one unit, and a second only when both rows have boxes of a fixed size (the List's slots): two measured DetailList bodies do not fit
2872
+ one key-free frame;
2873
+ - a slot runs only when the frame that renders its commit starts before the next repeat keydown can arrive. The next keydown is predicted from the
2874
+ hold's own keydowns (the median interval of the latest 8 and their earliest phase; `predictNextHoldKey`), and the frame interval is the shortest
2875
+ gap between the scheduler's consecutive frame callbacks, measured again for each hold. The decision is taken in the frame callback that would arm
2876
+ the slot, and taken again when the slot runs (a key that arrived after the frame began, or a frame that ran long, holds the unit back to the next
2877
+ frame); while the cadence or the frame interval is still unknown (the first one or two frames of a hold), no slot runs;
2878
+ - a newer loaded report for a visible row waits for the next slot that runs, ranked first.
2879
+
2880
+ So during a hold the units go to the frames without a key commit, and a row the keys reveal before a slot reached it renders its body in the key's
2881
+ own commit (counted).
2882
+ Decisions never wait for the drawing: the safety valve that removes a row whose report no longer exists, and a DetailList body's draft and
2883
+ ownership, its opening of the viewer's own draft in the editor and its edit button's action, read the loaded report itself.
2884
+ - **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
2885
+ position (half-open intervals in content pixels, V the viewport's size, s the scroll position): **visible** when it intersects [s, s + V);
2886
+ **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
2887
+ step of one or two rows can reveal, with any alignment, intersects the reach before the step, whatever the rows' heights; **near** when it
2888
+ intersects [s − V, s + 2V), the visible range widened by one viewport on each side; and **far** otherwise.
2889
+ A far row whose size is settled skips its rendering: `@aiquants/virtualscroll` writes `content-visibility: hidden` and `aria-hidden="true"` on its
2890
+ item container (`itemZones`; the peer's README, "Item zones and far-item skipping"). The container keeps its explicit height, so skipping and
2891
+ showing a row change no box.
2892
+ 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
2893
+ its last change, and becomes unsettled again when its body is replaced or its comment list changes, until it is measured again.
2894
+ **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
2895
+ 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.
2896
+ An engine without `content-visibility` (Safari before 18) skips nothing and hides nothing.
2897
+ - **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
2898
+ transition decides it (`nextActiveRow` in `src/client/keyboard/focus-ownership.ts`; the view only translates DOM events into it):
2899
+
2900
+ | Event | Active row after it |
2757
2901
  | --- | --- |
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.
2902
+ | A handled key | The key's destination; the selection follows |
2903
+ | Activation: a click on the List card's primary button or on a DetailList row body, `Enter` / `Space` on the composite | The activated row |
2904
+ | 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 |
2905
+ | 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 |
2906
+ | 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 |
2907
+ | 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 |
2908
+ | The visible range changes while focus is outside the view | The Tab-stop rule, for the same reason |
2909
+ | The visible range changes while focus is in the view | Unchanged: the row stays mounted and is never skipped |
2910
+ | 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 |
2911
+ | The selected report is deleted from the side pane or the mobile overlay | The report now at the deleted report's index |
2912
+
2913
+ 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
2914
+ 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
2915
+ click moves from the clicked row and stays on the key's destination.
2916
+ 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.
2917
+ 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
2918
+ left, the view hands it back (below). An empty list has no active row: the composite keeps focus and shows its pane ring.
2919
+ - **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
2920
+ 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,
2921
+ 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
2922
+ 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
2923
+ 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
2924
+ 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
2925
+ (`data-daily-report-row-focus`; `findRowFocusTarget`) — the List card's primary button, or the DetailList card's visually hidden name heading
2926
+ (`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
2927
+ 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
2928
+ focus to the composite instead. iOS VoiceOver, TalkBack and Switch Access follow DOM focus rather than the active descendant, so a touch hand-back
2929
+ lands on a real element of the row.
2930
+ 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
2931
+ (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
2932
+ 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
2933
+ the side pane, the open overlay or a host element stays where it is.
2773
2934
  - **Keyboard focus**: one predicate decides it for the whole view (`isKeyboardFocused` in `src/client/keyboard/keyboard-focus.ts`): the focused
2774
2935
  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.
2936
+ 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
2937
  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
2938
  that document is mounted (`observeInputModality` in `src/client/keyboard/input-modality.ts`, reference-counted across the views):
2778
2939
  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 +2943,25 @@ resize handle and the side pane lie outside the list.
2782
2943
  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
2944
  `: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
2945
  `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.
2946
+ - **Keyboard focus is revealed**: focus entering the composite from outside by `Tab` reveals the active row by the nearer edge when it is only
2947
+ 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
2948
+ (**One Tab stop per view**). When a control inside a row
2949
+ 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
2950
+ focused element with 8 px around it — measured against the composite's box. Focus from a pointer never scrolls.
2787
2951
  - **Keyboard-focus attribute**: while keyboard focus is inside a view, the view root carries `data-daily-report-keyboard-focus` (the List's
2788
2952
  `[data-testid="daily-report-root"]`, the DetailList's scroll container). Every `focusin` and `keydown` inside the view sets it from the focused
2789
2953
  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
2954
  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
2955
+ It is a plain DOM attribute toggled with `toggleAttribute`, so arrow keys moving the active row never rewrite it and nothing re-renders. The
2956
+ floating tap-scroll circle hides while it is present, because the circle is drawn over the rows.
2957
+ 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
2958
  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
2959
  `: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.
2960
+ - **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
2961
+ 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,
2962
+ 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
2963
+ body selects it without scrolling and makes it active, except clicks on controls inside the row (`button`, `a[href]`, `input`, `textarea`,
2964
+ `select`, `label`, `[role="button"]`, `contenteditable`). Without `onSelectItem` the DetailList handles neither clicks nor keys.
2800
2965
  - **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
2966
  top (when the selected report is not in the list yet, as soon as it arrives); focus does not move.
2802
2967
  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 +2980,7 @@ resize handle and the side pane lie outside the list.
2815
2980
  first visible row (its layout-shift compensation); the update, the compensation and the position are all complete when the call returns, so the
2816
2981
  record reads the compensated state and never mistakes the compensation for a scroll of the user.
2817
2982
  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.
2983
+ never reverted, the active row stays the key's destination, and a re-measured row shifts no report.
2819
2984
  The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read),
2820
2985
  so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled.
2821
2986
  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,
@@ -2839,10 +3004,10 @@ resize handle and the side pane lie outside the list.
2839
3004
  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
3005
  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
3006
  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.
3007
+ 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
3008
  At the start of the list (scroll position 0) no anchor is kept, so reports that arrive at the top are shown;
2844
3009
  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
3010
+ 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
3011
  report that followed it on screen is shown at its offset.
2847
3012
  - **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
2848
3013
  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 +3031,38 @@ resize handle and the side pane lie outside the list.
2866
3031
  The mobile overlay shows the report it opened, so its pane carries the same `data-displayed-report-id` and no `aria-busy`.
2867
3032
  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
3033
  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`):
3034
+ - **Screen readers**: the composite is a `treegrid` named by `labels.title`; it references `labels.listKeyboardHelp` (visually hidden, outside the
3035
+ composite) through `aria-describedby` — the DetailList only when it handles keys (with `onSelectItem`) — and carries `aria-rowcount`, the size of
3036
+ 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.
3037
+ Every row frame is a `row` with an `id` derived from the composite's id and the report id (`virtualScrollItemElementId`), `aria-rowindex` and
3038
+ `aria-posinset` (its place in the list, from 1), `aria-setsize`, `aria-level="1"` and an explicit `aria-selected` — `"true"` on the selected row,
3039
+ `"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
3040
+ cell is never named by the whole card.
3041
+ 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
3042
+ state (`labels.rowState`: read or unread, and starred), the markers the card paints, the preview and, while the screen holds a search marks store,
3043
+ the match description; the button itself is described first by its position in the list (`labels.listRowPosition`, a visually hidden text: en
3044
+ "3 of 40", ja 「全 40 件中 3 件目」), because touch screen readers land on the button, which carries no position attribute.
3045
+ 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
3046
+ markers its header paints and, while the screen holds a search marks store, the match indicator's hidden sentences last (**Match indicator** in
3047
+ [Text search](#text-search)).
3048
+ 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
3049
+ position text as well (`labels.listRowPositionInUnknownTotal`: en "Item 3", ja 「3 件目」); once the size is known the attributes alone carry the
3050
+ 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
3051
+ rendered window re-renders the frames and these texts, never a card's body.
3052
+ 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
3053
+ read the same value, which `DailyReportResolvedContent` decides once for both views (`resolveRowSetSize`):
2879
3054
  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.
3055
+ arrive); before the server has declared it, unknown — `-1`, and the header shows `labels.totalCountLoading`; once the scan has completed, the rows
3056
+ shown.
2882
3057
  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)).
3058
+ rows are the whole set; the views' `VirtualScroll` always counts the rows given.
2886
3059
  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`.
3060
+ otherwise its label); a description references only the markers that are painted. A shell, and a row without content (loading, failed, missing,
3061
+ editing), is named by its ISO business date, with `aria-busy="true"` while its report or its body is still to come. ★, 既読, 編集 and 削除 carry
3062
+ `aria-label` equal to their `title` and no `aria-pressed`; no element of a view carries `aria-current`.
3063
+ 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
3064
+ 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
3065
+ announce the treegrid rows is the manual checklist in [Assistive technology checklist](#assistive-technology-checklist).
2890
3066
  - **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
3067
  host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
2892
3068
  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 +3088,8 @@ resize handle and the side pane lie outside the list.
2912
3088
  `labels.reportTabList` (the report heading names only the business date, which reports of the same day share).
2913
3089
  - **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
3090
  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).
3091
+ 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
3092
+ report heading when the button is not rendered).
2917
3093
  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
3094
  moving focus; it closes by itself only when the report turns published, and an editor the viewer opened stays open then.
2919
3095
  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 +3111,14 @@ resize handle and the side pane lie outside the list.
2935
3111
  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
3112
  A cancel by the trash button or by Escape returns focus to the trash button when focus was on the confirmation.
2937
3113
  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`.
3114
+ 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
3115
+ 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
3116
+ pointer), and the pane's report heading in the side pane and the mobile overlay — so focus never falls to `body`.
2940
3117
  - **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
3118
  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
3119
  the comment form: see [External Source Badge Configuration](#external-source-badge-configuration-sourcetypeconfigs)).
3120
+ 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
3121
+ its own in the DetailList row, the side pane and the mobile overlay; its placeholder (`labels.commentPlaceholder`) stays a hint, not its name.
2943
3122
  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
3123
  so focus goes to the report's anchor above.
2945
3124
  - **Mobile detail overlay** (List, single-column layout): the platform's modal dialog, a `<dialog data-daily-report-mobile-overlay>` (implicit
@@ -2956,10 +3135,11 @@ resize handle and the side pane lie outside the list.
2956
3135
  the close path runs too, and the dialog opens modally again when the close is refused.
2957
3136
  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
3137
  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.
3138
+ 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
3139
+ 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
3140
+ the card's primary button after a tap or a click, with `preventScroll`.
2961
3141
  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
3142
+ `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
3143
  that is nowhere. Opening the overlay again during the slide-out shows the same dialog modally again.
2964
3144
  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
3145
  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 +3149,38 @@ resize handle and the side pane lie outside the list.
2969
3149
  the element's own window.
2970
3150
  - `VirtualScroll`'s own `enableKeyboardNavigation` stays `false`; the keys are handled by the package.
2971
3151
 
3152
+ #### Assistive technology checklist
3153
+
3154
+ The package's tests prove the treegrid semantics in jsdom and Chromium's accessibility tree; how screen readers announce a treegrid whose rows are
3155
+ 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 —
3156
+ 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
3157
+ (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
3158
+ total (the set size unknown); run the Switch Access, forced-colours and reduced-motion steps once each. Desktop readers stay in their focus (forms)
3159
+ mode on the composite; touch readers navigate by swiping, since they follow DOM focus rather than the active descendant. Record the reader, the browser
3160
+ and their versions, and per step either a pass or the text announced.
3161
+
3162
+ | Step | Do | Expect |
3163
+ | --- | --- | --- |
3164
+ | 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 |
3165
+ | ↑ / ↓ | 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`) |
3166
+ | 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 |
3167
+ | 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 |
3168
+ | 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 |
3169
+ | Escape | On ★ press Escape | Focus returns to the composite, the same active row is announced, and the selection does not change |
3170
+ | 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 |
3171
+ | 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) |
3172
+ | 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 |
3173
+ | 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 |
3174
+ | 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) |
3175
+ | 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 |
3176
+ | 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 |
3177
+ | 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 |
3178
+ | 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) |
3179
+ | 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 |
3180
+ | 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 |
3181
+ | 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 |
3182
+ | 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 |
3183
+
2972
3184
  ### Selection and focus appearance
2973
3185
 
2974
3186
  Selection and keyboard focus are two separate channels, identical in both views, so "selected and focused" never looks like "focused":
@@ -2976,7 +3188,8 @@ Selection and keyboard focus are two separate channels, identical in both views,
2976
3188
  | State | Channel | Geometry | Light | Dark | Forced colours |
2977
3189
  | --- | --- | --- | --- | --- | --- |
2978
3190
  | 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) |
3191
+ | 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) |
3192
+ | 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
3193
  | Keyboard focus on a control or link | outline | 2 px at offset 2 | blue-600 | blue-400 | kept |
2981
3194
  | Pointer focus | none | — | — | — | — |
2982
3195
  | 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 +3265,31 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3052
3265
  - **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
3266
  (−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
3267
  (`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
3268
+ 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
3269
  stays 1 px in every state, and the focus outline (4–6 px outside) sits beside the selection line without touching it.
3058
3270
  - **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.
3271
+ state — from its own `aria-selected` (the selection ring) and its own `data-daily-report-active` (the focus outline), and the List card surface
3272
+ styles itself from its direct-child primary button (`:has(> …)`).
3273
+ Conditions on an ancestor read only the attributes the package writes itself (`data-daily-report-scrolling`, `data-daily-report-keyboard-focus`)
3274
+ 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.
3275
+ The active row's outline requires the view root's `data-daily-report-keyboard-focus` and a `:focus-visible` ancestor — the composite, the only
3276
+ focusable ancestor of a row — (`:where([data-daily-report-keyboard-focus]) :where(:focus-visible) <frame>[data-daily-report-active] > :where(<surface>)`),
3277
+ and the List card's outline requires the same root attribute
3278
+ (`:where([data-daily-report-keyboard-focus]) <card surface>:has(> <primary button>:focus-visible)`): the ancestors sit in `:where()`, so the rules
3279
+ 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
3280
+ `: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,
3281
+ so it restyles the surfaces once per change of modality.
3282
+ No selector reads `aria-activedescendant`: it changes on every key and would invalidate the composite's subtree; a key restyles the two row frames
3283
+ whose `data-daily-report-active` and `aria-selected` change. `@aiquants/virtualscroll` draws its pane ring on the composite only while the active
3284
+ 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
3285
+ 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
3286
+ edge of the surface's 2 px outline). The ring therefore yields only once the outline's whole width shows at the
3287
+ clipped edge, and a keyboard-focused composite always shows a whole indicator: the active row's outline, or the ring.
3288
+ 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
3289
+ `VirtualScroll`'s root, so one focus shows one colour in each scheme (the peer's default `#2563eb` stays just
3290
+ 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,
3291
+ but never take focus in these views (`enableArrowButtonTabStops: false`), and the arrow buttons keep their own fill, whose glyph contrast depends
3292
+ on it.
3067
3293
  - **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
3294
  or attribute, and `:has()` sits only on the styled element itself. A featureless subject (`*:`, `group-*`) or an ancestor's `:has(:focus-visible)`
3069
3295
  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 +3310,13 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3084
3310
  held.
3085
3311
  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
3312
  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).
3313
+ it (one Tab stop per view, `Tab` revealing the active row in its keydown, keyboard focus revealed by the scroller).
3088
3314
  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
3315
  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
3316
  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
3317
  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.
3318
+ 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
3319
+ shell's first body, a newer report's card) is observed in its place.
3094
3320
  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
3321
  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
3322
  would clip (the resting `shadow-sm`, 4 px below, stays inside G; a selected surface paints no shadow).
@@ -3556,7 +3782,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
3556
3782
  The visually hidden copy, `title` and `data-attachment-file-name` stay plain.
3557
3783
  - **Tab order inside list rows**: the attachment links follow the row rule of
3558
3784
  [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.
3785
+ active row (`tabIndex=-1` in every other row); in the side pane and the mobile overlay they keep their natural order.
3560
3786
  - **Marker**: the List card and the DetailList header show `DailyReportAttachmentIndicator` when a report has attachments: a 24 px tall box without
3561
3787
  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
3788
  16 px line; it is named `<labels.attachments>: <count>` (`role="img"`).
@@ -3675,11 +3901,14 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
3675
3901
 
3676
3902
  | Hook | Element |
3677
3903
  | --- | --- |
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) |
3904
+ | `[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 |
3905
+ | `[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 |
3906
+ | `[data-daily-report-active]` | The active row's frame (the row the composite's `aria-activedescendant` names) |
3907
+ | `[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) |
3908
+ | `[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
3909
  | `[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) |
3910
+ | `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`) |
3911
+ | `[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
3912
  | `[data-list-star-button="<id>"]` / `[data-list-read-button="<id>"]` | List card toggles |
3684
3913
  | `[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
3914
  | `[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,7 +3928,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
3699
3928
  | `[data-daily-report-view-tabs]` | The view-mode tab list (its selected tab is the focus successor of a list without rows) |
3700
3929
  | `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
3930
 
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
3931
+ 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
3932
  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
3933
  a key's render the frame already names the new selection while the content still shows the previous report.
3705
3934
 
@@ -3836,8 +4065,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
3836
4065
  | `tabRelations` | side pane tab | Related | 関連 |
3837
4066
  | `reportTabList` | accessible name (`aria-label`) of the side pane's tab list | Report sections | 日報の表示の切り替え |
3838
4067
  | `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 で一覧の直前・直後の操作部品があればそこへ移ります。 |
4068
+ | `title` | `renderHeader({ title })`, both composites' `aria-label` | Daily Reports | 日報 |
4069
+ | `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
4070
  | `tabList` | top-level tab | 📄 List | 📄 一覧 |
3842
4071
  | `tabTree` | top-level tab | 🌳 Tree | 🌳 ツリー |
3843
4072
  | `tabDetailList` | top-level tab | 📋 DetailList | 📋 詳細一覧 |
@@ -3900,9 +4129,9 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
3900
4129
  | `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
3901
4130
  | `operationFailed` | the error notice / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
3902
4131
  | `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 })` → 未読、スター付き |
4132
+ | `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 })` → 未読、スター付き |
3904
4133
  | `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 件目 |
4134
+ | `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
4135
  | `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
4136
  | `searchFieldMatchedByReading` | the same, for a heading whose marks matched only through a romaji reading | `("Author")` → Author matched by its romaji reading | `("作成者")` → 作成者がローマ字の読みで一致 |
3908
4137
  | `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 +4257,10 @@ Documenting a name here is no reason to export it, and the values that tune the
4028
4257
  routes itself: `DAILY_REPORT_ACTION_INTENTS` / `DAILY_REPORT_ACTION_FIELDS` (the intents and the form's field names),
4029
4258
  `encodeDailyReportActionCommand(command)` (a command as its `FormData`), `parseDailyReportActionResult(intent, json)` (the strict reading of a 200
4030
4259
  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).
4260
+ names) and `DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` (the echo id's two canonical forms),
4261
+ and the source-type vocabulary a host builds its visibility port with: `DAILY_REPORT_INTERNAL_SOURCE_TYPE` (`"Internal"`, the source type of the
4262
+ reports the package creates) and `foldDailyReportSourceTypeToken(token)` (the one fold the server applies to the tokens the port returns:
4263
+ surrounding blanks removed, then lower case; [Source-type visibility](#source-type-visibility)).
4032
4264
  - Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment`
4033
4265
  / `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` /
4034
4266
  `DailyReportIdsStreamChunkLine` / `DailyReportIdsStreamChunk` (what `readIdsStreamChunkLine` answers), `DailyReportAttachmentDeliveryRequest` /
@@ -4125,14 +4357,18 @@ Documenting a name here is no reason to export it, and the values that tune the
4125
4357
  server entry alone, so client bundles do not carry it).
4126
4358
  - Request values: `parseClientTempId` and its branded result type `DailyReportClientTempId` (the action's echo id in its two canonical forms, the
4127
4359
  only id the service's write methods take; **The echo id** in [Request values](#request-values)).
4360
+ - Visibility: `createDailyReportVisibilityCache({ ttlMs, readViewerKey, resolve })` (the package's memoizer of the visibility port's answers, which
4361
+ returns a `DailyReportResolveVisibleSourceTypes`; its settings type `DailyReportVisibilityCacheConfig`) and
4362
+ `DAILY_REPORT_VISIBILITY_MAX_CACHE_TTL_MS` (60,000, the longest `ttlMs` it takes; **Performance** in
4363
+ [Source-type visibility](#source-type-visibility)).
4128
4364
  - Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` /
4129
4365
  `DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` /
4130
4366
  `DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared
4131
4367
  reader `createDailyReportHandlers` takes) / `DailyReportSseStreamEntry` (an entry as the reader hands it to every subscriber: its id and its
4132
4368
  message read once, or `null`) / `DailyReportExternalReportFields` (what an `externalSources` adapter's `mapRow` returns),
4133
4369
  the ports' own types `DailyReportResolveUserId` / `DailyReportEncodeUserId` / `DailyReportResolveVisibleSourceTypes` (with
4134
- `DailyReportVisibleSourceTypes`, `DailyReportVisibilityDimensions`, `DailyReportVisibilityResolveContext` and
4135
- `DailyReportVisibilityResolveReason`) / `DailyReportRedisClient` / `DailyReportRedisBlockingClient` / `DailyReportRedisStreamMessage` /
4370
+ `DailyReportVisibleSourceTypes`, `DailyReportVisibilityDimensions`, `DailyReportVisibilityOptOut` (the explicit opt-out
4371
+ `{ unrestricted: <reason> }`), `DailyReportVisibilityResolveContext` and `DailyReportVisibilityResolveReason`) / `DailyReportRedisClient` / `DailyReportRedisBlockingClient` / `DailyReportRedisStreamMessage` /
4136
4372
  `DailyReportSseEntryMessage` (an entry's message read once) / `DailyReportExternalSource` (an `externalSources` adapter),
4137
4373
  the injected tables `DailyReportHubTable` / `DailyReportInternalTable` / `DailyReportCommentTable` / `DailyReportLabelTable` /
4138
4374
  `DailyReportHubLabelTable` / `DailyReportUserStatusTable` / `DailyReportUserTable` / `DailyReportAttachmentTable` (the members of