@aiquants/enrichment-react 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,89 +1,271 @@
1
1
  # @aiquants/enrichment-react
2
2
 
3
- React UI components for storage metadata AI enrichment: DegradedSearchBanner, RAGPipelineGraphViewer, SyncStatusMonitor, and QdrantStorageCard.
3
+ React UI components for storage metadata AI enrichment: the ingest console, the pipeline flow viewer and run inspector, the search and model-comparison workbenches, the strategy board, and the status, health, storage, and ontology cards.
4
4
 
5
5
  ## Prerequisites
6
6
 
7
7
  - Node.js >= 18.0.0
8
- - React >= 18.0.0
9
- - `@aiquants/enrichment-core`
8
+ - React >= 19.0.0 and React DOM >= 19.0.0 (peer dependencies)
9
+ - `@aiquants/console-ui` (peer dependency — the console root and its stylesheet, the unmeasured label, `Measured` (every count drawn as a value, durations in ms included, and every value slot that was not measured in the components that write the root's figures), `RowButton`, the count rule (`formatCount`), the instant and text-width formats, and the contrast computation and its AA bars come from it)
10
+ - `@aiquants/enrichment-core` (peer dependency — every pipeline step, flow, gate, record shape, and search bound the components draw comes from it)
10
11
 
11
12
  ## Installation
12
13
 
13
14
  ```bash
14
- pnpm add @aiquants/enrichment-react @aiquants/enrichment-core
15
+ pnpm add @aiquants/enrichment-react @aiquants/enrichment-core @aiquants/console-ui
15
16
  ```
16
17
 
18
+ ## Design Rules
19
+
20
+ - **Host owns routes and lists.** No component names an application route or ships a domain list; the host passes `onSearch`, `onCompare`, `domainOptions`, and the like.
21
+ - **DOM hooks are `data-*` / `role` / element only.** Class names are styling and change freely.
22
+ - **Rendered inside the console root.** `InfrastructureHealthMonitor`, `PipelineFlowViewer`, `PipelineRunInspector`, `QdrantStorageCard`, `SyncStatusMonitor`, `DocumentIngestConsole`, and the instant slots `RecordedInstant` and `FinishedInstant` read `@aiquants/console-ui`'s `ConsoleUiRoot` and throw outside it. The other components do not read the root and render anywhere.
23
+ Every component that reads the root checks it before drawing anything (the root requirement below), so none of them reads the root, draws the root's label or writes in the root's zone under a root it was not written for.
24
+ This package ships no stylesheet (the host's Tailwind build compiles its classes), and the host loads `@aiquants/console-ui/styles/console-ui.css` once, with its `cui` cascade layer ranked above the host's reset, as `@aiquants/console-ui`'s README describes under "Loading the stylesheet" (for a Tailwind v4 host, `@import` it in the CSS entry after the layer statement `@layer theme, base, components, utilities;`).
25
+ Without it the root's `--cui-*` token variables are unset, a `Measured` count loses its tabular figures, `QdrantStorageCard`'s name buttons (`@aiquants/console-ui`'s `RowButton`) are drawn as the browser's plain buttons without the selected look, `InfrastructureHealthMonitor`'s probe-state marks and `QdrantStorageCard`'s health badge lose their role colours (`success-fg` / `danger-fg` and their tints), and the root's live region (`role="status"`) is drawn in the flow of the page instead of being visually hidden.
26
+ The package publishes only `dist`, and Tailwind v4's automatic source detection skips `node_modules`, so a Tailwind v4 host also adds `@source "../node_modules/@aiquants/enrichment-react/dist/**/*.js";` to its CSS entry (the path is relative to that file; inside this monorepo, point `@source` at `packages/enrichment-react/src/**/*.{ts,tsx}` instead).
27
+ Without that line the host generates none of the components' Tailwind utilities (layout, spacing, colours, borders), so none of the colour pairs under **Colour Contrast** is drawn. A host without Tailwind is not supported.
28
+ The root must be `locale="ja"` and `formatLocale="ja-JP"`: the package's own text is Japanese and its instants are written in `ja-JP` (`RecordedInstant`), so under any other root the root's words and digit grouping would sit inside Japanese sentences beside `ja-JP` times. Every component that reads the root throws `RangeError`, naming the root's `locale` and `formatLocale`, before drawing anything under any other root.
29
+
30
+ ```tsx
31
+ <ConsoleUiRoot locale="ja" formatLocale="ja-JP" timeZone={displayTimeZone} testId="console">
32
+ <InfrastructureHealthMonitor health={health} />
33
+ <QdrantStorageCard overview={qdrant} />
34
+ </ConsoleUiRoot>
35
+ ```
36
+
37
+ - **Unmeasured is not zero.** A figure the source could not measure renders as the root's unmeasured label (`未測定` under `locale="ja"`), never as `0`. In `InfrastructureHealthMonitor`, `PipelineFlowViewer`, `PipelineRunInspector`, and `QdrantStorageCard`, every value slot that holds one is `@aiquants/console-ui`'s `Measured` (directly or through `MeasuredText` / `MeasuredFigure`), so `data-measured="false"` marks every unmeasured value slot there.
38
+ The run inspector's report is the one place an unmeasured figure has no slot of its own: a report field that was not measured is named in the step's `未測定:` line instead (see `PipelineRunInspector`), and only a page pair with one half measured keeps both `Measured` slots.
39
+ The other components keep their own words for a missing value: `DocumentIngestConsole` writes a missing revision as `N/A` (it reads the root for its instants and its byte format only), `MetadataSearchWorkbench` a missing lexical or graph rank (`字句順位` / `グラフ順位`) as `N/A`, and `OntologyStatusCard` a missing count as `未設定`; `SyncStatusMonitor` writes its counts as bare numbers.
40
+ A count drawn as a value — the Qdrant totals, segment count, collection count, each collection's points and each named vector's dimensions (`次元`), the health probe's counted details, every probe latency in ms (応答時間 in both `InfrastructureHealthMonitor` and `QdrantStorageCard`), the flow metric's sample, succeeded, failed and skipped counts,
41
+ and every count the core record types as a measured count (the step duration and the report's `duration_ms` in ms, characters, pages, chunks, points, graph nodes, and the flow's p50 / p95 latencies in ms) — is `Measured`, written in the root's `formatLocale` with tabular figures and `data-measured="true"`.
42
+ The components that do not read the root write their durations as bare numbers, not grouped: `ModelComparisonWorkbench`'s execution time (`compare-execution-ms-${strategyId}`) and `MetadataSearchWorkbench`'s search latency.
43
+ A measured value that is not a count is written by its own format inside one of the package's two private value slots, in tabular figures with `data-measured="true"`, and when it was not measured the slot writes the same `Measured` with the label.
44
+ A value that reads as one unit — a byte size (`1.0 MiB`), an instant, a probe's port or boolean — is `MeasuredFigure`, which never breaks inside (`white-space: nowrap`), so a number never parts from its unit or a date from its time; a text — a probe's text, a service's version, the report's `prompt_version` and its other texts — is `MeasuredText`, which wraps with its container.
45
+ Every recorded instant, in each of the five components that write one (`InfrastructureHealthMonitor`, `PipelineFlowViewer`, `PipelineRunInspector`, `SyncStatusMonitor`, `DocumentIngestConsole`), is written by the exported `RecordedInstant` (or `FinishedInstant`, for a finish time that may be open) into a `MeasuredFigure` slot under a `-value` hook:
46
+ `health-checked-at-value`, `flow-metric-last-started-at-value`, `run-history-started-at-value-${id}`, `run-started-at-value`, `run-finished-at-value`, `failed-item-time-value-${id}`, `ingest-last-run-finished-at-value-${id}` and `ingest-created-at-value-${id}`, so a column of instants lines up digit by digit.
47
+ A state shown in place of an instant is a word, not a slot: `未完了` for a run that has not finished (`FinishedInstant` writes it in `run-finished-at` and `ingest-last-run-finished-at-${id}`) and `記録なし` for a failure the record holds no time for (`failed-item-time-${id}`).
48
+ A text with nothing to read is refused like a broken count, with a `RangeError` that names the slot's hook: drawn, it would be an empty slot that claims a measurement. Pass `null` for a value that was not measured.
49
+ Nothing to read means empty or made only of white space, and white space is the union of JavaScript's and Python's `\s` — 30 code points, among them U+3000 (the ideographic space), U+00A0, U+FEFF, U+001C–U+001F and U+0085 (the slots test each character against `[^\s\x1c-\x1f\x85\ufeff]`, which finds the same characters in JavaScript and in Python's `re`).
50
+ The core record schemas still accept such a text, so the refusal is a backstop: a host refuses the value where it reads it, with the same pattern.
51
+ So every value slot of those components, count or not, keeps one `-value` hook in both states (`data-measured="true"` or `"false"`): a test or a host that reads a value finds it whether or not it was measured. The word inside a note that names what was not measured (the report's `未測定: <fields>` line, the flow's empty-metric line and its note, each marked `data-unmeasured-note`) is the root's `labels.unmeasured`, and only there.
52
+ That label is the only spelling of a figure's unmeasured state: the sources write the word only in the gate-state and flag-state vocabulary (`門: 未測定`, the gate state `未測定` of the flow detail panel, `フラグ未測定`), each written directly in the element that itself carries `data-gate-state="unmeasured"` or `data-gate="unmeasured"`, which names a gate whose feature flag is absent — a state, not a figure — and stays Japanese with the rest of the copy.
53
+ The package's tests hold these rules over the sources and over every drawn state of every component; each test module's docstring describes the gate it keeps.
54
+
55
+ An unknown capability renders as 不明, never as 無効.
56
+ - **One presentation of a run.** Run and step statuses, skip reasons, and triggers are written only through the exported presentation module (`pipelinePresentation.ts`), so the same status reads the same word in the same colour everywhere.
57
+ - **Time zone is the root's.** No component takes a `timeZone` of its own. Every component that prints a recorded instant writes it with `RecordedInstant`, which reads the root's `timeZone` (an IANA name, e.g. `Asia/Tokyo`, that `ConsoleUiRoot` validates and canonicalises) on every render and formats it in `ja-JP` to the second (`2026/09/24 01:02:03`) through `@aiquants/console-ui`'s `formatInstant`, the one instant format of every console built on that package;
58
+ the host resolves the zone once on the server and passes it to the root, so the server-rendered page and the hydrated one print the same string, and a host that switches the root's zone re-writes every mounted instant in the new clock.
59
+ A timestamp is ISO 8601 with an explicit offset (`Z`, `±hh:mm` or `±hhmm`) naming an existing date and time: every text the core record schemas refuse (`z.string().datetime({ offset: true })`) throws, and so does an accepted text that names no instant the console can show (an offset beyond ±23:59, or a wall clock outside years 1–9999 in the zone). The refusal is a `ZonedTimeError` (a `RangeError` with `reason: "malformed"`).
60
+ - **Layout.** The components use Tailwind's stock spacing, sizes, and palette, not `@aiquants/console-ui`'s tokens (its 8-px grid and 11 / 13 / 16 / 20 / 25 px type scale).
61
+ Most cards are `rounded-xl border border-slate-200 border-solid bg-white p-6 shadow-sm`; `InfrastructureHealthMonitor` is `rounded-lg border border-solid bg-card p-5 shadow-sm` (the host's `card` theme colours), and its probe-state marks alone take `@aiquants/console-ui`'s colour roles (`success-fg` on `success-tint`, `danger-fg` on `danger-tint`, see its section). `PipelineFlowViewer`, `PipelineRunInspector`, and `ModelComparisonWorkbench` set type at 11 / 13 / 16 px; the other Tailwind-styled components also use 10 px, `text-xs` (12 px), and `text-sm` (14 px).
62
+ `DegradedSearchBanner`, `QdrantStorageCard`, and `SyncStatusMonitor` are styled inline, with no Tailwind classes. The two cards have an 8-px radius and 16-px padding, set type from 11 px up to 18 px (`QdrantStorageCard`) or 20 px (`SyncStatusMonitor`), 12 and 14 px included, and use spacings such as 2, 6, 10, and 14 px that are off the 4-px half-step grid; the banner has a 6-px radius and a padding of 12 px by 16 px, and takes its type size from the host.
63
+ - **One card-title scale.** Every card title (`h2`) is 16 px bold (`text-base font-bold`, or `fontSize: "16px", fontWeight: 700` where the component styles inline) — the same scale `@aiquants/enrichment-react-router` and `@aiquants/governance-react` use, so sibling cards on one tab read as one heading level. `tests/HeadingOutline.spec.tsx` checks every panel's `h2`.
64
+ - **Borders name their style.** Every Tailwind border-width utility (`border`, `border-t`, `border-2`, …) sits in the same class list as `border-solid` (or another explicit style), and every `divide-x` / `divide-y` next to `divide-solid`. A host whose base layer sets `border-style: none` on every element (for example `* { @apply border-none }`) would otherwise leave those utilities with a width and no line.
65
+ `tests/borderStyleContract.spec.ts` scans every string literal in `src/` and fails on a width without its style. Per-side widths (`border-t border-solid`) rely on the host's Tailwind preflight setting the other sides to width 0.
66
+ - **Nothing is clipped without a way to read it, and nothing that reads as one unit breaks inside where the layout holds it.** Labels that must stay on one line (`whitespace-nowrap`) move to the next row instead of shrinking (the search-mode buttons, the health badges, the Qdrant card's badge, latency and refresh button), and unbroken identifiers (URIs, versions, endpoints, the run inspector's report texts) wrap anywhere (`wrap-anywhere`) inside their card instead of overflowing it or ending in an ellipsis.
67
+ A value that reads as one unit — a count with its unit, a latency, a byte size, an instant, the unmeasured label — never breaks inside in these places, so `1,234 件` never stands as `1,23` over `4 件` and `1,234 ms` never as `1,234` over `ms` there:
68
+ `InfrastructureHealthMonitor`'s rows (in a narrow row the label wraps beside the value instead), the Qdrant card's header (the value moves to the next line whole) and its named-vector tags, the measured figure in every `MeasuredFigure` slot (byte sizes, instants, a probe's port or boolean), `PipelineFlowViewer`'s p50 / p95 latencies, and `PipelineRunInspector`'s report and run header (each report value sits below its label with the column's whole width, and the run's start and finish each take two of the header's columns at every width).
69
+ Any other `Measured` slot (a step's duration in its summary line, the flow metric's counts) does not hold the rule itself, and neither does an unmeasured `MeasuredFigure` slot elsewhere (the Qdrant card's RAM and disk tiles, the flow metric's last start), which is `@aiquants/console-ui`'s `Measured` with its label: `Measured` joins a count and its unit with a plain space and leaves its text free to wrap, so in a column narrower than the value a number can part from its unit and the label can break between its characters.
70
+
17
71
  ## Exported Components
18
72
 
19
73
  ### 1. `DegradedSearchBanner`
20
- Non-blocking warning banner shown when search runs in degraded mode (e.g. Qdrant unreachable, BM25 fallback, or circuit breaker open).
21
- Conforms to non-blocking UX principles with strict `data-*` DOM hooks (`data-testid="degraded-search-banner"`, `data-quality-tier`).
74
+
75
+ Non-blocking warning banner shown when search runs in degraded mode (Qdrant unreachable, BM25 fallback, or a circuit breaker open). Hooks: `data-testid="degraded-search-banner"`, `data-quality-tier`.
76
+
77
+ Each reason in `degradation_reason` is written through `REASON_LABELS` (one sentence per code of the core's closed `SearchDegradationReason` vocabulary). A failed remote search names what failed and what the operator can do:
78
+ `remote_tiers_unavailable` (the search service is unreachable or down), `remote_tiers_timed_out` (no answer in time — retry or narrow the query), `remote_tiers_refused_caller` (the service refused the caller — sign in again or check the grant), `remote_tiers_rejected_request` (the service refused the query — revise it) and `remote_tiers_failed` (the service failed while answering — report it if it persists).
79
+ None of them is folded into a generic "could not connect".
80
+
81
+ ### 2. `DocumentIngestConsole`
82
+
83
+ Manual ingestion form (URI, confidentiality level, target domain, labels), file inspection result, projection capability per level (`有効` / `無効` / `設定が渡されていないため不明`, `data-state="true|false|unknown"`), and the recent-ingest table.
84
+ The table shows sync status, the last pipeline run (`ingest-last-run-${id}`: the shared run badge `ingest-last-run-status-${id}` with `data-presentation`, the failed step, and the finish time `ingest-last-run-finished-at-${id}`, its instant under `ingest-last-run-finished-at-value-${id}` or `未完了` while the run is open), the registration time (`ingest-created-at-value-${id}` inside `ingest-created-at-${id}`), both in the root's zone, and — when `onShowHistory` is given —
85
+ a `履歴` button per row (`ingest-history-button-${id}`) that asks the host to open the document's whole processing history (called for rows without a recorded run too; the inspector then says there is none).
86
+
87
+ Required props: `tenantId`, `domainOptions: readonly { key; label }[]` (empty → the select is disabled and `ingest-domain-empty` explains why). The console reads the root's zone and format, so it renders inside `ConsoleUiRoot`. Each `RecentIngestItem` carries `lastRun: { runId; status; retryScheduled; failedStepKey; finishedAt } | null`; a `FAILED` run with `retryScheduled: true` reads `失敗 (再試行予定)`.
88
+ The inspection result writes the file's size under `inspection-file-size-value` (`MeasuredFigure`: tabular figures, `data-measured="true"`, never broken between the number and its unit, the same slot as `QdrantStorageCard`'s byte sizes) with the package's one byte format:
89
+ binary units (B below 1 KiB, then KiB / MiB / GiB / TiB) chosen after rounding (1,048,575 bytes is `1.0 MiB`), the mantissa grouped in the root's format locale (`3,072.00 TiB`). `fileSize` must be a non-negative safe integer; otherwise the console throws `RangeError` naming `fileSize`, with `@aiquants/console-ui`'s `formatCount` refusal as its `cause`, instead of writing a negative size as `0 B` or a fraction without a unit.
90
+ That refusal is a backstop: thrown while the console renders, it would take the operator's unsent form input with the console. The host refuses a broken size where it reads the inspect response (its schema fails the inspect request, shown as a failed inspection), so a value that reaches the console is valid.
91
+ A missing revision is written `N/A`, not the root's unmeasured label.
92
+
93
+ ### 3. `EnrichmentStrategyBoard`
94
+
95
+ Per-domain strategy rows with each stage's enablement laid over the tenant feature flags.
96
+
97
+ ### 4. `InfrastructureHealthMonitor`
98
+
99
+ Connectivity probes for Qdrant, Neo4j, Redis, SQL Server, and the backend API. Required prop: `health` (the check time is written in the root's zone under `health-checked-at-value`, inside `health-checked-at`). Text details (`health-detail-${service}-${key}`), the versions the probes read, and the endpoint footer (`service-endpoint-${service}`) wrap inside the card; the endpoint is shown in full.
100
+ Each service's version sits under one hook, `service-version-value-${service}` inside `service-version-${service}`: the version the probe read (`data-measured="true"`), set in monospace as code, or the root's unmeasured label when it read none (`version: null`, `data-measured="false"`), never `N/A`, set like the unmeasured label of every detail row and not as code. A version that is empty or only white space throws `RangeError` naming the hook.
101
+ A host whose probe cannot read a version passes `null`: a stand-in word such as `unknown` would be drawn as a version the probe read.
102
+ Each probe's state reads one word of the console's probe vocabulary (`service-status-${service}`): `正常接続` or `接続エラー`, the same words the overall badge composes (`全サービス正常接続` / `一部サービス接続エラー`) and `QdrantStorageCard` writes for the same probe.
103
+ Every probe-state mark — the overall badge and each probe's badge with its dot (`service-status-dot-${service}`) — is drawn in `@aiquants/console-ui`'s colour roles on the mark's own surface: `success-fg` on `success-tint` for a connected probe and `danger-fg` on `danger-tint` for a failed one, the pairs of the CUI's tinted success and danger pills (both in its `CONTRAST_PAIRS`), the roles `QdrantStorageCard`'s badge takes for the same probe.
104
+ Each probe's error text (`service-error-${service}`) is a failure mark in either state, always `danger-fg` on `danger-tint`, a connected probe's included.
105
+ Each graphic (the overall badge's icon, a badge's dot) paints `currentColor`, the mark's own text colour, and nothing in a mark pulses or fades.
106
+ The mark carries its surface, so it reads the same on the host's light or dark card (the CUI palette is light-only: its stylesheet declares `color-scheme: light` on the root). Outside those marks nothing in a service card changes with the probe's state (no element, class or attribute, its frame and fill included); the state is shown by the marks alone.
107
+ The error text is drawn only when the probe's `errorMessage` has something to read (the value slots' definition above); it breaks anywhere and is shown in full, like the endpoint. An `errorMessage` with nothing to read (empty or only white space, as an `Error` thrown with no message gives) says nothing, so the card draws no box for it and the failure reads from the probe's badge; the card does not throw on it, since a monitor that stopped on a probe's empty error would hide the failure it exists to show.
108
+ Each probe's latency is a `Measured` count with `ms` (`health-latency-value-${service}`, `data-measured="true"`), grouped like every other duration of the console (`1,234 ms`), and must be a non-negative safe integer (otherwise `RangeError`). Measure it on a monotonic clock and round to whole ms (`Math.round(performance.now() - started)`): the wall clock (`Date.now()`) can step back during a probe, and a negative latency stops the card.
109
+ Every detail's value sits under one hook, `health-detail-value-${service}-${key}`. A counted detail (`collectionsCount`, `nodeCount`, `totalKeys`, `endpointsCount`) given as a number is a `Measured` count with `件` and must be a non-negative safe integer (otherwise `RangeError`); any other number (a port, not grouped) and a boolean (`true` / `false` read 有効 / 無効) are figures, and a number that is not finite (`NaN`, `Infinity`) throws `RangeError` naming its hook; a string, a counted detail given as a string included, is the probe's text (all `data-measured="true"`).
110
+ A text detail must not be empty or only white space: it throws `RangeError` naming its hook (`health-detail-value-sqlServer-fullVersion: …`), and the whole card with it, so a host passes `null` for a text it could not read, never a stand-in word (`unknown`) or an empty string, which would read as a value the probe read. A detail the probe could not read (`null`), counted or not, is the root's unmeasured label (`data-measured="false"`), so a Redis whose DBSIZE failed (`totalKeys: null`) reads as unmeasured, never `0 件`.
111
+ Each row lays out by how its value may break: a count, the latency, the unmeasured label, a port and a boolean's word never break inside, and the label beside them wraps instead; a text (an edition, a URI) and a version the probe read break anywhere inside the card beside a label that stays whole.
112
+
113
+ ### 5. `MetadataSearchWorkbench`
114
+
115
+ Developer workbench for SQL / sparse / dense / hybrid / graph retrieval. `onSearch(params)` is required — the library never calls a route itself. The mode buttons (`search-mode-${mode}`, inside the `search-modes` group) keep their labels on one line and wrap to a second row when the form is narrow. Slider bounds and defaults come from `SEARCH_PARAM_LIMITS`; the inspector's pipeline trace shows `未実行` for every step until a search has run.
116
+ The optional `circuitBreakerStatus: Record<string, CircuitBreakerState>` (`CLOSED` / `OPEN` / `HALF_OPEN`) is the state of the search path's own breakers; only the search reads and feeds them, so no pipeline component takes it.
22
117
 
23
118
  ```tsx
24
- import { DegradedSearchBanner } from "@aiquants/enrichment-react"
25
-
26
- <DegradedSearchBanner
27
- metadata={{
28
- is_degraded: true,
29
- search_mode: "KEYWORD",
30
- quality_tier: "DEGRADED_KEYWORD_ONLY",
31
- degradation_reason: "circuit_breaker_open:vector"
32
- }}
33
- onDismiss={() => console.log("Banner dismissed")}
34
- />
119
+ <MetadataSearchWorkbench tenantId="default" onSearch={(params) => api.search(params)} />
35
120
  ```
36
121
 
37
- ### 2. `RAGPipelineGraphViewer`
38
- Interactive DAG pipeline graph viewer visualizing RAG ingestion, chunking, embedding, vector indexing, graph indexing, and synthesis stages with circuit breaker indicators and latency/throughput telemetry.
122
+ ### 6. `ModelComparisonWorkbench`
123
+
124
+ Runs one query against two strategy variants and lays their ranked hits side by side (`compare-column-${strategyId}`, `compare-hit-${strategyId}-${rank}`). A variant is identified by its `tm_enrichment_strategy` row (`strategyId`), never by its variant key: every domain has its own `default` row.
125
+ Options and column headings read `<label> (<embeddingModel>)`, where the host passes `label` as `<domain> / <variant>`. Hit rows are keyed by rank position, so one ranking may list the same id twice.
126
+ A hit title is cut to the column visually only: the full title is the `title` of `compare-hit-title-${strategyId}-${rank}` and part of the row's accessible name (`<rank> 位 <title> (スコア <score>)`).
127
+ Scores are never normalized (they are different quantities per tier); they are written rounded to `SCORE_DISPLAY_DECIMALS` (4) decimals by `formatComparisonScore(score)` (`compare-hit-score-${strategyId}-${rank}`), and the value as returned stays in the row's `data-score` and the score's tooltip.
128
+ Agreement is measured on ranks by the exported `rankAgreement(a, b)` — Jaccard overlap of hit ids and the mean absolute rank difference of shared ids.
129
+ A failed comparison — `{ ok: false, reason }`, a missing result (`応答に戦略 #<id> の結果がありません。`), or an `onCompare` that rejects (`比較を実行できませんでした: <message>`) — shows the `compare-failure` alert and removes the previous results; a rejection never escapes the component.
130
+
131
+ Types: `ComparisonVariant { strategyId; label; embeddingModel }`, `ComparisonInput { query; strategyIds: readonly [number, number]; limit }`, `ComparisonVariantResult { strategyId; executionMs; hits }`, `ComparisonOutcome = { ok: true; results } | { ok: false; reason }`.
39
132
 
40
133
  ```tsx
41
- import { RAGPipelineGraphViewer } from "@aiquants/enrichment-react"
42
-
43
- <RAGPipelineGraphViewer
44
- pipelineConfig={{
45
- enable_summary_generation: true,
46
- enable_vector_indexing: true,
47
- enable_graph_indexing: false,
48
- }}
49
- circuitBreakerStatus={{ vector: "CLOSED", graph: "OPEN" }}
50
- onNodeSelect={(nodeId) => console.log("Selected stage:", nodeId)}
134
+ <ModelComparisonWorkbench
135
+ variants={[{ strategyId: 11, label: "GENERAL_DOCUMENT / default", embeddingModel: "text-embedding-3-large" }, { strategyId: 12, label: "GENERAL_DOCUMENT / ruri", embeddingModel: "cl-nagoya/ruri-v3-310m" }]}
136
+ onCompare={({ query, strategyIds, limit }) => api.compare(query, strategyIds, limit)}
51
137
  />
52
138
  ```
53
139
 
54
- ### 3. `SyncStatusMonitor`
55
- Telemetry monitor and re-enqueue widget displaying SQL Server `sync_status` breakdown (synced, processing, pending, degraded, failed) and individual/bulk retry controls.
140
+ ### 7. `OntologyStatusCard`
56
141
 
57
- ```tsx
58
- import { SyncStatusMonitor } from "@aiquants/enrichment-react"
142
+ Ontology schema version, lifecycle phase, promotion thresholds, and class / predicate counts.
143
+ The card does not read `ConsoleUiRoot`, so its counts are not `Measured`: the constraint, index and HITL counts are written ungrouped with `件` (`未設定` when the source returned `null`), and each class's count is grouped by the runtime's default locale (`toLocaleString()`), so a server and a browser with different default locales can group it differently.
59
144
 
60
- <SyncStatusMonitor
61
- totalFiles={1250}
62
- counts={{ SYNCED: 1200, PENDING: 30, EXTRACTING: 10, FAILED_VECTOR: 10 }}
63
- onRetryAll={() => triggerRetryAll()}
64
- />
145
+ ### 8. `PipelineFlowViewer`
146
+
147
+ The ingestion pipeline as the flow each document kind actually takes, drawn from `PIPELINE_FLOWS` / `PIPELINE_STEPS`:
148
+
149
+ - **Kind tablist** (`flow-kind-${kind}`) following the ARIA tabs pattern: only the selected tab is in the Tab order; ArrowLeft / ArrowRight (wrapping), Home, and End move focus and select.
150
+ - **Figure** (`flow-svg`, `role="group"` named `<kind> のパイプライン`, so its step buttons stay in the accessibility tree), drawn at its natural size inside a horizontally scrolling tabpanel (`flow-scroll`, named `<kind> の流れ`, described by the visible hint `flow-scroll-hint`, with edge shadows on the side where the figure continues).
151
+ The figure is never scaled to fit. Nodes `flow-node-${key}` are disclosure buttons for the detail panel (`role="button"` with `aria-expanded`, and `aria-controls` naming the panel while it is open): click, Enter, or Space opens the panel on the step, or closes it when it already describes that step; Escape clears.
152
+ They carry `data-gate-state` = `open` / `closed-skip` / `closed-fail` / `unmeasured`, `data-gate-policy` = `none` / `skip` / `fail` / `mixed`, `data-selected`, `data-focused`; a focused node draws a focus ring (`flow-node-focus-ring-${key}`, sized by `FLOW_FOCUS_RING`). The node's gate line (`flow-gate-badge-${key}`) carries the node's `data-gate-state` too.
153
+ - **Dependencies in text as well as in the drawing.** Edges `flow-edge-${from}-${to}` draw each `dependsOn`; each node is also described (`aria-describedby` → its `<desc>`, `flow-node-dependencies-${key}`) by what it waits for in the drawn flow — `依存: <labels>` in declaration order, or `依存なし (流れの最初に走る)` — so a screen reader can tell the text-layer flows' branch after `extract_text` and join at `resolve_body` from a chain.
154
+ - **Gates as the flow applies them.** Every gate is read through the core's `flowStepGates(flowStep)`, so a flow that overrides a step's gate policy (the image flow's `ocr_document` and the media flow's `analyze_media`, their only body source) is drawn with the meaning it has there.
155
+ A node's gate line (`flow-gate-badge-${key}`, also its accessible name `<label> (<line>)`) is `gateCaption(state, policy)`: a closed state names its consequence (`門: 閉 (省略)` / `門: 閉 (失敗)`), and an open or unmeasured gated step adds its policy from `GATE_POLICY_LABELS` (e.g. `門: 開・閉じたら失敗`).
156
+ Nothing else is drawn on a step: the search path's circuit breaker is neither read nor fed by the pipeline.
157
+ - **Detail panel** (`flow-step-detail`) with the step's definition, the projection step's chunk source written `<words> (<code>)` through `CHUNK_SOURCE_LABELS` (`flow-step-chunk-source`, e.g. `前段が書いた行をそのまま使う (existing)`), the steps it waits for (`flow-step-depends-on`) and the steps that wait for it (`flow-step-dependents`), and its gates in the drawn flow (`flow-gate-${flag}` with `data-gate-state` and `data-when-closed`; a failing gate names its failure status, e.g. `閉じたときは FAILED_EXTRACT で失敗`), plus `flow-gate-policy-override` when the flow overrides the step's declaration.
158
+ When it opens or starts describing another step, the focused element outside it is recorded as its opener: the pressed node (pressing a node — click, Enter, or Space — leaves focus on it), or, in controlled mode, the host control that held focus when `selectedStepKey` changed.
159
+ Closing it with its close button returns focus to that opener while it is still in the document; with no opener (opened with nothing focused) or one that has left the document, focus goes to the node of the step it described.
160
+ - **Metrics per kind** (`flow-step-metric`, `data-kind`): only the `PipelineStepMetric` whose (`documentKind`, `stepKey`) is the selected kind and step; the heading and `flow-metric-scope` state the window (`直近の集計 (<kind>・直近 <metricSampleWindow> 件)`), and a pair without a metric says so in a sentence headed by the root's unmeasured label (`未測定 (<kind> の実行でこの段を記録したものはまだありません)`, `flow-step-metric-empty`) instead of showing another kind's figures.
161
+ The sample, succeeded, failed and skipped counts (`flow-metric-sample-count-value`, `flow-metric-succeeded-value`, `flow-metric-failed-value`, `flow-metric-skipped-value`) are `Measured` counts and p50 / p95 (`flow-metric-p50-value`, `flow-metric-p95-value`) are `Measured` milliseconds, all in the root's format locale (`1,234 ms`); p50 / p95 are taken over measured durations only (`flow-metric-note`, which names the same label: `所要 未測定`).
162
+ The last start sits under one hook either way (`flow-metric-last-started-at-value`): the time in the root's zone with `data-measured="true"`, or the root's unmeasured label with `data-measured="false"`; a p50 / p95 the metric did not measure is likewise the label.
163
+ `metricSampleWindow` in the heading and `flow-metric-scope` is written as a bare number: it is a count inside a sentence, so it is not `Measured`, not grouped, and does not follow the root's `formatLocale`.
164
+ - **MIME classification table** (`flow-mime-table`) inside the horizontal scroll wrapper `flow-mime-scroll` (`relative overflow-x-auto`), so narrow screens scroll it instead of pushing it past the card.
165
+
166
+ Kind and step selection are each controlled or uncontrolled. Required props: `featureFlags`, `metrics`, `metricSampleWindow` (the number of newest step records per (kind, step) the host aggregated into each metric; a positive integer, otherwise `RangeError`).
167
+
168
+ ```tsx
169
+ <PipelineFlowViewer featureFlags={flags} metrics={stepMetrics} metricSampleWindow={stepMetricWindow} onSelectStep={(key) => setStep(key)} />
65
170
  ```
66
171
 
67
- ### 4. `QdrantStorageCard`
68
- Telemetry card displaying Qdrant vector collections, vector counts, named vector dimensions (`dense`, `sparse`, `image`), health latency, and memory/disk resource utilization.
172
+ The pure layout lives in `layoutFlow(flow, geometry = FLOW_GEOMETRY)` (`pipelineFlowLayout.ts`): rank = longest dependency path; an edge that skips layers gets one virtual slot per skipped layer, the slots take part in barycenter ordering, and the edge runs through them, so no edge enters a node it does not connect.
173
+ Text is sized by `@aiquants/console-ui`'s fixed sans model (`textWidthEm`: full-width glyph 1 em, half-width 0.55 em), the one text-width model of the console, of which this package keeps no copy;
174
+ `FLOW_GEOMETRY.node.width` is the widest step label at 13 px plus the text inset on both sides (`FLOW_NODE_TEXT`), rounded up to the 8-px grid with `ceil8` (184 for the shipped steps); the padding keeps a focus ring (`FLOW_FOCUS_RING`) inside the figure and the gaps keep it off the neighbouring nodes.
175
+
176
+ ### 9. `PipelineRunInspector`
177
+
178
+ A non-modal dialog panel (`run-inspector`, `role="dialog"` without `aria-modal`) showing one document's processing history.
179
+
180
+ Props: `metadataId`, `runs: readonly PipelineRunRecord[]` (the document's runs, newest first), `selectedRunId: number | null`, `onSelectRun(runId)`, `isLoading`, `failure: string | null`, `onClose()`, `openRequest: number` (changed by the host on every open or re-target from a control outside the panel, including a press that names the document and run already shown), and `returnFocusFallback: () => HTMLElement | null` (the element that takes focus on close when the opener has left the document, e.g. the selected tab of a host that unmounts tab panels).
181
+
182
+ - **Heading** (`run-inspector-heading`) names the document — `文書 #<metadataId> の処理履歴`, followed by `(実行 #<id>)` when a run is selected — and the document header (`run-inspector-document`) shows the id and, for the selected run, its revision, kind, and flow key.
183
+ - **States, in precedence:** `failure` (`run-inspector-failure`, `role="alert"`), `isLoading` (`run-inspector-loading`), no run recorded (`run-inspector-no-runs`, a neutral `role="status"` note), then the run list.
184
+ With runs, `selectedRunId === null` shows a prompt (`run-inspector-select-prompt`) and an id the list does not hold shows a note (`run-inspector-selection-missing`); neither is replaced by another run.
185
+ - **Run list** (`run-history-item-${id}`, the selected one has `aria-current="true"`): trigger label, attempt, the shared run badge (`失敗 (再試行予定)` for a failed run with a retry scheduled), and start time (`run-history-started-at-value-${id}`, in tabular figures, so the end-aligned column of starts lines up row by row).
186
+ - **Selected run** (`run-detail`): trigger, attempt, status, requester, start (`run-started-at-value`) and finish (`run-finished-at-value`, or `未完了` while open) in the root's zone, job id, the run error, and the step timeline in `sequence` order.
187
+ Each step is a `<details>` card (`run-step-card-${sequence}`), open by default when it `FAILED` or when the run has at most `STEP_CARDS_OPEN_LIMIT` (8) step records.
188
+ The summary line shows the step, the target when it is another document, `戦略 #<strategyId>` when the step applied a strategy row, the status, and the duration (`run-step-duration-value-${sequence}`, `Measured` milliseconds such as `4,000 ms`, or the root's unmeasured label for `null`).
189
+ The body shows a duration bar proportional to the run's longest measured step (no bar when the step was not measured or the longest measured step took 0 ms), the error, and the report.
190
+ The report lists measured `StageProvenance` fields only under Japanese labels, each value under one hook (`run-step-provenance-value-${sequence}-${field}`, `data-measured="true"`): a count is a `Measured` value (`duration_ms` with `ms`), any other value is its text (`prompt_version` names a version, so it is not grouped).
191
+ Each value sits below its label: a count and the page pair never break inside, and a text (a prompt key, a collection name) breaks anywhere inside its column.
192
+ Then one line names the unmeasured fields, headed by the root's unmeasured label (`run-step-unmeasured-${sequence}`, e.g. `未測定: プロバイダ、モデル`), and `detail` follows as a collapsible JSON block.
193
+ A field that was not measured therefore has no `run-step-provenance-value-…` slot: one sentence names all of them instead of a column of `未測定` values.
194
+ A text field (`provider`, `model`, `engine`, `prompt_key`, `collection`, `embedding_model`) that is empty or only white space throws `RangeError` naming its hook (`run-step-provenance-value-${sequence}-provider: a measured text must not be empty or only white space …`), and the whole panel is not drawn. The core record schemas still accept such a string, so a host that reads persisted records refuses it where it reads them.
195
+ A skipped step shows its reason through `skipReasonLabel`; a `SKIPPED` step with no reason, which `PipelineStepRecordSchema` refuses, throws `RangeError` instead of drawing a stand-in. The child records of a fan-out step (`fanOut: "children"`) are grouped under the step (`run-step-group-${stepKey}`) with a count and a per-status breakdown; the group is open when a child failed or the run is short.
196
+ A page pair with one half measured is two `Measured` values (`…-pages_read`, `…-pages_total`) and reads `3 / 未測定` or `未測定 / 5`.
197
+ The run list's count (`実行の履歴 (<n> 件、新しい順)`) and a fan-out group's count (`対象 <n> 件`) and per-status breakdown are written as bare numbers: they are not `Measured`, so they are not grouped and do not follow the root's `formatLocale`.
198
+ - **Focus:** focus moves to the heading when the panel mounts and whenever `openRequest` changes; the element outside the panel that held focus then becomes the opener (none when nothing held focus; a request that arrives while focus is inside the panel keeps the previous opener).
199
+ A run picked in the panel's own list moves focus to that run's heading (`run-detail-heading`) once the host selects it; any other change of `metadataId` or `selectedRunId` — such as a load settling on the newest run — leaves focus where it is.
200
+ Escape (while focus is inside) and the close button call `onClose`; on unmount focus returns to the most recent opener while it is still in the document, and otherwise to the element `returnFocusFallback()` returns (nothing moves when it returns `null`).
69
201
 
70
202
  ```tsx
71
- import { QdrantStorageCard } from "@aiquants/enrichment-react"
72
-
73
- <QdrantStorageCard
74
- collections={[
75
- {
76
- collection_name: "default_general_dense",
77
- domain_key: "GENERAL_DOCUMENT",
78
- points_count: 5000,
79
- vectors_count: 5000,
80
- vectors_config: { dense: { size: 1024, distance: "Cosine" } }
81
- }
82
- ]}
83
- health={{ healthy: true, latency_ms: 12 }}
203
+ <PipelineRunInspector
204
+ metadataId={history.metadataId} runs={history.runs} selectedRunId={history.selectedRunId} onSelectRun={history.selectRun}
205
+ isLoading={history.isLoading} failure={history.failure} onClose={history.close}
206
+ openRequest={history.openRequest} returnFocusFallback={() => tabRefs.current[activeTab]}
84
207
  />
85
208
  ```
86
209
 
210
+ ### 10. `QdrantStorageCard`
211
+
212
+ Qdrant collections, point counts, named vector dimensions, health latency, and resource utilization.
213
+ Required prop: `overview: QdrantStorageOverview`, the collections the host measured or the reason it could not, as one value in one of two states: `{ collections; probe_failure: null }` when measured, `{ collections: []; probe_failure }` when not. Omitting it cannot assert a measurement the host did not make, and an empty list never stands for an unreachable store.
214
+ A reason that is empty or only white space (the value slots' definition above) throws `RangeError`, since it would draw an empty warning beside unmeasured totals, and so does a reason that comes with collections (the type has no such state; the card refuses one that arrives through JSON instead of dropping either half).
215
+ Every count on the card is a `Measured` count in the root's format locale (`qdrant-total-points-value`, `qdrant-total-vectors-value`, `qdrant-segment-count-value`, `qdrant-collections-count`, each row's `collection-points-value-${collection_name}`, each named vector's dimensions `vector-tag-dense-size-${collection_name}` / `vector-tag-image-size-${collection_name}` with `次元`, and the latency `qdrant-latency-value` with `ms`, labelled 応答時間 like `InfrastructureHealthMonitor`'s, each with `data-measured`), so the rows and the totals always group digits alike.
216
+ The point-count column is end-aligned, header included, so its tabular figures line up by place value; the other columns are start-aligned. While `overview.probe_failure` is set the totals and the collection count are unmeasured, not `0`.
217
+ RAM and disk sit under one hook each (`qdrant-ram-usage-value`, `qdrant-disk-usage-value`), written by the package's one byte format, the same as `DocumentIngestConsole`'s file size, in a slot that never breaks between the number and its unit.
218
+ A measured size is written in binary units (B below 1 KiB without decimals, so `512 B` and `1,023 B`, then KiB / MiB / GiB / TiB, powers of 1024), its unit chosen after rounding (1,048,575 bytes is `1.0 MiB`, never `1,024.0 KiB`), its mantissa grouped in the root's format locale in tabular figures (1,048,524 bytes is `1,023.9 KiB`; TiB has no upper bound, so 2^53 − 1 bytes is `8,192.00 TiB`) with `data-measured="true"`, and a size that is `null` or absent is the root's unmeasured label (`data-measured="false"`).
219
+ `latency_ms` follows the same clock rule as `InfrastructureHealthMonitor`'s `latencyMs`: a monotonic clock, rounded to whole ms. The health badge (`qdrant-health-badge`) reads the console's probe vocabulary, `正常接続` or `接続エラー`, in its colour roles (`success-fg` on `success-tint`, `danger-fg` on `danger-tint`): the words and the roles `InfrastructureHealthMonitor` draws for the same probe.
220
+ In the header the badge, the latency (`qdrant-latency`) and the refresh button (`更新`, `更新中…` while refreshing) never break inside; the title group takes the width the button leaves and wraps between its items, so in a narrow card the badge and the latency move under the title whole and the button stays at the end of the first line.
221
+ Every figure follows the console's one validity rule: `null` or absent means not measured (`ram_bytes`, `disk_bytes` and `segment_count` are typed `number | null`, like every measured count of `@aiquants/enrichment-core`), and a number — `points_count`, `vectors_count`, a named vector's `size`, `segment_count`, `ram_bytes`, `disk_bytes` or `latency_ms` — must be a non-negative safe integer: a negative, fractional or non-finite figure is a broken probe, so the card throws `RangeError` instead of writing it as a value or as unmeasured.
222
+ The card asks `@aiquants/console-ui`'s `formatCount` to judge every figure, keeping no copy of the rule; for a figure it sums, converts or writes per row, the error names the figure in front of `formatCount`'s message (`ram_bytes: …`, `points_count of collection <name>: …`, `vectors_config.dense.size of collection <name>: …`), with that error as its `cause`, and a broken latency throws `formatCount`'s own error through `Measured`.
223
+ Each collection's `points_count` and `vectors_count` is checked before it is summed, so a broken row (`-1` and `1001`) never hides inside a total that looks valid (`1,000`).
224
+ The collections table is named by its heading (`コレクション一覧 (n 件)`) and sits in a scroll frame (`qdrant-collections-scroll`) that is a region of the same name and takes focus (`tabIndex={0}`), as `@aiquants/console-ui`'s `DataTable` frames a table: a keyboard user can scroll a table wider than the card even when it holds no button, and the frame's 4 px inner padding (`space-1`) keeps the focus ring of a name button at its edge unclipped.
225
+ The frame itself takes the browser's own focus ring, not the console's `focus` token: `@aiquants/console-ui` draws its ring only on the elements it owns, and it publishes no table frame outside `DataTable`. That exception ends when the card moves to `DataTable` with selectable rows.
226
+ A collection name never breaks inside a word, with or without `selection`, so the name column is equally wide in both, and a named vector's tag (`Dense: 1,024 次元`) never breaks inside itself, so a number never parts from its unit and `次元` is never split; the tags wrap between one another, and a card too narrow for one tag per line scrolls the table instead of splitting the names or the tags.
227
+ With `selection` (`{ selectedCollectionName, onSelectCollection, detailId }`, one value, so a host cannot pass the selected name without the handler or the detail region), each row's collection name is `@aiquants/console-ui`'s `RowButton` (`collection-select-${collection_name}`), the same control `SelectableList` draws for a master list: `data-selected` and `aria-expanded` say whether it is `selectedCollectionName`, and `aria-controls` names `detailId`, the id of the host's region that shows the selected collection (render it while the card is drawn).
228
+ The button is stretched to the full height of its cell (the cell gives its height to a flex box the button fills), so its start-edge bar, its focus ring and its pointer target cover the whole cell however tall wrapping vector tags make the row.
229
+ The console stylesheet draws its focus ring (2 px of the `focus` token, 2 px out: 5.25:1 on the white row, 4.82:1 on the selected one) and its selected look: the `info-tint` surface, which is only 1.09:1 against white, and a 4 px `info-accent` bar at the start edge (4.82:1 on the tint), so the selection is not shown by colour alone, and the bar is redrawn in `Highlight` under forced colours. `tests/colorContrast.spec.tsx` recomputes these four ratios from `@aiquants/console-ui`'s `TOKENS` with its `contrastRatio`.
230
+ The selected row is tinted with the same `info-tint` token. The name button is the row's one control: Tab reaches it after the frame, Enter, Space or a click selects once, and a click elsewhere on the row selects nothing, so the other cells' text stays selectable.
231
+ The row stays a table row whose cells keep their column headers, and the name column (header, button text and plain names alike) starts at the button's `space-3` inset.
232
+ Without `selection` the names are text and every row is `data-selected="false"`.
233
+
234
+ ### 11. `SyncStatusMonitor`
235
+
236
+ `sync_status` breakdown with individual and bulk retry controls. Required props: `counts`, `totalFiles`; each failed item's `failed_at` (an ISO 8601 instant with an explicit offset) is written in the root's zone under `failed-item-time-value-${id}` inside `failed-item-time-${id}`, the same clock as every other panel, and `null` reads `記録なし`. The monitor reads the root's zone, so it renders inside `ConsoleUiRoot`.
237
+
238
+ ## Colour Contrast
239
+
240
+ `tests/colorContrast.spec.tsx` measures the colour pairs below against the stock Tailwind CSS v4 palette the host uses (listed in the spec). It reads the rendered colour classes, so a listed pair whose class drifts to a lighter shade fails there. Text needs 4.5:1 (WCAG 2 AA, 1.4.3) and the graphics the flow needs to be understood need 3:1 (1.4.11); the spec reads both bars from `@aiquants/console-ui`'s `CONTRAST_MINIMUM` (`text`, `nonText`).
241
+ The ratio is `@aiquants/console-ui`'s `contrastRatio`, the repository's only contrast computation: the spec resolves a class to its palette colour (a hex through `hexToSrgb`, an OKLCH colour through the OKLab matrices), composites a translucent colour over its backdrop, and passes both in.
242
+ The package keeps no luminance computation or AA bar of its own, the colour spec included.
243
+
244
+ - **Flow viewer:** every node's label (`flow-node-label-${key}`), phase (`flow-node-phase-${key}`), and gate line (`flow-gate-badge-${key}`) on the node's fill, unselected (`white`) and selected (`indigo-50`); the dependency edges and their arrowhead on the figure's background (`slate-500` on `rgb(248 250 252)`, 4.56:1).
245
+ - **Run inspector:** each run row's trigger, attempt, and start time (`run-history-trigger-${id}`, `run-history-attempt-${id}`, `run-history-started-at-${id}`) on the selected row (`indigo-50`) and on an unselected row at rest (`white`) and hovered (`slate-50`).
246
+ - **Status badges and counts:** the ontology card's HITL count (`ontology-unresolved-count`, `amber-700` on its tile) and the strategy board's stage switches, open (`emerald-700` on `emerald-50`) and closed (`slate-600` on `slate-100`).
247
+ - **Probe-state marks:** the health monitor's overall badge, each probe's badge, and each probe's error text (`service-error-${service}`), with every probe connected and with some probes failed (a connected one among them carrying an error text), each at rest and while re-probing, with and without the refresh button; the Qdrant card's health badge (`qdrant-health-badge`), connected and failed, at rest and while refreshing.
248
+ Each mark's texts pass `@aiquants/console-ui/testing`'s role scan `scanColourRoles` as exactly its own pair, its state's `success-fg` on `success-tint` or `danger-fg` on `danger-tint`, and the error text's `danger-fg` on `danger-tint` in either state (pairs `@aiquants/console-ui`'s `CONTRAST_PAIRS` lists, so each meets the text bar).
249
+ No class inside the mark can change what it paints (a colour, `opacity`, a filter, a blend mode, a mask, an animation or any arbitrary property such as `[color:…]`, under any variant).
250
+ On the component's elements around it there is no compositing class (`opacity`, a filter, a blend mode, a mask, an animation), no arbitrary property, and no class that paints the elements inside (a colour or compositing utility under a variant that reaches them: `*:`, `**:`, `marker:`, `selection:` or an arbitrary variant with `&` such as `[&_p]:`), which would set the mark's paint directly, whatever the order of the host's layers.
251
+ The overall badge's icon and each badge's dot paint only `currentColor`, the mark's text colour, so they meet the 3:1 bar with the text's pair.
252
+ - **Qdrant storage card:** every text of the collections table's rows on the background it is painted on — the selected row's name button (the `fg` role on `info-tint`), domain and point count on the row's `info-tint`, the unselected rows' texts on the card's white, and each vector tag's text on its own fill. CUI token colours (`var(--cui-…)`, the `data-cui-fg` and `data-cui-surface` roles) are resolved through `@aiquants/console-ui`'s `TOKENS`, the inline hex colours as they are written.
253
+ - **Ingest console:** the `記録なし` last-run note (`ingest-last-run-none-${id}`) and the blocked-action reason (`ingest-action-blocked-${id}`) on the row at rest and hovered (`hover:bg-slate-50/80`); the `旧版` badge (`ingest-item-superseded-${id}`, `slate-600` on its own `slate-100`, 6.90:1); the required-field marks of the URI, confidentiality, and domain fields (`ingest-required-mark-${field}`, `red-700` on the form's white card, 6.42:1).
254
+
255
+ Colour pairs outside this list are not measured.
256
+
257
+ ## Shared Presentation (`pipelinePresentation.ts`, `RecordedInstant.tsx`)
258
+
259
+ Exported for hosts that draw their own run tables, so a run reads the same everywhere.
260
+
261
+ - `PIPELINE_RUN_STATUS_PRESENTATION` — `{ label, className }` for `RUNNING` / `SUCCEEDED` / `FAILED` and `FAILED_RETRY_SCHEDULED` (`失敗 (再試行予定)`); pick the key with `pipelineRunPresentationKey({ status, retryScheduled })` (a retry marker on a non-failed run is ignored).
262
+ - `PIPELINE_STEP_STATUS_PRESENTATION` — `{ label, className, bar }` per step status.
263
+ - `PIPELINE_TRIGGER_LABELS` — operator-facing names of the triggers `PipelineTriggerSchema` allows: `ingest` (取り込み), `retry` (再試行), `resync` (再同期).
264
+ - `skipReasonLabel(text)` — reads the text through `parseSkipReason`: `gate_closed:<flag>` → `<flag> が無効`, `no_children` → `子アセットなし`, `source_skipped:<step>` → `<step label> が実行されなかった`, `nothing_to_read:<token>` → `NOTHING_TO_READ_LABELS[token]` (`no_pages` → `読む対象なし (ページなし)`, `no_pages_rendered` → `読む対象なし (描画できたページなし)`); text outside the grammar is returned verbatim. A fan-out step's per-child records — including a child skipped as `gate_closed:ENABLE_OCR` or `nothing_to_read:no_pages` — are grouped under the step in the run inspector, each with its own reason.
265
+ - `RecordedInstant({ iso, testId })` — a recorded instant in the root's zone, to the second (`2026/09/24 01:02:03`), in a measured figure slot under `testId` (`data-measured="true"`, tabular figures, never broken between the date and the time); `iso: null` is the root's unmeasured label (`data-measured="false"`). A refused instant throws `ZonedTimeError` (see **Time zone is the root's**). Render it inside the Japanese console root, like every component that reads the root.
266
+ - `FinishedInstant({ iso, testId })` — a finish time that may still be open: `RecordedInstant` for a finished run or step, or the word `未完了` (`UNFINISHED_LABEL`) outside any slot while `iso` is `null`. It checks the root in both cases.
267
+ - The package publishes no function that turns an instant into text: a host table draws its instants with these two, so they share the root's clock, the slot and its hook with every panel of the package.
268
+
87
269
  ## License
88
270
 
89
271
  MIT