@aiquants/daily-report 0.41.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +159 -0
- package/README.md +473 -237
- package/dist/client.js +5 -7
- package/dist/client.mjs +5 -7
- package/dist/index.d.mts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/server.d.mts +20 -11
- package/dist/server.d.ts +20 -11
- package/dist/server.js +13 -10
- package/dist/server.mjs +13 -10
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +6 -4
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
|
|
@@ -570,9 +597,15 @@ its payload — with one strict parser each, after authentication and before any
|
|
|
570
597
|
The client compares no name: the views open the viewer's own draft in the editor by `isDraft` alone, so a draft named by any of the names, or by a
|
|
571
598
|
variant the collation equates, behaves as one ([Client wiring](#client-wiring)). The SSE stream's key carries the wire version (`<streamKey>:v3`),
|
|
572
599
|
so an entry written before `isDraft` existed is never read.
|
|
573
|
-
- `resolveVisibleSourceTypes(request)` — **
|
|
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) |
|
|
@@ -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`).
|
|
@@ -711,59 +744,119 @@ streaming body is refused from the start.
|
|
|
711
744
|
### Source-type visibility
|
|
712
745
|
|
|
713
746
|
Restrict which report categories a viewer may see, without the package depending on any authorization library. The port receives the request and
|
|
714
|
-
returns plain strings; your app decides the policy.
|
|
747
|
+
returns plain strings; your app decides the policy. The port is required, so no deployment leaves every category open by omission: a deployment
|
|
748
|
+
that does not restrict by source type says so with the explicit opt-out and its reason.
|
|
715
749
|
|
|
716
750
|
```ts illustrative
|
|
751
|
+
import { createDailyReportVisibilityCache } from "@aiquants/daily-report/server"
|
|
752
|
+
|
|
717
753
|
createDailyReportServer({
|
|
718
754
|
// …existing config
|
|
719
|
-
resolveVisibleSourceTypes:
|
|
720
|
-
|
|
721
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
755
|
+
resolveVisibleSourceTypes: createDailyReportVisibilityCache({
|
|
756
|
+
ttlMs: 30_000,
|
|
757
|
+
// `refresh` is the periodic re-check of a live SSE connection: read the viewer from the session afresh there,
|
|
758
|
+
// so a viewer who signed out reads as null and the stream ends.
|
|
759
|
+
readViewerKey: async (request, { reason }) => (reason === "initial" ? yourAuth.viewerIdOf(request) : yourSession.viewerIdOf(request)),
|
|
760
|
+
resolve: async (request, { reason }) => {
|
|
761
|
+
const grants = await yourAuthz.grantsFor(request, { skipCache: reason !== "initial" })
|
|
762
|
+
return grants.canReadLegacy ? ["legacy"] : []
|
|
763
|
+
},
|
|
764
|
+
}),
|
|
725
765
|
})
|
|
726
766
|
```
|
|
727
767
|
|
|
768
|
+
A deployment that shows every category to every viewer answers `{ unrestricted: "<why this deployment needs no category restriction>" }`; the
|
|
769
|
+
reason is the audit trail, so `grep -rn "unrestricted:"` in the host lists every such answer.
|
|
770
|
+
|
|
728
771
|
#### Contract
|
|
729
772
|
|
|
730
773
|
| Return value | Meaning |
|
|
731
774
|
| --- | --- |
|
|
732
|
-
| `undefined` / `null` | Unrestricted — every source type (the default when the port is not injected). |
|
|
733
775
|
| `["legacy", "Internal"]` | Only those source types are visible (bodies **and** comments). |
|
|
734
|
-
| `[]` | Nothing is visible (zero rows)
|
|
776
|
+
| `[]` | Nothing is visible (zero rows): a revocation, which reads answer with no rows and SSE delivery with anchors only. |
|
|
735
777
|
| `{ read: [...] }` | Same as returning the bare array — the comment dimension follows `read`. |
|
|
736
778
|
| `{ read: [...], comment: [...] }` | Bodies and comments restricted independently. See [Comment visibility](#comment-visibility). |
|
|
737
|
-
|
|
|
779
|
+
| `{ unrestricted: "<reason>" }` | Every source type: the explicit opt-out of a deployment that does not restrict by source type, with a non-blank reason. |
|
|
780
|
+
| throws | The visibility of this request cannot be decided now: the request is answered "not now", never as `[]` (**Failure** below). |
|
|
781
|
+
| anything else — `null`, `undefined`, a string, an array with a non-string element, an object with another key (such as `comments`), a blank reason | Outside the contract: handled like a throw. Reading an unknown key as an omitted `comment` would silently let comments follow `read`. |
|
|
738
782
|
|
|
739
783
|
- **Vocabulary**: the strings are `DailyReportHub.source_type` **values** — the `sourceType` of each entry you registered in `externalSources`, plus
|
|
740
|
-
`"Internal"
|
|
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.
|
|
784
|
+
`"Internal"`, the package's own source type for the reports it creates (`DAILY_REPORT_INTERNAL_SOURCE_TYPE` on the shared entry). They are *not* your
|
|
785
|
+
authorization resource keys; mapping a resource key (e.g. `report_legacy`) onto a source type (e.g. `legacy`) is the consuming app's job. Read the
|
|
786
|
+
canonical set at boot from `dailyReportServer.knownSourceTypes` (frozen: `Internal` first, then each adapter's in order) and assert your mapping
|
|
787
|
+
against it — an unknown token silently matches zero rows. Fold your tokens with `foldDailyReportSourceTypeToken` (shared entry), the fold the server
|
|
788
|
+
applies to the tokens it reads, instead of a copy of it.
|
|
744
789
|
Two adapters whose source types are equal ignoring case (an exact duplicate included), or an adapter whose source type is `Internal` in any case,
|
|
745
790
|
stop `createDailyReportService` and `createDailyReportServer` with a `RangeError`
|
|
746
791
|
(`[daily-report] externalSources[1].sourceType must be a source type that differs, ignoring case, from externalSources[0].sourceType "legacy"; got "Legacy"`):
|
|
747
792
|
matching folds case, so two such source types could not be told apart.
|
|
748
|
-
- **Matching** is case-insensitive
|
|
749
|
-
|
|
750
|
-
|
|
793
|
+
- **Matching** is case-insensitive and exact, the same on the JS and SQL sides: a token is trimmed and lower-cased (`foldDailyReportSourceTypeToken`),
|
|
794
|
+
a stored value is lower-cased and never trimmed, and the two must be equal. In SQL the predicate compares bytes,
|
|
795
|
+
`CAST(LOWER(source_type) AS VARBINARY(2n)) IN (CAST(@token AS VARBINARY(2n)), …)` with n the column's declared length (**Required database schema**),
|
|
796
|
+
because SQL Server compares strings with trailing blanks padded and, under a width-insensitive collation, full-width and half-width forms alike: the
|
|
797
|
+
bytes do neither, so the predicate does not depend on the database collation.
|
|
798
|
+
A padded (`kintone␠`) or full-width stored value therefore matches no token and is invisible on every path — the SQL reads, SSE delivery, the write
|
|
799
|
+
gate and attachment bytes alike. A token longer than the column matches no stored value and is left out of the list (the cast would cut it, and its
|
|
800
|
+
cut head could match); a list left empty denies everything. The predicate cannot use an index, as `LOWER()` could not.
|
|
751
801
|
- **Where it applies**: the SQL predicate is injected in the service layer, so it holds for every delivery path (including attachment bytes) rather
|
|
752
|
-
than only the HTTP handlers. It also gates
|
|
753
|
-
|
|
802
|
+
than only the HTTP handlers. It also gates every write:
|
|
803
|
+
- **Creating** needs the package's own source type: a scope that does not show `Internal` is refused (`Forbidden`, answered 403
|
|
804
|
+
`{"error":"Creating reports is restricted"}`) before any query. A create names no existing row, so there is nothing to hide, and a report its author
|
|
805
|
+
could not read back would be stranded; give every author the read of `Internal`. The create's answer is the report read again under the actor's
|
|
806
|
+
scope.
|
|
807
|
+
- **Every write to an existing report** — `addComment`, `deleteComment`, star and read, and the author's own `updateDailyReport`,
|
|
808
|
+
`publishDailyReport` and `deleteDailyReport` — first passes the same gate (`assertHubMutable`, the read rule's category and draft clauses on the
|
|
809
|
+
live row), before the owner or author is compared. The comment writes, the update and the publish start their transaction with a lock of the
|
|
810
|
+
report's live hub row (`lockLiveHub`: an update that changes no value, conditioned on the row being live and, for the author's writes, owned), and
|
|
811
|
+
the delete conditions its own update the same way, so a delete committed after the gate turns the write into `Not Found` instead of a write to a
|
|
812
|
+
deleted report (a draft's labels stripped, its search document rewritten).
|
|
813
|
+
- A hidden report — a category outside the scope, another user's draft, a missing or a deleted row — is reported as `404`, never `403`, after the
|
|
814
|
+
same single read, so the answer does not tell whether a hidden row exists (a comment of such a report is `404` `Comment not found` for
|
|
815
|
+
`deleteComment`); a visible report of another user is still `403` for the three author mutations, and so is another viewer's comment on a visible
|
|
816
|
+
report for `deleteComment`.
|
|
817
|
+
- The publish action answers with the report read again under the actor's scope, so the comment dimension applies to the answer (other users'
|
|
818
|
+
comments of a category the scope's `comment` excludes are dropped, the actor's own posted comments stay, and external comments are emptied); the
|
|
819
|
+
`report-publish` event carries the whole report, and SSE delivery filters it for each recipient.
|
|
754
820
|
- **How it reaches the service**: the resolved set is wrapped in a `DailyReportViewerScope` and passed as the **first** argument of every gated
|
|
755
|
-
service method.
|
|
821
|
+
service method, the create and the three author mutations included.
|
|
756
822
|
The class is nominal (it holds a private field), so `{ visible: null }`, `{ ...scope, visible: null }` and `new DailyReportViewerScope(...)` are all
|
|
757
823
|
type errors — the only ways to obtain one are `DailyReportViewerScope.restrictTo(tokens)` — which accepts `readonly string[]` only, so it cannot be
|
|
758
|
-
handed a nullish value and quietly widen — and `DailyReportViewerScope.unrestricted(auditReason)
|
|
824
|
+
handed a nullish value and quietly widen — and `DailyReportViewerScope.unrestricted(auditReason)`, which the handlers call only for the port's
|
|
825
|
+
explicit opt-out, with its reason.
|
|
759
826
|
Because a bare `null` is not spellable as a visibility value, `grep -rn "DailyReportViewerScope.unrestricted("` enumerates every unfiltered read in
|
|
760
827
|
a codebase, each carrying a written reason.
|
|
761
|
-
- **
|
|
762
|
-
|
|
828
|
+
- **Failure**: a port that throws — an authorization-store outage, or a viewer who is gone (signed out or disabled) — or answers outside the contract
|
|
829
|
+
leaves the request's visibility undecided, and the package answers "not now" rather than "nothing visible", on every path, before any read or write:
|
|
830
|
+
- every JSON endpoint and the ids stream: 503 `{"error":{"message":"Report visibility is unavailable"}}` with `Retry-After: 5` and
|
|
831
|
+
`Cache-Control: no-store` (**Visibility unavailable** in [The API route](#the-api-route));
|
|
832
|
+
- the action: 503 `{"error":"Report visibility is unavailable"}` with the same headers, before the user lookup;
|
|
833
|
+
- an original or a thumbnail: the `busy` 503 with `Retry-After: 5` ([Attachments](#attachments));
|
|
834
|
+
- an SSE connect: 503 with `Retry-After: 5`; an open stream whose periodic re-check fails ends without writing anchors for the entries it has not
|
|
835
|
+
delivered (**SSE latency** below).
|
|
836
|
+
|
|
837
|
+
An empty answer for a failure would show an empty list during a blip, hand the client the live stream anchor with it, and let SSE skip the events of
|
|
838
|
+
the outage for good. Each failure writes one `error` line on the `[DailyReportVisibility]` channel per cause (`port_failure`, `port_contract`) and
|
|
839
|
+
window of 60 s — `visibility unavailable cause=<cause> reason=<initial|refresh>`, with the error (a throw) or the answer's shape (`answer=<null|array|type>`,
|
|
840
|
+
never its value) —, and the window's own timer writes
|
|
841
|
+
`visibility_unavailable_suppressed count=<N> since=<ISO 8601 time of the window's first failure> cause=<cause> reasons=initial:<n>,refresh:<n>` at its
|
|
842
|
+
end when it counted more.
|
|
843
|
+
- **Performance**: the port is called once per request on the critical path (cache keys incorporate the resolved set), so keep it fast.
|
|
844
|
+
`createDailyReportVisibilityCache({ ttlMs, readViewerKey, resolve })` (server entry) is the package's memoizer of the port's answers, which a host
|
|
845
|
+
wraps around its own lookup instead of restating the cache rules:
|
|
846
|
+
- it keeps one answer per viewer key (`readViewerKey`) for `ttlMs`, an integer from 1 to `DAILY_REPORT_VISIBILITY_MAX_CACHE_TTL_MS` (60,000; a
|
|
847
|
+
`RangeError` otherwise), measured on the monotonic clock, and sweeps expired answers on every write;
|
|
848
|
+
- it reads its memory for `initial` only; `refresh` (and any other reason) looks up afresh and writes the answer back, so the next `initial` uses it;
|
|
849
|
+
- concurrent `initial` calls share the pending lookup (written when it starts, so a slower earlier lookup never overwrites a later answer);
|
|
850
|
+
- a failed lookup is forgotten while the viewer's entry still holds it, keeping an answer written after it;
|
|
851
|
+
- a request whose `readViewerKey` answers null — nobody signed in, or a viewer gone by the time of a `refresh` — throws before any lookup.
|
|
852
|
+
|
|
853
|
+
A live SSE connection re-resolves on a dedicated 60 s timer (`DAILY_REPORT_SSE_VISIBILITY_REFRESH_MS`, independent of the heartbeat) to bound how
|
|
763
854
|
long a revoked grant keeps streaming; that call passes `reason: "refresh"` and reuses the connection's original `Request`, so a cache keyed on
|
|
764
|
-
request identity **must** be bypassed when `reason === "refresh"` or the re-check silently returns the stale set.
|
|
765
|
-
- **SSE latency**: because a live connection re-resolves periodically, a grant change (in either direction) takes effect for the stream within
|
|
766
|
-
Events dropped before a widening are not replayed
|
|
855
|
+
request identity **must** be bypassed when `reason === "refresh"` or the re-check silently returns the stale set (the memoizer does so).
|
|
856
|
+
- **SSE latency**: because a live connection re-resolves periodically, a grant change (in either direction) takes effect for the stream within the
|
|
857
|
+
re-check period plus the cache's lifetime. Events dropped as outside the scope before a widening are not replayed (the next read restores the
|
|
858
|
+
reports). A re-check that fails ends the stream instead: the last `id:` on the wire is the last delivered entry, so the browser's reconnect catches up
|
|
859
|
+
from there once the lookup answers again, and a viewer who signed out meets the stream's sign-in end on the reconnect.
|
|
767
860
|
|
|
768
861
|
### Comment visibility
|
|
769
862
|
|
|
@@ -787,7 +880,8 @@ resolveVisibleSourceTypes: async (request, { reason }) => {
|
|
|
787
880
|
| `["Internal"]` | Only those source types' comments are visible/writable. Narrowed to `read ∩ comment` at construction, since comments only ever ship inside an already-read-filtered report. |
|
|
788
881
|
|
|
789
882
|
- **`null` is rejected by the type system** — a second spelling for "follow `read`" would make the dimension four-state and reintroduce exactly the
|
|
790
|
-
unrestricted-vs-deny-all confusion the
|
|
883
|
+
unrestricted-vs-deny-all confusion the contract exists to prevent. A `null` (or any other value that is not a token array) that slips through at
|
|
884
|
+
runtime is outside the contract and answered like a port failure (**Failure** in [Source-type visibility](#source-type-visibility)).
|
|
791
885
|
- **What it covers**: `DailyReportDetail` carries comments in **two** structurally different fields — `postedComments` (the package's own comment
|
|
792
886
|
table) and `externalComments` (JSON supplied by an `externalSources` adapter). Both are gated. They are emptied, never omitted: the keys are
|
|
793
887
|
required by the SSE schema, and dropping them would make a receiver discard the whole message.
|
|
@@ -798,7 +892,7 @@ resolveVisibleSourceTypes: async (request, { reason }) => {
|
|
|
798
892
|
- **Writing to external sources remains impossible regardless** — `addComment` rejects any non-`Internal` source type outright, so for external
|
|
799
893
|
categories the comment dimension effectively controls *reading*.
|
|
800
894
|
- **Cache**: the dimension is folded into the cache-key digest, so revoking only the comment grant invalidates immediately. When `comment` follows
|
|
801
|
-
`read` (the default) the digest
|
|
895
|
+
`read` (the default) the digest equals the single-dimension digest of `read`, so such a deployment's cache keys do not depend on the dimension.
|
|
802
896
|
|
|
803
897
|
### Attachments
|
|
804
898
|
|
|
@@ -960,6 +1054,9 @@ type DailyReportReadAttachment = (
|
|
|
960
1054
|
|
|
961
1055
|
`busy`, `deadline` and `unavailable` are transient: a thumbnail generation concludes nothing from them, caches nothing and marks nothing missing.
|
|
962
1056
|
The requests that joined that generation receive the same failure, and the next request reads again.
|
|
1057
|
+
A request whose viewer visibility cannot be decided (the visibility port failed; **Failure** in [Source-type visibility](#source-type-visibility))
|
|
1058
|
+
gets the same transient 503 with `Retry-After: 5` on both deliveries, at the authorization step, before any query or read: an undecided scope is
|
|
1059
|
+
not read as "not visible", whose 404 a thumbnail would conclude from.
|
|
963
1060
|
- **`principal` is for attribution and audit only: the read result must not depend on it.** The SQL predicate (`getAttachmentForUser`) is the only
|
|
964
1061
|
authorization boundary; the port reads the `filePath` of a row that has already been authorized. Thumbnail generation relies on this: requests
|
|
965
1062
|
waiting for the same content share one read, made with the first requester's `principal`, and the outcome is cached for every authorized viewer of
|
|
@@ -1457,7 +1554,7 @@ The thumbnail delivery (`?thumbnail=tile`):
|
|
|
1457
1554
|
| 405 | Any method other than GET (`Allow: GET`) |
|
|
1458
1555
|
| 429 | An empty generation bucket (`reason=rate_limit`) or revalidation bucket (`reason=revalidation_rate_limit`), or a revalidation that is not a matching 304 when no generation token is left after authorization (`reason=rate_limit`) (`Retry-After: 60`) |
|
|
1459
1556
|
| 502 | Storage unreachable or a read ended early (`unavailable`), the port's `failed` (`render_failed`), or the render stage's deadline (`render_timeout`) |
|
|
1460
|
-
| 503 | Wait queue full or wait timed out (`queue`), the storage busy (`busy`),
|
|
1557
|
+
| 503 | Wait queue full or wait timed out (`queue`), the storage busy (`busy`), the generation abandoned by every waiting request (`aborted`; no one receives it), or the viewer's visibility undecided at authorization (the visibility port failed; **Failure** in [Source-type visibility](#source-type-visibility); logged on that channel, not here) (`Retry-After: 5`); the message is `Thumbnail temporarily unavailable` for every cause |
|
|
1461
1558
|
| 504 | A storage read past a deadline: the read port's own (`deadline`) or the read stage's `readMs` (`read_timeout`); never cached |
|
|
1462
1559
|
| 500 | Unexpected exception, including one thrown by a port during generation (logged as `500 attachment=<id> viewer=<id> variant=thumbnail cache=miss reason=port_exception message=<msg>`, never cached), or a port contract violation (`port_contract`: a render result outside the contract, logged with `code=result` for a value that is not an object, `code=failure_reason` for a failure whose reason is neither `unsupported` nor `failed`, and for a read failure whose reason is outside `DailyReportAttachmentFailure`, or the output check that failed, `code=` `content_type`, `bytes`, `empty`, `too_large`, `signature` or `dimensions`; or a read over `maxBytes`, `code=over_max_bytes`), or a host route that passes no `token` parameter (a route misconfiguration, below) |
|
|
1463
1560
|
|
|
@@ -1505,7 +1602,8 @@ also when the refusals stop, and a bucket the limiter evicts (it keeps 1,024 use
|
|
|
1505
1602
|
|
|
1506
1603
|
### Transactions
|
|
1507
1604
|
|
|
1508
|
-
Creating and editing a report each write in one transaction
|
|
1605
|
+
Creating and editing a report each write in one transaction; creating also writes the author's status row (read, not starred) in it, so the
|
|
1606
|
+
report reads back as read. The service has one entry to its transactions, which retries **only a transaction the
|
|
1509
1607
|
database chose as a deadlock victim** (the decision is
|
|
1510
1608
|
[`@aiquants/mssql`](../mssql/README.md)'s, and the closure is wrapped with its `capture`, so the cause is kept before the ORM drops it): concurrent
|
|
1511
1609
|
operations whose lock ranges overlap can deadlock,
|
|
@@ -1567,7 +1665,7 @@ and without the retry the victim would answer 500 and lose what it wrote.
|
|
|
1567
1665
|
|
|
1568
1666
|
Stop signals are **in-band**: the server answers 200 and writes `retry: 86400000` before one named terminal event, so a client that does not listen
|
|
1569
1667
|
for the event (an older bundle) reconnects at most once a day instead of in a loop. A non-200 response is reserved for a misconfigured endpoint, an
|
|
1570
|
-
unexpected error
|
|
1668
|
+
unexpected error, a request that is not the package's client (another origin's page) and the transient 503s the client retries on its own backoff.
|
|
1571
1669
|
|
|
1572
1670
|
| Server-side situation | Response | Client (`useDailyReportSseConnection`) |
|
|
1573
1671
|
| --- | --- | --- |
|
|
@@ -1579,6 +1677,8 @@ unexpected error and a request that is not the package's client (another origin'
|
|
|
1579
1677
|
| A `HEAD` request | the status and headers a `GET` would get (one of the rows here), with no body work: no subscription, no heartbeat, no visibility refresh timer ([Streaming routes](#streaming-routes)) | never sent by the client |
|
|
1580
1678
|
| Unexpected error before the stream starts (for example the internal-user lookup fails) | 500 with the body `Internal Server Error` (no error detail), `Set-Cookie` forwarded | treated as retryable; backoff up to 30 s |
|
|
1581
1679
|
| The viewer already holds every stream slot of the worker (**Streams per viewer** in [The API route](#the-api-route): 32, SSE and ids streams together), after the resume cursor is read | 503 with the body `Too many streams` and `Retry-After: 5`, `Set-Cookie` forwarded; nothing is subscribed and the visibility is not resolved | treated as retryable; backoff up to 30 s |
|
|
1680
|
+
| The viewer's visibility cannot be decided at connect (the visibility port throws or answers outside its contract; **Failure** in [Source-type visibility](#source-type-visibility)), after the stream slot is taken | 503 with the body `Report visibility is unavailable` and `Retry-After: 5`, `Set-Cookie` forwarded; the slot is given back and nothing is subscribed | treated as retryable; backoff up to 30 s |
|
|
1681
|
+
| The periodic re-check of an open stream's visibility fails (the port throws, a viewer who signed out included, or answers outside its contract) | the body ends without writing an anchor for any entry not yet delivered; the last `id:` on the wire is the last delivered entry | the browser reopens with `Last-Event-ID`, and the catch-up delivers what the closed stream had not; a viewer who signed out meets `auth-required` |
|
|
1582
1682
|
| Malformed cursor | 200, `retry: 86400000` → `event: resync-required` | rereads the ids (server cache bypassed) and reopens from the new anchor |
|
|
1583
1683
|
| Cursor older than the oldest retained entry (the stream is trimmed with `MAXLEN ~ streamMaxLen`) | 200, `connected` → heartbeat → `retry: 86400000` → `event: resync-required` → end | same as above |
|
|
1584
1684
|
| The start of a catch-up page is trimmed away before the page is read | 200, the entries delivered so far → `retry: 86400000` → `event: resync-required` → end | same as above |
|
|
@@ -1853,6 +1953,10 @@ released search or another view's search holds the viewer's slot until its SQL e
|
|
|
1853
1953
|
Its order: 404 without the `search` block, the 400s below, then a viewer without an internal user answered with the plan's words and no hits, with no
|
|
1854
1954
|
admission and no dataset query, then the admission with the request's signal, the search, the slot's release whatever the outcome, and the ETag'd
|
|
1855
1955
|
JSON; a search whose client left gets the bodyless 503 every search route answers (under `search-marks` below).
|
|
1956
|
+
Every search route is a GET, so the typed text (`q`, which the `search-marks` URL carries too) and the filter values (author and customer names,
|
|
1957
|
+
source types) travel in the URL and may stay in the access logs of the host's server and of a reverse proxy in front of it. That is accepted: there is
|
|
1958
|
+
no POST route, and the ETag with the quiet resend's `If-None-Match` (a 304, which only a GET gets) relies on it. The JSON routes answer 405 to other
|
|
1959
|
+
methods.
|
|
1856
1960
|
`words` is the plan's words, keyword by keyword: each word's alternatives `{ text, tier }` (`DailyReportSearchAlternative`), tier 0 the typed word's
|
|
1857
1961
|
forms and tier 1 its romaji readings. It depends on `q` and `combine` alone, never on the viewer, and the client reads it strictly with the rest of
|
|
1858
1962
|
the answer (at most 16 keywords, 16 words a keyword, 32 words, 6 alternatives a word of 1 to 100 units, the first alternative of tier 0, tiers that
|
|
@@ -2149,8 +2253,8 @@ While an answer shows, the List cards, the side pane and the DetailList rows mar
|
|
|
2149
2253
|
accessible description (`aria-description`: `labels.searchReadingMarkDescription`, en `Matched by its romaji reading`, ja `ローマ字の読みで一致`); a word mark
|
|
2150
2254
|
carries none, so the common case adds no speech. The attribute's value and the description come from one table keyed by the tier; a tier outside it
|
|
2151
2255
|
stops the render with an error naming the package.
|
|
2152
|
-
Moving through the rows with the keys, a row is announced
|
|
2153
|
-
marks sit inside its primary button,
|
|
2256
|
+
Moving through the rows with the keys, a row is announced as the composite's active descendant, whose name and description flatten the marks (a
|
|
2257
|
+
List card's author marks sit inside its primary button, which names the row, and its preview reaches the row only as a description), so the row says
|
|
2154
2258
|
in words what matched:
|
|
2155
2259
|
one sentence per field heading the row's leaves draw a match under, in the order the view draws them — `labels.searchFieldMatched(heading)` (en
|
|
2156
2260
|
`Author matched`, ja `作成者で一致`) when a typed word's mark or a dense field is among the heading's leaves,
|
|
@@ -2240,10 +2344,10 @@ narrow for the author and the slot the slot drops to a 16 px line of its own ins
|
|
|
2240
2344
|
`Matched in fields this view does not show, such as labels or details`), which shows only when no field is marked or dense.
|
|
2241
2345
|
A DetailList row's description (`aria-describedby`) ends with that element while the screen holds a marks store, so moving through the rows with the
|
|
2242
2346
|
arrow keys reads the sentences each row shows.
|
|
2243
|
-
- A List card shows no slot. While the screen holds a marks store, its primary button
|
|
2244
|
-
inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the marked
|
|
2245
|
-
(empty while the card draws no match; the List does not announce a hidden match), so
|
|
2246
|
-
a reading did. Without a store the card's description is unchanged.
|
|
2347
|
+
- A List card shows no slot. While the screen holds a marks store, the description of its row and of its primary button ends, after the preview,
|
|
2348
|
+
with a `hidden` element inside the card's marks scope (`SearchMatchDescription`, `data-daily-report-search-match-description`) that holds the marked
|
|
2349
|
+
fields' sentence alone (empty while the card draws no match; the List does not announce a hidden match), so the active row's announcement says what
|
|
2350
|
+
matched and whether only a reading did. Without a store the card's description is unchanged.
|
|
2247
2351
|
|
|
2248
2352
|
## Client wiring
|
|
2249
2353
|
|
|
@@ -2349,27 +2453,31 @@ package does nothing silently outside its provider.
|
|
|
2349
2453
|
technology); otherwise it renders in the provider's own slot after the page, centred with the row gutter G around it
|
|
2350
2454
|
(`data-daily-report-notice-slot`). Its block size is rounded up to the lattice like a DetailList body.
|
|
2351
2455
|
- **Focus is handed back, never dropped**: when the notice leaves while it holds focus, focus goes to the first visible successor (below) of: where it
|
|
2352
|
-
came from (when that element is still in the document), the view
|
|
2353
|
-
view
|
|
2456
|
+
came from (when that element is still in the document), the view (its composite, the one Tab stop, or after a pointer input the active row's card
|
|
2457
|
+
button; **Focus handed back to the view**), the dialog that hosts the notice, the selected view tab (no view is rendered) and the status panel (no
|
|
2458
|
+
view is mounted yet).
|
|
2354
2459
|
- **Focus goes only to what shows it**: every control that removes itself, or empties, while it holds focus — the two retry buttons, the notice's
|
|
2355
2460
|
close, the header badge's container, the status panel that the content replaces — hands focus on through one rule (`focusVisibleSuccessor` in
|
|
2356
2461
|
`src/client/keyboard/focus-successor.ts`).
|
|
2357
2462
|
A candidate takes focus only when it is in the document, focusable, has a box with an area and passes
|
|
2358
2463
|
`checkVisibility({ visibilityProperty: true, opacityProperty: true })`, and the rule confirms where focus landed before it stops, so a visually
|
|
2359
2464
|
hidden region, a 0 × 0 box and an element that is not rendered never receive it. When no candidate qualifies, the rule moves nothing.
|
|
2465
|
+
The one exception is a row's own focus target (`data-daily-report-row-focus`, the DetailList card's visually hidden name heading among them): it is
|
|
2466
|
+
checked without the opacity test, which its shared visually-hidden token would always fail, and with every other test (**Focus handed back to the
|
|
2467
|
+
view** below).
|
|
2360
2468
|
- **The page's stream status**: until the first ids chunk arrives `DailyReportPage` shows one status panel (`data-testid="daily-report-status-panel"`,
|
|
2361
2469
|
`data-phase` `loading` or `failed`) whose content switches between loading and the load error with its retry button, so the panel persists through a
|
|
2362
2470
|
retry, and the header's progress badge is one persistent container whose content switches by phase.
|
|
2363
2471
|
A retry button that its own press removes first hands focus to its container (the status panel, or the badge's container), which draws the control
|
|
2364
2472
|
focus outline while it holds keyboard focus.
|
|
2365
|
-
When the status panel leaves while it holds focus, the page keeps a pending hand-over (`FocusHandOverContext`): the content's view takes it in
|
|
2366
|
-
first commit
|
|
2367
|
-
user switch rebuilds takes it as its new panel mounts and focuses that panel.
|
|
2473
|
+
When the status panel leaves while it holds focus, the page keeps a pending hand-over (`FocusHandOverContext`): the content's view takes it in its
|
|
2474
|
+
first commit and hands focus to its composite (also when the list completed empty: the composite is a Tab stop in every state), and a status screen
|
|
2475
|
+
that a user switch rebuilds takes it as its new panel mounts and focuses that panel.
|
|
2368
2476
|
Either passes focus only while focus is still nowhere, so focus the user has put elsewhere in the meantime stays. Between the panel leaving and the
|
|
2369
2477
|
view's first rows focus is on `body` for one commit: a view draws no rows before its first height arrives (**One measurement, in one unit** in
|
|
2370
2478
|
[View height](#view-height-host-layout)).
|
|
2371
2479
|
The badge's container, emptied by the stream's completion while it holds focus (an empty container is a 0 × 0 box), hands focus to the page's
|
|
2372
|
-
successors: the view
|
|
2480
|
+
successors: the view, else the selected view tab.
|
|
2373
2481
|
One persistent, visually hidden region of the page (`data-testid="daily-report-stream-announcer"`, outside the screens the phase swaps, never
|
|
2374
2482
|
focusable) announces the changes of the stream's lifecycle that happen after it mounts: loading after a retry (`labels.loadingIds`) and reconnecting
|
|
2375
2483
|
(`labels.streamRetrying`) as a `role="status"`, a failure as a `role="alert"` (`labels.loadError` before any report arrived, `labels.streamFailed`
|
|
@@ -2381,8 +2489,13 @@ package does nothing silently outside its provider.
|
|
|
2381
2489
|
(`data-testid="daily-report-ids-stream-status-announcer"`, visually hidden, never focusable).
|
|
2382
2490
|
Its `role="status"` and `role="alert"` regions are mounted empty and only their texts change: reconnecting (`labels.streamRetrying`) is a status and
|
|
2383
2491
|
the stream's failure (`labels.streamFailed`) an alert, and the text present at mount is not written; the badge's pills are no live regions.
|
|
2384
|
-
The
|
|
2385
|
-
|
|
2492
|
+
The badge holds one slot in the header's row: an invisible sizer (every phase's pill in its widest form at the current digit counts, built like the
|
|
2493
|
+
header's counters) and the container share the slot's one grid cell, so the slot has one size in every phase, also while it shows nothing, and the
|
|
2494
|
+
header's rows and the search band below never move when a pill appears, disappears or changes (the width changes only with the count's digits).
|
|
2495
|
+
The pill's text starts at the slot's start (`text-start`) whatever the host header's alignment, so a narrower phase's label stays beside its dot
|
|
2496
|
+
instead of floating to the slot's other end.
|
|
2497
|
+
The regions sit beside the slot, not inside the container: the empty container is not painted (`visibility: hidden`; it stays laid out), so
|
|
2498
|
+
regions inside it would leave the accessibility tree while it is empty and come back already filled, which a screen reader may not announce.
|
|
2386
2499
|
Inside the package only that live announcer (`src/client/ui/live-announcer.tsx`) and three reviewed persistent regions — the notice's alert regions
|
|
2387
2500
|
(**Every failure is announced, once** above), the search box's status line and the filter row's status line — write a live role (`status`, `alert`,
|
|
2388
2501
|
`log`, `marquee`, `timer`), an `aria-live` or an `<output>`: `src/client/ui/live-regions.spec.ts` reads the shipped client's sources and fails on
|
|
@@ -2589,73 +2702,102 @@ view, and no script computes or writes a height.
|
|
|
2589
2702
|
「項目がありません」), which `VirtualScroll` draws over the top of its content, out of the flow, so an empty view keeps the same box. The views render no
|
|
2590
2703
|
message of their own; they only theme the engine's (on `VirtualScroll`'s root: 16 px above and below, slate-500 in light and slate-400 in dark:
|
|
2591
2704
|
4.55:1 and 7.66:1 against the page, where the engine's default grey reaches only 4.17:1 in dark).
|
|
2705
|
+
Because the views pass an active row (`activeItemIndex`, `null` while the list is empty), `VirtualScroll` draws the message outside the composite —
|
|
2706
|
+
in a layer over the composite's box, with the same pixels and the same `.aqvs-no-items-container` / `.aqvs-no-items-text` hooks — and names it first
|
|
2707
|
+
in the composite's `aria-describedby`, before the key help: the `treegrid` owns nothing but rows, and a screen reader entering an empty view (a
|
|
2708
|
+
viewer who may see no category, for example) hears the title, the empty state and the key help.
|
|
2592
2709
|
|
|
2593
2710
|
### Keyboard, focus and selection (List / DetailList)
|
|
2594
2711
|
|
|
2595
|
-
Each view
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2712
|
+
Each view is one composite widget: `VirtualScroll`'s content element carries `role="treegrid"`, the view's single Tab stop (`tabIndex=0`, also while
|
|
2713
|
+
the rows load and when the list is empty), and DOM focus stays on it while the keys move. What the keys move is the view's **active row** — the row
|
|
2714
|
+
assistive technology's focus is on — which the composite names through `aria-activedescendant`. `@aiquants/virtualscroll` writes that attribute
|
|
2715
|
+
itself, in the same render as the row it names (`activeItemIndex`), and keeps that row mounted even outside the rendered window, so the reference
|
|
2716
|
+
always resolves (the peer's README, "The active item"). The host never writes the attribute.
|
|
2717
|
+
Each view keeps one row cursor: the active row and the selected report, both keyed by report id; a key re-renders exactly the two rows whose state
|
|
2718
|
+
changes.
|
|
2719
|
+
The keys are delegated to each view's **list**: the element that holds the view's `VirtualScroll` (its composite and its scroll bar). In the List view
|
|
2720
|
+
the resize handle and the side pane lie outside the list.
|
|
2599
2721
|
|
|
2600
2722
|
| Key | List | DetailList | Default prevented |
|
|
2601
2723
|
| --- | --- | --- | --- |
|
|
2602
|
-
| `ArrowUp` / `ArrowDown` |
|
|
2724
|
+
| `ArrowUp` / `ArrowDown` | Makes the previous / next report active and selected, and reveals it by the nearer edge | Makes the previous / next report active and selected, and aligns it to the top (the reading start) | Yes, also at the ends (no wrap-around) |
|
|
2603
2725
|
| `Home` / `End` | First / last report, aligned to the top / bottom | First / last report, aligned to the top / bottom (to the top when the last row is taller than the viewport) | Yes |
|
|
2604
|
-
| `PageDown` / `PageUp` |
|
|
2605
|
-
| `Enter` / `Space` |
|
|
2726
|
+
| `PageDown` / `PageUp` | The farthest row that still fits in the viewport together with the active row, revealed by the nearer edge | The same; an active row taller than the viewport is first scrolled by min(overflow, ⌊0.875 × viewport⌋) without changing the selection | Yes |
|
|
2727
|
+
| `Enter` / `Space` on the composite | Activates the active row like a press of its card's primary button: selects it (on a mobile layout it also opens the detail overlay) | Selects the active row without scrolling | Yes, also without an active row (the page does not scroll) |
|
|
2728
|
+
| `Enter` / `Space` on a control of the active row | That control's own action | The same | Native |
|
|
2729
|
+
| `Escape` on a control of the active row, a field or button of an in-row form included | Focus returns to the composite; the active row does not change (below: where `Escape` keeps its own meaning) | The same | Yes |
|
|
2606
2730
|
| `Ctrl+Home` / `Ctrl+End` | Leaves the list: focus moves to the last Tab stop before the list / the first one after it (below; on the desktop layout `Ctrl+End` reaches the resize handle), which the browser scrolls into view; the selection does not change | The same | Yes, when there is such a stop (otherwise not handled: the key keeps its browser meaning) |
|
|
2607
|
-
| `Tab` / `Shift+Tab` |
|
|
2608
|
-
| Any other key, a modifier combination, IME | Not handled
|
|
2609
|
-
|
|
2610
|
-
- **The move starts from the
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2731
|
+
| `Tab` / `Shift+Tab` | Native order: the composite, then the active row's controls (below), then out of the view; Tab never selects | The same | No |
|
|
2732
|
+
| Any other key, a modifier combination, IME | Not handled | The same | No |
|
|
2733
|
+
|
|
2734
|
+
- **The move starts from the active row**, never from `document.activeElement` and never from the layout: Tab into a view, press `ArrowDown`, and the
|
|
2735
|
+
report after the active row is selected. A movement key pressed on a control of the active row (★, 既読, 編集, 削除, an attachment link, a comment
|
|
2736
|
+
control outside its form) moves from that row as on the composite and then focuses the composite once (`focus({ preventScroll: true })`), so the
|
|
2737
|
+
repeats that follow arrive at the composite. `Escape` on such a control returns focus to the composite without changing the active row — from the
|
|
2738
|
+
fields and buttons of the in-row edit and comment forms too, whose typed text stays where it was typed (the comment in its field, the editor's
|
|
2739
|
+
title and body in the view's editing store), so nothing is lost and row navigation is one key away instead of a dozen `Shift+Tab` presses —,
|
|
2740
|
+
unless the event's default is already prevented, the key belongs to an IME
|
|
2741
|
+
composition, the target is an element where `Escape` has its own meaning (`select`, `input[type="search"]`, which `Escape` clears, an editing host,
|
|
2742
|
+
`audio` / `video` with `controls`), the target or an ancestor up to the row carries `aria-expanded="true"` (an open disclosure, which `Escape` closes
|
|
2743
|
+
first), or it lies inside an ARIA widget that uses the keys itself (below).
|
|
2744
|
+
- **`Home` and `End` name a report**, the first or the last: pressed on an active row that already is that report but is not selected — the row left
|
|
2745
|
+
active after the selected report was deleted, for example — they select it in place, aligned like any `Home` / `End`. The relative moves name a
|
|
2746
|
+
neighbour, so an arrow, `PageUp` or `PageDown` that cannot move at an end selects nothing; and the frames of a held `Home` / `End` do not select again
|
|
2747
|
+
what its first press selected (or the host refused).
|
|
2615
2748
|
- **Held keys move at once, then at most once per frame.** The first repeated keydown (`repeat`) of a key is checked like any key and starts a repeat
|
|
2616
|
-
stream from the
|
|
2749
|
+
stream from the active row; it and every later repeat on the same axis have their default prevented.
|
|
2617
2750
|
A repeat that finds the stream's frame fence open — the first one, and any repeat after a frame in which nothing was pending — moves at once inside
|
|
2618
|
-
its own keydown (
|
|
2619
|
-
than the display refreshes moves once per keydown and never waits for a frame.
|
|
2751
|
+
its own keydown (the active row, the selection and the scroll in one commit; React commits the discrete event synchronously), so a key that repeats
|
|
2752
|
+
more slowly than the display refreshes moves once per keydown and never waits for a frame.
|
|
2620
2753
|
The repeats behind the fence only add their step: the arrows one row each (up and down cancel out), `PageUp` / `PageDown` one page each, and `Home`,
|
|
2621
|
-
`End` and `Enter` / `Space`
|
|
2754
|
+
`End` and `Enter` / `Space` count once; they read neither the DOM nor the layout.
|
|
2622
2755
|
The frame right after an immediate move applies nothing (two moves never commit in one frame); every later frame applies what accumulated as one
|
|
2623
|
-
move with `flushSync` from the row
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
A key that is not a repeat first resolves its command
|
|
2627
|
-
|
|
2628
|
-
|
|
2756
|
+
move with `flushSync` from the active row, so the next key already starts from the new row, and a frame with nothing pending opens the fence again.
|
|
2757
|
+
A key that repeats faster than the display therefore moves at most once per frame. A repeat on another axis (rows, pages, `Home`, `End`, activation)
|
|
2758
|
+
applies what is pending before it starts a new stream.
|
|
2759
|
+
A key that is not a repeat first resolves its command and then applies the pending repeats synchronously, so it moves from the active row they
|
|
2760
|
+
reached, however far they went. Every `keyup` applies what is pending and ends the stream; a pointer press, a wheel or touch start in the view, focus
|
|
2761
|
+
leaving the view and unmounting end it without applying it.
|
|
2629
2762
|
On a slow device the keys therefore never queue up, and the selection stops when the key is released. `Ctrl+Home` / `Ctrl+End` are never
|
|
2630
|
-
accumulated. While a stream lasts, the desktop side pane keeps showing the report the stream started from (see **List side pane** below)
|
|
2763
|
+
accumulated. While a stream lasts, the desktop side pane keeps showing the report the stream started from (see **List side pane** below), and rows
|
|
2764
|
+
far from the active row stay shells (see **Rows mount in two passes** below).
|
|
2631
2765
|
- **Ignored keys**: `Alt` / `Ctrl` / `Meta` / `Shift` combinations other than `Ctrl+Home` / `Ctrl+End` alone (so `Ctrl+PageDown` and `Shift+End` keep
|
|
2632
2766
|
their browser meaning); an event whose default is already prevented; IME composition (`isComposing` or `keyCode` 229);
|
|
2633
|
-
keys
|
|
2634
|
-
|
|
2635
|
-
`
|
|
2767
|
+
keys whose target is neither the view's composite nor a control inside one of its rows (the scroll bar, the side pane, the mobile overlay); the
|
|
2768
|
+
movement keys and the exits from an input region — a `form` (every control of the in-row edit form and of the comment form, their
|
|
2769
|
+
buttons included) or an editable element (`input`, `textarea`, `select`, `contenteditable` other than `"false"`, `audio[controls]`,
|
|
2770
|
+
`video[controls]`); `Escape` there follows its own rule above —
|
|
2636
2771
|
or from inside ARIA widgets that use these keys themselves (`combobox`, `listbox`, `menu`, `menubar`, `radiogroup`, `slider`, `spinbutton`,
|
|
2637
2772
|
`textbox`, `tree`, `grid`, `scrollbar`, `tablist`, `toolbar`, `treegrid`, `separator`) between the key's origin and the view's scroll container, the
|
|
2638
|
-
exits included.
|
|
2773
|
+
exits included; the view's own composite is the one `treegrid` that is exempt, since it is the element the keys arrive at.
|
|
2639
2774
|
So no key pressed in an open in-row editor moves the list: the keys keep their meaning in the field (the caret, the text selection, the form's
|
|
2640
|
-
buttons), and the row being edited stays where the viewer types
|
|
2775
|
+
buttons), and the row being edited stays where the viewer types; `Escape` only returns focus to the composite, and the editor stays open with what
|
|
2776
|
+
was typed. Only elements **inside** the view count: wrapping the whole view in a host `form`,
|
|
2641
2777
|
`role="grid"`, `role="listbox"` or `contenteditable` region does not disable the keys. While the viewport has no size yet, the movement keys are not
|
|
2642
2778
|
handled.
|
|
2643
|
-
- **One Tab
|
|
2644
|
-
|
|
2645
|
-
its
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2779
|
+
- **One Tab stop per view.** The composite is the view's only stop, and only the active row's controls follow it in the Tab order: the List card's ★
|
|
2780
|
+
and 既読, and the DetailList row's ★, 既読, 編集 (on the viewer's own reports) and 削除, the comment controls (delete and its confirmation, the comment
|
|
2781
|
+
field and its send button), the edit form's fields and buttons, and the attachment links, in their natural order; every other row's controls have
|
|
2782
|
+
`tabIndex=-1`. The List card's primary button is never a stop (`tabIndex=-1`; it stays the click target and the pointer hand-back target, below),
|
|
2783
|
+
and no row frame carries a `tabindex`.
|
|
2784
|
+
So the Tab path through a view is the composite plus one row's controls, however many rows are rendered: crossing the List takes exactly 3 presses
|
|
2785
|
+
(the composite, ★ and 既読), and `Shift+Tab` from the active row's first control returns to the composite. A row in edit mode is the active row (the
|
|
2786
|
+
title field took focus inside it), so its form stays in the Tab order.
|
|
2787
|
+
When the active row is not visible and the key's default action lands inside it — `Tab` from the composite when the row has a Tab stop, `Tab`
|
|
2788
|
+
from a control of the row with another stop after it, `Shift+Tab` from a control with another stop before it, never `Shift+Tab` from the composite,
|
|
2789
|
+
which leaves the view — the keydown first reveals the row by the nearer edge, synchronously, and then leaves the default action to the browser,
|
|
2790
|
+
which focuses a control already in view. A `Tab` or `Shift+Tab` that leaves the view moves nothing, so the list stays where the viewer left it.
|
|
2791
|
+
The landing is read from the package's one tab-order model (`tabLandsInRow` over `listTabbables` and `partitionTabbables` in
|
|
2792
|
+
`src/client/keyboard/row-tab-order.ts` and `src/client/keyboard/dom-node.ts`), the same model the exits use.
|
|
2793
|
+
The reveal cannot wait for `focusin`: Chromium decides the scroll of a focus move before `focusin` runs, so a row pinned far outside the window would
|
|
2794
|
+
scroll the document instead (an off-screen row is revealed by its index, never by its rect).
|
|
2653
2795
|
The view-mode tab panel that holds a view is no Tab stop: a panel is a stop when its first meaningful content is not focusable, or when it is a
|
|
2654
2796
|
scroll container that Tab must reach so the keys can scroll it (WAI-ARIA APG Tabs; `TabsContent` takes a required `tabStop`), and a view's content
|
|
2655
|
-
begins with its own stop, the
|
|
2797
|
+
begins with its own stop, the composite.
|
|
2656
2798
|
The panel carries no `tabindex` at all (not −1, which would let a pointer press on blank view space focus the panel, an ancestor of the list where
|
|
2657
|
-
the view's keys never arrive), so one Tab from the last control before the view reaches the
|
|
2658
|
-
control.
|
|
2799
|
+
the view's keys never arrive), so one Tab from the last control before the view reaches the composite, and `Ctrl+Home` from the view returns to
|
|
2800
|
+
that control.
|
|
2659
2801
|
The side pane's article and relations panels are stops of their own: each is the pane's scroll box, which Tab must reach so the keys scroll it,
|
|
2660
2802
|
although the article holds buttons, the comment field and attachment links further down.
|
|
2661
2803
|
The scroll bar is pointer-only in both views, like a native scroll bar, because the keys above already scroll the list: both views pass
|
|
@@ -2663,13 +2805,17 @@ resize handle and the side pane lie outside the list.
|
|
|
2663
2805
|
accessibility and the Tab order").
|
|
2664
2806
|
The bar carries `aria-hidden="true"`, so assistive technology sees no `scrollbar` and no `slider` in either view, named or not; its arrow buttons
|
|
2665
2807
|
have `tabIndex=-1` and the bar and its thumb no `tabindex`, so the bar adds no Tab stop; and a press anywhere on the bar has its default prevented,
|
|
2666
|
-
so it moves no focus, neither onto a part of the bar nor away from the
|
|
2667
|
-
|
|
2808
|
+
so it moves no focus, neither onto a part of the bar nor away from the composite. Every pointer interaction still scrolls: the arrows on a press and
|
|
2809
|
+
while held, the track, the thumb and the tap-scroll circle.
|
|
2668
2810
|
The bar and its thumb still carry their names (the engine keys `verticalScrollBar` and `verticalScrollThumb`, below), which the hidden bar does not
|
|
2669
2811
|
expose.
|
|
2670
2812
|
- **Leaving the list** (`Ctrl+Home` / `Ctrl+End`, as in the WAI-ARIA feed pattern): focus moves to the last Tab stop before the list or the first one
|
|
2671
2813
|
after it — where real `Shift+Tab` from the list's first stop and real `Tab` from its last stop go — and the browser scrolls it into view as it does
|
|
2672
2814
|
for Tab.
|
|
2815
|
+
This departs from the WAI-ARIA APG treegrid pattern, where `Ctrl+Home` / `Ctrl+End` on a row move to the first / last row: here `Home` / `End`
|
|
2816
|
+
already do that (as they do on a row in the APG pattern; the rows have no cells to cross), so the two chords keep the feed's exits, and a keyboard
|
|
2817
|
+
user leaves the view in one key instead of tabbing through the active row's controls (a DetailList row can hold a dozen or more). `labels.listKeyboardHelp` names both exits, so a screen reader
|
|
2818
|
+
announces them with the composite's description.
|
|
2673
2819
|
In the List view the exits are measured from the list like in the DetailList, so on the desktop layout `Ctrl+End` moves to the resize handle (then
|
|
2674
2820
|
Tab goes on to the side pane), and `Ctrl+Home` to the last stop before the list (in `DailyReportPage`, the search row's last control when the search
|
|
2675
2821
|
is configured — its clear button while it shows, otherwise the field — and the view-mode toolbar's last control when it is not).
|
|
@@ -2696,83 +2842,98 @@ resize handle and the side pane lie outside the list.
|
|
|
2696
2842
|
When there is no such element on that side, the key is not handled and keeps its browser meaning; for example, when nothing focusable follows the
|
|
2697
2843
|
list on the page, `Ctrl+End` scrolls the document as usual. `labels.listKeyboardHelp` names both exits and says that they apply when there is a
|
|
2698
2844
|
control before or after the list.
|
|
2699
|
-
- **
|
|
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
|
|
2845
|
+
- **A key commits once and forces no layout.** A key move sets the active row, the selection, the scroll and the remembered selection in one
|
|
2846
|
+
synchronous commit (`flushSync`) inside its keydown: a destination outside the rendered window gets the alignment scroll (`scrollToIndex`) in that
|
|
2847
|
+
same commit, which renders it, and `VirtualScroll` names it in the same render. No key calls `focus()` (DOM focus is already on the composite), and
|
|
2848
|
+
no key reads the layout, so a key task forces no style recalculation and no layout of its own.
|
|
2849
|
+
The commit renders the two row frames whose state changed (the old and the new active row, and the old and the new selection), `VirtualScroll` once
|
|
2850
|
+
(the active index and, when the key scrolled, the new window) through the view's `VirtualScroll` wrapper, and the host component that owns the
|
|
2851
|
+
controlled selection (the List's `selectedReportHubId`, the DetailList's `selectedItemId`); the view itself, report rows, card bodies and the row
|
|
2739
2852
|
renderer do not render, and no context value changes per key.
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2853
|
+
The app's keyboard harness counts the style and layout work that starts inside a keydown dispatch or inside a frame callback the key caused: 0 in
|
|
2854
|
+
every run of its nine conditions.
|
|
2855
|
+
- **Rows mount in two passes.** A row that a commit mounts outside the visible range first renders as a **shell**: its row frame, with its role,
|
|
2856
|
+
id, position, set size, level and selection, named by the report's ISO business date and `aria-busy="true"`, and an empty surface
|
|
2857
|
+
(`data-daily-report-row-shell`) that fills the box `VirtualScroll` gives the row, so nothing moves when the body arrives.
|
|
2858
|
+
One scheduler per view then advances the rows one at a time — a shell to its body, a body that still draws an older report to the latest loaded one
|
|
2859
|
+
(a report that arrived by a load's answer or an SSE update) — each unit applied with `flushSync` as one commit of its own, in a slot that runs right
|
|
2860
|
+
after a frame's rendering (a `MessageChannel` task armed from the scheduler's own task by `requestAnimationFrame`, never from a keydown, a commit or a
|
|
2861
|
+
held key's frame).
|
|
2862
|
+
The order is by zone — visible, then the active row's reach, then near, then far (**Far rows skip rendering** below) — and within a zone by the
|
|
2863
|
+
distance from the active row, the side the keys travel towards first. A slot applies one unit, and a second when the next pending unit is visible or
|
|
2864
|
+
in reach.
|
|
2865
|
+
A row that becomes visible with a pending unit renders its body inside the commit that made it visible, so a visible row is never a shell. A newer
|
|
2866
|
+
loaded report for a row that is already visible is applied inside the commit that brings it, outside a key hold, so such a row never shows an outdated
|
|
2867
|
+
rendering of a loaded report while no key is held. A commit that brings a pending row into the reach or the near band wakes a sleeping scheduler, so
|
|
2868
|
+
the rows a key can reveal get their bodies in a slot before that key.
|
|
2869
|
+
While a key is held, the scheduler keeps its slots out of the frames that render the keys:
|
|
2870
|
+
- far units wait: far rows stay shells and take no arriving card until the key is released;
|
|
2871
|
+
- a slot applies one unit, and a second only when both rows have boxes of a fixed size (the List's slots): two measured DetailList bodies do not fit
|
|
2872
|
+
one key-free frame;
|
|
2873
|
+
- a slot runs only when the frame that renders its commit starts before the next repeat keydown can arrive. The next keydown is predicted from the
|
|
2874
|
+
hold's own keydowns (the median interval of the latest 8 and their earliest phase; `predictNextHoldKey`), and the frame interval is the shortest
|
|
2875
|
+
gap between the scheduler's consecutive frame callbacks, measured again for each hold. The decision is taken in the frame callback that would arm
|
|
2876
|
+
the slot, and taken again when the slot runs (a key that arrived after the frame began, or a frame that ran long, holds the unit back to the next
|
|
2877
|
+
frame); while the cadence or the frame interval is still unknown (the first one or two frames of a hold), no slot runs;
|
|
2878
|
+
- a newer loaded report for a visible row waits for the next slot that runs, ranked first.
|
|
2879
|
+
|
|
2880
|
+
So during a hold the units go to the frames without a key commit, and a row the keys reveal before a slot reached it renders its body in the key's
|
|
2881
|
+
own commit (counted).
|
|
2882
|
+
Decisions never wait for the drawing: the safety valve that removes a row whose report no longer exists, and a DetailList body's draft and
|
|
2883
|
+
ownership, its opening of the viewer's own draft in the editor and its edit button's action, read the loaded report itself.
|
|
2884
|
+
- **Far rows skip rendering.** Every mounted row lies in one zone, computed in the render that positions the rows from that render's own scroll
|
|
2885
|
+
position (half-open intervals in content pixels, V the viewport's size, s the scroll position): **visible** when it intersects [s, s + V);
|
|
2886
|
+
**reach** when it intersects the active row's reach, from V above the row before the active one to V below the row after it — every row that a
|
|
2887
|
+
step of one or two rows can reveal, with any alignment, intersects the reach before the step, whatever the rows' heights; **near** when it
|
|
2888
|
+
intersects [s − V, s + 2V), the visible range widened by one viewport on each side; and **far** otherwise.
|
|
2889
|
+
A far row whose size is settled skips its rendering: `@aiquants/virtualscroll` writes `content-visibility: hidden` and `aria-hidden="true"` on its
|
|
2890
|
+
item container (`itemZones`; the peer's README, "Item zones and far-item skipping"). The container keeps its explicit height, so skipping and
|
|
2891
|
+
showing a row change no box.
|
|
2892
|
+
A List row is settled when it mounts (its slot is fixed); a DetailList row is settled once the view's size observer has measured its body after
|
|
2893
|
+
its last change, and becomes unsettled again when its body is replaced or its comment list changes, until it is measured again.
|
|
2894
|
+
**Consequences:** a far row is absent from the accessibility tree, from a screen reader's browse mode, from a touch screen reader's swipe order and
|
|
2895
|
+
from the browser's find-in-page, until it comes near; the active row, its reach and the rows within one viewport of the visible range never are.
|
|
2896
|
+
An engine without `content-visibility` (Safari before 18) skips nothing and hides nothing.
|
|
2897
|
+
- **The active row.** It is the row the composite names while focus is in the view, and the row focus lands on when it enters. One pure
|
|
2898
|
+
transition decides it (`nextActiveRow` in `src/client/keyboard/focus-ownership.ts`; the view only translates DOM events into it):
|
|
2899
|
+
|
|
2900
|
+
| Event | Active row after it |
|
|
2757
2901
|
| --- | --- |
|
|
2758
|
-
|
|
|
2759
|
-
|
|
|
2760
|
-
| Focus
|
|
2761
|
-
| Focus
|
|
2762
|
-
|
|
|
2763
|
-
|
|
|
2764
|
-
| The
|
|
2765
|
-
| The
|
|
2766
|
-
| The
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2902
|
+
| A handled key | The key's destination; the selection follows |
|
|
2903
|
+
| Activation: a click on the List card's primary button or on a DetailList row body, `Enter` / `Space` on the composite | The activated row |
|
|
2904
|
+
| Focus enters a control inside a row (Tab, a click, a script) | That row, so focus on a control always lies in the active row |
|
|
2905
|
+
| Focus enters the composite from outside the view | The row under the mouse or pen press that caused it, which is not revealed (a press never moves what the viewer pressed); otherwise (a Tab) the **Tab-stop rule**: the active row if it is visible, else the selected report if it is visible, else the first visible row, and a row that is only partly shown is then revealed by the nearer edge |
|
|
2906
|
+
| Focus enters the composite from inside the view (`Escape`, `Shift+Tab`, a hand-back), or by a focus move the view makes itself (the return to the composite after a key on a control, `Escape`, every hand-back) | Unchanged |
|
|
2907
|
+
| Focus leaves the view for an element outside it (`Tab` or `Shift+Tab` out of the view, the `Ctrl+Home` / `Ctrl+End` exits, a press outside the view) | The Tab-stop rule, so while focus is outside, the active row is a shown row, and any later entry — a reverse Tab included — lands on a shown row |
|
|
2908
|
+
| The visible range changes while focus is outside the view | The Tab-stop rule, for the same reason |
|
|
2909
|
+
| The visible range changes while focus is in the view | Unchanged: the row stays mounted and is never skipped |
|
|
2910
|
+
| The active report leaves the list (a delete, an SSE removal, a stale removal, a search answer that replaces the list) | The report now at its index, clamped to the new length; none in an empty list |
|
|
2911
|
+
| The selected report is deleted from the side pane or the mobile overlay | The report now at the deleted report's index |
|
|
2912
|
+
|
|
2913
|
+
The row under a press is recorded at the press (mouse and pen) and dropped by every `focusin` in the view and every handled key, so a press whose
|
|
2914
|
+
focus never entered the composite — a List card's primary button takes the click's focus — cannot decide a later entry: a far key after a card
|
|
2915
|
+
click moves from the clicked row and stays on the key's destination.
|
|
2916
|
+
A press in the List's row gutter moves no focus, so it changes nothing; a touch scroll fires no click and no focus, so it changes nothing either.
|
|
2917
|
+
When the active row is removed while the composite holds focus, only the active row moves (nothing scrolls); when focus was inside the UI that
|
|
2918
|
+
left, the view hands it back (below). An empty list has no active row: the composite keeps focus and shows its pane ring.
|
|
2919
|
+
- **Focus handed back to the view.** One rule serves every path that returns focus to the view: the mobile overlay closing, the active row or its
|
|
2920
|
+
comment section leaving while focus was inside it, a focused control that leaves its row for another reason (the row's body turned into an error,
|
|
2921
|
+
an edit closed with no edit button to return to: while a control of a row holds focus, the view watches that row for the control's removal, since
|
|
2922
|
+
browsers fire no `focusout`, or fire it while the control is still in the document), a report deleted from the side pane or the overlay, and the
|
|
2923
|
+
page's hand-over from its status screen to the views. It makes the row active, reveals it by the nearer edge, and focuses by the last input's
|
|
2924
|
+
modality: after a key (or with no input recorded) the composite, with `preventScroll`; after a pointer or a touch the row's own focus target
|
|
2925
|
+
(`data-daily-report-row-focus`; `findRowFocusTarget`) — the List card's primary button, or the DetailList card's visually hidden name heading
|
|
2926
|
+
(`tabIndex=-1`, "date author subject"), so a touch reader lands on the row and hears its name. The heading adds no Tab stop, and a key pressed on it
|
|
2927
|
+
acts as on any control of the row. A row that renders no card (a shell, loading, an error, the editor), or a target that does not show, hands
|
|
2928
|
+
focus to the composite instead. iOS VoiceOver, TalkBack and Switch Access follow DOM focus rather than the active descendant, so a touch hand-back
|
|
2929
|
+
lands on a real element of the row.
|
|
2930
|
+
Focus is only ever taken from nowhere — no element, `body`, or an element inside an `inert` subtree such as the mobile overlay while it slides out
|
|
2931
|
+
(such an element cannot keep focus; `isFocusNowhere` in `src/client/keyboard/dom-node.ts`) — or, for the overlay's close and for a leaving UI, from
|
|
2932
|
+
inside the view (a leaving comment section hands back just before it detaches; focus left on the composite stays there); focus that has moved to
|
|
2933
|
+
the side pane, the open overlay or a host element stays where it is.
|
|
2773
2934
|
- **Keyboard focus**: one predicate decides it for the whole view (`isKeyboardFocused` in `src/client/keyboard/keyboard-focus.ts`): the focused
|
|
2774
2935
|
element matches `:focus-visible` and the last input of its document was not a pointer press. `:focus-visible` alone is not enough, because browsers
|
|
2775
|
-
match it on a text field (`input`, `textarea`, an editing host) that a click or a tap focused.
|
|
2936
|
+
match it on a text field (`input`, `textarea`, an editing host) that a click or a tap focused, and on a composite that a press inside it focused.
|
|
2776
2937
|
The last input is recorded per document (the element's `ownerDocument`, so a view in an iframe or a pop-out window reads its own), while a view of
|
|
2777
2938
|
that document is mounted (`observeInputModality` in `src/client/keyboard/input-modality.ts`, reference-counted across the views):
|
|
2778
2939
|
a capture-phase `pointerdown` records the pointer, and a capture-phase `keydown` records the keyboard unless the key is a modifier alone (`Shift`,
|
|
@@ -2782,21 +2943,25 @@ resize handle and the side pane lie outside the list.
|
|
|
2782
2943
|
alone) turns the focus into keyboard focus without moving it. The row and card focus outlines follow this predicate through the attribute below, not
|
|
2783
2944
|
`:focus-visible` alone, so they never show while the view treats the focus as a pointer's (also where Chromium matches `:focus-visible` after a bare
|
|
2784
2945
|
`Shift` or `CapsLock` that follows a pointer press).
|
|
2785
|
-
- **Keyboard focus is revealed**:
|
|
2786
|
-
|
|
2946
|
+
- **Keyboard focus is revealed**: focus entering the composite from outside by `Tab` reveals the active row by the nearer edge when it is only
|
|
2947
|
+
partly shown (above; an entry by a press reveals nothing), and `Tab` reveals an active row that is not shown at all before focus moves into it
|
|
2948
|
+
(**One Tab stop per view**). When a control inside a row
|
|
2949
|
+
receives keyboard focus, the view scrolls the row frame into the visible area by the nearer edge — or, for a row taller than the viewport, the
|
|
2950
|
+
focused element with 8 px around it — measured against the composite's box. Focus from a pointer never scrolls.
|
|
2787
2951
|
- **Keyboard-focus attribute**: while keyboard focus is inside a view, the view root carries `data-daily-report-keyboard-focus` (the List's
|
|
2788
2952
|
`[data-testid="daily-report-root"]`, the DetailList's scroll container). Every `focusin` and `keydown` inside the view sets it from the focused
|
|
2789
2953
|
element by the same predicate (a key press can turn pointer focus into keyboard focus without moving it), and `pointerdown` inside the view or focus
|
|
2790
2954
|
leaving it removes it; a click into a text field of a row therefore leaves it off, and the tap-scroll circle stays pressable.
|
|
2791
|
-
It is a plain DOM attribute toggled with `toggleAttribute`, so arrow keys moving
|
|
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
|
|
2955
|
+
It is a plain DOM attribute toggled with `toggleAttribute`, so arrow keys moving the active row never rewrite it and nothing re-renders. The
|
|
2956
|
+
floating tap-scroll circle hides while it is present, because the circle is drawn over the rows.
|
|
2957
|
+
The keyboard-focus outlines of the active row and of the List cards paint only under it (**Selection and focus appearance**), so an outline is drawn
|
|
2794
2958
|
only while the circle is hidden and the two never show together; the package's controls (buttons, links, tabs, switches, fields) keep their plain
|
|
2795
2959
|
`:focus-visible` outlines.
|
|
2796
|
-
- **Pointer selection**: in the List the card's primary `<button>` selects on `click` (a pointer click, Enter or Space
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2960
|
+
- **Pointer selection**: in the List the card's primary `<button>` selects on `click` and makes its row active (a pointer click, or Enter or Space
|
|
2961
|
+
on the button after a pointer hand-back; the click that ends a drag is swallowed by the scroll pane); the button keeps the focus the click gives it,
|
|
2962
|
+
and the next key moves the active row from there and focuses the composite. ★ / 既読 run only their own action. In the DetailList a click on a row
|
|
2963
|
+
body selects it without scrolling and makes it active, except clicks on controls inside the row (`button`, `a[href]`, `input`, `textarea`,
|
|
2964
|
+
`select`, `label`, `[role="button"]`, `contenteditable`). Without `onSelectItem` the DetailList handles neither clicks nor keys.
|
|
2800
2965
|
- **Host selections and list changes**: when the host changes the selection, the selection ring follows it and the DetailList scrolls that row to the
|
|
2801
2966
|
top (when the selected report is not in the list yet, as soon as it arrives); focus does not move.
|
|
2802
2967
|
A change of the list alone (SSE inserts and deletes, a stale removal) keeps what is on screen in place. Rows are keyed by report id in both views,
|
|
@@ -2815,7 +2980,7 @@ resize handle and the side pane lie outside the list.
|
|
|
2815
2980
|
first visible row (its layout-shift compensation); the update, the compensation and the position are all complete when the call returns, so the
|
|
2816
2981
|
record reads the compensated state and never mistakes the compensation for a scroll of the user.
|
|
2817
2982
|
An insert or a delete that commits between such a change and the next frame therefore keeps the position: `End`, `PageDown` and a held key are
|
|
2818
|
-
never reverted,
|
|
2983
|
+
never reverted, the active row stays the key's destination, and a re-measured row shifts no report.
|
|
2819
2984
|
The single path is a type: the keyboard navigation and the views receive only a read-only handle (`ListNavigationHandle`, the getters they read),
|
|
2820
2985
|
so a view that calls `scrollToIndex`, `scrollBy`, `scrollTo`, `applyWheel` or `updateItemSize` fails the type check, however the call is spelled.
|
|
2821
2986
|
Only the anchor's scroller holds the full handle (the end-to-end test handle gets the read-only part and the scroller, so its one position change,
|
|
@@ -2839,10 +3004,10 @@ resize handle and the side pane lie outside the list.
|
|
|
2839
3004
|
Over rows of one height (the List) the new anchor follows from the distance by arithmetic; over measured rows (the DetailList) it walks the
|
|
2840
3005
|
previous list's row heights from the anchor to the report now at the top, so the walk is bounded by the rows passed since the record (one frame's
|
|
2841
3006
|
scroll), whatever the list's length or the position.
|
|
2842
|
-
An insert or a delete before or inside the rendered window therefore leaves the first visible report — and the
|
|
3007
|
+
An insert or a delete before or inside the rendered window therefore leaves the first visible report — and the active row — where it was.
|
|
2843
3008
|
At the start of the list (scroll position 0) no anchor is kept, so reports that arrive at the top are shown;
|
|
2844
3009
|
the anchor yields only to a pending selection reveal (a DetailList selection waiting for its report; a key move needs no precedence, since its
|
|
2845
|
-
scroll, its destination's render and the
|
|
3010
|
+
scroll, its destination's render and the active row all end inside its own commit); and when the anchor report itself is removed, the first remaining
|
|
2846
3011
|
report that followed it on screen is shown at its offset.
|
|
2847
3012
|
- **List side pane**: keys select through `onSelectItem` directly, so on a mobile layout they never open the detail overlay.
|
|
2848
3013
|
On the desktop layout the pane follows the selection in a deferred render: its frame (`[data-testid="daily-report-side-pane"]`) takes the new
|
|
@@ -2866,27 +3031,38 @@ resize handle and the side pane lie outside the list.
|
|
|
2866
3031
|
The mobile overlay shows the report it opened, so its pane carries the same `data-displayed-report-id` and no `aria-busy`.
|
|
2867
3032
|
The article tab scrolls to its end (where the comments are) only after the viewer's own comment has been posted; a comment that arrives over SSE,
|
|
2868
3033
|
from another user or another tab, never moves what the viewer is reading.
|
|
2869
|
-
- **Screen readers**:
|
|
2870
|
-
handles keys (with `onSelectItem`)
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
3034
|
+
- **Screen readers**: the composite is a `treegrid` named by `labels.title`; it references `labels.listKeyboardHelp` (visually hidden, outside the
|
|
3035
|
+
composite) through `aria-describedby` — the DetailList only when it handles keys (with `onSelectItem`) — and carries `aria-rowcount`, the size of
|
|
3036
|
+
the set its rows belong to. There is no live region: announcements come from the active descendant, which the composite names while it holds focus.
|
|
3037
|
+
Every row frame is a `row` with an `id` derived from the composite's id and the report id (`virtualScrollItemElementId`), `aria-rowindex` and
|
|
3038
|
+
`aria-posinset` (its place in the list, from 1), `aria-setsize`, `aria-level="1"` and an explicit `aria-selected` — `"true"` on the selected row,
|
|
3039
|
+
`"false"` on every other, so Chromium adds no implicit selection. Its surface is the row's one `gridcell`, named by the row's name element, so a
|
|
3040
|
+
cell is never named by the whole card.
|
|
3041
|
+
A List row with a card is named by its card's primary button, whose content is "YYYY-MM-DD creator" (`aria-labelledby`), and described by the row
|
|
3042
|
+
state (`labels.rowState`: read or unread, and starred), the markers the card paints, the preview and, while the screen holds a search marks store,
|
|
3043
|
+
the match description; the button itself is described first by its position in the list (`labels.listRowPosition`, a visually hidden text: en
|
|
3044
|
+
"3 of 40", ja 「全 40 件中 3 件目」), because touch screen readers land on the button, which carries no position attribute.
|
|
3045
|
+
A DetailList row with a body is named by its report heading (date, author and subject; `aria-labelledby`) and described by the row state, the
|
|
3046
|
+
markers its header paints and, while the screen holds a search marks store, the match indicator's hidden sentences last (**Match indicator** in
|
|
3047
|
+
[Text search](#text-search)).
|
|
3048
|
+
While the set's size is unknown (`aria-setsize="-1"`, whose support in assistive technology is unproven), a card row's description ends with its
|
|
3049
|
+
position text as well (`labels.listRowPositionInUnknownTotal`: en "Item 3", ja 「3 件目」); once the size is known the attributes alone carry the
|
|
3050
|
+
position, so it is not read twice. The position reads the row's place from the same context as the row frame, so an insert or a delete before the
|
|
3051
|
+
rendered window re-renders the frames and these texts, never a card's body.
|
|
3052
|
+
The total is the size of the set the rows belong to — every row frame's `aria-setsize`, the composite's `aria-rowcount` and the header's total count
|
|
3053
|
+
read the same value, which `DailyReportResolvedContent` decides once for both views (`resolveRowSetSize`):
|
|
2879
3054
|
while the ids stream delivers, the total the server declared on the stream's first line (never fewer than the rows shown, and unchanged as chunks
|
|
2880
|
-
arrive); before the server has declared it, unknown —
|
|
2881
|
-
|
|
3055
|
+
arrive); before the server has declared it, unknown — `-1`, and the header shows `labels.totalCountLoading`; once the scan has completed, the rows
|
|
3056
|
+
shown.
|
|
2882
3057
|
A host that renders `DailyReportList` or `DailyReportDetailList` itself passes that size as `rowSetSize` (−1 while unknown), or omits it when its
|
|
2883
|
-
rows are the whole set; the views' `VirtualScroll` always counts the rows given.
|
|
2884
|
-
subject; `aria-labelledby`) and described by the row state and the markers its header paints, and, while the screen holds a search marks store, by
|
|
2885
|
-
the match indicator's hidden sentences last (**Match indicator** in [Text search](#text-search)).
|
|
3058
|
+
rows are the whole set; the views' `VirtualScroll` always counts the rows given.
|
|
2886
3059
|
The markers are the attachment marker (`<labels.attachments>: <count>`) and the source badge (its full `name` when the configuration gives one,
|
|
2887
|
-
otherwise its label); a description references only the markers that are painted. A row without content (loading, failed, missing,
|
|
2888
|
-
by its ISO business date, with `aria-busy="true"` while
|
|
2889
|
-
`aria-label` equal to their `title` and no `aria-pressed`.
|
|
3060
|
+
otherwise its label); a description references only the markers that are painted. A shell, and a row without content (loading, failed, missing,
|
|
3061
|
+
editing), is named by its ISO business date, with `aria-busy="true"` while its report or its body is still to come. ★, 既読, 編集 and 削除 carry
|
|
3062
|
+
`aria-label` equal to their `title` and no `aria-pressed`; no element of a view carries `aria-current`.
|
|
3063
|
+
The active row is never skipped, so its name and description are always complete; a far row that skips rendering is absent from the accessibility
|
|
3064
|
+
tree (**Far rows skip rendering** above). The package's tests compute the active row's name and description in both views; how real screen readers
|
|
3065
|
+
announce the treegrid rows is the manual checklist in [Assistive technology checklist](#assistive-technology-checklist).
|
|
2890
3066
|
- **Document structure**: every report has a heading at `config.headingLevel` (an integer from 2 to 5, default 3; pick the level that continues the
|
|
2891
3067
|
host page's outline). A DetailList card starts with a visually hidden heading `<date> <author> <subject>`, which names the row.
|
|
2892
3068
|
The side pane and the mobile overlay start with two label / value pairs: `labels.businessDate`, whose value is the report heading (the business date
|
|
@@ -2912,8 +3088,8 @@ resize handle and the side pane lie outside the list.
|
|
|
2912
3088
|
`labels.reportTabList` (the report heading names only the business date, which reports of the same day share).
|
|
2913
3089
|
- **Editing**: on the viewer's own reports, an Edit button (`labels.edit`, `data-detail-edit-button`, a pencil icon) stands before Delete in the
|
|
2914
3090
|
DetailList header and in the side pane's action row. It is the keyboard way into the editor; a double click is the pointer shortcut. Entering the
|
|
2915
|
-
editor this way moves focus to its title field, and Cancel or a successful Publish returns focus to the Edit button (
|
|
2916
|
-
|
|
3091
|
+
editor this way moves focus to its title field, and Cancel or a successful Publish returns focus to the Edit button (the side pane focuses its
|
|
3092
|
+
report heading when the button is not rendered).
|
|
2917
3093
|
A draft of the viewer's own opens in the editor by itself, in a DetailList row and in the side pane alike (another user's draft never does), without
|
|
2918
3094
|
moving focus; it closes by itself only when the report turns published, and an editor the viewer opened stays open then.
|
|
2919
3095
|
The editor's state lives in one editing store per action provider, by report (`src/client/contexts/daily-report-draft-store.ts`), not in the row or
|
|
@@ -2935,11 +3111,14 @@ resize handle and the side pane lie outside the list.
|
|
|
2935
3111
|
belongs to an IME composition is left alone), by focus leaving the trash button and the confirmation, or by a pointer press outside them.
|
|
2936
3112
|
A cancel by the trash button or by Escape returns focus to the trash button when focus was on the confirmation.
|
|
2937
3113
|
Confirming moves focus before the comment is hidden, to a destination computed from what remains: the next remaining trash button, else the previous
|
|
2938
|
-
one, else the comment section's heading (`tabIndex=-1`) when the section stays, else the report's own anchor —
|
|
2939
|
-
|
|
3114
|
+
one, else the comment section's heading (`tabIndex=-1`) when the section stays, else the report's own anchor — in the DetailList the view, through
|
|
3115
|
+
its hand-back just before the section detaches (**Focus handed back to the view**: the composite after a key, the row's name heading after a
|
|
3116
|
+
pointer), and the pane's report heading in the side pane and the mobile overlay — so focus never falls to `body`.
|
|
2940
3117
|
- **One comment section rule**: the DetailList row, the side pane and the mobile overlay (which shows the side pane's content) render one comment
|
|
2941
3118
|
section, its `labels.comments` heading included, only while the report has comments or the viewer may comment on it (the same decision that shows
|
|
2942
3119
|
the comment form: see [External Source Badge Configuration](#external-source-badge-configuration-sourcetypeconfigs)).
|
|
3120
|
+
The comment field is named by that heading (`aria-labelledby`; `labels.comments`), which renders whenever the form does, so the field has a name of
|
|
3121
|
+
its own in the DetailList row, the side pane and the mobile overlay; its placeholder (`labels.commentPlaceholder`) stays a hint, not its name.
|
|
2943
3122
|
A report of a source that takes no comments and has none shows no empty section, and deleting the last comment of such a report removes the section,
|
|
2944
3123
|
so focus goes to the report's anchor above.
|
|
2945
3124
|
- **Mobile detail overlay** (List, single-column layout): the platform's modal dialog, a `<dialog data-daily-report-mobile-overlay>` (implicit
|
|
@@ -2956,10 +3135,11 @@ resize handle and the side pane lie outside the list.
|
|
|
2956
3135
|
the close path runs too, and the dialog opens modally again when the close is refused.
|
|
2957
3136
|
Closing it by any means (close button, Escape, backdrop, browser back, a switch to the desktop layout) first blurs the focus inside the dialog, then
|
|
2958
3137
|
ends the modal state with `close()` (the platform returns focus, without scrolling, to the element that had it when the dialog opened, when that
|
|
2959
|
-
element is still in the document), and then the view hands focus to the report's
|
|
2960
|
-
not fully visible
|
|
3138
|
+
element is still in the document), and then the view hands focus back to the report's row (**Focus handed back to the view**): the row becomes
|
|
3139
|
+
active and is revealed by the nearer edge only when it is not fully visible, and focus lands on the composite after a keyboard close (Escape) and on
|
|
3140
|
+
the card's primary button after a tap or a click, with `preventScroll`.
|
|
2961
3141
|
The dialog is never opened again to show the slide-out: it stays rendered while closed (its layout does not depend on the `open` attribute) and
|
|
2962
|
-
`inert` — no focus, no pointer, nothing in the accessibility tree — until it has slid out, and the
|
|
3142
|
+
`inert` — no focus, no pointer, nothing in the accessibility tree — until it has slid out, and the view's hand-back counts focus left inside it as focus
|
|
2963
3143
|
that is nowhere. Opening the overlay again during the slide-out shows the same dialog modally again.
|
|
2964
3144
|
The dialog itself takes focus (`tabIndex=-1`), so pressing a non-focusable part of it never drops focus to `body`. The backdrop is a decorative
|
|
2965
3145
|
layer (`data-daily-report-overlay-backdrop`, `aria-hidden`, without a role or a name, not focusable): pressing it closes the overlay, while keyboard
|
|
@@ -2969,6 +3149,38 @@ resize handle and the side pane lie outside the list.
|
|
|
2969
3149
|
the element's own window.
|
|
2970
3150
|
- `VirtualScroll`'s own `enableKeyboardNavigation` stays `false`; the keys are handled by the package.
|
|
2971
3151
|
|
|
3152
|
+
#### Assistive technology checklist
|
|
3153
|
+
|
|
3154
|
+
The package's tests prove the treegrid semantics in jsdom and Chromium's accessibility tree; how screen readers announce a treegrid whose rows are
|
|
3155
|
+
active descendants is checked by hand, with this checklist, which the package delivers and does not run. Run the steps below with each of six pairs —
|
|
3156
|
+
NVDA with Chrome, JAWS with Chrome and Narrator with Edge on Windows, VoiceOver with Safari on macOS, VoiceOver on iOS (Safari) and TalkBack on Android
|
|
3157
|
+
(Chrome) — in a List and in a DetailList that show at least 20 reports, and once more while the ids stream still delivers and has not declared its
|
|
3158
|
+
total (the set size unknown); run the Switch Access, forced-colours and reduced-motion steps once each. Desktop readers stay in their focus (forms)
|
|
3159
|
+
mode on the composite; touch readers navigate by swiping, since they follow DOM focus rather than the active descendant. Record the reader, the browser
|
|
3160
|
+
and their versions, and per step either a pass or the text announced.
|
|
3161
|
+
|
|
3162
|
+
| Step | Do | Expect |
|
|
3163
|
+
| --- | --- | --- |
|
|
3164
|
+
| Entry | Desktop: Tab from the last control before the view. Touch: swipe into the view | Desktop: the composite is announced as a tree grid named by the title, described by the keyboard help, and its active row (the selected report, else the first visible row) with its name, its description, its position (row M of N, or "Item M" while the total is unknown) and as selected. Touch: the first visible row's card button or row is reached, announced by its name and description |
|
|
3165
|
+
| ↑ / ↓ | Desktop: press ArrowDown three times, then ArrowUp once | Each key announces the new active row once — name, position, selection — and nothing else; if a reader repeats "level 1" or the row count on every key, note it (the alternative is the composite role `grid`) |
|
|
3166
|
+
| Home / End, PageUp / PageDown | Desktop: press End, then Home, then PageDown twice and PageUp once; repeat End right after opening the view, so that it lands on a row whose body has not loaded yet | Each key announces the row it lands on once, with its position. A row that lands before its body is announced by its ISO business date and as busy; note whether the reader announces the row's full name (date, author, subject) once the body arrives, or only on the next key |
|
|
3167
|
+
| Enter / Space | Desktop: on the composite press Enter, then move with ArrowDown and press Space; in the List on the phone layout, press Enter | The active row is selected in both views and announced as selected (the DetailList does not scroll); on the phone layout Enter opens the report's overlay, whose dialog is announced by the report heading and the author |
|
|
3168
|
+
| Tab to ★ | From the composite press Tab (the List: ★ of the active row; the DetailList: the row's first control) | Focus moves to that control of the active row and its label is announced; the next Tab reaches the row's next control; Shift+Tab from the first control returns to the composite, which announces the active row again |
|
|
3169
|
+
| Escape | On ★ press Escape | Focus returns to the composite, the same active row is announced, and the selection does not change |
|
|
3170
|
+
| Escape from the comment field | Desktop, DetailList: Tab to the active row's comment field, type a few characters, press Escape, then Tab back to the field | The field is announced by its name (the comment section's heading, 「コメント」), not only by its placeholder. Escape returns focus to the composite and announces the same active row; the typed characters are still in the field |
|
|
3171
|
+
| Exits | Desktop: on the composite press Ctrl+End, then Shift+Tab back into the view; press Ctrl+Home | Ctrl+End moves focus to the first control after the list (on the desktop List the resize handle) and Ctrl+Home to the last control before it, each announced by its own name; the keyboard help names both exits. These chords leave the view instead of moving to the first / last row as in the APG treegrid pattern (Home / End do that) |
|
|
3172
|
+
| Reverse entry | Desktop: from the first control after the view press Shift+Tab | Focus lands on the active row's last control, which is in view (the page does not scroll), and its label is announced; further Shift+Tab presses walk the row's controls back to the composite |
|
|
3173
|
+
| Tab into a far row | Desktop: with focus on the composite, wheel the list until the active row is far out of view (thousands of px), then press Tab | The active row is scrolled into view before focus lands on its first control, which is announced; the page does not scroll |
|
|
3174
|
+
| Removal of the active report | Desktop: with focus on the composite, delete the active report from another tab or window (an SSE removal) | Focus stays on the composite, nothing scrolls, and the report now at the removed report's position is announced as the active row (or nothing, when the list is empty) |
|
|
3175
|
+
| A search answer | Desktop: run a search from the search box and Tab into the view; then clear the search and Tab into the view again | The composite announces its active row, a shown row of the answer (the Tab-stop rule of **The active row**), with the answer's count as its set size; after the search is cleared, a shown row of the whole list, with the list's total |
|
|
3176
|
+
| Empty and no-category views | A search that matches nothing, and a viewer who may see no category: Tab into the view | The composite is announced by its title, described by the empty text (「項目がありません」) and the keyboard help, with no row; for the viewer without a category, the page's notice (「閲覧できる日報の区分がありません。…」) is reached in browse mode before the view |
|
|
3177
|
+
| Overlay close | Phone layout, List: open a report's overlay with Enter on the composite and close it with Escape; open it with a tap and close it with the close button | After the keyboard close, focus is on the composite and the same row is announced as active; after the tap close, focus is on the card's primary button, announced with its position first |
|
|
3178
|
+
| Touch hand-back in the DetailList | Touch reader, DetailList: in the active row of your own report from a source that takes no comments, delete its last comment (the trash button, then the confirmation) | The comment section leaves and focus lands on the row's name heading, announced as the report's date, author and subject (not on the list as a whole) |
|
|
3179
|
+
| Far rows | Browse mode (NVDA, JAWS, Narrator) or swiping (VoiceOver on iOS, TalkBack) past the visible rows | The rows within one viewport of the visible range are reached; rows farther away are reached only once the list has scrolled near them (**Far rows skip rendering**); the active row is always reached |
|
|
3180
|
+
| Switch Access | Android, Switch Access with Chrome, List on the phone layout: scan to a card and select it, then close the overlay with its close button | The card is reachable by scanning and opens the overlay; after the close, focus (which Switch Access follows) is on that card's primary button, or on the composite when the page recorded no pointer input for the action (a hand-back after no recorded input lands on the composite): record which, and that scanning continues from there |
|
|
3181
|
+
| Forced colours | Windows contrast theme (forced colours) with the keyboard: move the active row with the arrows, wheel it out of view, then press a key | The selected row shows the system's `Highlight` outline, the active row's keyboard outline shows in the system colour while it is visible, and the composite's 2 px pane ring shows while the active row is out of view |
|
|
3182
|
+
| Reduced motion | `prefers-reduced-motion: reduce`: move with the keys, hover a card, open and close the phone overlay | No transition or animation runs; every state change (selection, outline, hover lift, the overlay's opening) shows at once |
|
|
3183
|
+
|
|
2972
3184
|
### Selection and focus appearance
|
|
2973
3185
|
|
|
2974
3186
|
Selection and keyboard focus are two separate channels, identical in both views, so "selected and focused" never looks like "focused":
|
|
@@ -2976,7 +3188,8 @@ Selection and keyboard focus are two separate channels, identical in both views,
|
|
|
2976
3188
|
| State | Channel | Geometry | Light | Dark | Forced colours |
|
|
2977
3189
|
| --- | --- | --- | --- | --- | --- |
|
|
2978
3190
|
| Selected | box-shadow ring; the surface paints no shadow outside it | 2 px, 0–2 px outside the surface | blue-500 | blue-500 | 2 px `Highlight` outline of the row frame in the ring's band (below) |
|
|
2979
|
-
| Keyboard focus on
|
|
3191
|
+
| Keyboard focus on the active row (the composite holds keyboard focus) or on a List card's primary button (only while the view root carries `data-daily-report-keyboard-focus`) | outline | 2 px at offset 4 (4–6 px outside) | blue-600 | blue-400 | kept (system colour) |
|
|
3192
|
+
| Keyboard focus on the composite with no active row, or with the active row not shown beyond 4 px (below) | `@aiquants/virtualscroll`'s pane ring, coloured through its `--aqvs-focus-ring-color` | 2 px inset of the pane | blue-600 | blue-400 | kept (system colour) |
|
|
2980
3193
|
| Keyboard focus on a control or link | outline | 2 px at offset 2 | blue-600 | blue-400 | kept |
|
|
2981
3194
|
| Pointer focus | none | — | — | — | — |
|
|
2982
3195
|
| Hover (cards) | blue-300 border, `shadow-lg` (a selected card paints no shadow), a lift L of at most 2 px on whole device pixels (motion-safe; **Row gutter G** below) | — | — | — | — |
|
|
@@ -3052,18 +3265,31 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
|
|
|
3052
3265
|
- **Forced colours**: the ring (a box shadow) disappears, so the row frame draws the selection itself: a 2 px solid `Highlight` outline at offset −8
|
|
3053
3266
|
(−G), which puts the line in the ring's band, 0–2 px outside the surface. The frame's corner radius is the surface's radius plus G
|
|
3054
3267
|
(`calc(var(--radius-2xl) + 8px)`, 24 at a 16 px root), so the line is concentric with the surface at every root font size.
|
|
3055
|
-
|
|
3056
|
-
(`:has(> [data-daily-report-card] > [aria-current=true])`). The selected row differs by shape as well as by colour (WCAG 1.4.1); the surface border
|
|
3268
|
+
Every row reads its own `aria-selected="true"`, in every load state. The selected row differs by shape as well as by colour (WCAG 1.4.1); the surface border
|
|
3057
3269
|
stays 1 px in every state, and the focus outline (4–6 px outside) sits beside the selection line without touching it.
|
|
3058
3270
|
- **State comes from the row itself**: a row frame styles its surface — the direct child that carries `data-daily-report-row-surface` in every load
|
|
3059
|
-
state — from its own `aria-
|
|
3060
|
-
|
|
3061
|
-
an ancestor
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
`:where([data-daily-report-keyboard-focus])
|
|
3065
|
-
|
|
3066
|
-
|
|
3271
|
+
state — from its own `aria-selected` (the selection ring) and its own `data-daily-report-active` (the focus outline), and the List card surface
|
|
3272
|
+
styles itself from its direct-child primary button (`:has(> …)`).
|
|
3273
|
+
Conditions on an ancestor read only the attributes the package writes itself (`data-daily-report-scrolling`, `data-daily-report-keyboard-focus`)
|
|
3274
|
+
and the composite's `:focus-visible`, so an ancestor that carries shared attributes such as `aria-current` or `aria-selected` never lights up a row.
|
|
3275
|
+
The active row's outline requires the view root's `data-daily-report-keyboard-focus` and a `:focus-visible` ancestor — the composite, the only
|
|
3276
|
+
focusable ancestor of a row — (`:where([data-daily-report-keyboard-focus]) :where(:focus-visible) <frame>[data-daily-report-active] > :where(<surface>)`),
|
|
3277
|
+
and the List card's outline requires the same root attribute
|
|
3278
|
+
(`:where([data-daily-report-keyboard-focus]) <card surface>:has(> <primary button>:focus-visible)`): the ancestors sit in `:where()`, so the rules
|
|
3279
|
+
keep their specificity of (0,2,0) and (0,3,0). The root attribute is load-bearing: a press inside a keyboard-focused composite leaves the composite
|
|
3280
|
+
`:focus-visible`, and the attribute, removed by the press, keeps the outline off. It changes only when the input modality does, never per arrow key,
|
|
3281
|
+
so it restyles the surfaces once per change of modality.
|
|
3282
|
+
No selector reads `aria-activedescendant`: it changes on every key and would invalidate the composite's subtree; a key restyles the two row frames
|
|
3283
|
+
whose `data-daily-report-active` and `aria-selected` change. `@aiquants/virtualscroll` draws its pane ring on the composite only while the active
|
|
3284
|
+
row is not shown (`data-aqvs-active-item` is not `visible`), and both views count an active row that a viewport edge clips as shown only while more
|
|
3285
|
+
than 4 px of it show at that edge (`activeItemVisibleInset` = G − the outline's offset = 8 − 4: the distance from the row frame's edge to the inner
|
|
3286
|
+
edge of the surface's 2 px outline). The ring therefore yields only once the outline's whole width shows at the
|
|
3287
|
+
clipped edge, and a keyboard-focused composite always shows a whole indicator: the active row's outline, or the ring.
|
|
3288
|
+
The ring wears the outline's colour: both views bind the peer's neutral variable `--aqvs-focus-ring-color` to blue-600 (dark: blue-400) on
|
|
3289
|
+
`VirtualScroll`'s root, so one focus shows one colour in each scheme (the peer's default `#2563eb` stays just
|
|
3290
|
+
above 3:1 on the dark page, 3.90:1, against the outline's 7.63:1). The peer's scroll bar and edge pills read the same variable for their rings,
|
|
3291
|
+
but never take focus in these views (`enableArrowButtonTabStops: false`), and the arrow buttons keep their own fill, whose glyph contrast depends
|
|
3292
|
+
on it.
|
|
3067
3293
|
- **A key press restyles only what paints the change**: every selector that depends on another element's state ends in the styled element's own class
|
|
3068
3294
|
or attribute, and `:has()` sits only on the styled element itself. A featureless subject (`*:`, `group-*`) or an ancestor's `:has(:focus-visible)`
|
|
3069
3295
|
would make the browser restyle whole rows or the whole view on each key; `src/client/ui/tailwind-selector-scope.spec.ts` compiles the package's
|
|
@@ -3084,13 +3310,13 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
|
|
|
3084
3310
|
held.
|
|
3085
3311
|
Inside the box the rows' overflow is ink overflow, so a scroll the browser makes on its own to reveal an overscan row (a find-in-page match there)
|
|
3086
3312
|
cannot move the list and moves the nearest outer scroller instead when the row's box lies outside its view; the views' own reveals do not depend on
|
|
3087
|
-
it (one Tab stop per view,
|
|
3313
|
+
it (one Tab stop per view, `Tab` revealing the active row in its keydown, keyboard focus revealed by the scroller).
|
|
3088
3314
|
Size containment makes the frame's size independent of its content: its width is the row box `VirtualScroll` lays out, and its height is the slot P
|
|
3089
3315
|
the List writes on the frame or, in the DetailList, that row box itself (`h-full`), whose height `VirtualScroll` takes from the measured row.
|
|
3090
3316
|
The DetailList therefore measures each row's body — the frame's direct child, as tall as its content rounded up to the block quantum q (**Row slots
|
|
3091
3317
|
on the lattice**) — and adds 2G, handing the height to `VirtualScroll` through the scroller's `resizeRow` (see **Host selections and list
|
|
3092
|
-
changes**); measuring the frame would read back the box it fills. A
|
|
3093
|
-
|
|
3318
|
+
changes**); measuring the frame would read back the box it fills. A shell is not measured, and a body that is replaced (another load state, a
|
|
3319
|
+
shell's first body, a newer report's card) is observed in its place.
|
|
3094
3320
|
A frame with layout and size containment that is neither a flex nor a grid item is a relayout boundary in Chromium. Neither frame contains paint,
|
|
3095
3321
|
because the hover `shadow-lg` of an unselected surface (22 px below, 12 px to the sides) reaches beyond the 8 px gutter G that paint containment
|
|
3096
3322
|
would clip (the resting `shadow-sm`, 4 px below, stays inside G; a selected surface paints no shadow).
|
|
@@ -3556,7 +3782,7 @@ luminance by 0.026 in the light theme, and a `shadow-md` brings the ring down to
|
|
|
3556
3782
|
The visually hidden copy, `title` and `data-attachment-file-name` stay plain.
|
|
3557
3783
|
- **Tab order inside list rows**: the attachment links follow the row rule of
|
|
3558
3784
|
[Keyboard, focus and selection](#keyboard-focus-and-selection-list--detaillist): inside a DetailList row they are Tab stops only in the view's
|
|
3559
|
-
|
|
3785
|
+
active row (`tabIndex=-1` in every other row); in the side pane and the mobile overlay they keep their natural order.
|
|
3560
3786
|
- **Marker**: the List card and the DetailList header show `DailyReportAttachmentIndicator` when a report has attachments: a 24 px tall box without
|
|
3561
3787
|
padding or radius (its first ink starts at the content edge) holding a 16 px paperclip icon and, from 2 attachments on, the count in 12 px type on a
|
|
3562
3788
|
16 px line; it is named `<labels.attachments>: <count>` (`role="img"`).
|
|
@@ -3675,11 +3901,14 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
|
|
|
3675
3901
|
|
|
3676
3902
|
| Hook | Element |
|
|
3677
3903
|
| --- | --- |
|
|
3678
|
-
| `[
|
|
3679
|
-
| `[data-daily-report-row
|
|
3904
|
+
| `[role="treegrid"]` | Each view's composite: `VirtualScroll`'s content element, the view's one Tab stop and the element that holds DOM focus while the keys move; its `id` prefixes the row ids, `aria-activedescendant` names the active row's `id` (written by `@aiquants/virtualscroll`), `aria-rowcount` is the set's size, and `data-aqvs-active-item` is `visible` or `outside` while there is an active row |
|
|
3905
|
+
| `[data-daily-report-row="<id>"]` | Row frame of both views (`role="row"` with `id`, `aria-rowindex`, `aria-posinset`, `aria-setsize`, `aria-level` and an explicit `aria-selected`; the set size is the declared total while the ids stream delivers and `-1` before it is declared, see **Screen readers**), in every load state |
|
|
3906
|
+
| `[data-daily-report-active]` | The active row's frame (the row the composite's `aria-activedescendant` names) |
|
|
3907
|
+
| `[data-daily-report-row-surface]` | Surface of a row frame (`role="gridcell"`): its direct child that paints the selection ring and the focus outline, in every load state (opaque and never animated; a loading pulse runs inside it) |
|
|
3908
|
+
| `[data-daily-report-row-shell]` | The empty surface of a shell: a row mounted outside the visible range whose body is still to come (**Rows mount in two passes**) |
|
|
3680
3909
|
| `[data-daily-report-card]` | List card surface (present while the card is shown) |
|
|
3681
|
-
| `button[data-daily-report-button="<id>"]` | List card primary button (
|
|
3682
|
-
| `[
|
|
3910
|
+
| `button[data-daily-report-button="<id>"]` | List card primary button (the click target that selects, and the pointer hand-back target; never a Tab stop, `tabIndex=-1`) |
|
|
3911
|
+
| `[data-daily-report-row-focus]` | A row's own focus target, where a pointer or touch hand-back lands: the List card's primary button, or the DetailList card's visually hidden name heading (`tabIndex=-1`; never a Tab stop) |
|
|
3683
3912
|
| `[data-list-star-button="<id>"]` / `[data-list-read-button="<id>"]` | List card toggles |
|
|
3684
3913
|
| `[data-detail-star-button="<id>"]` / `[data-detail-read-button="<id>"]` / `[data-detail-edit-button="<id>"]` / `[data-detail-delete-button="<id>"]` | Toggles, edit and delete in the DetailList header and the List side pane (edit and delete on the viewer's own reports only) |
|
|
3685
3914
|
| `[data-testid="daily-report-side-pane"]` | List side pane: `data-report-id` is the selected report, `data-displayed-report-id` the report its content shows; the desktop pane carries `aria-busy="true"` while the two differ (the overlay's pane shows the report it opened, so they are equal) |
|
|
@@ -3699,7 +3928,7 @@ DOM hooks are `data-*` attributes, test ids and ARIA; class names are styling an
|
|
|
3699
3928
|
| `[data-daily-report-view-tabs]` | The view-mode tab list (its selected tab is the focus successor of a list without rows) |
|
|
3700
3929
|
| `data-testid` | `daily-report-root`, `daily-report-list`, `daily-report-detail-list`, `daily-report-side-pane`, `daily-report-error-banner` (the error notice), `daily-report-status-panel` (the status screen's panel, `data-phase` `loading` / `failed`), `daily-report-load-error-retry`, `daily-report-stream-announcer` (the page's status region, never focusable), `daily-report-ids-stream-status` and `daily-report-ids-stream-retry` (the badge's content and its retry button), `daily-report-ids-stream-status-announcer` (the badge's own live regions, rendered only while it announces its phases), and the attachment ids above |
|
|
3701
3930
|
|
|
3702
|
-
Only the package writes `data-daily-report-keyboard-focus` and `data-daily-report-
|
|
3931
|
+
Only the package writes `data-daily-report-keyboard-focus`, `data-daily-report-scrolling`, `data-daily-report-active`, `data-daily-report-row-shell` and `data-daily-report-row-focus`. Hosts may select on them — in tests, or in CSS to adapt
|
|
3703
3932
|
their own content inside a view — but never set them. When the pane content matters, wait for `data-displayed-report-id`, not `data-report-id`: during
|
|
3704
3933
|
a key's render the frame already names the new selection while the content still shows the previous report.
|
|
3705
3934
|
|
|
@@ -3836,8 +4065,8 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
3836
4065
|
| `tabRelations` | side pane tab | Related | 関連 |
|
|
3837
4066
|
| `reportTabList` | accessible name (`aria-label`) of the side pane's tab list | Report sections | 日報の表示の切り替え |
|
|
3838
4067
|
| `relationsEmpty` | side pane related tab body | No related view configured yet. | 関連ビューはまだ構成されていません。 |
|
|
3839
|
-
| `title` | `renderHeader({ title })`, both
|
|
3840
|
-
| `listKeyboardHelp` | both
|
|
4068
|
+
| `title` | `renderHeader({ title })`, both composites' `aria-label` | Daily Reports | 日報 |
|
|
4069
|
+
| `listKeyboardHelp` | both composites' `aria-describedby`, visually hidden and outside the composite (the DetailList only with `onSelectItem`) | Up and Down arrows: previous or next report. Home and End: first or last report. Page Up and Page Down: move by a page. Enter or Space: select. Tab: the report's controls. Escape: back to the report row. Control+Home and Control+End: move to the nearest control before or after the list, when there is one. | ↑↓ で前後の日報、Home / End で先頭・末尾、PageUp / PageDown でページ移動、Enter または Space で選択、Tab でその日報の操作部品へ、Escape で日報の行へ戻ります。Ctrl+Home / Ctrl+End で一覧の直前・直後の操作部品があればそこへ移ります。 |
|
|
3841
4070
|
| `tabList` | top-level tab | 📄 List | 📄 一覧 |
|
|
3842
4071
|
| `tabTree` | top-level tab | 🌳 Tree | 🌳 ツリー |
|
|
3843
4072
|
| `tabDetailList` | top-level tab | 📋 DetailList | 📋 詳細一覧 |
|
|
@@ -3900,9 +4129,9 @@ first), `DAILY_REPORT_LABEL_CATALOGS` (the frozen catalogs), `resolveDailyReport
|
|
|
3900
4129
|
| `interviewerWithAffiliation` | interviewer chip | `("Sato", "Acme")` → Sato (Acme) | `("佐藤", "山田商事")` → 佐藤 (山田商事) |
|
|
3901
4130
|
| `operationFailed` | the error notice / `DailyReportErrorInfo.message` | `("update")` → Failed to save the report. Please try again later. | `("update")` → 日報の保存に失敗しました。時間をおいて再度お試しください。 |
|
|
3902
4131
|
| `noticeRepeated` | the error notice, beside its message when failures with the same message follow one another (`count` is how many came in a row, from 2; part of the notice's name) | `(2)` → (2 times) | `(2)` → (2 回目) |
|
|
3903
|
-
| `rowState` | visually hidden row state, the first description of a
|
|
4132
|
+
| `rowState` | visually hidden row state, the first description of a card row in both views and the second of the List card's primary button | `({ isRead: false, isStarred: true })` → Unread, starred | `({ isRead: false, isStarred: true })` → 未読、スター付き |
|
|
3904
4133
|
| `listRowPosition` | visually hidden position of a List card in the list, the first description of its primary button (`position` is 1-based; `total` is the size of the views' set) | `(3, 1234)` → 3 of 1,234 | `(3, 1234)` → 全 1,234 件中 3 件目 |
|
|
3905
|
-
| `listRowPositionInUnknownTotal` | the same position while the size of the views' set is unknown (the ids stream has not declared its total yet), in place of `listRowPosition
|
|
4134
|
+
| `listRowPositionInUnknownTotal` | the same position while the size of the views' set is unknown (the ids stream has not declared its total yet), in place of `listRowPosition`; it also ends the description of a card row in both views while the size is unknown | `(3)` → Item 3 | `(3)` → 3 件目 |
|
|
3906
4135
|
| `searchFieldMatched` | match sentences of a row (the DetailList row's and the side pane's hidden indicator sentences, the List card's hidden match description): a field heading the row's leaves marked with a typed word's mark (`field` is the heading's resolved label) | `("Author")` → Author matched | `("作成者")` → 作成者で一致 |
|
|
3907
4136
|
| `searchFieldMatchedByReading` | the same, for a heading whose marks matched only through a romaji reading | `("Author")` → Author matched by its romaji reading | `("作成者")` → 作成者がローマ字の読みで一致 |
|
|
3908
4137
|
| `searchFiltersToggle` | search box: the filter toggle, with the number of selected filter values (no number at 0; digit grouping) | `(0)` → Filters; `(3)` → Filters (3) | `(0)` → 絞り込み; `(3)` → 絞り込み (3) |
|
|
@@ -4028,7 +4257,10 @@ Documenting a name here is no reason to export it, and the values that tune the
|
|
|
4028
4257
|
routes itself: `DAILY_REPORT_ACTION_INTENTS` / `DAILY_REPORT_ACTION_FIELDS` (the intents and the form's field names),
|
|
4029
4258
|
`encodeDailyReportActionCommand(command)` (a command as its `FormData`), `parseDailyReportActionResult(intent, json)` (the strict reading of a 200
|
|
4030
4259
|
answer: `{ result }` or `{ mismatch }`), `DAILY_REPORT_API_ENDPOINTS` / `DAILY_REPORT_API_QUERY_PARAMS` (the endpoint and GET query-parameter
|
|
4031
|
-
names) and `DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` (the echo id's two canonical forms)
|
|
4260
|
+
names) and `DAILY_REPORT_CLIENT_TEMP_ID_PATTERN` (the echo id's two canonical forms),
|
|
4261
|
+
and the source-type vocabulary a host builds its visibility port with: `DAILY_REPORT_INTERNAL_SOURCE_TYPE` (`"Internal"`, the source type of the
|
|
4262
|
+
reports the package creates) and `foldDailyReportSourceTypeToken(token)` (the one fold the server applies to the tokens the port returns:
|
|
4263
|
+
surrounding blanks removed, then lower case; [Source-type visibility](#source-type-visibility)).
|
|
4032
4264
|
- Types: `DailyReportItem` / `DailyReportDetail` / `DailyReportUser` / `DailyReportInterviewer` / `DailyReportLabelDef` / `DailyReportPostedComment`
|
|
4033
4265
|
/ `DailyReportExternalComment` / `DailyReportAttachmentSummary`, `DailyReportSseMessage` / `DailyReportSseStreamAnchor` /
|
|
4034
4266
|
`DailyReportIdsStreamChunkLine` / `DailyReportIdsStreamChunk` (what `readIdsStreamChunkLine` answers), `DailyReportAttachmentDeliveryRequest` /
|
|
@@ -4125,14 +4357,18 @@ Documenting a name here is no reason to export it, and the values that tune the
|
|
|
4125
4357
|
server entry alone, so client bundles do not carry it).
|
|
4126
4358
|
- Request values: `parseClientTempId` and its branded result type `DailyReportClientTempId` (the action's echo id in its two canonical forms, the
|
|
4127
4359
|
only id the service's write methods take; **The echo id** in [Request values](#request-values)).
|
|
4360
|
+
- Visibility: `createDailyReportVisibilityCache({ ttlMs, readViewerKey, resolve })` (the package's memoizer of the visibility port's answers, which
|
|
4361
|
+
returns a `DailyReportResolveVisibleSourceTypes`; its settings type `DailyReportVisibilityCacheConfig`) and
|
|
4362
|
+
`DAILY_REPORT_VISIBILITY_MAX_CACHE_TTL_MS` (60,000, the longest `ttlMs` it takes; **Performance** in
|
|
4363
|
+
[Source-type visibility](#source-type-visibility)).
|
|
4128
4364
|
- Types: `DailyReportServerConfig` / `DailyReportServer` (what `createDailyReportServer` returns) / `DailyReportHandlersConfig` /
|
|
4129
4365
|
`DailyReportHandlers` (what `createDailyReportHandlers` takes and returns) / `DailyReportServiceConfig` / `DailyReportTables` / `DailyReportDb` /
|
|
4130
4366
|
`DailyReportAuthenticate` / `DailyReportAuthResult` / `DailyReportIdCodec` / `DailyReportRedisProvider` / `DailyReportSseReaderPort` (the shared
|
|
4131
4367
|
reader `createDailyReportHandlers` takes) / `DailyReportSseStreamEntry` (an entry as the reader hands it to every subscriber: its id and its
|
|
4132
4368
|
message read once, or `null`) / `DailyReportExternalReportFields` (what an `externalSources` adapter's `mapRow` returns),
|
|
4133
4369
|
the ports' own types `DailyReportResolveUserId` / `DailyReportEncodeUserId` / `DailyReportResolveVisibleSourceTypes` (with
|
|
4134
|
-
`DailyReportVisibleSourceTypes`, `DailyReportVisibilityDimensions`, `
|
|
4135
|
-
`DailyReportVisibilityResolveReason`) / `DailyReportRedisClient` / `DailyReportRedisBlockingClient` / `DailyReportRedisStreamMessage` /
|
|
4370
|
+
`DailyReportVisibleSourceTypes`, `DailyReportVisibilityDimensions`, `DailyReportVisibilityOptOut` (the explicit opt-out
|
|
4371
|
+
`{ unrestricted: <reason> }`), `DailyReportVisibilityResolveContext` and `DailyReportVisibilityResolveReason`) / `DailyReportRedisClient` / `DailyReportRedisBlockingClient` / `DailyReportRedisStreamMessage` /
|
|
4136
4372
|
`DailyReportSseEntryMessage` (an entry's message read once) / `DailyReportExternalSource` (an `externalSources` adapter),
|
|
4137
4373
|
the injected tables `DailyReportHubTable` / `DailyReportInternalTable` / `DailyReportCommentTable` / `DailyReportLabelTable` /
|
|
4138
4374
|
`DailyReportHubLabelTable` / `DailyReportUserStatusTable` / `DailyReportUserTable` / `DailyReportAttachmentTable` (the members of
|