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