From 29a604e986d6f1b87fd06c9a24795bb530f6b190 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:37:19 +0200 Subject: [PATCH 01/24] docs: plan the E5 Data specs Twenty-one components, thirteen tasks, no fixes in the branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- docs/2026-08-22-phase-c-e5-data-plan.md | 74 +++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/2026-08-22-phase-c-e5-data-plan.md diff --git a/docs/2026-08-22-phase-c-e5-data-plan.md b/docs/2026-08-22-phase-c-e5-data-plan.md new file mode 100644 index 0000000..751ef30 --- /dev/null +++ b/docs/2026-08-22-phase-c-e5-data-plan.md @@ -0,0 +1,74 @@ +# Plan: Phase C — the `E5 Data` specs + +**Branch:** `spec/phase-c-e5-data` · **Base:** `60b2016` +(`2.24.3`, **published** — `v2.24.3` is tagged and on `origin/main`) + +`E5 Data` is the largest unwritten category: 21 components, none of them +covered today. `specs/E5 Data/` holds nothing but the two phase-C scaffolds. +Writing it moves the coverage meter from `31/127` to `52/127`. + +The work is spec-only, so no `` bump and no changelog entry belong to +it (`REL-01` binds *library code*, and a spec is not that). If reading a +component against its code turns up a defect — as the `E7` pass turned up five — +the defect is **not** fixed in this branch. It is written down under +`## Recorded gaps` in the spec that found it and collected in a follow-up plan, +so that the specs land as one reviewable artifact and the fixes as another. + +## Order of work + +One commit per task. Every task ends with `node scripts/check-spec-coverage.mjs` +and `node scripts/check-harness-links.mjs`; the coverage number is the evidence, +not a green exit (the check stays non-zero until `127/127`). + +| # | Task | Spec files | Covers | +|---|---|---|---:| +| 1 | Category companions | `_Api.md`, `_Interop.md` | 0 | +| 2 | Avatar family | `F1 DrylAvatar`, `F2 DrylAvatarGroup` | 2 | +| 3 | The three inline marks | `F3 DrylBadge`, `F10 DrylIcon`, `F12 DrylKbd` | 3 | +| 4 | Citation family | `F4 DrylCitation`, `F5 DrylCitationList`, `F6 DrylCitationListItem` | 3 | +| 5 | Code block | `F7 DrylCodeBlock` | 1 | +| 6 | Description family | `F8 DrylDescriptionList`, `F9 DrylDescriptionItem` | 2 | +| 7 | Image | `F11 DrylImage` | 1 | +| 8 | Pagination | `F13 DrylPagination` | 1 | +| 9 | Sparkline | `F14 DrylSparkline` | 1 | +| 10 | The two number tiles | `F15 DrylStat`, `F17 DrylTableKpi` | 2 | +| 11 | Timeline family | `F18 DrylTimeline`, `F19 DrylTimelineItem` | 2 | +| 12 | Tree family | `F20 DrylTreeView`, `F21 DrylTreeNode` | 2 | +| 13 | Table | `F16 DrylTable/` (split) | 1 | +| | | **Total** | **21** | + +Numbering is assigned up front and stays stable (`SPEC-02`); the tasks are +ordered by family rather than by number, so `F16` is written last. + +## Why `DrylTable` is split + +`SPEC-02` names it as one of the three components allowed the `F{n} {Name}/` +form, and the code agrees: `DrylTable.razor` is 2115 lines, an order of +magnitude past anything else in the category. It becomes +`F16 DrylTable/_Component.md` plus one `S{n}` per aspect — data source and +paging, sorting, filtering and search, selection, column mechanics +(pin/resize/reorder/visibility), inline editing, row reordering, and +presentation. `DrylColumn.cs` and the types under `Models/` are named in its +`Source`; `Internal/` is not, being implementation. + +## What is not in scope + +- **No fixes.** See above. `## Recorded gaps` is where the reading's findings + land. +- **`E4 Charts` is a separate category** and already written, so nothing under + `Components/Data/Charts/` is touched — `DrylSparkline` sits in `Data/` + itself and belongs here. +- **`Internal/SyntaxHighlighter.cs`** is implementation of `DrylCodeBlock`, not + public surface; it is described in prose, not claimed in `Source`. + +## Method + +Each component is read against **its code**, not its doc comments: the `E7` +pass found five doc comments that were wrong or misleading, and this category +carries the same risk (`AvatarSize` documents "24px / 28px / 40px" in its XML +doc — the tokens decide, and the spec names the token, never the value, +`SPEC-07`). + +For every component the six cross-cutting points of `SPEC-05` are walked and +evidenced in the spec text, including the demo page and `ComponentCatalog` +entry, which live in the `DRYL.Website` repository next door. From 8f4d7b6abe7a79eaf045ff211847dabb4657d7f9 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:41:23 +0200 Subject: [PATCH 02/24] =?UTF-8?q?spec(E5):=20the=20avatar=20family=20?= =?UTF-8?q?=E2=80=94=20DrylAvatar=20and=20DrylAvatarGroup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 31/127 → 33/127. Reading both against their code turned up seven recorded gaps, chief among them a failed image that is remembered for the lifetime of the instance and a presence dot no screen reader can hear. Per the plan, none of them is fixed here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- docs/2026-08-22-phase-c-e5-data-plan.md | 30 ++-- specs/E5 Data/F1 DrylAvatar.md | 206 ++++++++++++++++++++++++ specs/E5 Data/F2 DrylAvatarGroup.md | 161 ++++++++++++++++++ 3 files changed, 384 insertions(+), 13 deletions(-) create mode 100644 specs/E5 Data/F1 DrylAvatar.md create mode 100644 specs/E5 Data/F2 DrylAvatarGroup.md diff --git a/docs/2026-08-22-phase-c-e5-data-plan.md b/docs/2026-08-22-phase-c-e5-data-plan.md index 751ef30..8daae49 100644 --- a/docs/2026-08-22-phase-c-e5-data-plan.md +++ b/docs/2026-08-22-phase-c-e5-data-plan.md @@ -22,24 +22,28 @@ not a green exit (the check stays non-zero until `127/127`). | # | Task | Spec files | Covers | |---|---|---|---:| -| 1 | Category companions | `_Api.md`, `_Interop.md` | 0 | -| 2 | Avatar family | `F1 DrylAvatar`, `F2 DrylAvatarGroup` | 2 | -| 3 | The three inline marks | `F3 DrylBadge`, `F10 DrylIcon`, `F12 DrylKbd` | 3 | -| 4 | Citation family | `F4 DrylCitation`, `F5 DrylCitationList`, `F6 DrylCitationListItem` | 3 | -| 5 | Code block | `F7 DrylCodeBlock` | 1 | -| 6 | Description family | `F8 DrylDescriptionList`, `F9 DrylDescriptionItem` | 2 | -| 7 | Image | `F11 DrylImage` | 1 | -| 8 | Pagination | `F13 DrylPagination` | 1 | -| 9 | Sparkline | `F14 DrylSparkline` | 1 | -| 10 | The two number tiles | `F15 DrylStat`, `F17 DrylTableKpi` | 2 | -| 11 | Timeline family | `F18 DrylTimeline`, `F19 DrylTimelineItem` | 2 | -| 12 | Tree family | `F20 DrylTreeView`, `F21 DrylTreeNode` | 2 | -| 13 | Table | `F16 DrylTable/` (split) | 1 | +| 1 | Avatar family | `F1 DrylAvatar`, `F2 DrylAvatarGroup` | 2 | +| 2 | The three inline marks | `F3 DrylBadge`, `F10 DrylIcon`, `F12 DrylKbd` | 3 | +| 3 | Citation family | `F4 DrylCitation`, `F5 DrylCitationList`, `F6 DrylCitationListItem` | 3 | +| 4 | Code block | `F7 DrylCodeBlock` | 1 | +| 5 | Description family | `F8 DrylDescriptionList`, `F9 DrylDescriptionItem` | 2 | +| 6 | Image | `F11 DrylImage` | 1 | +| 7 | Pagination | `F13 DrylPagination` | 1 | +| 8 | Sparkline | `F14 DrylSparkline` | 1 | +| 9 | The two number tiles | `F15 DrylStat`, `F17 DrylTableKpi` | 2 | +| 10 | Timeline family | `F18 DrylTimeline`, `F19 DrylTimelineItem` | 2 | +| 11 | Tree family | `F20 DrylTreeView`, `F21 DrylTreeNode` | 2 | +| 12 | Table | `F16 DrylTable/` (split) | 1 | +| 13 | Category companions | `_Api.md`, `_Interop.md` | 0 | | | | **Total** | **21** | Numbering is assigned up front and stays stable (`SPEC-02`); the tasks are ordered by family rather than by number, so `F16` is written last. +The two category companions come **last**, not first: `_Api.md` inventories the +shared types and `_Interop.md` the JS surface, and both are only honest once +every component in the category has been read against its code. + ## Why `DrylTable` is split `SPEC-02` names it as one of the three components allowed the `F{n} {Name}/` diff --git a/specs/E5 Data/F1 DrylAvatar.md b/specs/E5 Data/F1 DrylAvatar.md new file mode 100644 index 0000000..b12e01a --- /dev/null +++ b/specs/E5 Data/F1 DrylAvatar.md @@ -0,0 +1,206 @@ +# DrylAvatar + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylAvatar.razor + code/DRYL.Components/Components/Data/AvatarShape.cs + code/DRYL.Components/Components/Data/AvatarSize.cs + code/DRYL.Components/Components/Data/AvatarStatus.cs + +## User Story + +As a Blazor developer, I want a small tile that stands for a person or an entity +and that always shows *something* — a photo, their initials, or an icon — so that +a row, a mention or a chat message never renders an empty hole when the picture +is missing or fails to load. + +## Description + +`DrylAvatar` resolves a face through a fixed chain and stops at the first link +that can be satisfied: the image, then initials, then the named icon, then a +generic user icon. The chain has no hole in it, which is the component's whole +point — a consumer can bind it to whatever their data happens to carry and never +has to write the fallback themselves. + +Initials are derived rather than demanded. Given a `Name`, the component takes +the first letter of the first word and the first letter of the last word; +`Initials` overrides that when the derivation would be wrong. + +Two optional extras sit on top of the face. `Status` adds a presence dot, which +also changes the markup: the avatar is wrapped so the dot has something to be +positioned against. `Shape` turns the circle into a rounded square, for entities +that are not people. + +Placed inside a `DrylAvatarGroup` (see `F2`) the avatar hands itself to that +group and takes its size from it, and may be collapsed behind the group's "+N" +counter entirely. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Src` | `string?` | `null` | Image URL. A load error falls through to the next link in the chain. | +| `Alt` | `string?` | `null` | Alternative text for the image, and the first choice of accessible label. | +| `Name` | `string?` | `null` | Full name; initials are derived from it. | +| `Initials` | `string?` | `null` | Explicit initials, overriding the derivation. | +| `Icon` | `string?` | `null` | `DrylIcon` name for the icon link of the chain. | +| `Size` | `AvatarSize` | `AvatarSize.Medium` | Rendered diameter. Overridden by a surrounding `DrylAvatarGroup`. | +| `Shape` | `AvatarShape` | `AvatarShape.Circle` | Outline shape. | +| `Status` | `AvatarStatus` | `AvatarStatus.None` | Presence dot. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the avatar's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the avatar element. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### The fallback chain + +- `Src` set and loading renders an `img` element inside the avatar. +- `Src` set but failing to load falls through to the next satisfiable link of the + chain, so a broken URL never leaves an empty tile. +- `Src` unset renders the initials when initials can be derived. +- `Initials` set wins over any value derived from `Name`. +- `Initials` is rendered upper-cased, so mixed-case input still reads as a + monogram. +- `Initials` is rendered trimmed, so stray whitespace does not shift it off + centre. +- `Name` holding a single word yields its first letter. +- `Name` holding two or more words yields the first letter of the first word and + the first letter of the last word. +- `Name` holding only whitespace yields no initials, so the chain falls through + rather than rendering a blank monogram. +- Neither an image nor initials renders `Icon` as a `DrylIcon`. +- Neither an image, nor initials, nor `Icon` renders the `User` icon, so the + component always renders a face. + +### Structure + +- `Status` at `AvatarStatus.None` renders the avatar element as the root, with no + wrapper around it. +- `Status` at any other value renders a wrapper element holding the avatar and + one dot element. +- The dot carries the modifier class of its `AvatarStatus` value, one per value. +- The wrapper carries the modifier class of the effective size, so the dot scales + with the avatar. +- The avatar element carries the modifier class of the effective size. +- The avatar element carries the modifier class of `AvatarShape.Square`; + `AvatarShape.Circle` is the unmodified element. +- `Class` is merged onto the avatar element's own classes rather than replacing + them. +- `AdditionalAttributes` are applied to the avatar element. +- The avatar clips its content, so an image with the wrong aspect ratio cannot + escape the shape. +- The image fills the avatar and is cropped to cover it, so a non-square source + is not distorted. +- The avatar does not shrink when placed in a flex row that runs out of space. + +### Inside a group + +- An avatar inside a `DrylAvatarGroup` registers itself with that group on + initialisation. +- An avatar inside a `DrylAvatarGroup` unregisters itself when it is disposed, so + a removed member stops counting toward the group's overflow. +- An avatar inside a `DrylAvatarGroup` renders at the group's `Size` and ignores + its own. +- An avatar the group reports as hidden renders nothing at all. +- An avatar outside a group renders at its own `Size`. + +### Keyboard and accessibility + +- The avatar element carries `role="img"`, so it is announced as one thing rather + than as its letters. +- `Alt` set is the accessible label. +- `Alt` unset and `Name` set makes `Name` the accessible label, so an + initials-only avatar still announces the person. +- Neither set yields a generic label rather than nothing. +- The avatar is not focusable and adds no stop to the tab order, because it is a + representation and not a control. +- The presence dot is hidden from assistive technology. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The face without an image is filled with `--accent-grad` and its letters are + drawn in `--on-accent`. +- The letters are set in `--font-mono`, so two-letter monograms of different + widths still centre alike. +- `AvatarStatus.Online` draws its dot from `--success`. +- `AvatarStatus.Busy` draws its dot from `--danger`. +- `AvatarStatus.Away` draws its dot from `--warning`. +- `AvatarStatus.Offline` draws its dot from `--fg-faint`. +- The three live statuses carry a glow of their own color; `Offline` deliberately + does not, so absence is the one state that does not attract the eye. +- `AvatarShape.Square` takes its corner from `--r-sm` rather than from a written + radius. +- The avatar paints no frost, being a small opaque tile rather than a floating + surface (`DESIGN-06`). +- The accent appears as a small tile no larger than a line of text, not as the + fill of a large surface (`DESIGN-08`). +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): an avatar states *who* something is, and + the AI vocabulary states *what is happening*. Those are different questions, + and the answer to the second belongs to the thing the avatar is attached to — + the message, the row, the timeline item — each of which carries its own `Ai`. + An assistant's avatar is identified by its `Icon` and its `Name`, not by an + aura that would then contradict the state of the message beside it. + +## Recorded gaps + +- **A failed image is remembered forever.** The load error sets an internal flag + that nothing resets, so assigning a new, working `Src` to an avatar whose + previous URL failed keeps showing the fallback for the lifetime of that + component instance. A list that reuses avatar instances across rows — + everything Blazor does without an explicit `@key` — can therefore show the + wrong person's initials. +- **The presence dot is silent.** `Status` is announced to nobody: the dot is + `aria-hidden` and the accessible label is built from `Alt` and `Name` alone. A + screen-reader user cannot tell an online colleague from an offline one, which + is the only information the dot exists to carry. +- **`Class` lands inside the wrapper, not on the root.** With `Status` set, the + root element is the wrapper and `Class` is merged onto the avatar *inside* it. + A consumer's margin or grid-placement class therefore applies to the wrong box + in exactly the configuration where the component's root changes. The same is + true of `AdditionalAttributes`. +- **The size scale is written in literals, in two languages.** `24px`, `28px` and + `40px` with their font sizes live in the `.avatar` rules in `dryl.css`, and the + matching icon sizes are bare integers in the component's `IconSize` switch. + Nothing relates the two, so a size change means editing CSS and C# and hoping + they still agree (`DESIGN-01`). +- **The ring assumes the ground is `--bg-0`.** Both the avatar's outer ring and + the dot's ring paint `--bg-0` to punch a gap out of whatever is behind them. + On a card, a toolbar or an app bar that is the wrong color, and the ring reads + as a hairline of page background rather than as a gap. +- **Nothing is animated.** The component has no enter, exit, hover or state + transition of any kind — not the status dot appearing, not the fallback taking + over from a failed image (`DESIGN-11`, `DESIGN-12`). This is recorded as debt + rather than claimed as the exception `DESIGN-11` allows: an avatar is not a + component with nothing to animate, it is one that was never animated. +- **No tests of its own.** None of the criteria above is guarded by a test, and + the avatar is absent from `tests/DRYL.Components.Tests/ClassMergeTests.cs` + despite carrying a `Class` parameter. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--accent-grad`, `--on-accent`, + `--success`, `--danger`, `--warning`, `--fg-faint` and `--glass-3` are the + mode-dependent tokens; the component defines no mode-specific rule. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is `role="img"` with a derived label; the substantive + omission is the silent presence dot, recorded above. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoAvatar.razor`, with the + examples `Components/Examples/Avatar/Faces.razor`, `.../Sizes.razor`, + `.../Shape.razor`, `.../Presence.razor` and `.../Group.razor`. +- **`ComponentCatalog`** — registered as `"Avatar"` / `avatar` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. diff --git a/specs/E5 Data/F2 DrylAvatarGroup.md b/specs/E5 Data/F2 DrylAvatarGroup.md new file mode 100644 index 0000000..88ff76f --- /dev/null +++ b/specs/E5 Data/F2 DrylAvatarGroup.md @@ -0,0 +1,161 @@ +# DrylAvatarGroup + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylAvatarGroup.razor + +## User Story + +As a Blazor developer, I want to show that several people are attached to one +thing without spending a row of the layout on them, so that a shared document, a +conversation or a task can name its participants in the width of two or three +avatars. + +## Description + +`DrylAvatarGroup` is a container for `DrylAvatar` (see `F1`) that does two +things its members cannot do for themselves. + +It **unifies the size**. Every avatar in the group renders at the group's +`Size`, whatever it was given individually, because a stack of avatars at +different diameters reads as a mistake rather than as a group. + +It **caps the length**. With `Max` set, the avatars beyond the cap render +nothing at all and a counter tile takes their place, reading "+N". The cap is +counted over the avatars that actually registered, so a member removed by a +conditional disappears from the count as well as from the row. + +The avatars overlap rather than sitting side by side, by a distance that scales +with the group's size. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Size` | `AvatarSize` | `AvatarSize.Medium` | Uniform size for every avatar in the group. | +| `Max` | `int?` | `null` | Number of avatars to show before collapsing the rest into the counter. `null` shows all. | +| `ChildContent` | `RenderFragment?` | `null` | The avatars. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the group's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the group element. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders a single root element holding the avatars. +- The root carries the modifier class of its `Size`, one per value. +- `ChildContent` is rendered inside a cascading value that hands the group itself + to its members. +- The cascade is fixed, so a member never re-subscribes to it. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. +- The root is inline-flex, so a group sits inside a sentence or a toolbar rather + than claiming a line. + +### Membership + +- An avatar registers itself with the group when it initialises. +- Registering an avatar that is already registered changes nothing, so a + re-render cannot double-count a member. +- An avatar unregisters itself when it is disposed. +- Registering an avatar re-renders the group, so the counter reflects the new + member. +- Unregistering an avatar re-renders the group, so the counter reflects its + departure. +- Unregistering an avatar that is not a member re-renders nothing. + +### The cap + +- `Max` left `null` renders every avatar and no counter. +- `Max` at or above the number of registered avatars renders every avatar and no + counter. +- `Max` below the number of registered avatars reports every avatar from that + position onward as hidden. +- A hidden avatar renders nothing, so the row's width is the visible avatars + plus the counter. +- Exceeding `Max` renders exactly one counter element, after the visible + avatars. +- The counter reads the number of avatars it stands for. +- The counter carries the modifier class of the group's `Size`, so it matches + the avatars beside it. +- The counter is not a `DrylAvatar` and carries no presence dot. + +### Keyboard and accessibility + +- The counter carries `role="img"`, so it is announced as one thing rather than + as a stray "+3". +- The counter carries an accessible label naming how many further avatars it + stands for. +- Neither the group nor the counter is focusable, and neither adds a stop to the + tab order, because the group is a representation and not a control. +- The group applies no role of its own, so it does not claim to be a list it + does not behave like. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The counter is filled with `--glass-3` and its text is drawn in `--fg-muted`, + so it reads as the quiet member of the row rather than as another identity. +- Each avatar overlaps the one before it, by a distance that differs per `Size`. +- The group paints no surface of its own — no fill, no border, no frost — so it + inherits whatever ground it is placed on and `DESIGN-06` has nothing to apply + to. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision follows `F1` for the same reason (`AI-05`): the group states *who* + is attached to something. If one of those participants is an assistant, that is + a property of the avatar's identity, not an activity signal, and the activity + belongs to the surface the group sits on. + +## Recorded gaps + +- **Registration order is not document order after the first change.** Members + are appended in the order they initialise and removed by identity, so an avatar + added later always lands at the end of the list. Remove the first participant + and add another, and the cap now hides an avatar that is not the last one in + the markup — the hidden member and the "+N" tile disagree with what the reader + sees. +- **A negative `Max` hides everything and over-counts.** Nothing constrains + `Max` to be non-negative. At `-1` every avatar reports as hidden and the + counter reads one *more* than the number of avatars in the group. +- **Every member costs the group a render.** Registration calls + `StateHasChanged` unconditionally, so mounting a group of *n* avatars queues + *n* extra renders of the group and its whole subtree during the first render + pass. The re-render is only needed when the counter's value actually changes. +- **The overlap distances are literals.** `-6px`, `-8px` and `-12px` are written + into the `.avatar-group` rules in `dryl.css` with no token behind them + (`DESIGN-01`), and they are the third place — after the avatar's own sizes and + the component's icon sizes — where the size scale is restated by hand. +- **Nothing is animated.** An avatar joining or leaving the group appears and + disappears instantly, and the counter's number changes without any transition + (`DESIGN-11`, `DESIGN-12`). The group is exactly the component that would + benefit — membership is the thing that changes at runtime. +- **No tests of its own.** None of the criteria above is guarded by a test, + including the cap, which is the component's only real logic. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--glass-3` and `--fg-muted` are + the mode-dependent tokens; the component defines no mode-specific rule. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the counter announces what it stands for rather + than being read as a piece of punctuation. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — shown on `DRYL.Website/Components/Pages/DemoAvatar.razor` + through the example `Components/Examples/Avatar/Group.razor`; the component + has no page of its own, being usable only around `DrylAvatar`. +- **`ComponentCatalog`** — reached through the `"Avatar"` / `avatar` entry in + `DRYL.Website/Components/ComponentCatalog.cs`. The catalog registers the lead + component of a family and not its parts, which is why the group, like every + other member component in this category, carries no entry of its own. From 8dbd7ba3c828b311e583064f27540cf45d4b6a85 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:44:46 +0200 Subject: [PATCH 03/24] =?UTF-8?q?spec(E5):=20the=20three=20inline=20marks?= =?UTF-8?q?=20=E2=80=94=20DrylBadge,=20DrylIcon,=20DrylKbd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 33/127 → 36/127. Reading DrylIcon against its call sites found a live defect: DrylFileUpload asks for the icon name "UploadCloud", which the set does not contain, so the drop zone's 32px leading icon has been rendering as empty space. An unknown name produces a correctly sized, entirely empty svg with no console warning — the silence is what let it survive. Recorded in F10, not fixed here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F10 DrylIcon.md | 173 ++++++++++++++++++++++++++++++++++ specs/E5 Data/F12 DrylKbd.md | 154 ++++++++++++++++++++++++++++++ specs/E5 Data/F3 DrylBadge.md | 144 ++++++++++++++++++++++++++++ 3 files changed, 471 insertions(+) create mode 100644 specs/E5 Data/F10 DrylIcon.md create mode 100644 specs/E5 Data/F12 DrylKbd.md create mode 100644 specs/E5 Data/F3 DrylBadge.md diff --git a/specs/E5 Data/F10 DrylIcon.md b/specs/E5 Data/F10 DrylIcon.md new file mode 100644 index 0000000..b532a49 --- /dev/null +++ b/specs/E5 Data/F10 DrylIcon.md @@ -0,0 +1,173 @@ +# DrylIcon + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylIcon.razor + +## User Story + +As a Blazor developer, I want to place a line icon by name and have it inherit +the colour of the text around it, so that every icon in my app matches its +context without me shipping an icon font, an SVG sprite or a second package. + +## Description + +`DrylIcon` renders one inline `svg` from a built-in set of line icons, selected +by `Name`. The set is a static dictionary of path markup, compiled into the +assembly — there is no request, no sprite sheet and no runtime dependency +(`CODE-03`). The paths come from Lucide under the ISC licence, recorded in +`THIRD_PARTY_NOTICES.md`; the DRYL-side name is the public identifier and the +upstream name is noted beside each entry. + +Two properties make it composable everywhere else in the library. It is stroked +in `currentColor`, so an icon in a danger button is red and the same icon in a +muted caption is muted, with no parameter passed. And its accessibility is +opt-in the right way round: an icon is **decorative by default** and hidden from +assistive technology, and becomes an announced image only when the consumer +gives it an `AriaLabel`. That default is what makes it safe for the dozens of +icons the library places inside components that already have their own label. + +The set itself is public: `DrylIcon.Icons` can be enumerated, which is what the +docs site's icon gallery does, and what a consumer does to offer an icon picker. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Name` | `string` | `""` (`EditorRequired`) | Icon name; a key of `Icons`. | +| `Size` | `int` | `16` | Rendered width and height in pixels. | +| `StrokeWidth` | `string` | `"2"` | SVG stroke width. | +| `AriaLabel` | `string?` | `null` | Accessible label. `null` makes the icon decorative. | +| `Class` | `string?` | `null` | CSS class(es) applied to the `svg` root. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the `svg` root. | +| `Icons` | `static IReadOnlyDictionary` | — | The built-in set, keyed by name. | + +`Icons` is `public static readonly` and therefore part of the frozen 1.0 surface: +its type, its keys and the fact that it can be enumerated are all binding. Its +*values* — the path markup — are not a contract and may be replaced when an +upstream icon is redrawn. + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders exactly one `svg` element and nothing around it. +- The `svg` carries a view box of the icon set's native coordinate space, so + every icon in the set aligns on the same grid. +- The `svg` renders at `Size` in both dimensions, so an icon is always square. +- The `svg` is unfilled and stroked, which is what makes the set a line set + rather than a solid one. +- The stroke is `currentColor`, so the icon inherits the colour of its + surrounding text without a parameter. +- The stroke uses `StrokeWidth`. +- The stroke caps and joins are rounded, so a short path does not read as a + spike. +- `Name` matching a key of `Icons` renders that entry's path markup inside the + `svg`. +- `Name` matching no key renders an `svg` with no path markup inside it. +- `Class` is applied to the `svg` root. +- `AdditionalAttributes` are applied to the `svg` root. + +### The icon set + +- `Icons` is exposed as a read-only dictionary keyed by icon name. +- Every entry of `Icons` has non-empty path markup, so no name in the set + renders blank. +- Adding a name to `Icons` is the only step needed to make it usable, because + the component looks the name up rather than switching on it. + +### Keyboard and accessibility + +- `AriaLabel` left `null` marks the `svg` `aria-hidden`, so a decorative icon + beside its own label is not announced twice. +- `AriaLabel` left `null` applies no role, so the `svg` does not claim to be an + image with no name. +- `AriaLabel` set applies `role="img"` and that label to the `svg`. +- `AriaLabel` set does not mark the `svg` `aria-hidden`, so a labelled icon is + reachable by assistive technology. +- The icon is not focusable and adds no stop to the tab order. + +### Appearance + +- The component renders no colour of its own and therefore names no literal + colour (`DESIGN-01`). +- The component paints no surface — no fill, no border, no frost — so it + inherits whatever ground it is placed on and `DESIGN-06` has nothing to apply + to. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). +- Any colour, glow or accent an icon appears to have comes from the component + around it, so `DESIGN-08` is answered where the accent is decided rather than + here. + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): the icon is a glyph, not a surface. It + has nothing to paint an aura on and no state of its own to signal, and every + component that places one — `DrylAvatar`, `DrylBadge`, `DrylStat`, + `DrylButton` — already carries the AI state for the surface the icon sits in. + +## Recorded gaps + +- **An unknown `Name` fails silently — and one is live in the library.** A + misspelt name renders a correctly sized, correctly stroked, entirely empty + `svg`: an invisible hole in the layout, with nothing in the browser console, + no exception and no fallback glyph. `Name` is `EditorRequired`, which catches + an omitted name at compile time but says nothing about a wrong one. + `DrylFileUpload` asks for `UploadCloud`, and the set has only `Upload` — so + the drop zone's 32px leading icon has been rendering as empty space rather + than as a glyph, and nothing anywhere reported it. This is the component's one + real defect and the reason it is worth fixing first: the silence is what let + the wrong name survive. + + Comparing the `Name` values passed to `DrylIcon` anywhere under `code/` + against the keys of `Icons` finds exactly this one mismatch out of 38 names + in use. +- **`Size` is an `int` of pixels.** A parameter of raw pixels is the one place + the component contradicts `DESIGN-01`: every call site picks a number, and the + library's own call sites picked `11`, `13`, `15`, `16` and `20` for what is + conceptually one small scale. A size token or a size enum would give the set a + rhythm; today it has a habit. +- **`StrokeWidth` is a `string`.** It reads as a violation of `CODE-02` and is + not quite one: the value goes straight into an SVG attribute, and a `double` + would be formatted by the current culture, turning `1.5` into `1,5` on a + German machine and silently breaking the attribute. The string is the safe + form; what is missing is the `FormattableString.Invariant` wrapper that would + let it be typed. +- **The set is not enumerated by a test.** `tests/DRYL.Components.Tests/DrylIconTests.cs` + asserts that three specific names exist, which guards the icons one feature + needed rather than the invariant "every name any component uses is in the + set". That invariant is deliberately **not** an acceptance criterion of this + spec — it is a statement about the components that call `DrylIcon`, not about + `DrylIcon`, and a criterion referring to code outside its own component fails + INVEST's first letter (`SPEC-06`). Its natural home is a test over the whole + `code/` tree, which is also the only form in which it could have caught + `UploadCloud`. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — the component names no colour at all; it inherits + `currentColor`. `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs` have nothing of this component's to + check, which is the strongest form of `DESIGN-02` compliance available. +- **Enter/exit animation** — none, and this is the written exception + `DESIGN-11` allows: a glyph that renders its own path markup and nothing else + has no state to transition between and no surface to move. Icons *are* + animated in DRYL — in `DrylButton`'s press, in `DrylExpansion`'s caret, in + `DrylSpinner`'s loop — always by the component that places them, which owns + the state the motion expresses. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is decorative-by-default: the icon is silent unless the + consumer says otherwise, which is the correct default for a set placed + overwhelmingly beside existing labels. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoIcons.razor`, with the + example `Components/Examples/Icons/All.razor`, which enumerates `Icons` + rather than listing names by hand. +- **`ComponentCatalog`** — registered as `"Icons"` / `icons` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. +- **Tests** — `tests/DRYL.Components.Tests/DrylIconTests.cs` guards three names + of the set; see the recorded gap above for what it does not guard. diff --git a/specs/E5 Data/F12 DrylKbd.md b/specs/E5 Data/F12 DrylKbd.md new file mode 100644 index 0000000..7c87d7d --- /dev/null +++ b/specs/E5 Data/F12 DrylKbd.md @@ -0,0 +1,154 @@ +# DrylKbd + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylKbd.razor + code/DRYL.Components/Components/Data/DrylKbd.razor.css + +## User Story + +As a Blazor developer, I want to show a keyboard shortcut the way a keyboard +shows it, so that a user scanning a menu, a command palette or a tooltip can +tell a shortcut apart from the words around it at a glance. + +## Description + +`DrylKbd` renders a shortcut as one or more key caps. It has two forms and picks +between them by which parameter is set. + +Given `Keys`, it renders a **chord**: one cap per key, joined by a separator +that is decoration rather than content. Given `ChildContent` instead, it renders +a **single cap** holding whatever was passed — which is the form to use for a +composed glyph like `⌘K` that is one key press rather than two. + +The component takes no view on what a key is called. It does not translate +`Ctrl` to `⌘` on macOS, does not know which platform the browser is on, and does +not reorder modifiers. A consumer who wants platform-aware shortcuts computes +the strings and passes them, which keeps the component from guessing wrong in +the one place a wrong guess is unrecoverable. + +It is pure markup and CSS — no interop, no measurement, nothing to dispose. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Keys` | `string[]?` | `null` | The keys of a chord, each in its own cap. When set, `ChildContent` is ignored. | +| `Separator` | `string` | `"+"` | Text shown between chord caps. | +| `ChildContent` | `RenderFragment?` | `null` | Content of a single cap. Ignored when `Keys` is set. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the component's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Choosing a form + +- `Keys` holding at least one entry renders the chord form. +- `Keys` left `null` renders the single-cap form. +- `Keys` set to an empty array renders the single-cap form, so an empty chord + does not render an empty group. +- The chord form ignores `ChildContent` entirely. + +### The chord form + +- The chord form renders a group element as its root. +- The chord form renders one `kbd` element per entry of `Keys`. +- The caps appear in the order the entries appear in `Keys`. +- A separator element is rendered between every pair of adjacent caps. +- No separator is rendered before the first cap or after the last one. +- The separator element renders `Separator`. +- `Class` is merged onto the group's own class rather than replacing it. +- `AdditionalAttributes` are applied to the group. + +### The single-cap form + +- The single-cap form renders one `kbd` element as its root, with no group + around it. +- `ChildContent` is rendered inside that `kbd`. +- `Class` is merged onto the cap's own class rather than replacing it. +- `AdditionalAttributes` are applied to the cap. + +### Keyboard and accessibility + +- Every key is rendered as a `kbd` element, so assistive technology announces it + as keyboard input rather than as prose. +- The separator is hidden from assistive technology, so a chord is announced as + its keys and not as "Ctrl plus K". +- The component is not focusable and adds no stop to the tab order, because it + displays a shortcut rather than offering one. +- The component binds no key handler; showing a shortcut and handling it are + separate jobs and this component does the first one only. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- A cap is filled with `--glass-2`, outlined with `--line-strong` and set in + `--fg-muted`. +- A cap carries a hairline of `--line-strong` under it, so it reads as a raised + key rather than as a flat box. +- A cap's corner comes from `--r-xs`. +- A cap's text is set in `--font-mono`, so caps of different keys align on the + same character grid. +- A cap is at least as wide as it is tall, so a single-letter key renders as a + square rather than as a sliver. +- The separator is set in `--fg-dim`, quieter than the caps it joins. +- The gap between caps comes from `--sp-1`. +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The cap paints no frost, being a small in-flow surface rather than a floating + one (`DESIGN-06`). +- The component renders no accent, so `DESIGN-08` has nothing to apply to. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): a key cap is a piece of typography that + names a physical key. Nothing about it is ever in progress, so there is no + state for the aura vocabulary to express, and the surfaces that *do* place + shortcuts — `DrylCommandPalette`, `DrylMenu`, `DrylTooltip` — carry their own + AI state around it. + +## Recorded gaps + +- **The cap's geometry is literal.** `20px` of minimum width and height and the + `11px` type are written into `.kbd` in `DrylKbd.razor.css` with no token + behind them, as is the separator's type size (`DESIGN-01`). The paddings and + the gap *are* tokens, so the file is half-converted rather than untouched. +- **`Separator` is rendered but never used as a value.** It is a `string` + parameter whose only job is to be displayed, which is correct — but it is also + the reason the chord form cannot be given a separator that is markup, an icon + or nothing at all. Passing an empty string renders an empty element that still + occupies a gap. +- **Nothing is animated.** The component has no enter, no exit and no + transition of any kind (`DESIGN-11`, `DESIGN-12`). Unlike `DrylIcon` this is + recorded as debt rather than claimed as an exception: a key cap has an obvious + thing to animate — the press — and several DRYL surfaces show a shortcut at + the moment it is being used. +- **No tests of its own.** None of the criteria above is guarded by a test, + including the form-selection rule, which is the component's only logic, and + the component is absent from `tests/DRYL.Components.Tests/ClassMergeTests.cs` + despite carrying a `Class` parameter in two different roots. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--glass-2`, `--line-strong`, + `--fg-muted` and `--fg-dim` are the mode-dependent tokens; the component + defines no mode-specific rule. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is the hidden separator: the chord is announced as its + keys, so a screen-reader user hears the shortcut rather than its punctuation. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoKbd.razor`, with the + examples `Components/Examples/Kbd/SingleKey.razor`, `.../Chords.razor` and + `.../Inline.razor`. +- **`ComponentCatalog`** — registered as `"Keyboard Key"` / `kbd` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. diff --git a/specs/E5 Data/F3 DrylBadge.md b/specs/E5 Data/F3 DrylBadge.md new file mode 100644 index 0000000..25fa0d7 --- /dev/null +++ b/specs/E5 Data/F3 DrylBadge.md @@ -0,0 +1,144 @@ +# DrylBadge + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylBadge.razor + +## User Story + +As a Blazor developer, I want to attach a short, colour-coded label to a row, a +heading or a card — a status, a version, a count — so that a reader can classify +the thing beside it without reading a sentence about it. + +## Description + +`DrylBadge` is the library's smallest labelled surface: a pill holding a few +characters, tinted by one of five semantic kinds. It is content-agnostic — the +label is `ChildContent`, so it takes text, a number or a formatted value alike. + +Two optional marks sit before the label. `Icon` renders a `DrylIcon` inside the +pill. `Dot` prefixes a small glowing dot that takes the badge's own foreground +colour, which is what turns a classification into a live status: "Healthy" reads +differently with a green dot pulsing beside it than as green text alone. + +The badge is not a control. It has no press, no dismiss and no link — anything +that needs those is a `DrylChip` or a `DrylButton` instead. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Kind` | `DrylBadge.BadgeKind` | `BadgeKind.Neutral` | Colour treatment. | +| `Dot` | `bool` | `false` | Prefixes the label with a glowing dot in the badge's own colour. | +| `Icon` | `string?` | `null` | `DrylIcon` name rendered before the label. | +| `ChildContent` | `RenderFragment?` | `null` | The label. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the badge's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +`BadgeKind` is nested in `DrylBadge` and is therefore written qualified — +`DrylBadge.BadgeKind.Success` — unless the file has the component in scope. Its +members are listed in [`_Api.md`](_Api.md). + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders a single root element carrying the badge class. +- The root carries the modifier class of its `Kind`, one per value. +- `BadgeKind.Neutral` adds no modifier class, being the unmodified pill. +- Any `BadgeKind` value the switch does not match is treated as + `BadgeKind.Neutral`, so an unmapped value still renders a badge. +- `Dot` set adds the dot modifier class to the root. +- `Icon` set renders one `DrylIcon` inside the root, before `ChildContent`. +- `Icon` unset renders no icon element. +- `ChildContent` is rendered inside the root. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. +- The root is inline-flex, so a badge sits on the text row of a heading or a + table cell without breaking the line. + +### Keyboard and accessibility + +- The badge is not focusable and adds no stop to the tab order, because it is a + label and not a control. +- The badge carries no role of its own, so its text is announced as the text it + is. +- The badge's meaning is carried by its label, not by its colour alone, because + `ChildContent` is the only thing it renders that a screen reader can read. +- The dot is drawn by the stylesheet rather than by markup, so it contributes + nothing to the accessible name. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- `BadgeKind.Neutral` is filled with `--glass-2`, outlined with `--line-strong` + and set in `--fg-muted`. +- `BadgeKind.Accent` is filled with `--accent-soft`, outlined with + `--accent-line` and set in `--accent-fg`. +- `BadgeKind.Success`, `BadgeKind.Warning` and `BadgeKind.Danger` each derive + their text, border and fill from their own semantic token — `--success`, + `--warning` and `--danger` respectively. +- The three semantic kinds derive their border and fill from the same token as + their text, so a new semantic colour needs one value rather than three. +- The pill's corner comes from `--r-pill`. +- The label is set in `--font-mono`, so a badge holding a number does not change + width as the number changes. +- The dot takes `currentColor`, so it matches whatever kind the badge is without + a rule of its own. +- The dot carries a glow of `currentColor`. +- The badge paints no frost, being a small in-flow surface rather than a + floating one (`DESIGN-06`). +- The accent appears as a soft tint behind a few characters with a 1px border, + never as the fill of a large surface (`DESIGN-08`). +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): the badge is a *classification*, and the + library already has a component for AI provenance at this size — a + `DrylBadge` with `BadgeKind.Accent` states what a thing is, while + `DrylAiIndicator` in `E3 AI` states what a model is doing. Giving the badge an + aura would put two competing AI signals in the same row. + +## Recorded gaps + +- **The pill's own geometry is literal.** `22px` of height, `10px` of padding, + `6px` of gap, the `11px` type and the `6px` dot are written into the `.badge` + rules in `dryl.css` with no token behind any of them, and the icon's size is a + bare `11` passed from the component (`DESIGN-01`). +- **Nothing is animated.** The badge has no enter, no exit and no transition, + and the dot that exists to say "live" does not move (`DESIGN-11`, + `DESIGN-12`). A status badge whose kind changes from `Success` to `Danger` + snaps between two colours. +- **`Icon` is not part of the accessible name.** The icon inside the badge is + rendered without an `AriaLabel` and is therefore `aria-hidden`, which is right + for decoration — but nothing stops a consumer from using the icon *as* the + label and passing no `ChildContent`, producing a badge that is silent to a + screen reader. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--glass-2`, `--line-strong`, + `--fg-muted`, `--accent-soft`, `--accent-line`, `--accent-fg`, `--success`, + `--warning` and `--danger` are the mode-dependent tokens; the component + defines no mode-specific rule. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the badge claims no role, so it does not announce + itself as something a user could act on. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoBadge.razor`, with the + examples `Components/Examples/Badge/Kinds.razor`, `.../Dots.razor` and + `.../Icons.razor`. +- **`ComponentCatalog`** — registered as `"Badge"` / `badges` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. +- **Tests** — the `Class` merge is guarded in + `tests/DRYL.Components.Tests/ClassMergeTests.cs`; no other criterion above is. From bf3c4664006a1091474f8fec9251a7df6cba015a Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:46:57 +0200 Subject: [PATCH 04/24] =?UTF-8?q?spec(E5):=20the=20citation=20family=20?= =?UTF-8?q?=E2=80=94=20chip,=20list=20and=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 36/127 → 39/127. The interesting decision written down here is why the components most obviously about AI carry no Ai parameter: an aura says something is happening, and a citation says the opposite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F4 DrylCitation.md | 184 +++++++++++++++++++++++ specs/E5 Data/F5 DrylCitationList.md | 122 +++++++++++++++ specs/E5 Data/F6 DrylCitationListItem.md | 155 +++++++++++++++++++ 3 files changed, 461 insertions(+) create mode 100644 specs/E5 Data/F4 DrylCitation.md create mode 100644 specs/E5 Data/F5 DrylCitationList.md create mode 100644 specs/E5 Data/F6 DrylCitationListItem.md diff --git a/specs/E5 Data/F4 DrylCitation.md b/specs/E5 Data/F4 DrylCitation.md new file mode 100644 index 0000000..75ca14d --- /dev/null +++ b/specs/E5 Data/F4 DrylCitation.md @@ -0,0 +1,184 @@ +# DrylCitation + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylCitation.razor + +## User Story + +As a Blazor developer, I want a generated answer to carry its sources inline +without the prose being interrupted by them, so that a reader can check where a +claim came from at the moment they doubt it, and ignore the sources entirely +while they do not. + +## Description + +`DrylCitation` is the inline half of the library's source-attribution pair. It +renders a superscript `[n]` chip in the flow of the text; pressing it opens a +popover holding the source's title, an optional grounding snippet, and a link to +the source itself. The numbered list of all sources is the other half, in `F5`. + +The design decision the component makes is that **attribution is on demand**. A +generated paragraph that names its sources in full is unreadable, and one that +names them nowhere is unverifiable. The chip is the smallest mark that can carry +both: two characters in the prose, everything else one press away. + +The panel is a `DrylPopover` (`specs/E11 Surfaces/F1 DrylPopover.md`), so the +portal, the placement, the outside-click and the `Escape` handling — and that +component's recorded debt — belong to it rather than being re-specified here. + +The chip is numbered by the consumer, not by the component: `Index` is a +parameter. Nothing derives it, because the numbering has to agree with a list +that may be rendered somewhere else entirely. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Index` | `int` | `1` | 1-based reference number shown in the chip and named in its label. | +| `Title` | `string?` | `null` | Source title, shown at the top of the panel. | +| `Url` | `string?` | `null` | Source URL, rendered as an external link in the panel. | +| `Snippet` | `string?` | `null` | Excerpt that grounds the cited claim. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the chip's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the chip. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### The chip + +- The component renders a `button` as its trigger. +- The button is of type `button`, so a citation inside a form never submits it. +- The button renders `Index` as its content. +- The button carries an open-state modifier class while the panel is open. +- `Class` is merged onto the button's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the button. +- The chip sits raised above the text baseline, so it reads as a reference mark + rather than as part of the sentence. +- The chip renders inline, so a citation does not break the line it is in. + +### The panel + +- Pressing the chip opens the panel; pressing it again closes it. +- The panel is rendered by `DrylPopover`, so the portalling, placement, outside + click and `Escape` behaviour specified for that component apply unchanged. +- The panel prefers to open above the chip and aligned to its start edge. +- `Title` set renders a title row holding a quote icon and the title. +- `Title` left unset renders no title row. +- `Snippet` set renders the excerpt as a paragraph. +- `Snippet` left unset renders no excerpt. +- `Url` set renders a link holding a link icon and the URL's display form. +- `Url` left unset renders no link. +- All three parts left unset renders an empty panel rather than failing. +- The panel is width-capped, so a long snippet wraps instead of stretching + across the viewport. + +### The link + +- The link's target is `Url` exactly as given. +- The link's visible text is the URL's host when `Url` parses as an absolute + URI, so a long tracking URL does not fill the panel. +- The link's visible text is `Url` unchanged when it does not parse as an + absolute URI. +- The link opens in a new browsing context, so following a source does not + discard the answer that cited it. +- The link is opened with `noopener` and `noreferrer`, so the source page can + neither reach back into the opener nor be told where the reader came from. +- A long URL breaks across lines rather than overflowing the panel. + +### Keyboard and accessibility + +- The chip is a real button, so it is reachable by `Tab` and activated by + `Enter` and `Space` without a key handler of its own. +- The chip carries an accessible label naming it as a source and stating its + number. +- The chip's accessible label includes `Title` when one is set, so a + screen-reader user hears which source they are about to open. +- The panel carries `role="dialog"`. +- The panel carries the same accessible label as the chip that opened it. +- The panel's `Escape`, outside-click and focus behaviour is + `DrylPopover`'s — including that component's recorded focus debt. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The chip at rest is set in `--accent-b`, filled with `--accent-soft` and + outlined with `--accent-line`. +- The chip on hover and while open is filled with `--accent-grad` and set in + `--fg`. +- The chip's corner comes from `--r-xs`. +- The chip's number is set in `--font-mono`, so chips of one and two digits + align. +- The panel's title icon is drawn in `--accent-b`. +- The panel's snippet is set in `--fg-muted`, quieter than the title above it. +- The panel's link is set in `--accent-b` at rest and `--fg` on hover. +- The panel's frost and fill are `DrylPopover`'s `--panel-float` and + `--glass-fx-float`, because the panel floats (`DESIGN-06`). +- The accent appears as a two-character chip and a 1px border, never as the fill + of a large surface (`DESIGN-08`). +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### Motion + +- The chip transitions its fill and its text colour between rest and hover. +- Both transitions run at `--dur-fast` with `--ease-out`. +- The panel's enter animation is `DrylPopover`'s, including that component's + recorded absence of an exit animation. + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`), and it is the interesting one in this + category: a citation is the component most obviously *about* AI, and precisely + for that reason it must not carry the AI vocabulary. The aura says "something + is happening here". A citation says the opposite — that this claim is settled + and here is where it came from. A chip that glowed while a model was thinking + would attach an activity signal to the one element whose job is to be a + verifiable fact. The surface that streams the answer carries `Ai`; + the citations inside it do not. + +## Recorded gaps + +- **Nothing enforces that `Index` agrees with anything.** The chip's number, the + matching `DrylCitationListItem`'s number and the actual position of a source in + the list are three independent values a consumer keeps in step by hand. Two + chips can carry the same number, and a chip can point at a number no list + entry has. +- **The chip's geometry is literal.** `16px` of minimum width and height, `4px` + of padding, `1px` of margin and the `10px` type are written into + `.citation-chip` in `dryl.css` with no token behind them (`DESIGN-01`). The + same is true of the panel's `280px` cap and its `13px`/`12.5px`/`12px` type + sizes. +- **The external link does not announce that it leaves.** The link opens a new + browsing context with no marker in its accessible name and no icon saying so, + so a screen-reader user is moved to another tab without warning. +- **The panel has no exit animation**, inherited from `DrylPopover` along with + that component's other recorded a11y debt — `Escape` not reaching an unfocused + panel, the portalled panel falling out of the tab order, and focus not being + returned on close (`DESIGN-12`). +- **No tests of its own.** None of the criteria above is guarded by a test, + including the URL display rule, which is the component's only logic. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--accent-b`, `--accent-soft`, + `--accent-line`, `--accent-grad`, `--fg` and `--fg-muted` are the + mode-dependent tokens; the component defines no mode-specific rule. +- **Enter/exit animation** — the chip's hover transition is its own; the panel's + enter animation and its missing exit are `DrylPopover`'s, recorded above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the chip is a real `button` with a label naming + both the number and the source, so a citation is operable and identifiable + without sight of the superscript. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoCitation.razor`, with the + examples `Components/Examples/Citation/InlineChips.razor` and + `.../SourceList.razor`. +- **`ComponentCatalog`** — registered as `"Citation"` / `citation` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable — + consistent with the AI-mode decision above. diff --git a/specs/E5 Data/F5 DrylCitationList.md b/specs/E5 Data/F5 DrylCitationList.md new file mode 100644 index 0000000..4401513 --- /dev/null +++ b/specs/E5 Data/F5 DrylCitationList.md @@ -0,0 +1,122 @@ +# DrylCitationList + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylCitationList.razor + +## User Story + +As a Blazor developer, I want the sources behind a generated answer collected in +one numbered list under it, so that a reader who wants to check the whole answer +rather than one claim has a single place to look. + +## Description + +`DrylCitationList` is the block half of the source-attribution pair whose inline +half is `DrylCitation` (`F4`). It is deliberately thin: an optional heading and +an ordered list holding `DrylCitationListItem` entries (`F6`). It holds no +state, cascades nothing to its children, and derives no numbering — every entry +carries its own `Index`, for the same reason the inline chip does. + +That thinness is the point. The list exists to give the entries a semantic +container and a rhythm, not to own them; an application that renders its sources +from a collection binds the loop itself and the list stays out of the way. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Title` | `string?` | `null` | Heading above the list, e.g. "Sources". | +| `ChildContent` | `RenderFragment?` | `null` | The `DrylCitationListItem` entries. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the list's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders a single root element holding the heading and the list. +- `Title` set renders one heading element above the list. +- `Title` left unset renders no heading element. +- `ChildContent` is rendered inside an `ol` element, so the entries are an + ordered list in the document rather than a stack of divs. +- `Class` is merged onto the root's own class rather than replacing it. +- `AdditionalAttributes` are applied to the root. +- The entries are stacked vertically with a gap of `--sp-2` between them. +- The heading and the list are separated by that same gap, so the block has one + rhythm rather than two. + +### Keyboard and accessibility + +- The list is not focusable and adds no stop to the tab order; the links inside + its entries are the operable parts. +- The heading is rendered as plain text rather than as an `h*` element, so + dropping a source list into an arbitrary place in a document cannot corrupt + that document's heading outline. +- The component binds no key handler and manages no focus, because it owns no + interaction. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The heading is set in `--fg-dim`, quieter than the entries below it. +- The heading is upper-cased and letter-spaced, so it reads as a label rather + than as the first line of content. +- The list renders no bullet or number of its own, because each entry draws its + own number. +- The list paints no surface of its own — no fill, no border, no frost — so it + inherits whatever ground it is placed on and `DESIGN-06` has nothing to apply + to. +- The component renders no accent, so `DESIGN-08` has nothing to apply to. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision follows `F4` and for the same reason (`AI-05`): the list is the + settled record of where an answer came from, and an activity signal on it + would contradict that. While the sources are still arriving, the surface + streaming the answer carries the `Ai` state; the list appears when they have. + +## Recorded gaps + +- **Removing the list markers may remove the list semantics.** The `ol` is + styled with `list-style: none`, which in WebKit-based browsers also drops the + element's list role — so the sources can be announced as a run of paragraphs + rather than as "list, 4 items". The numbers a reader sees are drawn by each + entry and are not the list's own markers, so nothing visible would change if + the role were restored explicitly. +- **The heading is not a heading.** Rendering `Title` as plain text is the safe + choice for arbitrary placement (above), but it also means a screen-reader user + cannot jump to the sources by heading navigation, which is exactly how such a + user would look for them. Neither behaviour is available as a parameter. +- **The heading's type is literal.** The `11px` size and the letter-spacing are + written into `.citation-list-title` in `dryl.css` with no token behind them + (`DESIGN-01`). +- **Nothing is animated.** Sources appearing under an answer — the one moment + this component exists for — appear instantly, and no entry animates in + (`DESIGN-11`, `DESIGN-12`). The list is not wrapped in `DrylPresence` and does + not wrap its entries in one. +- **No tests of its own.** None of the criteria above is guarded by a test. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--fg-dim` is the one + mode-dependent token it names; the component defines no mode-specific rule. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is the plain-text heading, whose cost is recorded as a + gap. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — shown on `DRYL.Website/Components/Pages/DemoCitation.razor` + through the example `Components/Examples/Citation/SourceList.razor`. +- **`ComponentCatalog`** — reached through the `"Citation"` / `citation` entry + in `DRYL.Website/Components/ComponentCatalog.cs`; the catalog registers the + lead component of a family and not its parts. diff --git a/specs/E5 Data/F6 DrylCitationListItem.md b/specs/E5 Data/F6 DrylCitationListItem.md new file mode 100644 index 0000000..d13e73a --- /dev/null +++ b/specs/E5 Data/F6 DrylCitationListItem.md @@ -0,0 +1,155 @@ +# DrylCitationListItem + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylCitationListItem.razor + +## User Story + +As a Blazor developer, I want each source under a generated answer shown with +its number, its title, the passage it contributed and a link to it, so that a +reader can judge whether a source supports the claim without opening it. + +## Description + +`DrylCitationListItem` is one entry of a `DrylCitationList` (`F5`). It renders +as a bordered row: the reference number in its own tile on the leading edge, and +a body holding the title, the grounding snippet and the external link — each +part optional and each omitted from the markup entirely when it is not given. + +It is the block counterpart of the panel `DrylCitation` (`F4`) opens, and it +shows the same four values in the same order. The difference is permanence: the +chip's panel is a glance, this is the record. + +Like the chip, it is numbered by the consumer through `Index`. Unlike the chip, +it is not interactive — the only operable thing in the entry is the link. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Index` | `int` | `1` | 1-based reference number, matching the inline `DrylCitation`. | +| `Title` | `string?` | `null` | Source title. | +| `Url` | `string?` | `null` | Source URL, rendered as an external link. | +| `Snippet` | `string?` | `null` | Grounding excerpt. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the item's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the list item. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders an `li` element as its root, so an entry is a list item + of the `ol` around it rather than a div inside one. +- The root carries `Index` as its ordinal value, so assistive technology reading + the list's numbering agrees with the number drawn in the entry. +- The root holds a number tile and a body, in that order. +- The number tile renders `Index`. +- `Title` set renders a title element inside the body. +- `Title` left unset renders no title element. +- `Snippet` set renders the excerpt as a paragraph inside the body. +- `Snippet` left unset renders no excerpt. +- `Url` set renders a link holding a link icon and the URL's display form. +- `Url` left unset renders no link. +- All three parts left unset renders an entry holding its number alone rather + than failing. +- `Class` is merged onto the root's own class rather than replacing it. +- `AdditionalAttributes` are applied to the root. +- The number tile does not shrink, so a long title cannot squeeze the number out + of shape. +- The body may shrink below its content's intrinsic width, so a long unbroken + title wraps rather than widening the entry. + +### The link + +- The link's target is `Url` exactly as given. +- The link's visible text is the URL's host when `Url` parses as an absolute + URI. +- The link's visible text is `Url` unchanged when it does not parse as an + absolute URI. +- The link opens in a new browsing context, so following a source does not + discard the answer that cited it. +- The link is opened with `noopener` and `noreferrer`. +- A long URL breaks across lines rather than overflowing the entry. + +### Keyboard and accessibility + +- The entry itself is not focusable and adds no stop to the tab order, because + it is a record and not a control. +- The link is the entry's only tab stop. +- The link icon is decorative and is not part of the link's accessible name, so + the link is announced as the source it points at. +- The number is rendered as text in the entry, so it is announced along with the + title rather than being carried by a marker the stylesheet removed. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The entry is filled with `--glass-1` and outlined with `--line`. +- The entry's corner comes from `--r-sm`. +- The number tile is set in `--accent-b` and filled with `--accent-soft`, so it + matches the inline chip that points at it. +- The number tile's corner comes from `--r-xs`, the same as the inline chip's. +- The number is set in `--font-mono`, so entries of one and two digits align. +- The title is set in `--fg`. +- The snippet is set in `--fg-muted`, quieter than the title above it. +- The link is set in `--accent-b` at rest and `--fg` on hover. +- The entry sits in the flow rather than floating, so it carries no frost + (`DESIGN-06`). +- The accent appears as a small number tile and the link's text, never as the + fill of the entry (`DESIGN-08`). +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision follows `F4` and `F5` for the same reason (`AI-05`): the entry is + a settled record of provenance, and an activity signal on it would contradict + what it is for. + +## Recorded gaps + +- **The link changes colour but does not transition.** Unlike the inline chip, + which transitions its hover state at `--dur-fast`, the entry's link swaps + colour instantly and the entry itself has no hover treatment at all + (`DESIGN-11`). +- **Nothing is animated.** An entry appearing under an answer appears instantly; + the component is not wrapped in `DrylPresence` and has no enter or exit + (`DESIGN-12`). +- **The external link does not announce that it leaves**, exactly as in `F4`: + a new browsing context with nothing in the accessible name saying so. +- **The entry's type sizes are literal.** `13px` for the title, `12.5px` for the + snippet, `12px` for the link and `11px` for the number, along with the number + tile's `18px` box, are written into the `.citation-item` rules in `dryl.css` + with no token behind them (`DESIGN-01`). +- **`Index` is trusted twice.** It is drawn as the entry's visible number *and* + set as the `li`'s ordinal value, so a consumer who numbers two entries alike + produces a list whose semantics and whose appearance are consistently wrong + together. Nothing derives the number from the entry's position. +- **No tests of its own.** None of the criteria above is guarded by a test, + including the URL display rule it shares with `F4` — the same logic is written + out twice in the two components and tested in neither. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--glass-1`, `--line`, + `--accent-b`, `--accent-soft`, `--fg` and `--fg-muted` are the mode-dependent + tokens; the component defines no mode-specific rule. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the number is rendered as text rather than left + to the list marker the stylesheet removes, so it survives into the accessible + name. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — shown on `DRYL.Website/Components/Pages/DemoCitation.razor` + through the example `Components/Examples/Citation/SourceList.razor`. +- **`ComponentCatalog`** — reached through the `"Citation"` / `citation` entry + in `DRYL.Website/Components/ComponentCatalog.cs`; the catalog registers the + lead component of a family and not its parts. From 9460e5d806d65f0d9f9533497e9758a028214714 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:48:41 +0200 Subject: [PATCH 05/24] spec(E5): DrylCodeBlock, and a correction to the badge's test evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 39/127 → 40/127. The copy button's fixed aria-label means the one user who most needs the "Copied" confirmation never hears it; the scrollable body has no keyboard access. Both recorded, neither fixed here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F3 DrylBadge.md | 8 +- specs/E5 Data/F7 DrylCodeBlock.md | 227 ++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 specs/E5 Data/F7 DrylCodeBlock.md diff --git a/specs/E5 Data/F3 DrylBadge.md b/specs/E5 Data/F3 DrylBadge.md index 25fa0d7..3809733 100644 --- a/specs/E5 Data/F3 DrylBadge.md +++ b/specs/E5 Data/F3 DrylBadge.md @@ -140,5 +140,9 @@ The component takes **no** `Ai` and no `Aura` — see "AI mode" below. `.../Icons.razor`. - **`ComponentCatalog`** — registered as `"Badge"` / `badges` in `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. -- **Tests** — the `Class` merge is guarded in - `tests/DRYL.Components.Tests/ClassMergeTests.cs`; no other criterion above is. +- **Tests** — `tests/DRYL.Components.Tests/DrylBadgeTests.cs` guards the child + content, the unmodified neutral pill, the four modifier classes, the dot + modifier and the attribute splat; the `Class` merge is guarded separately in + `tests/DRYL.Components.Tests/ClassMergeTests.cs`. The badge is the + best-covered component in the category, and the file says so explicitly — it + doubles as the worked example of how a DRYL component is bUnit-tested. diff --git a/specs/E5 Data/F7 DrylCodeBlock.md b/specs/E5 Data/F7 DrylCodeBlock.md new file mode 100644 index 0000000..8dbbea7 --- /dev/null +++ b/specs/E5 Data/F7 DrylCodeBlock.md @@ -0,0 +1,227 @@ +# DrylCodeBlock + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylCodeBlock.razor + code/DRYL.Components/Components/Data/DrylCodeBlock.razor.css + +## User Story + +As a Blazor developer, I want code — my own or a model's — shown on a surface +that names its language, colours its syntax and lets a reader copy it in one +press, so that a snippet in my app is as usable as one in an editor without me +adding a JavaScript highlighter to the page. + +## Description + +`DrylCodeBlock` is a bordered glass surface in two parts: a header carrying the +language label and a copy button, and a body holding the code with an optional +line-number gutter. + +Its defining decision is that **highlighting happens on the server, in C#**. +`SyntaxHighlighter` under `Components/Data/Internal/` lexes the source into +token spans whose classes map onto tokens the palette already has — no new +colours were invented for code, and no highlighting library was added +(`CODE-03`). Every token's text is HTML-encoded before it is wrapped, which is +what makes it safe to render model-authored code as markup. + +It is the natural surface for code an LLM produced, and it says so twice: it +takes `Ai` and renders the shared aura vocabulary, and `DrylMarkdown` delegates +every fenced code block to it, so a streamed markdown answer gets this surface +without the consumer asking for it. + +The copy button is stateful in a small way: it swaps its icon and its label to a +confirmation for a moment after a successful copy, and swaps back. The swap is +cancellable, so a rapid second press or a disposal never lands late. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Code` | `string` | `""` | The source to display. | +| `Language` | `string?` | `null` | Language label, and the highlighter's language selector. | +| `ShowLineNumbers` | `bool` | `false` | Renders a leading gutter of 1-based line numbers. | +| `Highlight` | `bool` | `true` | Highlights server-side. `false` renders encoded plain text. | +| `Ai` | `AiState` | `AiState.None` | Ambient AI state. | +| `Aura` | `AiAura?` | `null` | Pins the aura variant; `null` inherits a surrounding `DrylAiScope`. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the block's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +`SyntaxHighlighter` is `internal` and is not part of the public surface; the +languages it understands are listed under "Highlighting" below. + +## Acceptance Criteria + +### Structure + +- The component renders a single root element holding a header and a body. +- The header renders the language label and the copy button. +- The body renders the code inside a `pre` holding a `code` element, so the + whitespace of the source is preserved by the document rather than by CSS + alone. +- `ShowLineNumbers` set renders one gutter element before the code. +- `ShowLineNumbers` left `false` renders no gutter element. +- The gutter holds one number per line of `Code`, counting from 1. +- Empty `Code` yields a gutter holding the single number 1, so an empty block + does not render an empty gutter. +- Line counting treats a CRLF pair as one line break, so a Windows-authored + snippet is not numbered twice over. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. +- The root clips its content, so the header's corners are not overdrawn by the + body's fill. +- The body scrolls horizontally rather than widening the block, so a long line + does not stretch the page. + +### The language label + +- The label renders `Language` when one is given. +- The label renders a generic text label when `Language` is null, empty or + whitespace, so the header never renders an empty slot. +- The label is rendered upper-cased by the stylesheet, so the label's casing + does not depend on how the consumer spelled the language. + +### Highlighting + +- `Highlight` left `true` renders the code as token markup produced by the + server-side highlighter. +- `Highlight` set to `false` renders the code as HTML-encoded plain text. +- Every token's text is HTML-encoded before it is wrapped, so code containing + markup cannot inject anything into the page. +- An unrecognised `Language` renders HTML-encoded plain text rather than + failing. +- A null or empty `Language` renders HTML-encoded plain text. +- `Language` is matched case-insensitively and after trimming. +- Common aliases of a language select the same highlighter — for example `cs`, + `c#` and `dotnet` all select C#. +- The highlighter recognises C#, JavaScript, TypeScript, JSON, CSS, Bash, SQL + and Python through one generic lexer, and HTML and XML through a second. +- The highlighting is lexical rather than semantic, which is the same + granularity a browser highlighter uses. +- Each token kind maps onto an existing palette token; the highlighter + introduces no colour of its own. + +### Copying + +- Pressing the copy button writes `Code` to the clipboard. +- A successful copy swaps the button's label and its icon to a confirmation. +- The confirmation reverts on its own after a moment. +- A second press before the confirmation reverts supersedes the first, so the + label does not revert early. +- A failed copy leaves the button unchanged, so the confirmation never claims + something that did not happen. +- Disposing the component while the confirmation is pending cancels the revert, + so nothing calls into a disposed component. +- A copy attempted after the circuit has disconnected is abandoned silently + rather than throwing. +- Copying an empty `Code` writes an empty string rather than failing. + +### Keyboard and accessibility + +- The copy button is a `DrylButton` and is therefore reachable by `Tab` and + activated by `Enter` and `Space` without a key handler of its own. +- The copy button carries an accessible label, so it is announced as copying + code rather than as its icon. +- The line-number gutter is hidden from assistive technology, so the code is + announced as code rather than interleaved with its line numbers. +- The gutter's numbers are not selectable, so selecting the code and copying it + by hand does not pick them up. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The root is filled with `--glass-1` and outlined with `--line`. +- The root's corner comes from `--r-md`. +- The header is filled with `--glass-2` and separated from the body by a rule of + `--line`, so the two parts read as one surface with a seam rather than as two + surfaces. +- The language label is set in `--fg-dim` and in `--font-mono`. +- The code is set in `--fg` and in `--font-mono`. +- The gutter is set in `--fg-faint`, quieter than the code beside it, and + separated from it by a rule of `--line`. +- Keyword tokens are drawn in `--accent-a` and type tokens in `--accent-b`. +- String tokens are drawn in `--success` and number tokens in `--warning`. +- Comment tokens are drawn in `--fg-faint` and italicised. +- Punctuation tokens are drawn in `--fg-muted`. +- The token rules reach into the highlighter's markup through `::deep`, because + that markup is rendered as a `MarkupString` and carries no scope attribute. +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The block sits in the flow rather than floating, so it carries no frost + (`DESIGN-06`). +- The accent appears as syntax colour and, in AI mode, as a border and a glow — + never as the fill of the surface (`DESIGN-08`). +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- `Ai` defaults to `AiState.None`, so AI styling is opt-in. +- The aura variant follows `Aura` when set and a surrounding `DrylAiScope` + otherwise. +- The component renders the shared aura vocabulary rather than a code-specific + AI treatment (`AI-02`). +- Leaving AI mode keeps the aura mounted for one `--dur-slow` beat, so it + dissolves rather than snapping away. +- Entering `AiState.Generated` replays the one-shot completion wash, every time + it is entered. +- Re-entering `AiState.Generated` from a different state replays the wash rather + than being suppressed as a no-op. +- The aura lifecycle's timer is disposed with the component. + +## Recorded gaps + +- **The copy button never announces that it copied.** Its accessible label is + fixed, and an `aria-label` overrides the visible text — so the label a screen + reader reads stays "copy code" while the visible label reads "Copied". The one + user who most needs the confirmation is the one who does not get it. +- **The scrollable code has no keyboard access.** The body scrolls horizontally + and carries no `tabindex`, so a keyboard-only user cannot scroll a long line + into view — the classic WCAG 2.1.1 failure of a scroll container that is not + focusable. Two nested elements are scrollable, which also means the inner + scroll can be reached only with a pointer. +- **The code names no language to assistive technology.** `Language` is drawn as + a visible label in the header but is not carried on the `code` element, so a + screen reader has no way to know what it is reading. +- **The confirmation's duration is a literal.** The revert waits a + hand-picked number of milliseconds, written into the component in C#. It is + not a `--dur-*` violation — that scale governs CSS transitions — but it is the + one duration in the component with nothing behind it. +- **Streaming code is highlighted as if it were finished.** The highlighter runs + over whatever `Code` currently holds, so a half-arrived string literal or an + unclosed comment colours the remainder of the block until the closing + character arrives. The colours settle correctly; they flicker on the way. +- **The type sizes are literal.** `11px` for the language label and `12.5px` for + the code and the gutter are written into `DrylCodeBlock.razor.css` with no + token behind them (`DESIGN-01`). The paddings and gaps *are* tokens, so the + file is half-converted rather than untouched. +- **No tests of its own.** None of the criteria above is guarded by a test — + neither the line counting, nor the alias mapping, nor the encoding guarantee, + which is the one criterion with a security consequence. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--glass-1`, `--glass-2`, + `--line`, `--fg`, `--fg-dim`, `--fg-faint`, `--fg-muted`, `--accent-a`, + `--accent-b`, `--success` and `--warning` are the mode-dependent tokens; the + component defines no mode-specific rule. +- **Enter/exit animation** — none of its own, and that is the written exception + `DESIGN-11` allows for a static surface whose host decides when it appears; + `DrylMarkdown` and the AI surfaces that place it wrap it where its appearance + should be animated. The component *is* animated in the state that matters to + it — the aura, whose enter, dissolve and completion wash are specified above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decisions are the hidden, unselectable gutter and the labelled + copy button; the substantive omissions are the unannounced confirmation, the + unreachable scroll container and the unnamed language, all recorded above. +- **AI mode** — yes. The block carries `Ai` and `Aura` and renders the shared + vocabulary, because code is the artifact a model most often produces and the + surface should say while it is still arriving. +- **Demo page** — `DRYL.Website/Components/Pages/DemoCodeBlock.razor`, with the + examples `Components/Examples/CodeBlock/Basic.razor`, `.../Languages.razor`, + `.../LineNumbers.razor`, `.../HighlightingOff.razor` and `.../AiStates.razor`. +- **`ComponentCatalog`** — registered as `"Code Block"` / `code-block` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. From 610474631e8b28b381e8831a46108dc5548ace44 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:50:08 +0200 Subject: [PATCH 06/24] =?UTF-8?q?spec(E5):=20the=20description=20family=20?= =?UTF-8?q?=E2=80=94=20list=20and=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 40/127 → 42/127. The list is container-query-measured, so it collapses inside a narrow drawer on a wide screen; the item is one of the two components in the category with neither Class nor AdditionalAttributes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F8 DrylDescriptionList.md | 149 ++++++++++++++++++++++++ specs/E5 Data/F9 DrylDescriptionItem.md | 142 ++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 specs/E5 Data/F8 DrylDescriptionList.md create mode 100644 specs/E5 Data/F9 DrylDescriptionItem.md diff --git a/specs/E5 Data/F8 DrylDescriptionList.md b/specs/E5 Data/F8 DrylDescriptionList.md new file mode 100644 index 0000000..2160ebd --- /dev/null +++ b/specs/E5 Data/F8 DrylDescriptionList.md @@ -0,0 +1,149 @@ +# DrylDescriptionList + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylDescriptionList.razor + code/DRYL.Components/Components/Data/DrylDescriptionList.razor.css + code/DRYL.Components/Components/Data/DescriptionLayout.cs + +## User Story + +As a Blazor developer, I want to show a record's fields as read-only term/value +pairs that reflow sensibly when the space narrows, so that a detail panel is +legible on a phone and dense on a desktop without me writing two layouts. + +## Description + +`DrylDescriptionList` is the read-only counterpart of a form: a semantic `dl` +holding `DrylDescriptionItem` pairs (`F9`). It owns two decisions and cascades +one of them. + +`Layout` decides how each pair is arranged — term above value, or term beside +value in a label column — and is cascaded to every item, so a list is +consistently one or the other rather than a mix. + +`Columns` decides how many pairs sit in a row, as a grid. The number is a +maximum rather than a promise: the list is measured against **its own container** +rather than the viewport, and collapses to a single column when that container +gets narrow. That is what lets the same list be used in a wide detail page and +in a narrow drawer without a parameter changing. + +The container measurement is why the component renders a wrapper around its +`dl`: a container query cannot match the element that declares the containment, +so the containment lives one level up. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Layout` | `DescriptionLayout` | `DescriptionLayout.Stacked` | Term/value arrangement, applied to every item. | +| `Columns` | `int` | `1` | Number of pairs per row while there is room. | +| `ChildContent` | `RenderFragment?` | `null` | The `DrylDescriptionItem` entries. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the list's own class. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the `dl` element. | + +`DescriptionLayout`'s members are listed in [`_Api.md`](_Api.md). + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders a `dl` element holding its items, so a record is a + description list in the document rather than a grid of divs. +- The `dl` is wrapped in one element that establishes the size containment the + layout is measured against. +- `ChildContent` is rendered inside a cascading value that hands the list itself + to its items. +- The cascade is fixed, so an item never re-subscribes to it. +- `Columns` is published to the stylesheet as a custom property on the `dl`. +- `Class` is merged onto the `dl`'s own class rather than replacing it. +- `AdditionalAttributes` are applied to the `dl`. +- The `dl` carries no margin of its own, so the list does not add space its host + did not ask for. + +### Layout + +- The items are laid out in a grid of `Columns` equal columns. +- Each column may shrink below its content's intrinsic width, so a long value + wraps rather than widening the list. +- The gap between rows and the gap between columns are different, so a + two-column list does not read as a grid of four unrelated cells. +- Both gaps come from the spacing scale rather than from written lengths. +- The list collapses to a single column when its own container is narrow, + whatever `Columns` says. +- The collapse is measured against the list's container and not against the + viewport, so a list inside a narrow drawer collapses on a wide screen. +- `Layout` is readable by every item in the list. + +### Keyboard and accessibility + +- The list is not focusable and adds no stop to the tab order, because it is a + read-only record. +- The pairs are announced as terms and values by virtue of being a `dl`, without + any ARIA of the component's own. +- The component binds no key handler and manages no focus, because it owns no + interaction. + +### Appearance + +- The component renders no colour of its own and therefore names no literal + colour (`DESIGN-01`); the colours belong to the items. +- The list paints no surface of its own — no fill, no border, no frost — so it + inherits whatever ground it is placed on and `DESIGN-06` has nothing to apply + to. +- The component renders no accent, so `DESIGN-08` has nothing to apply to. +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): the list is a container that paints + nothing, so it has no surface for an aura to sit on. Where a *value* is + model-produced, the AI state belongs to whatever the consumer renders inside + that item — `DrylAiText`, a `DrylBadge`, a `DrylSkeleton` — at the granularity + of the field that is actually generated rather than the whole record. + +## Recorded gaps + +- **The collapse breakpoint is a literal, and a private one.** The width at + which the list drops to one column is written into the container query in + `DrylDescriptionList.razor.css` as a raw length, with no token behind it and + no relation to the `Breakpoint` scale the rest of the library uses + (`DESIGN-01`). A consumer cannot change it, and a list beside a + `DrylGrid` collapses at a different width than the grid does. +- **`Columns` is unvalidated.** Zero or a negative value produces an invalid + grid declaration that the browser discards, so the list silently falls back to + the browser's default rather than to one column, and a very large value + produces columns narrower than their content. Nothing clamps the parameter and + nothing reports it. +- **Nothing is animated.** Neither the list nor its collapse is animated: the + grid snaps from two columns to one as the container crosses the threshold, + and items appearing or leaving do so instantly (`DESIGN-11`, `DESIGN-12`). The + component is exactly the kind of layout the library animates elsewhere. +- **No tests of its own.** None of the criteria above is guarded by a test, and + the component is absent from `tests/DRYL.Components.Tests/ClassMergeTests.cs` + despite carrying a `Class` parameter. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — the component names no colour at all, so + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs` have nothing of its own to check; + the mode-dependent tokens are the items'. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the semantics come from the `dl` element rather + than from ARIA, so they survive every layout the component offers. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoDescriptionList.razor`, + with the examples `Components/Examples/DescriptionList/Stacked.razor` and + `.../Inline.razor`. +- **`ComponentCatalog`** — registered as `"Description List"` / + `description-list` in `DRYL.Website/Components/ComponentCatalog.cs`, flagged + not AI-capable. diff --git a/specs/E5 Data/F9 DrylDescriptionItem.md b/specs/E5 Data/F9 DrylDescriptionItem.md new file mode 100644 index 0000000..4625131 --- /dev/null +++ b/specs/E5 Data/F9 DrylDescriptionItem.md @@ -0,0 +1,142 @@ +# DrylDescriptionItem + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylDescriptionItem.razor + code/DRYL.Components/Components/Data/DrylDescriptionItem.razor.css + +## User Story + +As a Blazor developer, I want one field of a record shown as a labelled value +that arranges itself the way the record around it decided, so that I write the +field once and the detail panel stays consistent whichever layout it is in. + +## Description + +`DrylDescriptionItem` is one term/value pair inside a `DrylDescriptionList` +(`F8`). The term is a string with an optional leading icon; the value is a +`RenderFragment`, so it takes text, a badge, a link or a whole composed row. + +It owns no layout decision of its own. The arrangement — term above value, or +term beside value — is read from the list it sits in, which is what keeps a +record from being half stacked and half inline. + +The pair is grouped so that the two elements move together in the list's grid: +each item is one grid cell holding a term and a value, rather than the terms and +the values being two independent runs. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Term` | `string?` | `null` | The label. | +| `Icon` | `string?` | `null` | `DrylIcon` name rendered before the term. | +| `ChildContent` | `RenderFragment?` | `null` | The value. | + +The component has **no** `Class` and **no** `AdditionalAttributes` — see +"Recorded gaps". It takes no `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders one grouping element holding a `dt` and a `dd`, in that + order. +- The grouping element is what the list's grid places, so a term and its value + are never separated across a row boundary. +- `Term` is rendered inside the `dt`. +- `ChildContent` is rendered inside the `dd`. +- `Icon` set renders one `DrylIcon` inside the `dt`, before the term. +- `Icon` unset renders no icon element. +- The item may shrink below its content's intrinsic width, so a long value wraps + rather than widening its column. +- Neither the `dt` nor the `dd` carries a margin of its own, so the pair's + spacing is the list's. + +### Layout + +- The item reads its arrangement from the `DrylDescriptionList` it is inside. +- `DescriptionLayout.Stacked` arranges the term above the value. +- `DescriptionLayout.Inline` arranges the term and the value on one row, aligned + on their text baselines. +- `DescriptionLayout.Inline` gives the term a fixed label column that does not + shrink, so the values of several items line up with each other. +- An item rendered outside a `DrylDescriptionList` arranges itself as + `DescriptionLayout.Stacked` rather than failing. +- A value long enough to have no break opportunity is broken anyway rather than + overflowing its column. + +### Keyboard and accessibility + +- The term is a `dt` and the value is a `dd`, so the pair is announced as a term + and its description without any ARIA of the component's own. +- The item is not focusable and adds no stop to the tab order, because it is a + read-only field. +- The term's icon is decorative and is not part of the term's accessible name, + so the field is announced by its label rather than by its glyph. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The term is set in `--fg-muted` and the value in `--fg`, so the value is the + louder of the two. +- The value is set larger than the term, so the hierarchy survives in + monochrome. +- The item paints no surface of its own — no fill, no border, no frost — so it + inherits whatever ground it is placed on and `DESIGN-06` has nothing to apply + to. +- The component renders no accent, so `DESIGN-08` has nothing to apply to. +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`) and follows `F8`: the item paints no + surface for an aura to sit on, and a generated *value* is better expressed by + what the consumer renders into `ChildContent` — a `DrylAiText` while it + streams, a `DrylSkeleton` while it is pending — than by an aura around the + label as well. + +## Recorded gaps + +- **No `Class`, no `AdditionalAttributes`.** The item is one of the two + components in the category that carry neither, so a consumer cannot attach a + test hook, a `data-*` attribute or a style class to a single field. It is a + known hole in the library-wide `Class` rollout and not specific to this + component. +- **`Term` cannot be markup.** It is a `string`, so a field label carrying a + unit, a tooltip trigger or a required marker has to be built by not using + `Term` at all — and there is no term template to fall back on. +- **The label column's width is a literal.** The fixed width the inline layout + gives the term is written into `DrylDescriptionItem.razor.css` as a raw + length, as are the term's and the value's type sizes and the stacked layout's + gap (`DESIGN-01`). The inline layout's gap *is* a token, so the file is + half-converted rather than untouched. +- **Nothing is animated.** Switching the list's `Layout` re-arranges every item + instantly, and an item appearing or leaving does so with no transition + (`DESIGN-11`, `DESIGN-12`). +- **No tests of its own.** None of the criteria above is guarded by a test, + including the fallback to the stacked arrangement outside a list. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--fg-muted` and `--fg` are the + mode-dependent tokens; the component defines no mode-specific rule. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the pair's semantics come from `dt`/`dd` rather + than from ARIA, so they hold in both arrangements. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — shown on + `DRYL.Website/Components/Pages/DemoDescriptionList.razor` through the examples + `Components/Examples/DescriptionList/Stacked.razor` and `.../Inline.razor`. +- **`ComponentCatalog`** — reached through the `"Description List"` / + `description-list` entry in `DRYL.Website/Components/ComponentCatalog.cs`; the + catalog registers the lead component of a family and not its parts. From baf8c34d7efa0f959e958fc0cd422694a712da08 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:51:53 +0200 Subject: [PATCH 07/24] =?UTF-8?q?spec(E5):=20DrylImage=20=E2=80=94=20the?= =?UTF-8?q?=20category's=20most=20AI-native=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 42/127 → 43/127. Two findings worth the reading: the AI state label is written into an aria-label on a live region, which announces content changes and not label changes, so nothing reads it out; and AiState.Active adds an image-specific class for which no rule exists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F11 DrylImage.md | 266 +++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 specs/E5 Data/F11 DrylImage.md diff --git a/specs/E5 Data/F11 DrylImage.md b/specs/E5 Data/F11 DrylImage.md new file mode 100644 index 0000000..27eb422 --- /dev/null +++ b/specs/E5 Data/F11 DrylImage.md @@ -0,0 +1,266 @@ +# DrylImage + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylImage.razor + code/DRYL.Components/Components/Data/DrylImage.razor.css + +## User Story + +As a Blazor developer, I want an image that reserves its space before it +arrives, shows something sensible while it loads and something sensible when it +fails, so that a page does not jump, flash or leave a hole because of a picture. + +## Description + +`DrylImage` is a frame around an `img` that removes the boilerplate a careful +`img` needs. It lazy-loads and decodes asynchronously by default. It takes an +aspect ratio — either named through `Ratio` or derived from `Width` and `Height` +— and holds that box from the first paint, which is what keeps a loading image +from shifting the layout under it. It fades in when it loads, shows a shimmer +placeholder while it does not, and falls back twice on failure: first to +`FallbackSrc` if one was given, then to a stylised tile carrying an icon and the +alt text. + +It is also the category's most AI-native surface, and the only one where the +**AI state changes the image itself** rather than only the frame around it. +`AiState.Thinking` drifts a coloured cloud over the picture. `AiState.Streaming` +sharpens it out of blur — driven by `Progress` when the consumer knows how far +along the generation is, and on a timer when they do not. `AiState.Generated` +reveals it with a one-shot scale-in. All three are built from tokens and the +shared aura primitives; no colour, state or animation was invented for them +(`AI-04`). + +The frame has two layout modes and picks between them itself. With a ratio, the +frame owns the box and the image fills it absolutely. Without one, the frame +wraps the image's natural height, and the placeholder defines the box until the +image has one. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Src` | `string` | `""` (`EditorRequired`) | Image URL. | +| `Alt` | `string` | `""` (`EditorRequired`) | Alternative text; reused by the error fallback. | +| `Width` | `int?` | `null` | Intrinsic width in px. Caps the rendered width and, with `Height`, sets the ratio. | +| `Height` | `int?` | `null` | Intrinsic height in px. With `Width`, sets the ratio. | +| `Fit` | `DrylImage.ImageFit` | `ImageFit.Cover` | How the image fills its box. | +| `Position` | `DrylImage.ImagePosition` | `ImagePosition.Center` | Focal point when cropped. | +| `Rounded` | `DrylImage.ImageRounded` | `ImageRounded.None` | Corner rounding, from the radius scale. | +| `Ratio` | `DrylImage.ImageRatio` | `ImageRatio.Auto` | Forced aspect ratio; overrides the `Width`/`Height` ratio. | +| `Lazy` | `bool` | `true` | Lazy-loads the image. | +| `FallbackSrc` | `string?` | `null` | Second URL tried before the icon fallback. | +| `FallbackIcon` | `string?` | `null` | Icon of the error fallback. `null` uses a default. | +| `ShowSkeleton` | `bool` | `true` | Shows the shimmer placeholder while loading. | +| `Border` | `bool` | `false` | Outlines the frame. | +| `Shadow` | `bool` | `false` | Lifts the frame. | +| `Ai` | `AiState` | `AiState.None` | Ambient AI state; also drives effects on the image itself. | +| `Aura` | `AiAura?` | `null` | Pins the aura variant; `null` inherits a surrounding `DrylAiScope`. | +| `Progress` | `int?` | `null` | 0–100 sharpen progress for `AiState.Streaming`. `null` runs it on a timer. | +| `BlurDuration` | `int` | `2000` | Duration in ms of the timed sharpen. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the frame's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the frame. | + +The four enums are nested in `DrylImage` and are therefore written qualified — +`DrylImage.ImageRatio.Wide`. Their members are listed in [`_Api.md`](_Api.md). + +## Acceptance Criteria + +### Structure + +- The component renders a frame element holding the image and, depending on the + load phase, a placeholder or a fallback. +- The frame is a block that fills the width available to it. +- `Class` is merged onto the frame's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the frame. +- The frame carries the modifier class of its layout mode, one of the two. +- The frame carries the modifier class of its `Rounded` value, one per value. +- Every child of the frame inherits the frame's corner, so the image, the + placeholder and the fallback are rounded alike without repeating the value. +- `Border` set outlines the frame with `--line`. +- `Shadow` set lifts the frame with `--shadow-sm`. +- The frame does not clip its children, so the aura's glow can breathe outside + the box. + +### The box + +- `Ratio` at any value other than `ImageRatio.Auto` sets that ratio on the + frame. +- `Ratio` at `ImageRatio.Auto` with both `Width` and `Height` positive sets + their ratio on the frame. +- `Ratio` at `ImageRatio.Auto` with either dimension missing sets no ratio, and + the frame wraps the image's natural height instead. +- `Ratio` set wins over `Width` and `Height`. +- The ratio is written with invariant formatting, so a German locale does not + emit a decimal comma into the style attribute. +- `Width` set positive caps the frame's rendered width. +- In ratio mode the image fills the frame absolutely, so the box is held from + the first paint and the layout never shifts. +- In natural mode a not-yet-loaded image is taken out of the flow, so the + placeholder alone defines the height until the image has one. +- `Fit` maps to the image's object fit, one value per member. +- `Position` maps to the image's object position, one value per member. +- Any unmapped `Fit` value renders as `ImageFit.Cover` and any unmapped + `Position` value as `ImagePosition.Center`, so an unknown value still renders + an image. + +### Loading and failing + +- The image is lazy-loaded when `Lazy` is `true` and eagerly loaded when it is + `false`. +- The image is decoded asynchronously, so decoding a large picture does not + block the frame. +- The component starts in its loading phase whenever `Src` changes. +- An empty `Src` goes straight to the failed phase rather than requesting + nothing. +- Changing `Src` re-arms the fallback chain, so an image that failed once can + succeed at a new URL. +- A load error with an unused `FallbackSrc` retries at that URL and returns to + the loading phase. +- A load error with no `FallbackSrc`, or with one already tried, enters the + failed phase. +- The failed phase renders no `img` element at all, so a broken URL is not + requested again on every render. +- `ShowSkeleton` left `true` renders a shimmer placeholder while loading. +- `ShowSkeleton` set to `false` renders no placeholder. +- The placeholder is removed as soon as the image loads or fails. +- The fallback renders an icon and, when `Alt` is not empty, the alt text + beneath it. +- The fallback's icon is `FallbackIcon` when given and a default one otherwise. +- The fallback is outlined with a dashed border, so a failure reads as a missing + thing rather than as a deliberate empty tile. + +### Keyboard and accessibility + +- The image carries `Alt` as its alternative text. +- The fallback tile carries `role="img"` and `Alt` as its accessible label, so a + failed image is still announced as the picture it was meant to be. +- The placeholder is hidden from assistive technology, so a loading image is not + announced as a decoration. +- Every aura layer is hidden from assistive technology. +- The frame becomes a polite live region while `Ai` is anything but + `AiState.None`, and carries no live region at all otherwise. +- The frame is not focusable and adds no stop to the tab order, because it is a + picture and not a control. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The frame and the image are backed by `--glass-1`, so an image with + transparency sits on the library's ground rather than on the page's. +- The fallback is backed by `--glass-1`, outlined with `--line-strong` and set + in `--fg-dim`, with its text in `--fg-muted`. +- Each `ImageRounded` value maps to one radius token; `ImageRounded.Full` maps + to `--r-pill`. +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The frame sits in the flow rather than floating, so it carries no frost + (`DESIGN-06`). +- The accent appears as the aura's ring, comet, glow and wash and as the + thinking cloud — never as the fill of the frame (`DESIGN-08`). +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### Motion + +- The image fades in when it loads, at `--dur-med` with `--ease-out`. +- The fade is driven by a class the load handler adds, so it runs once per load + rather than on every render. +- `AiState.Thinking` drifts a two-part coloured cloud over the image, + continuously and alternating, so it reads as weather rather than as a loop. +- `AiState.Streaming` with `Progress` set eases the image's blur between values + at `--dur-med`. +- `AiState.Streaming` with `Progress` null animates the blur to zero over + `BlurDuration` and holds it there. +- `AiState.Generated` reveals the image with a one-shot scale-in at `--dur-slow` + with `--ease-out`. +- Under `prefers-reduced-motion: reduce` the thinking cloud stops moving. +- Under `prefers-reduced-motion: reduce` the timed sharpen does not run and the + image is left unblurred rather than stuck blurred. +- Under `prefers-reduced-motion: reduce` the generated reveal does not run. + +### AI mode + +- `Ai` defaults to `AiState.None`, so AI styling is opt-in. +- The aura variant follows `Aura` when set and a surrounding `DrylAiScope` + otherwise. +- The component renders the shared aura vocabulary — ring, comet, glow, wash — + rather than an image-specific aura (`AI-02`). +- The aura's markup is written out by the component rather than delegated to the + shared helper, because the image adds effects of its own on top of it. +- `AiState.Active` and `AiState.Generated` render the wash layer; the other + states do not. +- Leaving AI mode keeps the aura mounted for one `--dur-slow` beat, so it + dissolves rather than snapping away. +- The wash is not rendered while the aura is exiting, so a dissolving aura does + not flash a wash on its way out. +- Entering `AiState.Generated` replays the one-shot wash, every time it is + entered. +- The aura lifecycle's timer is disposed with the component. +- `Progress` is clamped into 0–100 before it is used, so an out-of-range value + cannot produce a negative blur. + +## Recorded gaps + +- **The state label is announced by nobody.** `Ai` set makes the frame a polite + live region and puts the state into the frame's `aria-label`. A live region + announces changes to its *content*, not to its label, and the frame's content + does not change when the state does — so "Generating image… 40%" is written + into the DOM and read by no screen reader. The percentage in particular + changes only in the label. +- **A dead class.** `AiState.Active` adds an image-specific modifier class to + the frame for which no rule exists anywhere in the library. The `Active` wash + a reader sees comes from the shared wash layer; the extra class does nothing + and has never done anything. +- **The blur radius is written twice, in two languages.** The timed sharpen's + starting blur lives in a keyframe in `DrylImage.razor.css` and the + progress-driven blur is computed against the same number in C#. Changing one + and not the other makes the two streaming modes disagree about how blurry + "0 %" is. +- **`BlurDuration` is a duration outside the scale.** It is a one-shot + animation's duration expressed as an `int` of milliseconds with a hand-picked + default, rather than one of the three motion tokens (`DESIGN-10`). It is + consumer-facing, so it cannot simply become a token — but nothing relates its + default to the rest of the library's motion. +- **The thinking cloud blends toward white.** It is composited with a screen + blend, which lightens whatever is under it. Over a dark image that reads as a + coloured drift; over a light image in light mode it mostly reads as a wash of + white. The component branches on no mode, so this is not a `DESIGN-02` + violation — but it is a mode-dependent *result* that needs checking by eye in + both modes rather than being assumed from the tokens. +- **`FallbackSrc` is only tried once, and only per `Src`.** Changing + `FallbackSrc` after a failure does not retry, because only a change of `Src` + re-arms the chain. +- **The fallback's geometry is literal.** Its minimum height and its text size + are written into `DrylImage.razor.css` with no token behind them + (`DESIGN-01`). +- **No tests of its own.** None of the criteria above is guarded by a test — + not the ratio derivation, not the two-step fallback chain, not the clamp on + `Progress`, and not the invariant formatting, which is the one criterion with + a known-recurring failure mode in this repository. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--glass-1`, `--line`, + `--line-strong`, `--fg-dim`, `--fg-muted`, `--accent-soft` and `--accent-b` + are the mode-dependent tokens; the component defines no mode-specific rule. + The screen-blended thinking cloud is the one effect whose *result* differs per + mode, recorded above. +- **Enter/exit animation** — the load fade is the enter animation and it is the + component's own. There is no exit: an image's removal is its host's decision, + and a host that wants it animated wraps the frame in `DrylPresence`. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the error fallback keeps `role="img"` and the alt + text, so a failure degrades rather than disappears; the substantive omission + is the unannounced state label, recorded above. +- **AI mode** — yes, and further than anywhere else in the category: the image + itself blurs, drifts and reveals per state, which is the cue a viewer reads + before they read the aura around the frame. +- **Demo page** — `DRYL.Website/Components/Pages/DemoImage.razor`, with the + examples `Components/Examples/Image/Basics.razor`, `.../Fallback.razor` and + `.../AiStates.razor`. +- **`ComponentCatalog`** — registered as `"Image"` / `image` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. From 56d9aada3345bbaf50d762d8b3ddb6fff89b0dd0 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:53:17 +0200 Subject: [PATCH 08/24] spec(E5): DrylPagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 43/127 → 44/127. The clamp that protects navigation does not protect the summary line, so an out-of-range CurrentPage renders "Showing 261-247 of 247" while the controls behave correctly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F13 DrylPagination.md | 213 ++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 specs/E5 Data/F13 DrylPagination.md diff --git a/specs/E5 Data/F13 DrylPagination.md b/specs/E5 Data/F13 DrylPagination.md new file mode 100644 index 0000000..5d6a1a0 --- /dev/null +++ b/specs/E5 Data/F13 DrylPagination.md @@ -0,0 +1,213 @@ +# DrylPagination + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylPagination.razor + +## User Story + +As a Blazor developer, I want a page navigator I can bind to any paged list — +not only to a `DrylTable` — so that a card grid, a gallery or a search result +page gets the same navigation, the same keyboard behaviour and the same summary +line without me rebuilding it. + +## Description + +`DrylPagination` is a bar in three parts: a summary of which items are showing, +an optional page-size selector, and the page controls themselves — first, +previous, a numbered range, next, last. + +It is **fully controlled**. It stores no page and no size of its own; it renders +what `CurrentPage` and `PageSize` say and raises a change when the user asks for +a different one. That is what lets the same component serve a table whose paging +happens in memory and a list whose paging is a server request. + +Pages are **zero-indexed on the wire and 1-based on screen**. `CurrentPage` is +`0` for the first page; the labels and the accessible names say "1". The +component owns that conversion so a consumer's collection maths and the user's +mental model can each stay natural. + +The numbered range is elided rather than complete: past a handful of pages it +shows the first, the last, a window around the current page, and ellipses for +what it left out — so a hundred pages fit in the same width as five. On a narrow +container the numbers drop out entirely and the arrow controls carry the +navigation, measured against the bar's own container rather than the viewport. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `CurrentPage` | `int` | `0` | Zero-indexed current page. | +| `CurrentPageChanged` | `EventCallback` | — | Raised with the new zero-indexed page. Pairs for `@bind-CurrentPage`. | +| `PageSize` | `int` | `20` | Items per page. | +| `PageSizeChanged` | `EventCallback` | — | Raised with the new size. Pairs for `@bind-PageSize`. | +| `TotalCount` | `int` | `0` | Total items across all pages. | +| `PageSizeOptions` | `IReadOnlyList` | `[10, 20, 50, 100]` | Choices in the size selector. | +| `ShowPageSize` | `bool` | `true` | Renders the size selector. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the bar's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the bar. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders a bar holding the summary, the optional size selector + and the page controls. +- The bar is wrapped in one element that establishes the size containment the + narrow layout is measured against. +- `Class` is merged onto the bar's own class rather than replacing it. +- `AdditionalAttributes` are applied to the bar. +- The bar wraps onto a second line rather than overflowing when its content does + not fit. +- The page controls are pushed to the trailing edge of the bar. + +### The summary + +- `TotalCount` above zero renders a summary naming the first item, the last item + and the total on the current page. +- The summary's item numbers are 1-based. +- The last number of the range never exceeds `TotalCount`, so the final page + reports its real end rather than a full page's worth. +- `TotalCount` at zero renders an explicit empty message instead of a range. + +### Paging + +- The page count is `TotalCount` divided by `PageSize`, rounded up. +- The page count is at least one, so an empty list still renders one page rather + than none. +- A `PageSize` of zero or less yields one page rather than a division by zero. +- Pressing a page control raises `CurrentPageChanged` with the target page. +- The target page is clamped into the valid range before it is raised, so "last + page" on a short list cannot ask for a page that does not exist. +- A control that would raise the page already shown raises nothing, so a + redundant press costs no round trip. +- The first and previous controls are disabled on the first page. +- The next and last controls are disabled on the last page. +- The component stores no page of its own: what it renders is `CurrentPage` + until the consumer changes it. + +### The numbered range + +- Seven pages or fewer are all listed, with no ellipsis. +- More than seven pages always list the first page and the last page. +- A current page near the start lists the pages after it and one trailing + ellipsis. +- A current page near the end lists the pages before it and one leading + ellipsis. +- A current page in the middle lists it with one page either side, between two + ellipses. +- The page shown as current is rendered in a different button variant from the + others, so it is distinguishable without colour alone. +- The numbered range is hidden when the bar's container is narrow, leaving the + summary and the arrow controls. +- The narrow layout is measured against the bar's container and not against the + viewport, so a bar inside a narrow panel collapses on a wide screen. + +### The page-size selector + +- `ShowPageSize` left `true` renders a size selector listing `PageSizeOptions`. +- `ShowPageSize` set to `false` renders no selector. +- The option matching `PageSize` is the selected one. +- Picking a size raises `PageSizeChanged` with that size. +- A size that does not parse as a number raises nothing. +- A size of zero or less raises nothing, so the bar cannot ask its host for an + impossible page size. +- The selector's `label` is associated with it by a per-instance identifier, so + two bars on one page do not share a label. + +### Keyboard and accessibility + +- The page controls sit in a landmark labelled as pagination, so a screen-reader + user can jump to them. +- Every arrow control carries an accessible label naming what it does rather + than being announced as its icon. +- Every numbered control carries an accessible label naming its 1-based page + number. +- The control for the page currently shown is marked as the current page for + assistive technology. +- The ellipsis is hidden from assistive technology, so the range is announced as + its pages. +- The ellipsis is not selectable, so selecting the bar's text does not pick it + up. +- Every control is a real button, so the bar is fully operable by `Tab`, `Enter` + and `Space` without a key handler of its own. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The summary and the selector's label are set in `--fg-muted`, quieter than the + controls. +- The summary is set in `--font-mono`, so its numbers do not shift the bar as + the page changes. +- The ellipsis is set in `--fg-dim` and in `--font-mono`. +- The bar paints no surface of its own — no fill, no border, no frost — so it + inherits the ground it is placed on, whether that is a table's footer or a + page (`DESIGN-06`). +- The component renders no accent of its own; the current page's emphasis comes + from the button variant (`DESIGN-08`). +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): paging is navigation, and navigation is + the user's action rather than a model's. Where the *content* being paged is + generated, the surface holding it carries the `Ai` state; an aura on the + navigator would say a model was working on the page numbers. + +## Recorded gaps + +- **The size selector is a raw `select`, not a `DrylSelect`.** It carries the + library's `select` class rather than the library's select component, so it + does not get that component's keyboard behaviour, its panel, its frost or its + animation — and it is the one control in the bar that does not look like the + rest of DRYL. It also carries both a visible `label` and an `aria-label`; the + latter wins, so the visible label is announced to nobody. +- **The summary trusts `CurrentPage`.** The range is computed from the parameter + without clamping, so a consumer who passes a page beyond the end renders a + summary like "Showing 261–247 of 247" while the controls behave correctly. The + clamp exists in the navigation path and not in the display path. +- **The elision window is written in literals.** The seven-page threshold, the + four pages listed near an edge and the one-page window around the current page + are hand-picked numbers in `BuildPageList`, with nothing relating them to each + other. Changing the window means changing three of them consistently. +- **The collapse breakpoint is a literal, and a private one.** The width at + which the numbers disappear is written into the container query in `dryl.css` + as a raw length, with no token behind it and no relation to the `Breakpoint` + scale the rest of the library uses (`DESIGN-01`) — the same gap `F8` records. +- **Nothing is animated.** The page controls animate because `DrylButton` does; + the bar itself has no transition, and the numbered range appearing or + disappearing at the breakpoint snaps (`DESIGN-11`, `DESIGN-12`). Nothing moves + when the current page changes, which is the one moment the component exists + for. +- **The bar's own type sizes are literal**, as are the selector's paddings and + minimum width and the gaps between the page buttons (`DESIGN-01`). + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--fg-muted` and `--fg-dim` are + the mode-dependent tokens; the component defines no mode-specific rule. +- **Enter/exit animation** — **absent** for the bar itself, and recorded above + as debt rather than as an exception; its buttons carry `DrylButton`'s motion. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decisions are the pagination landmark, the per-control labels and + the current-page marking; the substantive omission is the selector's competing + labels, recorded above. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoPagination.razor`, with the + examples `Components/Examples/Pagination/Full.razor`, `.../Minimal.razor`, + `.../ManyPages.razor`, `.../CustomSizes.razor` and `.../Empty.razor`. +- **`ComponentCatalog`** — registered as `"Pagination"` / `pagination` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. +- **Tests** — `tests/DRYL.Components.Tests/DrylPaginationTests.cs` guards the + summary, the empty message, the disabled edge controls, the next and last + controls' raised values including the clamp, a numbered control's raised + value, the size change and the hidden selector. It is the second-best-covered + component in the category, and it exists because the two callbacks were + renamed — the file pins the current names against the 1.0 freeze. From e2dcc3d85c262697de999d987104d92744f409fb Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:54:22 +0200 Subject: [PATCH 09/24] spec(E5): DrylSparkline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 44/127 → 45/127. The one component in the category whose header comment names the invariant-culture trap is also the one with no test guarding it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F14 DrylSparkline.md | 199 +++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 specs/E5 Data/F14 DrylSparkline.md diff --git a/specs/E5 Data/F14 DrylSparkline.md b/specs/E5 Data/F14 DrylSparkline.md new file mode 100644 index 0000000..7fa4386 --- /dev/null +++ b/specs/E5 Data/F14 DrylSparkline.md @@ -0,0 +1,199 @@ +# DrylSparkline + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylSparkline.razor + code/DRYL.Components/Components/Data/DrylSparkline.razor.css + code/DRYL.Components/Components/Data/SparklineKind.cs + +## User Story + +As a Blazor developer, I want a trend drawn at the size of a line of text, so +that a KPI tile, a table cell or a list row can show the shape of its history +without becoming a chart. + +## Description + +`DrylSparkline` draws a series as an inline `svg` — a line, a filled area or a +row of bars — sized in pixels rather than laid out. It is the smallest member of +the library's charting surface and deliberately not part of it: the components +in `E4 Charts` have axes, legends, tooltips and a shared base class, and this +one has a path. + +It is computed entirely on the server and rendered as markup. There is no +measurement, no interop and nothing to dispose, which is what makes it safe to +put a hundred of them in a table. + +Every coordinate it emits is formatted with invariant culture. That is not a +detail: a German locale renders `0.5` as `0,5`, and an SVG point list containing +a comma where a decimal point belongs is silently mis-parsed. The component +formats every number through `FormattableString.Invariant`, including the ones +inside path data and rectangle attributes. + +The series is scaled to its own extremes rather than to zero, so a sparkline +shows the *shape* of a movement rather than its magnitude — and a flat series is +drawn as a centred line rather than collapsing onto an edge. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Data` | `IReadOnlyList?` | `null` | The series to plot. | +| `Kind` | `SparklineKind` | `SparklineKind.Line` | Render style. | +| `Width` | `int` | `96` | Width in pixels. | +| `Height` | `int` | `32` | Height in pixels. | +| `ShowLastDot` | `bool` | `false` | Marks the final point with a dot. Line and area only. | +| `AriaLabel` | `string` | `"Trend"` | Accessible label for the chart. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the sparkline's own class. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the `svg`. | + +`SparklineKind`'s members are listed in [`_Api.md`](_Api.md). + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- `Data` holding at least one value renders one `svg` element. +- `Data` null or empty renders nothing at all. +- The `svg` renders at `Width` and `Height`. +- The `svg`'s coordinate space matches `Width` and `Height`, so a unit of data + space is a pixel. +- The `svg` declares one gradient whose identifier is unique per component + instance, so two sparklines on one page do not share a definition. +- `Class` is merged onto the `svg`'s own class rather than replacing it. +- `AdditionalAttributes` are applied to the `svg`. +- The `svg` does not clip its content, so a stroke on the outer edge is not cut + in half. + +### Scaling + +- The series is scaled between its own smallest and largest value rather than to + zero. +- A series whose values are all equal is drawn on the vertical centre line. +- A series of one value is drawn at the horizontal centre. +- Both axes are inset by a constant padding, so a stroke at the extreme does not + touch the edge of the box. +- The vertical scale is inverted, so a larger value is drawn higher. + +### Drawing + +- `SparklineKind.Line` renders one polyline through the points. +- `SparklineKind.Area` renders a filled path beneath the line **and** the line + itself, so the area kind is the line kind with a fill rather than a different + chart. +- The area path is closed along the baseline, so the fill has a flat bottom + rather than following the line back. +- `SparklineKind.Bar` renders one rectangle per value and no line. +- Each bar is centred in its slot and narrower than it, so bars are separated + without a gap value being written. +- A bar has a minimum width, so a long series does not render as invisible + hairlines. +- A bar has a minimum height, so a value at the series minimum still renders a + visible mark. +- A bar's corner radius never exceeds half its width, so a narrow bar does not + render as a lozenge. +- `ShowLastDot` set renders one dot at the final point, for the line and area + kinds. +- `ShowLastDot` set renders no dot for the bar kind. +- `ShowLastDot` left `false` renders no dot. + +### Locale safety + +- Every coordinate the component emits is formatted with invariant culture, so a + locale that uses a decimal comma cannot corrupt the SVG. +- This holds for the polyline's point list, the area path's commands, every + rectangle attribute and the dot's position alike. +- Coordinates are rounded to at most two decimals, so the emitted markup stays + small. + +### Keyboard and accessibility + +- The `svg` carries `role="img"`, so it is announced as one image rather than as + its shapes. +- The `svg` carries `AriaLabel` as its accessible label. +- `AriaLabel` has a non-null default, so a sparkline is never an unlabelled + image. +- The sparkline is not focusable and adds no stop to the tab order, because it + is a picture and not a control. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The line, the area and the bars are painted from a horizontal gradient running + from `--accent-a` to `--accent-b`. +- The gradient's stops are set by classes in the stylesheet rather than by + attributes, so the colours follow the theme rather than the markup. +- The last-point dot is filled with `--accent-b`. +- The area's fill is drawn at a low opacity, so the line stays the loudest part + of the mark. +- The line's ends and joins are rounded, so a short series does not read as a + spike. +- The sparkline paints no surface of its own — no fill behind the data, no + border, no frost — so it inherits whatever ground it is placed on + (`DESIGN-06`). +- The accent appears as a hairline, a faint fill or a row of small bars — never + as the fill of a large surface (`DESIGN-08`). +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): the sparkline is a mark inside another + surface — a `DrylStat`, a table cell, a list row — and every one of those + carries its own `Ai`. An aura at this size would be larger than the data it + surrounded, and it would compete with the aura of the tile it sits in. + +## Recorded gaps + +- **Nothing is animated.** The sparkline has no draw-in, no transition when the + data changes and no enter or exit (`DESIGN-11`, `DESIGN-12`). Its larger + siblings in `E4 Charts` animate their paths in; this one, which is most often + bound to live data, redraws by snapping. It is not wrapped in `DrylPresence` + and does not wrap itself in one. +- **An empty series renders nothing, not an empty box.** A sparkline whose data + has not arrived occupies no space and then occupies `Width` by `Height`, which + shifts whatever is beside it. Returning an empty frame of the same size would + cost nothing. +- **The label describes the chart, not the data.** `AriaLabel` defaults to a + generic word and nothing derives anything from the series, so a screen-reader + user learns that there is a trend and never learns which way it goes. The + component knows the first value, the last value and both extremes at render + time. +- **The geometry is written in literals across two files.** The stroke width and + the area's opacity are in `DrylSparkline.razor.css`; the padding, the minimum + bar width, the minimum bar height, the bar's share of its slot, the corner cap + and the dot's radius are constants and inline numbers in the component + (`DESIGN-01`). `Width` and `Height` are `int` pixels, the same gap `F10` + records for icons. +- **The bounds are computed more often than needed.** Every drawing helper + recomputes the series' extremes with its own pass, and the helper that returns + the last point's horizontal position computes them and discards the result. + Harmless at this size, and pointless at any size. +- **No tests of its own.** None of the criteria above is guarded by a test — in + particular not the invariant formatting, which is this repository's + best-documented recurring failure and the reason the component's own header + comment calls it out. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--accent-a` and `--accent-b` are + the mode-dependent tokens; the component defines no mode-specific rule. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the mark is always labelled; the substantive + omission is that the label says nothing about the data, recorded above. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoSparkline.razor`, with the + examples `Components/Examples/Sparkline/Kinds.razor`, `.../Sizes.razor` and + `.../EdgeCases.razor`. +- **`ComponentCatalog`** — registered as `"Sparkline"` / `sparkline` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. From bc62b4a6d88820881eb881ccb45cd1d96bf40243 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:57:15 +0200 Subject: [PATCH 10/24] =?UTF-8?q?spec(E5):=20the=20two=20number=20tiles=20?= =?UTF-8?q?=E2=80=94=20DrylStat=20and=20DrylTableKpi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 45/127 → 47/127. DrylTableKpi is the find of this pass: its sparkline paints four hardcoded colour literals, and DESIGN-01's enforcement grep cannot see them because it scans .razor.css and these live in .razor markup. They are the only such literals in the library. The visible consequence is that the tile ignores DrylThemeProvider — every accent on a re-themed page follows the seed except this chart. The stylesheet's own token-based rule for the same stroke has never had an effect, being overridden by the inline style. The component has no demo page and no catalog entry, which is why nothing ever showed it. Recorded, not fixed — the fix belongs to the follow-up plan. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F15 DrylStat.md | 195 ++++++++++++++++++++++++++++ specs/E5 Data/F17 DrylTableKpi.md | 209 ++++++++++++++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 specs/E5 Data/F15 DrylStat.md create mode 100644 specs/E5 Data/F17 DrylTableKpi.md diff --git a/specs/E5 Data/F15 DrylStat.md b/specs/E5 Data/F15 DrylStat.md new file mode 100644 index 0000000..522826b --- /dev/null +++ b/specs/E5 Data/F15 DrylStat.md @@ -0,0 +1,195 @@ +# DrylStat + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylStat.razor + code/DRYL.Components/Components/Data/DrylStat.razor.css + code/DRYL.Components/Components/Data/DeltaDirection.cs + +## User Story + +As a Blazor developer, I want one number presented as the headline of a card, +with its label, its movement and its recent history, so that a dashboard reads +as a set of answers rather than as a table of figures. + +## Description + +`DrylStat` is a glass card built around a single value. Above it sits a label +with an optional icon; beside it, an optional delta chip whose colour and arrow +follow `Direction`; below it, an optional sparkline slot pinned to the bottom of +the card so a row of stats lines up whether or not each one has a chart. + +The value is a **pre-formatted string**, not a number. That is deliberate: a KPI +is formatted by the application that owns it — currency, units, thousands +separators, culture — and a component that took a `double` would have to guess +all four. + +Two behaviours are worth naming. `CountUp` tweens the headline number rather +than snapping to it, counting from zero on the first render and from the +previous number afterwards; the tween runs in JS, rewrites the span's text +between renders and **always lands on exactly the string Blazor rendered**, so +the DOM a test or a screen reader sees is the real value with or without it. And +`Ai` gives the card the shared aura, because a KPI is one of the values a model +most often produces. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Label` | `string?` | `null` | Caption for the metric. | +| `Value` | `string?` | `null` | The headline value, pre-formatted. | +| `Icon` | `string?` | `null` | `DrylIcon` name shown before the label. | +| `Delta` | `string?` | `null` | Delta text, e.g. `"+12.4%"`. | +| `Direction` | `DeltaDirection` | `DeltaDirection.None` | Trend; drives the delta's colour and arrow. | +| `Sparkline` | `RenderFragment?` | `null` | Slot for a trend chart, typically a `DrylSparkline`. | +| `CountUp` | `bool` | `false` | Tweens the headline value instead of snapping to it. | +| `Ai` | `AiState` | `AiState.None` | Ambient AI state. | +| `Aura` | `AiAura?` | `null` | Pins the aura variant; `null` inherits a surrounding `DrylAiScope`. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the card's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +`DeltaDirection`'s members are listed in [`_Api.md`](_Api.md). + +## Acceptance Criteria + +### Structure + +- The component renders a single root element carrying the library's card + classes and the stat's own. +- The root holds a head row, a value row and, when the slot is filled, a + sparkline area. +- `Icon` set renders one `DrylIcon` before the label. +- `Icon` unset renders no icon element. +- `Label` is rendered in the head row. +- `Value` is rendered in the value row. +- `Sparkline` set renders the slot's content in its own area. +- `Sparkline` unset renders no sparkline area. +- The sparkline area is pushed to the bottom of the card, so cards of differing + content still align their charts. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. +- The card has a minimum height, so a stat with no delta and no chart is not + shorter than its neighbours. + +### The delta + +- The delta chip is rendered only when `Delta` is non-empty **and** `Direction` + is not `DeltaDirection.None`. +- `DeltaDirection.Up` renders an upward arrow before the delta text. +- `DeltaDirection.Down` renders a downward arrow before the delta text. +- `DeltaDirection.Neutral` renders no arrow. +- The chip carries the modifier class of its `Direction`, one per value. +- The chip sits on the value's text baseline, so a long value and its delta read + as one line. +- The chip wraps onto its own line rather than overflowing when the value is + wide. + +### Counting up + +- `CountUp` left `false` makes no interop call at all. +- `CountUp` set to `true` requests one tween on the first render. +- A further tween is requested only when `Value` actually changes, so a + re-render with an unchanged value does not replay the count. +- The rendered markup is identical with and without `CountUp`, so the value a + test or a screen reader reads never depends on JS having run. +- Switching `CountUp` on later starts the tween from the value currently shown + rather than replaying the whole history as one jump. +- A tween requested after the circuit has disconnected is abandoned silently + rather than throwing. +- A tween requested during prerender is abandoned silently rather than throwing. + +### Keyboard and accessibility + +- The card is not focusable and adds no stop to the tab order, because it is a + read-only metric. +- The label and the value are rendered as text, so a screen reader announces + them in that order. +- The delta's arrow is decorative and is not part of the chip's accessible name, + so the delta is announced as its text. +- The delta's direction is carried by its arrow and its text — the sign is in + the string — rather than by colour alone. +- Every aura layer is hidden from assistive technology. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The card's fill, border and frost are the library's shared card treatment + rather than the component's own. +- The label is set in `--fg-muted` and the value in `--fg`, so the number is the + loudest thing on the card. +- The icon is drawn in `--accent-a`. +- `DeltaDirection.Up` draws its chip in `--success`. +- `DeltaDirection.Down` draws its chip in `--danger`. +- `DeltaDirection.Neutral` draws its chip in `--fg-dim`. +- The delta is set in `--font-mono`, so a changing delta does not shift the row. +- The value is set with negative letter-spacing and a tight line height, so a + large number reads as a headline rather than as body text. +- The accent appears as the icon, the aura's ring and glow, and whatever the + sparkline slot draws — never as the fill of the card (`DESIGN-08`). +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- `Ai` defaults to `AiState.None`, so AI styling is opt-in. +- The aura variant follows `Aura` when set and a surrounding `DrylAiScope` + otherwise. +- The component renders the shared aura vocabulary through the shared helper + rather than a stat-specific AI treatment (`AI-02`). +- Leaving AI mode keeps the aura mounted for one `--dur-slow` beat, so it + dissolves rather than snapping away. +- Entering `AiState.Generated` replays the one-shot completion wash, every time + it is entered. +- The aura lifecycle's timer is disposed with the component. + +## Recorded gaps + +- **The card has no enter or exit animation of its own.** The aura animates, the + value can count up, and the card itself appears and disappears instantly + (`DESIGN-11`, `DESIGN-12`). A dashboard whose stats arrive one by one — the + case `Ai` exists for — pops them in. +- **The delta does not animate either.** A KPI whose trend flips from `Up` to + `Down` swaps its colour and its arrow between two frames, with no transition + on the one element whose whole job is to say that something moved. +- **`Value` is a string, so the count-up has to re-parse it.** The tween finds + the first number in the rendered string and animates that, which is what + allows a currency symbol or a unit to ride along — and also means a value with + two numbers in it (`"3 of 12"`) counts the wrong one, and a value with none + counts nothing. +- **The card's type sizes and its minimum height are literal.** The label's + `12px`, the value's `32px`, the delta's `11px` and the card's `132px` floor + are written into `DrylStat.razor.css` with no token behind them + (`DESIGN-01`). The gaps *are* tokens, so the file is half-converted rather + than untouched. +- **Only the count-up is tested.** `tests/DRYL.Components.Tests/DrylStatCountUpTests.cs` + guards the interop contract thoroughly; nothing guards the delta's render + condition, the direction mapping or the sparkline slot. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--accent-a`, `--fg`, + `--fg-muted`, `--fg-dim`, `--success` and `--danger` are the mode-dependent + tokens; the component defines no mode-specific rule. +- **Enter/exit animation** — **absent** for the card, and recorded above as debt + rather than as an exception; the aura's enter, dissolve and completion wash + are specified above, and the optional count-up is the value's own motion. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the tween never changes what the DOM says, so the + count-up is invisible to assistive technology by design rather than by + accident. +- **AI mode** — yes. A KPI is a value a model produces, and the card carries + `Ai` and `Aura` and renders the shared vocabulary. +- **Demo page** — `DRYL.Website/Components/Pages/DemoStat.razor`, with the + examples `Components/Examples/Stat/DashboardGrid.razor`, `.../CountUp.razor` + and `.../AiMode.razor`. +- **`ComponentCatalog`** — registered as `"Stat"` / `stat` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. +- **Tests** — `tests/DRYL.Components.Tests/DrylStatCountUpTests.cs` guards that + the count-up is opt-in, that it fires once on first render and that it fires + again only on a real value change; the `Class` merge is guarded in + `tests/DRYL.Components.Tests/ClassMergeTests.cs`. diff --git a/specs/E5 Data/F17 DrylTableKpi.md b/specs/E5 Data/F17 DrylTableKpi.md new file mode 100644 index 0000000..4f82c0d --- /dev/null +++ b/specs/E5 Data/F17 DrylTableKpi.md @@ -0,0 +1,209 @@ +# DrylTableKpi + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylTableKpi.razor + +## User Story + +As a Blazor developer, I want a row of summary figures above a table, so that a +reader sees what the data adds up to before they start reading rows. + +## Description + +`DrylTableKpi` is a single tile for `DrylTable`'s summary slot: a label, a large +value, an optional delta with a direction arrow, and an optional sparkline drawn +from raw numbers. Several tiles sit side by side in a grid the table provides, +separated by rules, and the tile is built to survive that — its label truncates +rather than wrapping and its content cannot widen its column. + +It is a **near-duplicate of two other components in this category**, and the +spec says so rather than describing it as a design. Its label/value/delta half +repeats `DrylStat` (`F15`) with a second set of enums for the same concept, and +its sparkline half repeats `DrylSparkline` (`F14`) with a second implementation +of the same maths. That duplication is where every gap recorded below comes +from, including the one real defect. + +Unlike `DrylStat` it takes no `Ai`, has no card of its own, and its value is +plain — no count-up. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Label` | `string` | `""` (`EditorRequired`) | Short caption above the value. | +| `Value` | `string` | `""` (`EditorRequired`) | The value, pre-formatted. | +| `Delta` | `string?` | `null` | Delta text, e.g. `"+8.2%"`. | +| `DeltaKind` | `DrylTableKpi.KpiDeltaKind` | `KpiDeltaKind.Neutral` | Colour treatment of the delta. | +| `Trend` | `DrylTableKpi.KpiTrend` | `KpiTrend.None` | Direction arrow before the delta. | +| `SparklineData` | `IReadOnlyList?` | `null` | Raw points for the mini chart; normalised automatically. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the tile's own class. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the tile. | + +Both enums are nested in `DrylTableKpi` and are therefore written qualified — +`DrylTableKpi.KpiTrend.Up`. Their members, and their relationship to +`DeltaDirection`, are set out in [`_Api.md`](_Api.md). + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders a single tile element holding a label, a value and a + footer. +- The footer holds the delta and the sparkline. +- `Delta` non-empty renders a delta element; empty or null renders none. +- `SparklineData` holding at least two points renders one `svg`; fewer renders + none. +- The tile's gradient definitions carry an identifier unique per instance, so + several tiles in one row do not share a definition. +- `Class` is merged onto the tile's own class rather than replacing it. +- `AdditionalAttributes` are applied to the tile. +- The tile may shrink below its content's intrinsic width and clips its + overflow, so one long value cannot widen the summary row. +- The label truncates with an ellipsis rather than wrapping to a second line, so + every tile in a row is the same height. +- The footer has a minimum height, so a tile without a delta or a chart aligns + with the tiles beside it. +- The tile is separated from the next by a rule, and the last tile in a row + carries none. + +### The delta + +- `KpiTrend.Up` renders an upward arrow before the delta text. +- `KpiTrend.Down` renders a downward arrow before the delta text. +- `KpiTrend.None` renders no arrow. +- The delta carries the modifier class of its `DeltaKind`, one per value. +- `DeltaKind` and `Trend` are independent, so a tile can show a downward arrow + in the positive colour — which is what a falling latency needs. +- The delta is aligned to the bottom of the footer, so it sits on the same line + as the base of the chart beside it. + +### The sparkline + +- The series is scaled between its own smallest and largest value rather than to + zero. +- A series whose values are all equal is drawn without dividing by zero. +- The chart renders a filled area beneath a line, both derived from the same + points. +- The area is closed along the bottom edge of the chart, so the fill has a flat + base. +- Both axes are inset by a constant margin, so the stroke at the extreme is not + cut by the edge. +- Every coordinate the chart emits is formatted with invariant culture, so a + locale that uses a decimal comma cannot corrupt the SVG. +- The chart is hidden from assistive technology, because the value and the delta + beside it already carry the information. + +### Keyboard and accessibility + +- The tile is not focusable and adds no stop to the tab order, because it is a + read-only summary. +- The label and the value are rendered as text, so a screen reader announces + them in that order. +- The trend arrow is decorative and is not part of the delta's accessible name. +- The delta's meaning is carried by its text and its arrow — the sign is in the + string — rather than by colour alone. + +### Appearance + +- The label is set in `--fg-muted` and the value in `--fg`, so the number is the + loudest thing on the tile. +- `KpiDeltaKind.Positive` draws the delta in `--success`. +- `KpiDeltaKind.Negative` draws the delta in `--danger`. +- `KpiDeltaKind.Neutral` draws the delta in `--fg-muted`. +- The delta is set in `--font-mono`, so a changing delta does not shift the + footer. +- The value is set with tabular figures, so a value that changes in place does + not shift the tile. +- The tile's separators come from `--line`. +- The tile paints no fill and no frost of its own, sitting on the summary bar + the table provides (`DESIGN-06`). +- The tile's colours, **with the exception of the sparkline's gradient**, come + from tokens; the exception is recorded as a violation below. + +### Motion + +- The sparkline is drawn at a reduced opacity and rises to full opacity when the + tile is hovered. +- That transition runs at `--dur-med` with `--ease-out`. + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): the tile is one cell of a summary bar + belonging to `DrylTable`, and that table carries the `Ai` state for the data + underneath it. Auras on each of four tiles above one table would be four + signals for one activity. A consumer who wants a stat card that carries its + own state uses `DrylStat` (`F15`), which does. + +## Recorded gaps + +- **Four hardcoded colours — and the enforcement grep cannot see them.** The + sparkline's stroke gradient and its fill gradient are written as literal + colour values in `DrylTableKpi.razor`. This is a flat `DESIGN-01` violation, + and it is not documented debt: `DESIGN-01`'s Check line greps + `code/*/**/*.razor.css` and reads **clean**, because these four literals live + in a `.razor` file and not in a stylesheet. Searching `.razor` files under + `code/` for colour literals returns exactly these four hits and nothing else + in the library. + + The consequence is not that the colours are wrong today — they happen to + equal the current accent values — but that they are *fixed*. `DrylThemeProvider` + re-derives the accents at runtime from a consumer's seed colour, and every + accent in the library follows except this chart, which keeps painting the + library's default violet-to-cyan on a re-themed page. + + The stylesheet even contains the correct rule: `.tbl-kpi-sparkline polyline` + sets its stroke from `--accent-b`. It has never had an effect, because the + inline `style` attribute on the same element overrides it. +- **A second sparkline implementation.** The scaling, the point list and the + area path are written again here rather than by placing a `DrylSparkline`, + with a different margin, a different rounding precision, a different flat-series + rule and no bar kind. Two implementations of one chart drift, and this one is + the copy that is not on the docs site. +- **A second delta vocabulary.** `KpiDeltaKind` and `KpiTrend` together express + what `DeltaDirection` expresses in one enum, and neither is convertible to the + other. A consumer with a row of `DrylStat` cards and a table summary writes + the same trend twice, in two types. Both are frozen by the 1.0 API freeze, so + this is recorded rather than corrected. +- **No demo page and no catalog entry.** The component appears nowhere in + `DRYL.Website` except inside one AI example, and there without + `SparklineData` — so the chart with the hardcoded colours has never been + rendered on the docs site at all. That is a `CODE-20` and a `REL-04` gap, and + it is the direct reason the colour violation survived: nothing shows it. +- **The tile itself is not animated.** Only the sparkline's hover opacity moves. + A tile appearing above a table, and a value changing in it, do so instantly + (`DESIGN-11`, `DESIGN-12`). +- **The chart's geometry is literal, in the markup.** The chart's width, height + and coordinate space are written into the `svg`'s attributes and repeated in + the scaling maths, so the chart's size cannot be changed without editing four + numbers in two places. Its type sizes and the footer's minimum height are + literals in `dryl.css` (`DESIGN-01`). +- **No tests of its own.** None of the criteria above is guarded by a test, + including the invariant formatting. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — **not clean.** The tile's own text and separators are + token-driven and verified by `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`; `--fg`, `--fg-muted`, `--success`, + `--danger` and `--line` are the mode-dependent tokens. The sparkline's four + literal colours are outside that system entirely — they neither swap with the + mode nor follow a runtime theme — and are recorded above as a `DESIGN-01` + violation. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception; only the sparkline's hover opacity transitions. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the chart is hidden from assistive technology + rather than given a label that would repeat the value beside it. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — **none.** Recorded above as a `CODE-20` gap; the component's + only appearance in `DRYL.Website` is + `Components/Examples/Ai/StreamingRows.razor`, which uses neither its delta nor + its sparkline. +- **`ComponentCatalog`** — **not registered.** Recorded above as a `REL-04` gap. + It is not reached through a family entry either: `DrylTable`'s entry does not + mention it, and unlike the other member components in this category it is not + shown on its parent's page. From 9c4021554f4cce06e123496d5b0cfdeb663cbe71 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 01:58:51 +0200 Subject: [PATCH 11/24] =?UTF-8?q?spec(E5):=20the=20timeline=20family=20?= =?UTF-8?q?=E2=80=94=20rail=20and=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 47/127 → 49/127. The family is split in two for one reason, now written down: in an agent trace one step is thinking while the steps above it are finished, so Ai lives on the item and not the rail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F18 DrylTimeline.md | 123 ++++++++++++++++ specs/E5 Data/F19 DrylTimelineItem.md | 196 ++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 specs/E5 Data/F18 DrylTimeline.md create mode 100644 specs/E5 Data/F19 DrylTimelineItem.md diff --git a/specs/E5 Data/F18 DrylTimeline.md b/specs/E5 Data/F18 DrylTimeline.md new file mode 100644 index 0000000..351b569 --- /dev/null +++ b/specs/E5 Data/F18 DrylTimeline.md @@ -0,0 +1,123 @@ +# DrylTimeline + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylTimeline.razor + code/DRYL.Components/Components/Data/DrylTimeline.razor.css + +## User Story + +As a Blazor developer, I want a sequence of events shown as a vertical rail, so +that an activity feed, an audit log or an agent's steps read as one ordered +story rather than as a stack of unrelated rows. + +## Description + +`DrylTimeline` is a container for `DrylTimelineItem` entries (`F19`). It is +deliberately almost empty: it stacks its items and declares them a list for +assistive technology, and that is all. The rail a reader sees — the marker, the +connecting line, the spacing under each entry — is drawn by each item, not by +the container. + +That split is what lets an item be styled by its own state. A marker's colour +follows the item's `Variant`, and a marker in AI mode carries the shared aura, +so an agent trace can show which step is in flight. A container that drew the +rail itself would have to know all of that. + +The container cascades nothing and holds no state, so an application that +renders its events from a collection binds the loop itself. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `ChildContent` | `RenderFragment?` | `null` | The `DrylTimelineItem` entries. | +| `AriaLabel` | `string` | `"Timeline"` | Accessible label for the list. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the timeline's own class. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders a single root element holding its items. +- The items are stacked vertically in the order they were written. +- `ChildContent` is rendered directly into the root, with no wrapper per item. +- `Class` is merged onto the root's own class rather than replacing it. +- `AdditionalAttributes` are applied to the root. +- The container draws no rail, no marker and no spacing of its own; each item + draws its own. + +### Keyboard and accessibility + +- The root carries `role="list"`, so the events are announced as a list with a + count rather than as loose text. +- The root carries `AriaLabel` as its accessible label. +- `AriaLabel` has a non-null default, so a timeline is never an unlabelled list. +- The timeline is not focusable and adds no stop to the tab order; anything + operable inside an item is the item's own. +- The component binds no key handler and manages no focus, because it owns no + interaction. + +### Appearance + +- The component renders no colour of its own and therefore names no literal + colour (`DESIGN-01`); the colours belong to the items. +- The timeline paints no surface of its own — no fill, no border, no frost — so + it inherits whatever ground it is placed on and `DESIGN-06` has nothing to + apply to. +- The component renders no accent, so `DESIGN-08` has nothing to apply to. +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`) and is the reason the family is split in + two: in an agent trace, one step is thinking while the steps above it are + finished. The state belongs per event, so `DrylTimelineItem` carries `Ai` and + the rail around it does not. An aura on the whole timeline would say the + entire history was in flight. + +## Recorded gaps + +- **The list role is asserted rather than structural.** The container is a `div` + with `role="list"` and each item a `div` with `role="listitem"`, instead of an + `ol`/`li` pair that would carry the same semantics natively and survive a CSS + reset, a copy-paste into another document or an `AdditionalAttributes` splat + that happens to set a role. +- **Anything but an item breaks the rail.** Each item hides its connecting line + when it is the last child of the container. That test is positional, so a + consumer who places any other element after the last item — a footer, a "load + more" row, a conditional block that renders something — gets a line dangling + off the end of the timeline into nothing. +- **Nothing is animated.** The timeline is the component in this category whose + content most obviously arrives over time — an event feed, an agent trace — and + neither the container nor a newly appended item is animated in any way + (`DESIGN-11`, `DESIGN-12`). It neither is nor wraps a `DrylPresence`. +- **No tests of its own.** None of the criteria above is guarded by a test; the + `Class` merge is the one thing about this component that is, in + `tests/DRYL.Components.Tests/ClassMergeTests.cs`. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — the component names no colour at all, so + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs` have nothing of its own to check; + the mode-dependent tokens are the items'. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is the always-labelled list role; its structural weakness + is recorded above. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoTimeline.razor`, with the + examples `Components/Examples/Timeline/EventFeed.razor` and + `.../AiTrace.razor`. +- **`ComponentCatalog`** — registered as `"Timeline"` / `timeline` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable — the flag + refers to the items, which is where `Ai` lives. diff --git a/specs/E5 Data/F19 DrylTimelineItem.md b/specs/E5 Data/F19 DrylTimelineItem.md new file mode 100644 index 0000000..44ef237 --- /dev/null +++ b/specs/E5 Data/F19 DrylTimelineItem.md @@ -0,0 +1,196 @@ +# DrylTimelineItem + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylTimelineItem.razor + code/DRYL.Components/Components/Data/DrylTimelineItem.razor.css + code/DRYL.Components/Components/Data/TimelineVariant.cs + +## User Story + +As a Blazor developer, I want one event on a timeline to carry its own marker, +its own colour and its own activity state, so that a finished step, a failed one +and one still running are distinguishable at a glance in the same feed. + +## Description + +`DrylTimelineItem` is one entry of a `DrylTimeline` (`F18`), and it draws the +whole rail for its own row: a circular marker on the leading edge, the +connecting line beneath it, and a body holding a title, a timestamp and free +content. + +The marker is where the component's two state systems meet. `Variant` tints it +semantically — a completed step green, a failed one red — and `Ai` wraps it in +the shared aura, so a tool call in flight glows while the steps above it sit +still. The two are independent: a `Success` step can be re-running. + +The marker's content follows the same "always something" rule as `DrylAvatar`: +an `Icon` when one is given, and a plain dot when none is. The dot takes the +marker's own colour, so the variant reads even without an icon. + +The connecting line is drawn by every item except the last, and the bottom +spacing is dropped on the last one, so a timeline ends flush rather than +trailing off. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Title` | `string?` | `null` | Title line for the event. | +| `Timestamp` | `string?` | `null` | Pre-formatted timestamp shown beside the title. | +| `Icon` | `string?` | `null` | `DrylIcon` name in the marker. `null` renders a dot. | +| `Variant` | `TimelineVariant` | `TimelineVariant.Default` | Colour treatment of the marker. | +| `ChildContent` | `RenderFragment?` | `null` | Body content for the event. | +| `Ai` | `AiState` | `AiState.None` | Ambient AI state; wraps the marker in the aura. | +| `Aura` | `AiAura?` | `null` | Pins the aura variant; `null` inherits a surrounding `DrylAiScope`. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the item's own class. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +`TimelineVariant`'s members are listed in [`_Api.md`](_Api.md). + +## Acceptance Criteria + +### Structure + +- The component renders a root element holding a gutter and a body, in that + order. +- The gutter holds the marker and the connecting line. +- The gutter does not shrink, so a long title cannot squeeze the rail. +- `Icon` set renders one `DrylIcon` inside the marker. +- `Icon` unset renders a dot inside the marker. +- `Title` or `Timestamp` set renders a head row inside the body. +- Neither set renders no head row. +- `Title` set renders the title in the head row. +- `Timestamp` set renders the timestamp in the head row, after the title. +- `ChildContent` set renders a content area inside the body. +- `ChildContent` unset renders no content area. +- An item with none of `Title`, `Timestamp` and `ChildContent` renders a marker + and an empty body rather than failing. +- The title and the timestamp sit on the same text baseline and wrap onto a + second line rather than overflowing. +- The body may shrink below its content's intrinsic width, so a long word wraps + rather than widening the timeline. +- `Class` is merged onto the root's own class rather than replacing it. +- `AdditionalAttributes` are applied to the root. + +### The rail + +- Every item draws a connecting line beneath its marker. +- The last item in its container draws no connecting line, so the rail ends at + the last marker. +- The connecting line stretches to fill the height of the item, so a tall body + and a short one both end at the next marker. +- The connecting line has a minimum height, so two adjacent short items still + read as connected. +- Every item carries bottom spacing under its body. +- The last item in its container carries none, so a timeline ends flush. + +### Keyboard and accessibility + +- The root carries `role="listitem"`, so the event is counted as one item of the + timeline's list. +- The item is not focusable and adds no stop to the tab order; anything operable + in `ChildContent` is the consumer's own. +- The marker's icon is decorative and is not part of the item's accessible name, + so the event is announced by its title rather than by its glyph. +- Every aura layer is hidden from assistive technology. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The marker is filled with `--glass-2` and outlined with `--line-strong` by + default, and set in `--fg-muted`. +- `TimelineVariant.Accent` outlines the marker with `--accent-line` and sets it + in `--accent-a`. +- `TimelineVariant.Success`, `TimelineVariant.Warning` and + `TimelineVariant.Danger` each derive the marker's border and text from their + own semantic token — `--success`, `--warning` and `--danger` respectively. +- The three semantic variants derive their border from the same token as their + text, so a new semantic colour needs one value rather than two. +- Any `TimelineVariant` value the switch does not match is treated as + `TimelineVariant.Default`, so an unmapped value still renders a marker. +- The dot takes `currentColor`, so it matches whatever variant the marker is + without a rule of its own. +- The connecting line is drawn in `--line`, quieter than any marker. +- The title is set in `--fg`, the body in `--fg-muted` and the timestamp in + `--fg-dim`, so the three levels of the entry are distinguishable in + monochrome. +- The timestamp is set in `--font-mono`, so timestamps of different events align + with each other. +- The marker sits in the flow rather than floating, so it carries no frost + (`DESIGN-06`). +- The accent appears as a 1px marker border, a small dot and the aura's ring and + glow — never as the fill of the marker or the row (`DESIGN-08`). +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- `Ai` defaults to `AiState.None`, so AI styling is opt-in. +- The aura variant follows `Aura` when set and a surrounding `DrylAiScope` + otherwise. +- The aura is applied to the **marker**, not to the row, so a running step is + marked on the rail where a reader is already looking. +- The component renders the shared aura vocabulary through the shared helper + rather than a timeline-specific AI treatment (`AI-02`). +- `Ai` and `Variant` are independent, so a step can be both semantically + successful and currently re-running. +- Leaving AI mode keeps the aura mounted for one `--dur-slow` beat, so it + dissolves rather than snapping away. +- Entering `AiState.Generated` replays the one-shot completion wash, every time + it is entered. +- The aura lifecycle's timer is disposed with the component. + +## Recorded gaps + +- **The timestamp is not a time.** It is a pre-formatted `string` rendered into + a `span`, so a screen reader gets whatever the consumer wrote and nothing + machine-readable — no `time` element, no `datetime` attribute. A relative + label like "2 min ago" is therefore frozen text with no underlying instant. +- **The rail's end is positional.** The connecting line and the bottom spacing + are dropped by a last-child selector, so any non-item element after the last + item leaves a line dangling — the container-side half of this is recorded in + `F18`. +- **Nothing about the item is animated except its aura.** An event appended to a + feed appears instantly, and a `Variant` change swaps colours between two + frames (`DESIGN-11`, `DESIGN-12`). For an agent trace — the use the component + documents — a step arriving is the moment the component exists for. +- **The marker's geometry is literal.** The marker's `28px` box, the dot's + `7px`, the line's minimum height and margins, the body's inner gap and the + three type sizes are written into `DrylTimelineItem.razor.css` with no token + behind them (`DESIGN-01`). The outer gaps and the bottom spacing *are* tokens, + so the file is half-converted rather than untouched. +- **The marker's size does not follow the icon's.** The icon inside it is + rendered at a bare pixel size chosen in the component, the same literal-size + gap `F10` records. +- **No tests of its own.** None of the criteria above is guarded by a test, + including the independence of `Ai` and `Variant`, which is the component's one + non-obvious rule. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--glass-2`, `--line-strong`, + `--line`, `--fg`, `--fg-muted`, `--fg-dim`, `--accent-a`, `--accent-line`, + `--success`, `--warning` and `--danger` are the mode-dependent tokens; the + component defines no mode-specific rule. +- **Enter/exit animation** — **absent** for the item, and recorded above as debt + rather than as an exception; the aura's enter, dissolve and completion wash + are specified above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the marker's icon never enters the accessible + name, so the event is announced by its title; the substantive omission is the + unmachine-readable timestamp, recorded above. +- **AI mode** — yes, and placed deliberately: the aura wraps the marker rather + than the row, so a running step is signalled on the rail rather than by + lighting up a paragraph. +- **Demo page** — shown on `DRYL.Website/Components/Pages/DemoTimeline.razor` + through the examples `Components/Examples/Timeline/EventFeed.razor` and + `.../AiTrace.razor`. +- **`ComponentCatalog`** — reached through the `"Timeline"` / `timeline` entry + in `DRYL.Website/Components/ComponentCatalog.cs`; the catalog registers the + lead component of a family and not its parts. From 8dc31c1a7df0faa9c71a84a5890e17c4b327dbe3 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 02:01:04 +0200 Subject: [PATCH 12/24] =?UTF-8?q?spec(E5):=20the=20tree=20family=20?= =?UTF-8?q?=E2=80=94=20view=20and=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 49/127 → 51/127. The division of labour is now written down: the view owns selection and roving focus because both are singular, the node owns expansion because it is per-node and toggled far more often. Clearing SelectedValue to null does not deselect, and Disabled disables selection only — both recorded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/F20 DrylTreeView.md | 203 ++++++++++++++++++++++++++++ specs/E5 Data/F21 DrylTreeNode.md | 213 ++++++++++++++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 specs/E5 Data/F20 DrylTreeView.md create mode 100644 specs/E5 Data/F21 DrylTreeNode.md diff --git a/specs/E5 Data/F20 DrylTreeView.md b/specs/E5 Data/F20 DrylTreeView.md new file mode 100644 index 0000000..0d6a269 --- /dev/null +++ b/specs/E5 Data/F20 DrylTreeView.md @@ -0,0 +1,203 @@ +# DrylTreeView + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylTreeView.razor + code/DRYL.Components/Components/Data/DrylTreeView.razor.css + +## User Story + +As a Blazor developer, I want a hierarchy I can write as nested markup and +navigate with the arrow keys, so that a file browser, a category picker or a +document outline behaves the way a tree is expected to behave without me +implementing the WAI-ARIA pattern myself. + +## Description + +`DrylTreeView` is the owner half of a two-part tree. It is declarative: a +consumer nests `DrylTreeNode` elements (`F21`) and the view discovers the shape +from the nodes that register with it. + +The division of labour is the component's central decision. **The view owns +selection and focus; each node owns its own expansion.** Selection has to be +central because there is one selected node in a tree and it is two-way bound. +Focus has to be central because a tree is one tab stop with a roving +`tabindex`, and only something that can see every node can decide which one +carries it. Expansion, by contrast, is per node, is toggled by the user far more +often, and is two-way bound per node. + +Keyboard navigation is the WAI-ARIA tree pattern: the arrow keys move through +the *visible* nodes — a pre-order walk that steps into expanded parents and over +collapsed ones — `ArrowRight` expands or steps in, `ArrowLeft` collapses or +steps out, `Home` and `End` jump to the ends, and `Enter` or `Space` selects. + +One interop call supports this and does one thing: it stops the browser from +scrolling the page when the navigation keys are pressed, so the component's own +key handler can move focus instead. The tree's `keydown` handling itself is +Blazor's. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `SelectedValue` | `object?` | `null` | Value of the selected node. Supports `@bind-SelectedValue`. | +| `SelectedValueChanged` | `EventCallback` | — | Raised when the selection changes. | +| `ChildContent` | `RenderFragment?` | `null` | The root `DrylTreeNode` elements. | +| `AriaLabel` | `string` | `"Tree"` | Accessible label for the tree. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the tree's own class. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders a `ul` as its root, so the hierarchy is a list in the + document rather than a stack of divs. +- `ChildContent` is rendered inside a cascading value that hands the view itself + to its nodes. +- The cascade is fixed, so a node never re-subscribes to it. +- `Class` is merged onto the root's own class rather than replacing it. +- `AdditionalAttributes` are applied to the root. +- The root renders no list marker, because each node draws its own chevron. + +### Membership + +- A node registers itself with the view when it initialises. +- Registering a node that is already registered changes nothing. +- A node unregisters itself when it is disposed. +- Unregistering the selected node clears the selection, so a removed node does + not stay selected in absentia. +- Unregistering the focused node clears the focus target, so the roving + `tabindex` falls back rather than pointing at a node that is gone. +- The view derives a node's children from the nodes that registered with it, + rather than from the markup. + +### Selection + +- The view holds exactly one selected node at a time. +- Selecting a node raises `SelectedValueChanged` with that node's `Value`. +- Selecting a node also makes it the focus target, so the arrow keys continue + from where the user clicked. +- A `SelectedValue` supplied before the nodes exist selects the matching node as + soon as it registers, so a tree can be pre-selected by value. +- A `SelectedValue` changed to a value matching a registered node moves the + selection to it. +- Values are compared by equality rather than by reference, so a `string` or a + record works as a node value. +- Changing the selection re-renders every node, not only the view, so the + previously selected node loses its highlight. + +### Focus and keyboard + +- The tree is a single tab stop: exactly one node carries `tabindex="0"` and + every other carries `-1`. +- The focus target defaults to the focused node, then the selected node, then + the first visible node. +- `ArrowDown` moves focus to the next visible node. +- `ArrowUp` moves focus to the previous visible node. +- `Home` moves focus to the first visible node. +- `End` moves focus to the last visible node. +- Movement is over *visible* nodes only — a pre-order walk that descends into + expanded parents and skips the subtrees of collapsed ones. +- Moving to the first or last node from an end of the list is a no-op rather + than a wrap-around. +- Moving focus re-renders every node, so the roving `tabindex` is correct on all + of them before the browser moves focus. +- The browser's default page scroll is suppressed for the navigation keys, so + arrowing through a long tree does not scroll the page. +- `Tab`, `Enter` and `Escape` are left to the browser and to the node's own + handler, so focus can still leave the tree. + +### Interop and cleanup + +- The key-suppression handler is attached once, on the first render. +- The handler is detached when the component is disposed. +- Detaching is skipped entirely when nothing was ever attached, so a statically + rendered tree does not attempt interop while being torn down. +- Detaching survives a disconnected circuit, a missing element and a static + render without throwing. + +### Keyboard and accessibility + +- The root carries `role="tree"`. +- The root carries `AriaLabel` as its accessible label. +- `AriaLabel` has a non-null default, so a tree is never an unlabelled widget. +- The roving `tabindex` is what makes the tree one stop in the page's tab order, + as the WAI-ARIA tree pattern requires. + +### Appearance + +- The component renders no colour of its own and therefore names no literal + colour (`DESIGN-01`); the colours belong to the nodes. +- The tree paints no surface of its own — no fill, no border, no frost — so it + inherits whatever ground it is placed on and `DESIGN-06` has nothing to apply + to. +- The component renders no accent, so `DESIGN-08` has nothing to apply to. +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): a tree is a navigation structure the + user drives, and the view paints no surface to put an aura on. Where a subtree + is being generated — an agent building a file layout, say — the state belongs + to the individual node, and `DrylTreeNode` does not carry it either. That is + the honest position: the tree family currently has no route to the AI + vocabulary, and adding one to the node would be a new feature, not a fix. + +## Recorded gaps + +- **Clearing `SelectedValue` does not deselect.** The view adopts an externally + supplied `SelectedValue` only when it is non-null, so setting the bound value + back to `null` leaves the previously selected node highlighted and still + reported as selected. The only way to clear a selection is to remove the node. +- **Every node costs the view a render.** Registration calls `StateHasChanged` + unconditionally, so mounting a tree of *n* nodes queues *n* extra renders of + the view and its whole subtree during the first render pass — and each of + those re-renders every node explicitly. It is the same gap `F2` records for + the avatar group, at a size where it matters more. +- **`SelectedValue` is written to from inside the component.** Selection assigns + the parameter directly before raising its callback, which Blazor's parameter + contract does not sanction. It works with `@bind-SelectedValue` and it is what + makes the pre-selection lookup in `OnParametersSet` see a consistent value — + but a consumer who binds only `SelectedValueChanged` and re-supplies + `SelectedValue` from their own state will find the component briefly + disagreeing with them. +- **No type-ahead.** The WAI-ARIA tree pattern expects typing a character to + move focus to the next node starting with it, which is how a user finds a node + in a long list. The tree handles the arrow keys, `Home`, `End`, `Enter` and + `Space` and nothing else. +- **No expand-all or collapse-all key.** The pattern's `*` on a level, and the + `ArrowLeft`-to-root behaviour on a root node, are not implemented. +- **Nothing about the view is animated.** The rows transition their colours and + the chevron rotates — both the node's — but expanding a subtree shows and + hides it instantly, with no height or opacity transition and no `DrylPresence` + (`DESIGN-11`, `DESIGN-12`). Expansion is the tree's main gesture. +- **No tests of its own.** None of the criteria above is guarded by a test — + not the visible-node walk, not the roving `tabindex`, not the pre-selection by + value, and not the interop attach/detach pair, which is the part with a + known-recurring prerender failure mode in this repository. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — the component names no colour at all, so + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs` have nothing of its own to check; + the mode-dependent tokens are the nodes'. +- **Enter/exit animation** — **absent**, and recorded above as debt rather than + as an exception. +- **Keyboard and a11y** — the "Focus and keyboard" and "Keyboard and + accessibility" criteria above. This is the category's most substantial + keyboard implementation: a real roving `tabindex` over a real visible-node + walk. What it is missing against the full WAI-ARIA pattern — type-ahead and + the expand-all keys — is recorded above. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoTreeView.razor`, with the + example `Components/Examples/TreeView/Files.razor`. +- **`ComponentCatalog`** — registered as `"Tree View"` / `tree` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. diff --git a/specs/E5 Data/F21 DrylTreeNode.md b/specs/E5 Data/F21 DrylTreeNode.md new file mode 100644 index 0000000..2ed5c08 --- /dev/null +++ b/specs/E5 Data/F21 DrylTreeNode.md @@ -0,0 +1,213 @@ +# DrylTreeNode + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylTreeNode.razor + code/DRYL.Components/Components/Data/DrylTreeNode.razor.css + +## User Story + +As a Blazor developer, I want a tree node I can nest inside another node to +build a hierarchy, and that keeps a user's expand/collapse gesture even when the +page around it re-renders, so that a tree does not snap shut while the user is +working in it. + +## Description + +`DrylTreeNode` is one row of a `DrylTreeView` (`F20`), and it becomes a parent +simply by having nodes nested inside it — there is no "is folder" parameter. +Each node renders its own row: a chevron if it has children and a spacer if it +does not, an optional icon, and the label. Its depth is derived by walking its +ancestors and is published to the stylesheet, so the indent is one rule rather +than one class per level. + +The subtlety worth naming is **how the node holds its expansion**. `Expanded` is +two-way bindable, so a consumer can open a branch programmatically — but a bare +re-render of the parent re-supplies the same literal value, and naively adopting +it would slam a user's open branch shut on every unrelated render. The node +therefore tracks the last value it *received as a parameter* separately from the +value it is *currently showing*, and adopts the parameter only when the consumer +actually changes it. A user's toggle survives; a consumer's assignment wins. + +Selection and focus are not the node's: they belong to the view, which re-renders +each node when either moves. The node contributes the keyboard handler, because +the key event arrives on the row it rendered. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Text` | `string?` | `null` | Node label. | +| `Icon` | `string?` | `null` | `DrylIcon` name shown before the label. | +| `Value` | `object?` | `null` | Value used for the tree's selection binding. | +| `Expanded` | `bool` | `false` | Whether the node is expanded. Supports `@bind-Expanded`. | +| `ExpandedChanged` | `EventCallback` | — | Raised when the expanded state changes. | +| `Disabled` | `bool` | `false` | Prevents selection of the node. | +| `ChildContent` | `RenderFragment?` | `null` | Child nodes. | + +The component has **no** `Class` and **no** `AdditionalAttributes` — see +"Recorded gaps". It takes no `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Structure + +- The component renders an `li` as its root. +- The root holds a row element and, when `ChildContent` is set, a nested `ul` + for the children. +- The row holds a chevron or a spacer, an optional icon and the label, in that + order. +- A node with registered children renders a chevron. +- A node without registered children renders a spacer of the same size, so + labels at one level line up whether or not their siblings have children. +- `Icon` set renders one `DrylIcon` in the row. +- `Icon` unset renders no icon element. +- `Text` is rendered as the row's label. +- The label truncates with an ellipsis rather than wrapping, so a deep tree does + not grow rows of different heights. +- The node's depth is published to the stylesheet as a custom property on the + row, and the indent is derived from it. +- A node placed outside a `DrylTreeView` throws on initialisation rather than + rendering a broken row. + +### Expansion + +- The node adopts `Expanded` as its initial state. +- Toggling the chevron flips the node's expanded state. +- Toggling raises `ExpandedChanged` with the new state, when a handler is bound. +- Toggling notifies the view, so the visible-node walk and the roving `tabindex` + account for the change. +- A re-render that re-supplies the same `Expanded` value does not reset a user's + toggle. +- A change of `Expanded` by the consumer does override the user's toggle. +- The children group is hidden while the node is collapsed and shown while it is + expanded. +- The chevron press does not also select the node, so opening a folder and + choosing it are separate gestures. + +### Selection + +- The row reports itself as selected when the view says it is. +- Clicking the row selects the node. +- Clicking the row of a disabled node selects nothing. + +### Keyboard + +- `ArrowDown` and `ArrowUp` ask the view to move focus to the next and previous + visible node. +- `Home` and `End` ask the view to move focus to the first and last visible + node. +- `ArrowRight` on a collapsed parent expands it. +- `ArrowRight` on an expanded parent moves focus to its first child. +- `ArrowRight` on a node with no children does nothing. +- `ArrowLeft` on an expanded parent collapses it. +- `ArrowLeft` on a collapsed node or a leaf moves focus to its parent. +- `ArrowLeft` on a root-level node with nothing to collapse does nothing. +- `Enter` and `Space` select the node. +- `Enter` and `Space` on a disabled node select nothing. +- The row is focusable programmatically, so the view can move focus onto it. + +### Keyboard and accessibility + +- The root carries `role="treeitem"`. +- The children group carries `role="group"`. +- A node with children carries `aria-expanded` reflecting its state. +- A node without children carries no `aria-expanded`, because a leaf has no + expanded state to report. +- Every node carries `aria-selected` reflecting whether it is the selected one. +- A disabled node carries `aria-disabled`. +- The chevron and the icon are hidden from assistive technology, so the node is + announced by its label. +- The row carries `tabindex="0"` only when the view names it the focus target, + and `-1` otherwise. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The row is set in `--fg-muted` at rest and `--fg` on hover, over `--glass-2`. +- The selected row is filled with `--accent-soft` and set in `--fg`. +- The selected row's icon is drawn in `--accent-a`, so selection is marked twice + — by fill and by icon colour — rather than by colour alone. +- The chevron and the icon are drawn in `--fg-dim`, quieter than the label. +- The row's corner comes from `--r-sm` and the chevron's from `--r-xs`. +- The row shows a focus ring when focused by keyboard. +- The row's own outline is suppressed in favour of that ring. +- The row sits in the flow rather than floating, so it carries no frost + (`DESIGN-06`). +- The accent appears as a soft fill behind one row and the colour of one small + icon, never as the fill of a large surface (`DESIGN-08`). +- The component uses its own isolated stylesheet rather than adding to the + global one. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### Motion + +- The row transitions its fill and its text colour between rest, hover and + selected. +- The chevron rotates between its collapsed and expanded positions. +- Both transitions run at `--dur-fast` with `--ease-out`. + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision follows `F20` (`AI-05`): the tree family has no route to the AI + vocabulary at all. That is recorded as a deliberate present-tense "no" rather + than as an oversight — adding one would be a new feature and belongs in the + idea stage. + +## Recorded gaps + +- **No `Class`, no `AdditionalAttributes`.** The node is one of the two + components in the category that carry neither, so a consumer cannot attach a + test hook, a `data-*` attribute or a style class to one node. `F9` records the + other; both are holes in the library-wide `Class` rollout. +- **`Disabled` only disables selection.** A disabled node still takes keyboard + focus, still counts as a stop in the arrow-key walk, still expands and + collapses, and can still be the roving-`tabindex` target. It reports + `aria-disabled`, so assistive technology is told something the component does + not enforce. +- **The focus ring is drawn from `--accent-soft`.** That token is a heavily + transparent tint intended for fills, so the keyboard focus indicator on a tree + row is far fainter than a focus indicator should be — and the component + suppresses the browser's own outline in its favour. This is the one appearance + criterion above whose *result* is questionable even though its token is legal. +- **A node with non-node children renders an empty group.** The children `ul` is + rendered whenever `ChildContent` is set, but the chevron and `aria-expanded` + are driven by whether any child *node* registered. Putting anything else in a + node's content produces a `role="group"` with no treeitems in it and a leaf + that owns a group. +- **The indent step and the row's metrics are literal.** The per-level indent, + the row's vertical padding, the chevron's box and the label's type size are + written into `DrylTreeNode.razor.css` with no token behind them + (`DESIGN-01`). The gaps and radii *are* tokens, so the file is half-converted + rather than untouched. +- **Expansion is not animated.** The children group is shown and hidden by an + attribute, with no height or opacity transition and no `DrylPresence` + (`DESIGN-12`) — recorded on the view side in `F20` as well, because it is the + tree's main gesture and neither half owns it today. +- **No tests of its own.** None of the criteria above is guarded by a test — + in particular not the parameter-versus-toggle rule, which is the component's + most subtle behaviour and the one a future refactor is most likely to break. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--fg`, `--fg-muted`, `--fg-dim`, + `--glass-2`, `--accent-soft` and `--accent-a` are the mode-dependent tokens; + the component defines no mode-specific rule. +- **Enter/exit animation** — the row's hover and selection transitions and the + chevron's rotation are the component's own motion; the expansion itself is + **not** animated and is recorded above as debt rather than as an exception. +- **Keyboard and a11y** — the "Keyboard" and "Keyboard and accessibility" + criteria above. The substantive decisions are the full arrow-key contract and + the leaf that reports no `aria-expanded`; the substantive omissions are the + under-enforced `Disabled` and the faint focus ring, both recorded above. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — shown on `DRYL.Website/Components/Pages/DemoTreeView.razor` + through the example `Components/Examples/TreeView/Files.razor`. +- **`ComponentCatalog`** — reached through the `"Tree View"` / `tree` entry in + `DRYL.Website/Components/ComponentCatalog.cs`; the catalog registers the lead + component of a family and not its parts. From 57c40219f8ea4fb1d2179f5f8e987554f1cbaadb Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 02:08:07 +0200 Subject: [PATCH 13/24] spec(E5): DrylTable, split into nine aspects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage 51/127 → 52/127, and E5 Data's components are complete. The split is the one SPEC-02 names: 2115 lines, an order of magnitude past anything else in the category. _Component.md carries the Meta block and the public API; the criteria live in S1-S9. Findings worth the reading: the table declares role="grid" and implements none of the grid keyboard pattern; the filter surface and the column menu are hand-built floating surfaces rather than DrylPopover, so both are clipped inside a scrolling table and both answer Escape only if the user focused them first; "Select all rows" selects the page; the hash-code fallback for view-transition names can collide, which aborts the whole morph silently; and a documented field for the pinned-column re-measure is never read or written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- .../S1 Data source and paging.md | 97 ++++++++++ .../F16 DrylTable/S2 Search and filtering.md | 117 ++++++++++++ specs/E5 Data/F16 DrylTable/S3 Sorting.md | 81 +++++++++ specs/E5 Data/F16 DrylTable/S4 Selection.md | 74 ++++++++ specs/E5 Data/F16 DrylTable/S5 Columns.md | 124 +++++++++++++ .../F16 DrylTable/S6 Inline editing.md | 90 +++++++++ .../S7 Row reordering and motion.md | 103 +++++++++++ .../S8 Grouping, detail rows and export.md | 85 +++++++++ .../S9 Presentation, accessibility and AI.md | 168 +++++++++++++++++ specs/E5 Data/F16 DrylTable/_Component.md | 171 ++++++++++++++++++ 10 files changed, 1110 insertions(+) create mode 100644 specs/E5 Data/F16 DrylTable/S1 Data source and paging.md create mode 100644 specs/E5 Data/F16 DrylTable/S2 Search and filtering.md create mode 100644 specs/E5 Data/F16 DrylTable/S3 Sorting.md create mode 100644 specs/E5 Data/F16 DrylTable/S4 Selection.md create mode 100644 specs/E5 Data/F16 DrylTable/S5 Columns.md create mode 100644 specs/E5 Data/F16 DrylTable/S6 Inline editing.md create mode 100644 specs/E5 Data/F16 DrylTable/S7 Row reordering and motion.md create mode 100644 specs/E5 Data/F16 DrylTable/S8 Grouping, detail rows and export.md create mode 100644 specs/E5 Data/F16 DrylTable/S9 Presentation, accessibility and AI.md create mode 100644 specs/E5 Data/F16 DrylTable/_Component.md diff --git a/specs/E5 Data/F16 DrylTable/S1 Data source and paging.md b/specs/E5 Data/F16 DrylTable/S1 Data source and paging.md new file mode 100644 index 0000000..64893d7 --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/S1 Data source and paging.md @@ -0,0 +1,97 @@ +# Data source and paging + +## Meta +- **State:** Implemented + +## Acceptance Criteria + +### Choosing a mode + +- `DataProvider` left `null` puts the table in client mode: it reads `Items` and + owns the whole pipeline. +- `DataProvider` set puts the table in server mode: it reads nothing from + `Items` and renders whatever the delegate returns. +- The mode is decided per rebuild rather than once, so a consumer can supply a + provider conditionally. + +### The client pipeline + +- The view is rebuilt whenever the `Items` reference changes. +- The change is detected by reference, so re-handing the same collection does + not rebuild. +- The pipeline applies search, then filters, then sort, then paging, in that + order. +- The reported total is the count after search and filters and before paging, so + the footer reports what the filter matched rather than what the page shows. +- The pipeline is materialised once per rebuild, so each stage is not + re-enumerated by the next. +- A `null` `Items` is treated as an empty sequence rather than throwing. + +### The server pipeline + +- A rebuild in server mode issues one `DataRequest` carrying the current skip, + take, search text, sort descriptors and filter descriptors. +- The skip and take are derived from the current page and page size, and are + zero and unbounded when paging is off. +- The returned items become the view and the returned total becomes the reported + count, so the footer's paging is the server's arithmetic rather than the + table's. +- A request in flight is cancelled when a newer one starts, so a fast typist + does not render a stale page. +- A cancelled request neither replaces the view nor clears the loading state, + because a newer request owns both. +- The loading state is raised before the request and cleared after it. + +### Loading and emptiness + +- `Loading` set renders a loading row instead of the data, whatever the view + holds. +- A server request in flight renders the same loading row without `Loading` + being set. +- An empty view renders `EmptyContent` when one is supplied. +- An empty view with no `EmptyContent` renders a default empty message rather + than an empty table body. +- The loading and empty rows span the table's full width. + +### Paging + +- `PageSize` above zero renders the pagination footer. +- `PageSize` at zero renders no footer and no paging. +- `Virtualize` set renders no footer, because a virtualised table has one + page. +- The current page is clamped to the last page that has rows, so shrinking the + result set cannot leave the table on an empty page. +- Navigating raises `PageChanged` and rebuilds the view. +- Changing the page size raises `PageSizeChanged`, returns to the first page and + rebuilds the view. +- A `Page` changed by the consumer is adopted, and one re-supplied unchanged is + not, so the table's own navigation is not undone by the next render. +- `PageSize` follows the same rule. + +### Virtualization + +- `Virtualize` set renders only the rows in view. +- `Virtualize` set applies no paging, so the virtualised list is the whole + filtered result. +- `Virtualize` and `GroupBy` are mutually exclusive; grouping is ignored and the + conflict is reported once on the console. + +## Recorded gaps + +- **Misconfiguration is reported to the console, not to a logger.** All three + conflict warnings — virtualize versus grouping, reorder versus its three + exclusions, editing versus server mode — are written with `Console.WriteLine`. + On Blazor Server that is the server's console, where no consumer looking at + their browser will see it; there is no `ILogger` anywhere in the component. +- **The warnings are the only signal.** Nothing in the rendered UI indicates + that a configured feature was silently dropped, so a table with `Reorderable` + and `Virtualize` simply has no grip column and no explanation. +- **The client pipeline recomputes everything on every rebuild.** Search, + filter and sort run over the whole collection each time any one of them + changes, with no memoisation between stages. That is the right shape for the + sizes the component is used at, and it is worth recording as the reason + `Virtualize` exists. +- **Server mode cannot report its own errors.** A `DataProvider` that throws + anything other than a cancellation propagates out of the render, because only + `OperationCanceledException` is caught. There is no error slot and no failed + state — the exception reaches the circuit's error boundary. diff --git a/specs/E5 Data/F16 DrylTable/S2 Search and filtering.md b/specs/E5 Data/F16 DrylTable/S2 Search and filtering.md new file mode 100644 index 0000000..0e32ecd --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/S2 Search and filtering.md @@ -0,0 +1,117 @@ +# Search and filtering + +## Meta +- **State:** Implemented + +## Acceptance Criteria + +### The global search + +- `Searchable` set renders a search input in the toolbar. +- The input is of type `search`, so a browser offers its own clear affordance. +- The input carries `SearchPlaceholder` as both its placeholder and its + accessible label, so the control is named without a visible label. +- Typing does not apply immediately: the search is applied after + `SearchDebounceMs` of quiet. +- A keystroke during the debounce supersedes the pending application, so one + burst of typing applies once. +- Applying a search returns to the first page. +- A search of only whitespace is treated as no search. +- Clearing the search restores the unfiltered view. + +### What the search matches + +- With no `SearchPredicate`, a row matches when any `Searchable` column's value + contains the search text. +- The comparison is case-insensitive. +- A column that is not `Searchable` is never matched against. +- A column with no `Field` is never matched against, because there is no value + to compare. +- `SearchPredicate` set replaces the per-column search entirely, and the + `Searchable` flags stop having any effect. +- In server mode the search text is passed through in the `DataRequest` and the + table matches nothing itself. + +### Per-column filters + +- A column marked `Filterable` renders a filter control in its header. +- The control reports whether a filter is currently applied to its column. +- Pressing the control opens that column's filter surface; pressing it again + closes it. +- Opening one column's filter closes any other, so only one is ever open. +- The filter surface's press does not also sort the column. + +### The two filter kinds + +- A column's filter kind is `FilterType` when set, and derived from the field's + type when it is `ColumnFilterType.Auto`. +- An enum, a `bool` or a nullable `bool` field derives the select kind. +- Any other field type derives the text kind. +- The text kind renders one input and filters rows whose value contains the + entered text, case-insensitively. +- The select kind renders one checkbox per distinct value and filters rows whose + value is among those checked. +- The select kind's values come from `FilterValues` when the column supplies it, + and are otherwise the distinct values of the field across `Items`. +- The derived values are sorted by their text, case-insensitively, so the list + is stable between renders. +- A select column with no values renders an explicit empty message rather than + an empty list. +- A null value is offered under an explicit placeholder rather than as a blank + row. + +### Applying and clearing + +- Setting a text filter to only whitespace removes that filter rather than + matching everything. +- Unchecking the last value of a select filter removes that filter. +- Applying or clearing any filter returns to the first page. +- Applying or clearing any filter marks the persisted state dirty. +- Filters combine with each other and with the search by conjunction: a row must + satisfy all of them. +- A filter on a column that no longer exists is skipped rather than excluding + every row. + +### The filter chips + +- Every applied filter renders one chip in the toolbar. +- A chip names its column and the value being filtered on. +- A text filter's chip shows the text in quotes. +- A select filter's chip lists its values when there are at most two, and + otherwise reports how many are selected. +- A chip carries a control that removes its filter, labelled with the column it + removes the filter from. +- A chip whose column has been removed is skipped rather than rendered + unlabelled. +- The chips are announced as a labelled list. + +## Recorded gaps + +- **The filter surface is not portalled and is not a `DrylPopover`.** It is + rendered inside its own header cell, so on a table with `Height` set — or in + any scrolling container — it is clipped by the scroll box it lives in. Every + other floating surface in the library goes through `DrylPopover`, which + portals to the document body for exactly this reason. +- **`Escape` closes the filter only if the user focused it.** The key handler + sits on the filter surface, which carries `tabindex="-1"` and is never focused + programmatically when it opens. A user who opens a filter with the mouse and + presses `Escape` gets nothing. This is the same defect recorded against + `DrylPopover` in `specs/E11 Surfaces/F1 DrylPopover.md`, reproduced here + because this surface was built separately. +- **Nothing closes a filter on an outside click.** The only ways out are the + filter control, the surface's own close and done controls, and `Escape` after + focusing it. +- **The search covers hidden columns.** Matching iterates every registered + column rather than the visible ones, so a row can match a search on a column + the user has switched off and appear for no visible reason. +- **A select filter's value set is mutated in place.** Toggling a value edits + the same `HashSet` the descriptor already holds rather than replacing it, so + the descriptor's value is not a snapshot; a consumer who captured it from a + persisted state sees it change underneath them. +- **Restored filters lose their type.** State persistence stores select-filter + values as strings, so a filter on an enum column comes back as a set of + strings. The component compensates with a string-based membership check on + every comparison, which is why matching a filter value is done twice — once by + equality and once by text. +- **The distinct-value list is recomputed on every render of an open filter.** + It enumerates `Items` and sorts it each time the surface is drawn. diff --git a/specs/E5 Data/F16 DrylTable/S3 Sorting.md b/specs/E5 Data/F16 DrylTable/S3 Sorting.md new file mode 100644 index 0000000..ffa0742 --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/S3 Sorting.md @@ -0,0 +1,81 @@ +# Sorting + +## Meta +- **State:** Implemented + +## Acceptance Criteria + +### Making a column sortable + +- A column marked `Sortable` renders a clickable, focusable header. +- A column not marked `Sortable` renders a header that is neither clickable nor + a tab stop, unless it is reorderable. +- A sortable header renders a direction indicator. +- The indicator reports whether that column is currently part of the sort. + +### The cycle + +- Clicking a sortable header with no sort on it sorts it ascending. +- Clicking it again sorts it descending. +- Clicking it a third time removes the sort entirely, so a user can get back to + the source order. +- `Enter` and `Space` on a focused sortable header do the same as a click. + +### Single and multi-column sort + +- A plain click replaces the whole sort with the clicked column. +- A click with `Shift` adds the column to the existing sort instead of replacing + it. +- A `Shift` click on a column already in the sort moves it to the end of the + sort order at its next direction. +- The sort is applied in the order the columns were added, so the first is + primary. +- A sort descriptor naming a column that no longer exists is skipped rather than + breaking the sort. +- A column with no `Field` cannot contribute a sort key and is skipped. + +### Comparing values + +- Two nulls compare equal. +- A null sorts before any non-null value. +- Two values of the same comparable type are compared by that type, so numbers + and dates sort numerically and chronologically rather than as text. +- Values of differing types fall back to an ordinal text comparison rather than + throwing. + +### Reach into other features + +- Changing the sort marks the persisted state dirty. +- Changing the sort rebuilds the view through the same morph path as a row move, + so a re-sort glides when the morph is active. +- In server mode the sort descriptors are passed through in the `DataRequest` + and the table sorts nothing itself. +- Row reordering is disabled while any sort is applied, because a manual order + over a sorted view is meaningless — the grip stays visible and becomes + inoperable rather than disappearing. + +### Accessibility + +- A sortable header reports its sort state to assistive technology, as + ascending, descending or none. +- A header that is not sortable reports no sort state at all, rather than + reporting that it is unsorted. + +## Recorded gaps + +- **The multi-sort gesture is undiscoverable.** `Shift`-click is the only way to + build a multi-column sort, and nothing in the header, the tooltip or the + accessible name mentions it. There is no visible sort order — a header that is + second in the sort looks exactly like one that is first. +- **The keyboard cannot build a multi-column sort reliably.** `Shift`+`Enter` is + handled, but a screen-reader user has no way to learn that it exists and no + feedback about the resulting order. +- **The fallback comparison is ordinal text.** Values of different types, and + values of the same type that are not `IComparable`, are compared by + `ToString`. That is a defensible fallback, but it is culture-independent by + construction, so a column of localised strings sorts by code point rather than + by the user's collation. +- **The sort's direction indicator is always drawn.** A sortable column that is + not part of the sort still renders an ascending arrow, marked inactive by a + class. Whether that reads as an affordance or as a wrong state depends + entirely on the inactive styling. diff --git a/specs/E5 Data/F16 DrylTable/S4 Selection.md b/specs/E5 Data/F16 DrylTable/S4 Selection.md new file mode 100644 index 0000000..c51e50d --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/S4 Selection.md @@ -0,0 +1,74 @@ +# Selection + +## Meta +- **State:** Implemented + +## Acceptance Criteria + +### The checkbox column + +- `Selectable` set renders a leading checkbox column in the header and in every + row. +- `Selectable` left `false` renders no checkbox column at all. +- A row's checkbox reflects whether that row is selected. +- Toggling a row's checkbox adds or removes it from the selection. +- Clicking a row's checkbox cell does not also raise `OnRowClick`. +- Every selection change raises `SelectedItemsChanged` with a copy of the + selection rather than with the table's own set. + +### The header checkbox + +- The header checkbox is checked when every row in the current view is selected. +- The header checkbox is unchecked when the view is empty, so an empty table + does not claim everything is selected. +- Checking it selects every row in the current view. +- Unchecking it deselects every row in the current view. +- Rows outside the current view keep whatever selection state they had. + +### Binding + +- `SelectedItems` supplied replaces the table's selection on every parameter + set, so the consumer's set is the source of truth when they bind one. +- `SelectedItems` left `null` lets the table keep its own selection across + renders. +- The selection survives paging, sorting and filtering, because it is held by + item rather than by index. + +### The bulk-action bar + +- The bar is rendered only when `BulkActions` is supplied **and** at least one + row is selected. +- The bar reports how many rows are selected. +- The bar renders `BulkActions` with the current selection. +- The bar carries a control that clears the selection. +- Clearing the selection raises `SelectedItemsChanged` with an empty set. +- Clearing an already-empty selection raises nothing. +- The bar is announced as a labelled region. + +### Row clicks + +- Clicking a row raises `OnRowClick` with that row's item. +- Clicking the checkbox cell, the expand cell, the grip cell or the actions cell + does not raise it, so an affordance inside a row is not also a row click. +- `OnRowClick` and `Selectable` are independent: a table can raise row clicks + without a checkbox column, and vice versa. + +## Recorded gaps + +- **"Select all rows" selects the page, not the rows.** The header checkbox's + accessible label says it selects all rows; it selects the rows in the current + view, which under paging is one page of them. A user on page 1 of 13 who + checks it and then acts on the selection gets 20 rows, not 247, and nothing in + the UI says so. +- **Selection is by item identity.** The set is a `HashSet` with the + default comparer, so a table over a record type treats two equal-valued rows + as one selection, and a table whose items are replaced by fresh instances on + every fetch loses its selection even though the same rows are on screen. There + is a `RowIdSelector`, but it is used only for the morph animations and not for + selection. +- **`SelectedItems` is copied in on every parameter set.** The whole set is + cleared and refilled on each render pass rather than only when the reference + changes, which is proportional work per render for a large selection. +- **The bulk bar's appearance is not animated.** It is mounted and unmounted by + a plain conditional, with no `DrylPresence` and no transition, so the table's + content jumps down the moment a first row is selected (`DESIGN-12`). diff --git a/specs/E5 Data/F16 DrylTable/S5 Columns.md b/specs/E5 Data/F16 DrylTable/S5 Columns.md new file mode 100644 index 0000000..a5efd75 --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/S5 Columns.md @@ -0,0 +1,124 @@ +# Columns + +## Meta +- **State:** Implemented + +## Acceptance Criteria + +### Registration + +- A `DrylColumn` placed in the `Columns` slot registers itself with the + table when it initialises. +- A column registered twice is added once. +- A column unregisters itself when it is disposed. +- Unregistering a column also drops its sort, its filter, its hidden state, its + width override and its position in the column order, so a removed column + leaves nothing behind. +- A column renders nothing itself; the table reads its registered columns to + build the header and the cells. +- A column outside a table's `Columns` slot throws on initialisation rather than + rendering nothing silently. +- Registration does not itself request a render; the table rebuilds once after + the render pass in which its columns registered. + +### Keys + +- A column's key is `ColumnKey` when set. +- A column with a `Field` and no `ColumnKey` derives its key from the field's + member name. +- A column with neither gets a generated key, so every column has one. +- The key is what sort, filter, visibility, width, order and persistence are all + recorded against. + +### Values and templates + +- A column with a `CellTemplate` renders it with the row's item. +- A column with no `CellTemplate` renders its `Field`'s value as text. +- A column with a `HeaderTemplate` renders it as the header's content. +- A column with no `HeaderTemplate` renders `Title` as text. +- A column with no `Title` renders its key as the header, so a header is never + blank. +- A column marked `Primary` renders its cells with the primary emphasis, so the + identity column is legible at a glance. +- `Align` sets the horizontal alignment of both the header and the cells, and + the start alignment adds no class of its own. + +### Visibility + +- A column marked `Hidden` starts hidden. +- `AllowColumnVisibility` set renders a menu listing every registered column + with a checkbox reflecting its visibility. +- Toggling an entry shows or hides that column immediately. +- Toggling an entry marks the persisted state dirty. +- The menu is closed by its own close control and by `Escape`. +- Hidden columns are excluded from the header, from every row and from the CSV + export. + +### Width and resizing + +- A column with `Width` renders at that width. +- `ResizableColumns` set renders a resize handle on every column that has not + opted out through `Resizable`. +- A completed resize stores the new width as a runtime override for that column. +- A runtime override wins over the column's declared `Width`. +- A resize of zero or less is ignored, so a handle dragged past its own column + cannot collapse it. +- The stored width is written with invariant formatting, so a German locale + cannot emit a decimal comma into a CSS length. +- A completed resize marks the persisted state dirty. +- The resize helper is attached once and detached when the table is disposed. +- A failed attach is not remembered as attached, so a later render can retry. + +### Order + +- Columns render in registration order until the user moves one. +- `ReorderableColumns` set makes a column draggable unless it has opted out + through `Reorderable` or is pinned. +- Dropping a column onto another moves it to that position. +- `Alt` with the left or right arrow on a focused header moves the column one + step in that direction. +- A keyboard move returns focus to the moved header, so a sequence of moves does + not lose the user's place. +- A move is confined to one pin group: a column cannot be dragged out of or into + a frozen edge. +- A move at either end of its group is a no-op rather than a wrap-around. +- Any move marks the persisted state dirty. + +### Pinning + +- A column pinned to the start renders before every unpinned column. +- A column pinned to the end renders after every unpinned column. +- The pin partition preserves the user's order within each group, because the + ordering is stable. +- A pinned column stays in view while the table is scrolled horizontally. +- The pinned columns' offsets are re-measured after every render, so a resize, a + visibility change or a reorder does not leave them overlapping. +- A pinned column is excluded from drag reordering. + +## Recorded gaps + +- **The column-visibility menu is not portalled and is not a `DrylPopover`.** + Like the filter surface in `S2`, it is rendered in place, closes on no outside + click, and answers `Escape` only if the user has focused it — which nothing + does when it opens. +- **The visibility menu lists hidden columns by their key.** A column with + neither a `Title` nor a `Field`-derived name appears in the menu as a + generated identifier. +- **Nothing is animated.** A column appearing, disappearing, moving or being + resized happens between two frames. The table has a whole view-transition + machinery for row moves (`S7`) and none of it is applied to columns + (`DESIGN-11`, `DESIGN-12`). +- **`Pinned` requires a scroll container that the table does not enforce.** + Pinning has no effect unless the table is horizontally scrollable, which + requires `Height` or a constrained container; a consumer who pins a column in + an unconstrained table sees only the reordering restriction, with no + indication why. +- **A dead field.** The component declares a flag documented as re-measuring the + pinned offsets after a render; nothing ever reads or writes it, and the + re-measure actually runs unconditionally whenever any column is pinned. The + comment describes an intent the code does not implement — the field should go + or the comment should. +- **The resize handle is a `separator` with no keyboard operation.** It carries + the separator role and an orientation, which announces it as a resizable + divider, but it is driven entirely by pointer events. There is no keyboard + path to resizing a column, unlike reordering, which has one. diff --git a/specs/E5 Data/F16 DrylTable/S6 Inline editing.md b/specs/E5 Data/F16 DrylTable/S6 Inline editing.md new file mode 100644 index 0000000..d01127c --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/S6 Inline editing.md @@ -0,0 +1,90 @@ +# Inline editing + +## Meta +- **State:** Implemented + +## Acceptance Criteria + +### When editing is possible + +- `Editable` set with no `DataProvider` enables inline editing. +- `Editable` set with a `DataProvider` disables it and reports the conflict once + on the console, because a server-side source owns its own rows. +- Editing affordances appear only when at least one column supplies an + `EditTemplate`. +- A column with no `EditTemplate` stays read-only while its row is being edited. + +### Starting an edit + +- The trailing actions cell renders an edit control on every row while editing + is enabled and some column is editable. +- Pressing that control starts editing the row. +- Double-clicking an editable cell starts editing. +- Double-clicking a cell of a row already being edited does nothing, so a stray + double-click cannot discard buffered changes. +- Double-clicking a non-editable cell does nothing. +- `BeginEdit` starts editing a row programmatically. +- `BeginEditCell` starts editing one named cell programmatically, and is + honoured only in cell mode and only for an editable column. +- Starting an edit schedules focus into the first editor, so the user can type + immediately. +- Only one row is ever being edited. + +### Row mode and cell mode + +- `TableEditMode.Row` puts every editable column of the row into its editor. +- `TableEditMode.Cell` puts only the activated column into its editor. +- `BeginEdit` in cell mode activates the first editable visible column. +- A cell rendering its editor is marked as such, so the editing cell is visually + distinct from its neighbours. + +### The working copy + +- `CloneRow` set makes the editors bind to a clone, so cancelling reverts + cleanly. +- `CloneRow` left `null` makes the editors bind to the live item, so edits are + applied as they are typed and cancelling reverts nothing. +- The distinction is the consumer's to make, and it is the difference between a + cancellable edit and a live one. + +### Committing and cancelling + +- `Enter` in the row being edited commits. +- `Escape` in the row being edited cancels. +- The commit and cancel controls in the actions cell do the same. +- Committing raises `OnRowCommitted` with both the original item and the edited + one, so a consumer can diff, validate or persist. +- Cancelling raises `OnRowCancelled` with the original item. +- Both clear the edit state before raising, so a handler that re-renders the + table does not find it still in edit mode. +- The table applies nothing itself: committing an edit changes the collection + only if the consumer's handler does. + +### Losing the row + +- An edit whose row is no longer in `Items` is dropped on the next parameter + set, so commit and cancel cannot act on a row that has vanished. + +## Recorded gaps + +- **Keyboard commit and cancel reach only the row's own key events.** The + handler sits on the row element, so an editor that stops key propagation — or + a `DrylSelect` panel portalled out of the row — swallows `Enter` and `Escape` + before the table sees them. +- **There is no validation hook.** `OnRowCommitted` is raised unconditionally; + the table offers no way to refuse a commit, and no way to show a per-field + error inside the editing row. A consumer who needs validation has to prevent + the edit from being committed by re-rendering the row back into edit mode. +- **Cell mode has no cell-to-cell keyboard movement.** `Tab` leaves the editor + to the next focusable element in the document rather than to the next editable + cell, and there is no arrow-key movement between cells. +- **Editing is not announced.** Entering and leaving edit mode changes the + markup with no live region and no focus announcement beyond the editor + receiving focus, so a screen-reader user is told which field they are in and + not that the row became editable. +- **Nothing about entering or leaving an edit is animated** (`DESIGN-11`, + `DESIGN-12`): the cells swap between text and editors between two frames. +- **The identity comparison is the default one.** Whether a row "is" the row + being edited is decided by `EqualityComparer.Default`, so a table over + a record type can consider two equal-valued rows the same row — the same + identity gap `S4` records for selection. diff --git a/specs/E5 Data/F16 DrylTable/S7 Row reordering and motion.md b/specs/E5 Data/F16 DrylTable/S7 Row reordering and motion.md new file mode 100644 index 0000000..0c182db --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/S7 Row reordering and motion.md @@ -0,0 +1,103 @@ +# Row reordering and motion + +## Meta +- **State:** Implemented + +## Acceptance Criteria + +### When reordering is possible + +- `Reorderable` set renders a leading grip column. +- The grip column is rendered only over a plain client-side list: virtualization, + grouping and a `DataProvider` each suppress it, because none of them keeps a + 1:1 mapping between a displayed row and a stable position. +- A suppressed configuration is reported once on the console. +- The grip is rendered but inoperable while any sort is applied, and its tooltip + says to clear the sort — a manual order over a sorted view is meaningless, and + a grip that vanished would be harder to understand than one that explains + itself. + +### Dragging + +- Dragging a grip marks its row as the one being dragged. +- Dragging over another row marks that row as the drop target. +- Dropping moves the dragged row to the target's position. +- The drag highlight is cleared before the move, so no drop-target styling + lingers through the animation. +- Ending a drag without dropping clears the highlight and moves nothing. +- A drop outside the valid range is clamped rather than ignored. +- Moving a row to its own position does nothing. + +### The keyboard path + +- `Alt` with the up or down arrow on a focused grip moves the row one position. +- A move at either end of the list is a no-op rather than a wrap-around. +- Focus follows the moved row to its new position, so repeated presses keep + moving the same row. +- The grip carries an accessible label naming its position, the total, and the + key combination that moves it. + +### What a move does + +- A move reorders the displayed view immediately, before the consumer is + notified, so the table never appears to ignore the gesture. +- A move raises `OnRowReordered` with the old and the new index. +- The consumer's backing collection is theirs to update; the table's optimistic + reorder is replaced by the next rebuild. + +### The morph + +- `AnimateReorder` set morphs rows between their old and new positions using a + same-document view transition. +- The morph is available only under the same constraints as reordering itself, + minus the sort lock — a re-sort morphs too. +- Every row carries a view-transition name while a morph mode is active, and + none when neither is, so the browser has stable per-row targets to glide + between. +- A row's transition name is derived from `RowIdSelector` when one is supplied + and from the item's hash code otherwise. +- The name is sanitised to a valid CSS identifier, so an item whose id contains + punctuation does not abort the transition. +- The name is scoped per table instance, so two morph-enabled tables on one page + cannot collide on a document-global name. +- A mutation that arrives while a transition is in flight is applied directly + rather than starting a second one, because view transitions serialise and the + overlapping start would only be skipped. +- The mutation is guaranteed to run even when the transition never calls back — + during prerender, on a disconnected circuit, or under a test renderer. +- A browser without view transitions falls back to applying the mutation + directly. + +### The streaming glide + +- `AiState.Streaming` and `AiState.Generated` turn the morph on without + `AnimateReorder` being set, under the same three constraints. +- The glide runs only when the incoming `Items` actually carry rows the table + has not rendered yet, so a re-handed reference with unchanged content does not + pay for a no-op morph. +- The rows the table has rendered are recorded after each render, which is what + makes that comparison possible. +- A row the table has not seen before, while streaming, is marked so it can be + animated in by CSS — which is the fallback when the browser cannot morph. +- A rebuild caused only by a column change never morphs. + +## Recorded gaps + +- **The hash-code fallback can collide.** Without a `RowIdSelector`, two rows + whose items share a hash code get the same view-transition name. A duplicate + name aborts the whole transition, so the symptom is not a wrong animation but + no animation at all, intermittently and without a message. +- **Reordering is drag-and-drop first.** The keyboard path exists and is + labelled, which is more than most tables offer — but it is `Alt`+arrow on a + grip the user has to find and focus first, and there is no announcement of the + new position after a move. +- **Nothing announces a move.** Neither the drag nor the keyboard path updates a + live region, so a screen-reader user gets no confirmation that a row moved or + where it went. +- **The optimistic reorder can disagree with the consumer.** The view is + reordered before `OnRowReordered` is raised; a consumer who ignores the event, + or applies a different move, sees the table's order revert on the next + rebuild, with the rows visibly jumping back. +- **The drop target is the row entered last, not a gap.** There is no insertion + indicator between rows, so dropping onto a row is ambiguous about whether the + dragged row lands above or below it until it happens. diff --git a/specs/E5 Data/F16 DrylTable/S8 Grouping, detail rows and export.md b/specs/E5 Data/F16 DrylTable/S8 Grouping, detail rows and export.md new file mode 100644 index 0000000..087f398 --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/S8 Grouping, detail rows and export.md @@ -0,0 +1,85 @@ +# Grouping, detail rows and export + +## Meta +- **State:** Implemented + +## Acceptance Criteria + +### Grouping + +- `GroupBy` set groups the current view by its result and renders a header row + before each group. +- A group header names the group and reports how many rows it holds. +- A group with a null key is labelled with an explicit placeholder rather than + being left blank. +- A group header is a control that collapses and expands its group. +- A collapsed group renders its header and none of its rows. +- The header reports its expanded state to assistive technology. +- Grouping is applied after paging, so a group header describes the rows on the + current page. +- `GroupBy` and `Virtualize` are mutually exclusive; grouping is ignored when + virtualization is on. +- `GroupBy` suppresses row reordering, because a manual position across groups + has no meaning. + +### Detail rows + +- `DetailTemplate` set renders a leading expand column with a control per row. +- Pressing the control expands or collapses that row's detail. +- The control reports its expanded state to assistive technology and carries a + label naming what it will do. +- An expanded row renders one further row beneath it holding the template's + output, spanning the table's full width. +- Several rows can be expanded at once. +- Clicking the expand cell does not also raise `OnRowClick`. +- `DetailTemplate` left `null` renders no expand column at all. + +### CSV export + +- `ShowExport` set renders an export control in the toolbar. +- The export covers the current search, filters and sort — and **all** pages, + not the page in view. +- In server mode the export issues its own request with the current search, sort + and filters and an unbounded take, so it exports what the user filtered rather + than what they are looking at. +- The export includes only the visible columns, in their current order. +- The first line is the columns' display titles. +- A field containing a comma, a quote or a line break is quoted, and an inner + quote is doubled. +- A null value is exported as an empty field. +- A formattable value is written with invariant culture, so the file is the same + whatever the server's locale. +- The file is prefixed with a UTF-8 byte-order mark, so a spreadsheet opens it + in the right encoding. +- The file is named `ExportFileName`. + +## Recorded gaps + +- **A group header is not a group.** The headers are ordinary rows carrying a + toggle; the rows beneath them are not associated with their header by any + ARIA relationship, so a screen-reader user hears a row that says "Production, + 12" and then twelve rows that do not say they belong to it. +- **Collapsing a group hides rows that stay selected.** A collapsed group's rows + remain in the selection and in the export, which is correct — but the header + checkbox's "all selected" state is computed over the whole view including + them, so the header can appear unchecked with no visible unchecked row. +- **Group state is keyed by the group's text.** A group is identified by its + key's string form, so two distinct keys with the same text collapse together, + and a collapsed group's state does not survive the key's type changing. +- **Expanded rows are keyed by hash code.** The detail row's key is built from + the item's hash code, so two rows sharing a hash share a key — which in + Blazor's diff means the wrong detail row can be reused. +- **Neither grouping nor expansion is animated.** Collapsing a group and + expanding a detail row both change the DOM between two frames, with no + `DrylPresence` and no height transition (`DESIGN-11`, `DESIGN-12`). Expansion + is the most-used gesture the table has after sorting. +- **The export's line ending follows the host.** Rows are appended with the + environment's newline, so the same application produces CRLF-terminated files + on Windows and LF-terminated ones on Linux. RFC 4180, which the quoting rules + do follow, specifies CRLF. +- **The export is built in memory as one string.** Every row of every page is + formatted into a single `StringBuilder` and handed to JS as one argument, so + a large export is bounded by what the circuit will carry rather than streamed. +- **The unbounded server export is documented but not enforced.** The export's + request asks for every matching row; a backend that honours it literally will + materialise the whole table. diff --git a/specs/E5 Data/F16 DrylTable/S9 Presentation, accessibility and AI.md b/specs/E5 Data/F16 DrylTable/S9 Presentation, accessibility and AI.md new file mode 100644 index 0000000..aef9391 --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/S9 Presentation, accessibility and AI.md @@ -0,0 +1,168 @@ +# Presentation, accessibility and AI + +## Meta +- **State:** Implemented + +## Acceptance Criteria + +### The surface + +- `Bordered` left `true` renders the table on the library's card surface, and + its fill, border and frost are that shared treatment rather than the + component's own (`DESIGN-06`). +- `Bordered` set to `false` renders a bare table with no surface of its own. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. +- `Height` set caps the table's height and makes it scroll. +- `Virtualize` set makes it scroll whether or not a height was given. +- `StickyHeader` left `true` keeps the header visible while the body scrolls. +- `StickyHeader` set to `false` adds the modifier that releases it. +- Every optional surface — the bulk bar, the summary bar, the toolbar, the + footer — is absent from the markup entirely when it is not configured, rather + than rendered empty. + +### Tokens + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The header is set in `--fg-dim` and the cells in `--fg-muted`, so the data is + louder than its labels. +- The header is set in `--font-mono`, upper-cased and letter-spaced, so it reads + as a label row rather than as a first row of data. +- Every horizontal rule in the table is drawn in `--line`. +- The accent appears as the aura's ring and glow, the active sort and filter + indicators, and the selected row's tint — never as the fill of the table + (`DESIGN-08`). +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### Keyboard and accessibility + +- The table carries `AriaLabel` as its accessible label. +- A sortable header is a tab stop; a plain header is not. +- A reorderable header is a tab stop even when it is not sortable, so the + keyboard column move is reachable. +- Every control the table renders — the checkboxes, the expand, filter, group, + grip, edit, commit, cancel, chip-remove and bulk-clear controls — carries an + accessible label naming what it does rather than being announced as its icon. +- Every disclosure the table renders reports its expanded state. +- Every icon the table renders is decorative and stays out of its control's + accessible name. +- The loading row is announced as a status. +- The bulk bar and the filter chips are announced as labelled regions. + +### AI mode + +- `Ai` defaults to `AiState.None`, so AI styling is opt-in. +- The aura variant follows `Aura` when set and a surrounding `DrylAiScope` + otherwise. +- The aura is rendered only on a bordered table, because a borderless one has no + box for the ring and the glow to trace. +- The component renders the shared aura vocabulary through the shared helper + rather than a table-specific AI treatment (`AI-02`). +- The aura's ring and glow are pulled inside the table's clipped bounds, because + the surface clips to its rounded corners and would otherwise eat both. +- Leaving AI mode keeps the aura mounted for one `--dur-slow` beat, so it + dissolves rather than snapping away. +- Entering `AiState.Generated` replays the one-shot completion wash, every time + it is entered. +- A row that arrives while streaming rises and flashes in, at `--dur-slow` and + `--dur-choreo` with `--ease-out`. +- Under `prefers-reduced-motion: reduce` that row animation does not run. + +### State persistence + +- `PersistStateKey` set restores the sort, the filters, the page, the page size, + the hidden columns, the column widths and the column order on the first + render. +- Restoring rebuilds the view and re-renders. +- A missing or empty stored value leaves the defaults in place. +- A stored value that is not valid JSON, or that cannot be read at all, leaves + the defaults in place rather than throwing — restoring is best-effort. +- The state is written back after a render in which something persistable + changed, and only after a restore has been attempted, so the defaults cannot + overwrite the stored state before it is read. +- Writing is best-effort in the same way as reading. +- `PersistStateKey` left `null` reads and writes nothing. + +### Cleanup + +- The search debounce is cancelled and disposed with the component. +- An in-flight data-provider request is cancelled and its source disposed with + the component. +- The aura lifecycle's timer is disposed with the component. +- The view-transition helper is disposed with the component. +- The column-resize helper is detached, and its object reference disposed, with + the component. +- Detaching is skipped when nothing was attached, and survives a disconnected + circuit without throwing. +- Every interop call the table makes after a render is best-effort: a missing + element or a closed circuit is swallowed rather than surfaced. + +## Recorded gaps + +- **The table claims the grid role without implementing the grid pattern.** + That role promises two-dimensional keyboard navigation — arrow keys between + cells, `Home` and `End` within a row, one tab stop for the whole widget. The + table offers none of it: the tab order runs through each interactive element + in turn, and no arrow key moves between cells. A screen-reader user is told + they are in a grid and then finds none of the behaviour a grid implies. The + plain table role would be the honest claim for what the component does today. +- **The loading indicator is not a `DrylSpinner`.** The loading row renders a + bare span carrying the spinner class, inside an element with a hand-written + inline style attribute — the one place in the component where layout is + written inline rather than in the stylesheet. The padding inside it is a + token; the rest is not. +- **The full-width rows span a hard-coded 99 columns.** The loading, empty, + group-header and detail rows all declare a column span far larger than any + table will have, rather than the number of columns the table actually + rendered. +- **The table's own type sizes and paddings are literal.** The body and header + type sizes and the cell paddings are written into the `.tbl` rules in + `dryl.css` with no token behind them (`DESIGN-01`). +- **Persisted state is not versioned.** The stored shape has grown twice — the + widths and the order were appended as optional members — and a future + incompatible change has no version field to detect. A stored state from an + older shape deserialises with its new members null, which happens to work, but + only by luck of the shape's history. +- **Persisted state is per key, not per user.** It goes to the browser's storage + under `PersistStateKey` alone, so two accounts on one browser share one + table's layout. +- **`Bordered` silently disables the aura.** A consumer who sets `Ai` on a + borderless table gets no aura at all and no indication why. The reason is + sound; the silence is not. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — token-only colors, verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs`. `--fg`, `--fg-muted`, `--fg-dim`, + `--line` and the accent tokens are the mode-dependent ones; the component + defines no mode-specific rule. +- **Enter/exit animation** — the table has the category's most developed motion + and its most uneven coverage. Row moves, re-sorts and streaming inserts morph + through view transitions, and a streaming row rises and flashes in. Nothing + else moves: the bulk bar, the filter surface, the column menu, a group + collapsing, a detail row expanding, a column moving and an edit starting all + happen between two frames. Each is recorded in the `S{n}` file that owns it. +- **Keyboard and a11y** — the criteria above, plus the per-feature keyboard + contracts in `S3`, `S5`, `S6` and `S7`. The substantive decisions are the + labelled controls and the `Alt`-plus-arrow paths for both row and column + moves; the substantive omission is the unearned grid role, recorded above. +- **AI mode** — yes, in two ways: the table carries the shared aura, and + `AiState.Streaming` additionally turns on the row-glide and the per-row enter + animation without any opt-in, because a table is the surface an agent most + often fills a row at a time. +- **Demo page** — `DRYL.Website/Components/Pages/DemoTable.razor`, with the + examples under `DRYL.Website/Components/Examples/Table/`. +- **`ComponentCatalog`** — registered as `"Table"` / `tables` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. +- **Tests** — `tests/DRYL.Components.Tests/DrylTableTests.cs` guards row + rendering, the sort cycle's first two steps, the selection callback, the two + paging callbacks, the externally set page, and six aspects of the + view-transition naming — that names are absent by default, present under + `AnimateReorder`, present under `AiState.Streaming`, absent under + `AiState.None`, sanitised to a valid identifier and scoped per instance — plus + that a drag reorder and a sort still work with the morph active. Fourteen + tests over the largest component in the library: the morph is well covered and + almost nothing else is. diff --git a/specs/E5 Data/F16 DrylTable/_Component.md b/specs/E5 Data/F16 DrylTable/_Component.md new file mode 100644 index 0000000..4c8a2e4 --- /dev/null +++ b/specs/E5 Data/F16 DrylTable/_Component.md @@ -0,0 +1,171 @@ +# DrylTable + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/DrylTable.razor + code/DRYL.Components/Components/Data/DrylColumn.cs + code/DRYL.Components/Components/Data/Models/ColumnAlign.cs + code/DRYL.Components/Components/Data/Models/ColumnFilterType.cs + code/DRYL.Components/Components/Data/Models/ColumnPin.cs + code/DRYL.Components/Components/Data/Models/DataRequest.cs + code/DRYL.Components/Components/Data/Models/DataResult.cs + code/DRYL.Components/Components/Data/Models/FilterDescriptor.cs + code/DRYL.Components/Components/Data/Models/RowEditEventArgs.cs + code/DRYL.Components/Components/Data/Models/RowReorderEventArgs.cs + code/DRYL.Components/Components/Data/Models/SortDescriptor.cs + code/DRYL.Components/Components/Data/Models/TableEditMode.cs + +This spec is split (`SPEC-02`). The acceptance criteria live in the `S{n}` files +beside this one; this file carries the `Meta` block, the description and the +public API. The state above is the rolled-up state: it reads `Implemented` only +while every `S{n}` beside it does. + +| File | Subject | +|---|---| +| `S1 Data source and paging.md` | `Items` versus `DataProvider`, the view pipeline, paging, virtualization | +| `S2 Search and filtering.md` | The toolbar search, per-column filters, the filter chips | +| `S3 Sorting.md` | Click-to-sort, multi-sort, the sort's reach into other features | +| `S4 Selection.md` | Row selection, the header checkbox, the bulk-action bar | +| `S5 Columns.md` | Registration, order, visibility, resize, reorder, pinning | +| `S6 Inline editing.md` | Row and cell editing, the working copy, commit and cancel | +| `S7 Row reordering and motion.md` | Drag and keyboard reorder, view-transition morphs, streaming glide | +| `S8 Grouping, detail rows and export.md` | `GroupBy`, `DetailTemplate`, CSV export | +| `S9 Presentation, accessibility and AI.md` | Layout, tokens, roles, keyboard, `Ai`, state persistence, cleanup | + +## User Story + +As a Blazor developer, I want a table I describe by declaring its columns, and +that then handles searching, sorting, filtering, paging, selecting, editing and +exporting on its own, so that a data screen is a description of what the columns +are rather than an implementation of what a table does. + +## Description + +`DrylTable` is the largest component in the library by an order of +magnitude, and its size comes from one design choice: **the columns are +components**. A consumer writes `DrylColumn` elements into the `Columns` +slot; each column registers itself with the table through a cascade and renders +nothing itself. The table then reads its registered columns to build the header +and every cell. + +That is what makes the feature list declarative rather than configured. A column +that says `Sortable` gets a clickable header and joins the sort pipeline. A +column that says `Searchable` joins the toolbar search. `Filterable` adds a +filter control to its header and a chip to the toolbar when it is active. +`Field` supplies the value, the sort key and the search source at once, and its +member name becomes the column's stable key, which is what filter state, the +column order, the resize widths and the persisted state are all recorded +against. + +The table works in **two modes and knows which one it is in**. With `Items` it +owns the whole pipeline in memory: search, filter, sort, page. With +`DataProvider` it owns none of it — it describes what the user asked for as a +`DataRequest` and renders whatever comes back. Several features are meaningful +only in the first mode, and the table disables rather than mis-applies them: +inline editing, row reordering and the row-morph animations all require a plain +client-side list, and each one says so on the console when it is configured +where it cannot work. + +Around that core sit the optional surfaces: a KPI summary bar, a toolbar with +search and export and a column-visibility menu, a bulk-action bar that appears +with the first selection, per-row detail rows, per-row action slots, grouping +headers, and a pagination footer. Each of them is absent from the markup +entirely when it is not configured. + +## Public API + +### Data + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Items` | `IEnumerable` | `[]` | The rows, when no `DataProvider` is set. | +| `DataProvider` | `Func>>?` | `null` | Server-side mode: the table asks, the delegate answers. | +| `Columns` | `RenderFragment?` | `null` | The `DrylColumn` declarations. | +| `RowIdSelector` | `Func?` | `null` | Stable per-row identity for the morph animations. | + +### Slots + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `SummaryContent` | `RenderFragment?` | `null` | KPI bar above the toolbar. | +| `ToolbarContent` | `RenderFragment?` | `null` | Extra toolbar content. | +| `EmptyContent` | `RenderFragment?` | `null` | Shown instead of the default empty message. | +| `DetailTemplate` | `RenderFragment?` | `null` | Expandable detail row per row. | +| `RowActions` | `RenderFragment?` | `null` | Trailing per-row action cell. | +| `BulkActions` | `RenderFragment>?` | `null` | Content of the bulk-action bar. | + +### Toolbar, search and export + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `ShowToolbar` | `bool` | `false` | Renders the toolbar row. | +| `Searchable` | `bool` | `false` | Renders the global search input. | +| `SearchPlaceholder` | `string` | `"Search…"` | Placeholder and accessible label of the search input. | +| `SearchDebounceMs` | `int` | `200` | Debounce before a search is applied. | +| `SearchPredicate` | `Func?` | `null` | Replaces the per-column search entirely. | +| `ShowExport` | `bool` | `false` | Renders the CSV export button. | +| `ExportFileName` | `string` | `"export.csv"` | File name of the export. | +| `AllowColumnVisibility` | `bool` | `false` | Renders the column-visibility menu. | + +### Paging and size + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `PageSize` | `int` | `0` | Rows per page. `0` disables paging. | +| `PageSizeChanged` | `EventCallback` | — | Raised when the user picks a size. | +| `PageSizeOptions` | `IReadOnlyList` | `[10, 20, 50, 100]` | Choices in the footer's size selector. | +| `Page` | `int` | `0` | Zero-indexed current page. | +| `PageChanged` | `EventCallback` | — | Raised when the user navigates. | +| `Virtualize` | `bool` | `false` | Renders only the visible rows. Excludes paging and grouping. | +| `VirtualizeItemSize` | `float` | `44f` | Row height hint for virtualization. | +| `Height` | `string?` | `null` | Maximum height; makes the table scroll. | +| `StickyHeader` | `bool` | `true` | Keeps the header visible while scrolling. | + +### Selection and interaction + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Selectable` | `bool` | `false` | Renders the checkbox column. | +| `SelectedItems` | `IReadOnlySet?` | `null` | The selection, for two-way binding. | +| `SelectedItemsChanged` | `EventCallback>` | — | Raised on every selection change. | +| `OnRowClick` | `EventCallback` | — | Raised when a row is clicked. | +| `GroupBy` | `Func?` | `null` | Groups rows under collapsible headers. | + +### Reordering and editing + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Reorderable` | `bool` | `false` | Renders the drag grip column. | +| `OnRowReordered` | `EventCallback` | — | Raised with the moved row's old and new index. | +| `AnimateReorder` | `bool` | `false` | Morphs rows between positions with a view transition. | +| `Editable` | `bool` | `false` | Enables inline editing. | +| `EditMode` | `TableEditMode` | `TableEditMode.Row` | Whole row or single cell. | +| `CloneRow` | `Func?` | `null` | Supplies the working copy so cancel can revert. | +| `OnRowCommitted` | `EventCallback>` | — | Raised with the original and the edited item. | +| `OnRowCancelled` | `EventCallback` | — | Raised with the item whose edit was abandoned. | +| `ResizableColumns` | `bool` | `false` | Renders per-column resize handles. | +| `ReorderableColumns` | `bool` | `false` | Allows header drag and `Alt`+arrow column moves. | + +### Presentation and state + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Loading` | `bool` | `false` | Renders the loading row instead of the data. | +| `Bordered` | `bool` | `true` | Renders the table on the library's card surface. | +| `AriaLabel` | `string?` | `null` | Accessible label for the table. | +| `PersistStateKey` | `string?` | `null` | Persists sort, filters, page, size, hidden columns, widths and order. | +| `Ai` | `AiState` | `AiState.None` | Ambient AI state. | +| `Aura` | `AiAura?` | `null` | Pins the aura variant; `null` inherits a surrounding `DrylAiScope`. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the table's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +### Public methods + +| Member | Signature | Purpose | +|---|---|---| +| `BeginEdit` | `void BeginEdit(TItem item)` | Starts editing a row. | +| `BeginEditCell` | `void BeginEditCell(TItem item, string columnKey)` | Starts editing one cell. | +| `OnColumnResized` | `[JSInvokable] void OnColumnResized(string key, double widthPx)` | Called by the resize helper; not for consumer use. | + +`DrylColumn`'s own parameters, and every supporting type named in +`Source`, are set out in [`../_Api.md`](../_Api.md). From fab2e125a0caf073cf3555262539a325af02df1a Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 02:10:46 +0200 Subject: [PATCH 14/24] =?UTF-8?q?spec(E5):=20the=20category=20companions?= =?UTF-8?q?=20=E2=80=94=20=5FApi=20and=20=5FInterop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E5 Data is complete: 21 of 21 components covered, 31/127 → 52/127. _Api inventories nineteen public types across three declaration styles, the three vocabularies this category expresses twice, and — the part worth reading — why sixteen of the twenty-one components carry no Ai, grouped by the three reasons that recur. _Interop records that only four components touch JS at all, that DrylTable is the category's only two-way interop, and the three cleanup patterns whose absence has broken this repository before. Also corrected: FilterOperator has ten members and the client pipeline implements two of them. The other eight are inert unless a DataProvider translates them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- specs/E5 Data/_Api.md | 369 +++++++++++++++++++++++++++++++++++++- specs/E5 Data/_Interop.md | 128 ++++++++++++- 2 files changed, 485 insertions(+), 12 deletions(-) diff --git a/specs/E5 Data/_Api.md b/specs/E5 Data/_Api.md index bdbf3c1..84101c2 100644 --- a/specs/E5 Data/_Api.md +++ b/specs/E5 Data/_Api.md @@ -1,15 +1,368 @@ # Data — Public API -Shared enums, parameter contracts and services of the Data category — the -part of the data contract the 1.0 freeze binds. +Shared enums, parameter contracts and services of the Data category — the part +of the data contract the 1.0 freeze binds. **Source folder:** `code/DRYL.Components/Components/Data/` -*Scaffold. The shared types below are filled in during phase C, each listed with -the exact spelling used in code. Until then this file claims nothing: it carries -no `Meta` block and the coverage check does not treat it as covering a -component (`SPEC-03`).* +This file carries no `Meta` block: it is a reference for the specs around it, +not a unit of implementation (`SPEC-03`). -## Shared types +The category holds twenty-one components and is the library's most varied. It is +better read as five unrelated groups than as one family: -*(phase C)* +| Group | Components | +|---|---| +| Inline marks | `DrylBadge`, `DrylIcon`, `DrylKbd` | +| Identity | `DrylAvatar`, `DrylAvatarGroup` | +| Records | `DrylDescriptionList`, `DrylDescriptionItem`, `DrylTimeline`, `DrylTimelineItem`, `DrylTreeView`, `DrylTreeNode`, `DrylCitation`, `DrylCitationList`, `DrylCitationListItem` | +| Numbers | `DrylStat`, `DrylTableKpi`, `DrylSparkline` | +| The table | `DrylTable`, and its `DrylColumn` plus the types under `Models/` | +| Media | `DrylImage`, `DrylCodeBlock` | + +`DrylSparkline` sits in this category rather than in `E4 Charts` because it +lives in `Components/Data/` and not in `Components/Data/Charts/`; `SPEC-02` +derives a component's category from its path. + +## Where the enums live — and why it is not uniform + +The category declares nineteen public types. Eight are namespace-level enums in +their own file; six are nested inside the component that uses them; and five are +records or enums under `Models/` belonging to the table. A nested enum must be +written qualified at the call site, and the 1.0 freeze binds that difference +into every consumer's markup: + +```razor + + + +``` + +| Type | Declared in | Written as | +|---|---|---| +| `AvatarShape` | `AvatarShape.cs` | `AvatarShape.Square` | +| `AvatarSize` | `AvatarSize.cs` | `AvatarSize.Large` | +| `AvatarStatus` | `AvatarStatus.cs` | `AvatarStatus.Online` | +| `DeltaDirection` | `DeltaDirection.cs` | `DeltaDirection.Up` | +| `DescriptionLayout` | `DescriptionLayout.cs` | `DescriptionLayout.Inline` | +| `SparklineKind` | `SparklineKind.cs` | `SparklineKind.Area` | +| `TimelineVariant` | `TimelineVariant.cs` | `TimelineVariant.Success` | +| `ColumnAlign` | `Models/ColumnAlign.cs` | `ColumnAlign.End` | +| `ColumnFilterType` | `Models/ColumnFilterType.cs` | `ColumnFilterType.Select` | +| `ColumnPin` | `Models/ColumnPin.cs` | `ColumnPin.Start` | +| `FilterOperator` | `Models/FilterDescriptor.cs` | `FilterOperator.In` | +| `SortDirection` | `Models/SortDescriptor.cs` | `SortDirection.Descending` | +| `TableEditMode` | `Models/TableEditMode.cs` | `TableEditMode.Cell` | +| `DrylBadge.BadgeKind` | `DrylBadge.razor` | `DrylBadge.BadgeKind.Danger` | +| `DrylImage.ImageFit` | `DrylImage.razor` | `DrylImage.ImageFit.Contain` | +| `DrylImage.ImagePosition` | `DrylImage.razor` | `DrylImage.ImagePosition.Top` | +| `DrylImage.ImageRounded` | `DrylImage.razor` | `DrylImage.ImageRounded.Full` | +| `DrylImage.ImageRatio` | `DrylImage.razor` | `DrylImage.ImageRatio.Square` | +| `DrylTableKpi.KpiDeltaKind` | `DrylTableKpi.razor` | `DrylTableKpi.KpiDeltaKind.Positive` | +| `DrylTableKpi.KpiTrend` | `DrylTableKpi.razor` | `DrylTableKpi.KpiTrend.Up` | + +Two enums live in a file named after another type — `FilterOperator` in +`FilterDescriptor.cs` and `SortDirection` in `SortDescriptor.cs`. Recorded as a +fact, not corrected: the declaring *file* is not part of a C# type's identity, +so unlike a move between namespaces or nesting levels this costs a consumer +nothing. + +## The duplicated vocabularies + +Three concepts are expressed twice in this category, by types that are not +convertible to each other. All six are frozen, so this is an inventory rather +than a plan. + +| Concept | Expressed as | And also as | +|---|---|---| +| A trend and its colour | `DeltaDirection` (`DrylStat`) | `DrylTableKpi.KpiDeltaKind` **and** `DrylTableKpi.KpiTrend` | +| A tiny trend chart | `DrylSparkline` with `SparklineKind` | `DrylTableKpi`'s inline chart, with no parameter | +| A size scale | `AvatarSize` | `DrylIcon.Size`, an `int` of pixels | + +`DeltaDirection` folds direction and colour into one value; `DrylTableKpi` keeps +them apart, which is the one thing the duplicate does better — a falling latency +wants a downward arrow in the positive colour, and `DrylStat` cannot express +that. See `F17` for the rest of that component's recorded debt. + +## `AvatarShape` + +| Member | Notes | +|---|---| +| `Circle` | The default of `DrylAvatar.Shape`. The unmodified avatar. | +| `Square` | Rounded square, from `--r-sm`. For entities that are not people. | + +## `AvatarSize` + +| Member | Notes | +|---|---| +| `Small` | Dense lists, inline mentions. | +| `Medium` | The default of `DrylAvatar.Size` and of `DrylAvatarGroup.Size`. The unmodified avatar. | +| `Large` | Headers, profile cards, chat messages. | + +A `DrylAvatarGroup` overrides the `Size` of every avatar inside it. + +## `AvatarStatus` + +| Member | Notes | +|---|---| +| `None` | The default of `DrylAvatar.Status`. No dot, and no wrapper element either — the markup changes with this value. | +| `Online` | `--success`, with a glow. | +| `Busy` | `--danger`, with a glow. | +| `Away` | `--warning`, with a glow. | +| `Offline` | `--fg-faint`, deliberately without one. | + +## `DeltaDirection` + +| Member | Notes | +|---|---| +| `None` | The default of `DrylStat.Direction`. Suppresses the delta chip entirely, whatever `Delta` says. | +| `Up` | `--success`, with an upward arrow. | +| `Down` | `--danger`, with a downward arrow. | +| `Neutral` | `--fg-dim`, with no arrow. | + +## `DescriptionLayout` + +| Member | Notes | +|---|---| +| `Stacked` | The default of `DrylDescriptionList.Layout`. Term above value. Also what an item outside a list falls back to. | +| `Inline` | Term and value on one row, with a fixed label column. | + +## `SparklineKind` + +| Member | Notes | +|---|---| +| `Line` | The default of `DrylSparkline.Kind`. | +| `Area` | The line, plus a filled area beneath it. | +| `Bar` | One bar per point; the one kind that ignores `ShowLastDot`. | + +## `TimelineVariant` + +| Member | Notes | +|---|---| +| `Default` | The default of `DrylTimelineItem.Variant`, and the fallback for any unmatched value. Neutral glass marker. | +| `Accent` | `--accent-a` on an `--accent-line` border. | +| `Success` | `--success`. | +| `Warning` | `--warning`. | +| `Danger` | `--danger`. | + +Independent of the item's `Ai`: a step can be semantically successful and +currently re-running. + +## `DrylBadge.BadgeKind` + +| Member | Notes | +|---|---| +| `Neutral` | The default of `DrylBadge.Kind`, and the fallback for any unmatched value. The unmodified pill. | +| `Accent` | `--accent-fg` on `--accent-soft`. | +| `Success` | `--success`. | +| `Warning` | `--warning`. | +| `Danger` | `--danger`. | + +## `DrylImage`'s four enums + +| Type | Members | Notes | +|---|---|---| +| `ImageFit` | `Cover`, `Contain`, `Fill`, `None`, `ScaleDown` | `Cover` is the default and the fallback for any unmatched value. | +| `ImagePosition` | `Center`, `Top`, `Bottom`, `Left`, `Right` | `Center` is the default and the fallback. | +| `ImageRounded` | `None`, `Sm`, `Md`, `Lg`, `Full` | Maps onto the radius scale; `Full` maps to `--r-pill`. | +| `ImageRatio` | `Auto`, `Square`, `Video`, `Portrait`, `Wide` | `Auto` derives the ratio from `Width` and `Height`; any other value overrides them. | + +## `DrylTableKpi.KpiDeltaKind` and `DrylTableKpi.KpiTrend` + +| Type | Members | Notes | +|---|---|---| +| `KpiDeltaKind` | `Positive`, `Negative`, `Neutral` | `Neutral` is the default. Colour only. | +| `KpiTrend` | `Up`, `Down`, `None` | `None` is the default. Arrow only. | + +Deliberately independent of each other — see the duplicated-vocabularies note +above. + +## The table's types + +### `DrylColumn` + +The declarative column. Placed in `DrylTable`'s `Columns` slot, it registers +itself through a cascade and renders nothing of its own. + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Field` | `Expression>?` | `null` | Value, default sort key and default search source. | +| `Title` | `string?` | `null` | Header text. | +| `ColumnKey` | `string?` | `null` | Stable key. Derived from `Field`'s member name when unset. | +| `Sortable` | `bool` | `false` | Click-to-sort on this column's header. | +| `Searchable` | `bool` | `false` | Includes this column in the toolbar search. | +| `Filterable` | `bool` | `false` | Renders a filter control in the header. | +| `FilterType` | `ColumnFilterType` | `ColumnFilterType.Auto` | Kind of filter UI. | +| `FilterValues` | `Func, IEnumerable>?` | `null` | Overrides the derived option set of a select filter. | +| `Primary` | `bool` | `false` | Marks the identity column; its cells get the primary emphasis. | +| `Align` | `ColumnAlign` | `ColumnAlign.Start` | Horizontal alignment of header and cells. | +| `Width` | `string?` | `null` | Explicit width, any CSS length. | +| `Pinned` | `ColumnPin` | `ColumnPin.None` | Freezes the column to an edge. Excludes it from drag reorder. | +| `Resizable` | `bool` | `true` | Whether `ResizableColumns` applies to this column. | +| `Reorderable` | `bool` | `true` | Whether `ReorderableColumns` applies to this column. | +| `Hidden` | `bool` | `false` | Starts hidden; the visibility menu can show it. | +| `CellTemplate` | `RenderFragment?` | `null` | Cell renderer. | +| `EditTemplate` | `RenderFragment?` | `null` | Editor for inline editing. Its presence is what makes the column editable. | +| `HeaderTemplate` | `RenderFragment?` | `null` | Header renderer. | + +Read-only members a consumer may use: + +| Member | Type | Purpose | +|---|---|---| +| `Key` | `string` | The resolved stable key. | +| `DisplayTitle` | `string` | `Title`, falling back to `Key`. | +| `FieldGetter` | `Func?` | The compiled accessor. | +| `FieldType` | `Type?` | The field expression's CLR type. | +| `ResolvedFilterType` | `ColumnFilterType` | `FilterType` with `Auto` resolved against `FieldType`. | +| `IsEditable` | `bool` | Whether an `EditTemplate` was supplied. | +| `TextAlign` | `string` | `Align` as a CSS alignment value. | + +Either `Field` or a `CellTemplate` is required. `Auto` resolves to +`ColumnFilterType.Select` for an enum, a `bool` or a nullable `bool`, and to +`ColumnFilterType.Text` for everything else. + +### `ColumnAlign` + +| Member | Notes | +|---|---| +| `Start` | The default. Adds no modifier class and no inline alignment. | +| `Center` | | +| `End` | | + +### `ColumnFilterType` + +| Member | Notes | +|---|---| +| `Auto` | The default of `DrylColumn.FilterType`. Resolved from the field's type. | +| `Text` | A contains filter over the value's text. | +| `Select` | A checkbox list over the distinct values. | + +### `ColumnPin` + +| Member | Notes | +|---|---| +| `None` | The default. The only value that can be reordered. | +| `Start` | Frozen to the leading edge; rendered before every unpinned column. | +| `End` | Frozen to the trailing edge; rendered after every unpinned column. | + +### `TableEditMode` + +| Member | Notes | +|---|---| +| `Row` | The default of `DrylTable.EditMode`. Every editable column of the row enters its editor. | +| `Cell` | Only the activated column does. | + +### `SortDirection` and `SortDescriptor` + +`SortDirection` is `Ascending` or `Descending`; there is no "none" member — +absence from the sort list is what "not sorted" means. + +`SortDescriptor` is a `sealed record` of a `ColumnKey` and a `Direction`. +Several combine into a multi-sort, applied in list order, first primary. + +### `FilterOperator` and `FilterDescriptor` + +`FilterDescriptor` is a `sealed record` of a `ColumnKey`, an `Operator` and a +`Value`. Several are AND-combined. + +`FilterOperator` has ten members: `Contains`, `Equals`, `NotEquals`, +`GreaterThan`, `LessThan`, `GreaterThanOrEqual`, `LessThanOrEqual`, `In`, +`IsNull`, `IsNotNull`. + +**The table's own pipeline implements two of them.** The filter UI emits only +`Contains` and `In`, and the client-side matcher understands only those two — a +descriptor carrying any other operator is currently ignored by the client +pipeline rather than applied. The remaining eight exist for a `DataProvider`, +which receives whatever descriptors it is given and is free to translate all +ten. A consumer constructing descriptors by hand for a client-side table should +know that eight of the ten are inert. + +### `DataRequest` and `DataResult` + +The server-mode contract, both `sealed record`s. + +| `DataRequest` | Type | Purpose | +|---|---|---| +| `Skip` | `int` | Rows to skip; `0` when paging is off. | +| `Take` | `int` | Rows to take; `int.MaxValue` when paging is off, and for an export. | +| `SearchText` | `string?` | The applied search, or `null`. | +| `Sort` | `IReadOnlyList` | Sort in priority order. | +| `Filters` | `IReadOnlyList` | Active filters. | + +| `DataResult` | Type | Purpose | +|---|---|---| +| `Items` | `IReadOnlyList` | The rows for this request. | +| `TotalCount` | `int` | Rows matching the search and filters across all pages. | + +### `RowEditEventArgs` and `RowReorderEventArgs` + +| Type | Shape | Purpose | +|---|---|---| +| `RowEditEventArgs` | `(TItem Item, TItem EditedItem)` | Raised on commit. `Item` is the original; `EditedItem` is the working copy, or the same instance when no `CloneRow` was supplied. | +| `RowReorderEventArgs` | `(int OldIndex, int NewIndex)` | Raised after a row move. Indices into the displayed view. | + +## The AI parameters + +Five of the twenty-one components — `DrylCodeBlock`, `DrylImage`, `DrylStat`, +`DrylTable` and `DrylTimelineItem` — carry the same two parameters, with the +same types and the same defaults: + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Ai` | `AiState` | `AiState.None` | Ambient AI state. AI styling is opt-in. | +| `Aura` | `AiAura?` | `null` | Pins the aura variant; `null` inherits a surrounding `DrylAiScope`. | + +Both types belong to `E1 Foundation`; the aura vocabulary they drive is +specified in `E3 AI`. + +The sixteen that do not carry them are not oversights: every one of their specs +records the decision and its reason under "AI mode" (`AI-05`). Three reasons +recur. A component that **paints no surface** has nothing to put an aura on — +`DrylIcon`, `DrylKbd`, `DrylDescriptionList`, `DrylTimeline`, `DrylTreeView`. A +component that **states an identity or a classification** rather than an +activity would contradict itself with one — `DrylAvatar`, `DrylBadge`, the whole +citation family. And a component that is **a mark inside another surface** would +compete with that surface's own state — `DrylSparkline`, `DrylTableKpi`. + +Two of these are worth reading in full because the "no" is the interesting +answer: `F4 DrylCitation`, where the component most obviously about AI must not +carry the AI vocabulary, and `F20`/`F21`, where the tree family's "no" is +recorded as a present-tense fact rather than a principle. + +Three components render the aura in three different ways, which is worth knowing +before changing any of them: + +| Component | How | +|---|---| +| `DrylStat`, `DrylTimelineItem`, `DrylCodeBlock`, `DrylTable` | The shared helper writes both the classes and the layers. | +| `DrylImage` | Writes the layers by hand, because it adds effects to the image itself on top of them. | +| `DrylTable` | Uses the shared helper, but only when `Bordered` — a borderless table has no box for the ring to trace. | + +## `Class` and `AdditionalAttributes` + +Nineteen of the twenty-one components carry both: + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Class` | `string?` | `null` | Extra CSS class(es) **merged** onto the component's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +`Class` exists because a splatted `class` would otherwise clobber the +component's own classes. Blazor matches parameter names case-insensitively, so a +consumer writing `class="my-thing"` binds the typed `Class` parameter — not +`AdditionalAttributes` — and the classes merge. + +**Two components carry neither: `DrylDescriptionItem` and `DrylTreeNode`.** Both +are member components of a family, and in both cases it means a consumer cannot +attach a class, a `data-*` attribute or a test hook to a single field or a +single node. They are the category's outstanding half of the library-wide +rollout. + +`DrylAvatar` carries both and applies them to the wrong element when `Status` is +set: the root is then a wrapper, and both land on the avatar inside it. See +`F1`. + +Of the nineteen, three are guarded in +`tests/DRYL.Components.Tests/ClassMergeTests.cs`: `DrylBadge`, `DrylStat` and +`DrylTimeline`. The other sixteen — including `DrylTable`, where a clobbered +class would take the whole card surface with it — are unguarded. diff --git a/specs/E5 Data/_Interop.md b/specs/E5 Data/_Interop.md index d2d9934..45d2267 100644 --- a/specs/E5 Data/_Interop.md +++ b/specs/E5 Data/_Interop.md @@ -4,16 +4,136 @@ The JS interop surface this category uses, the DI services it registers, and the cleanup duties each imposes (`CODE-05` in [`../../harness/code.md`](../../harness/code.md)). -*Scaffold. Filled in during phase C.* +Four of the twenty-one components inject an `IJSRuntime`: `DrylCodeBlock`, +`DrylStat`, `DrylTreeView` and `DrylTable`. The other seventeen are markup and +CSS end to end, which is why none of them needs a prerender guard — there is no +`OnAfterRenderAsync` interop to guard. + +The distribution is lopsided on purpose. `DrylSparkline` computes an entire +chart on the server and emits it as SVG; `DrylImage` gets its loading, error and +AI behaviour from CSS and DOM events; `DrylCitation` gets a whole floating panel +without touching JS, by composing `DrylPopover`. Interop here is reserved for +what the server genuinely cannot do: reach the clipboard, tween text between +renders, measure a table, suppress a browser default, and write a file. ## Interop -none *(phase C)* +| Entry point | Called by | Purpose | +|---|---|---| +| `dryl.clipboard.copy` | `DrylCodeBlock` | Writes the code to the clipboard. Returns whether it succeeded. | +| `dryl.motion.countUp` | `DrylStat` | Tweens the value span's text toward the rendered value. | +| `dryl.tree.attach` / `dryl.tree.detach` | `DrylTreeView` | Suppresses the browser's default scroll for the navigation keys. | +| `dryl.table.initColumnResize` / `dryl.table.disposeColumnResize` | `DrylTable` | Pointer-driven column resizing; calls back into `OnColumnResized`. | +| `dryl.table.focusGrip` | `DrylTable` | Moves focus onto a row's drag grip after a keyboard reorder. | +| `dryl.table.focusHeader` | `DrylTable` | Moves focus onto a header after a keyboard column move. | +| `dryl.table.focusFirstEditor` | `DrylTable` | Moves focus into the first editor when an inline edit starts. | +| `dryl.table.layoutPinned` | `DrylTable` | Re-measures the sticky offsets of pinned columns. | +| `dryl.storage.get` / `dryl.storage.set` | `DrylTable` | Reads and writes the persisted table state. | +| `dryl.download.csv` | `DrylTable` | Hands the built CSV to the browser as a download. | +| `DrylViewTransition` (wrapping `dryl.viewTransition.start`) | `DrylTable` | Same-document view transitions for row morphs. | +| `dryl.popover.*` | `DrylCitation`, transitively | Portalling and placing the citation panel. | + +### `dryl.tree` is an alias, and the name is misleading + +`window.dryl.tree` is assigned from `window.dryl.keynav`, which is also used by +`DrylSelect`. It does exactly one thing: it installs a `keydown` listener that +calls `preventDefault` for the six navigation keys — the four arrows, `Home` and +`End` — so a Blazor `@onkeydown` handler can move a roving focus without the +page scrolling underneath it. `Tab`, `Enter` and `Escape` are deliberately left +alone, so focus can still leave the widget and activation still works. + +It moves no focus and knows nothing about trees. All of `DrylTreeView`'s +keyboard behaviour is C#; this call only stops the browser from competing with +it. + +### `DrylStat`'s tween never changes what the DOM says + +`dryl.motion.countUp` rewrites the value span's text *between* renders and +always lands on exactly the string Blazor rendered. The consequence is the +contract worth knowing: the markup is identical with and without `CountUp`, so a +bUnit test, a screen reader or a consumer reading the DOM sees the real value +whether or not JS ever ran. That property is what +`tests/DRYL.Components.Tests/DrylStatCountUpTests.cs` pins. + +### `DrylTable`'s calls are all best-effort + +Every post-render call the table makes — the four focus and layout calls, the +resize attach, the storage read and write — is wrapped so that a missing element +or a closed circuit is swallowed rather than surfaced. The reasoning is that +none of them is load-bearing: a focus that does not land, a pin offset that is +not re-measured or a state that is not restored degrades the table without +breaking it, and the alternative is an exception during a render triggered by +the DOM being one frame behind. + +The resize attach is the exception that proves it: a failed attach explicitly +un-sets the "attached" flag, so a later render retries rather than leaving the +handles inert forever. + +### `DrylTable` is the category's only two-way interop + +It hands JS a `DotNetObjectReference` to itself so the pointer-driven resize +helper can report a finished drag through the `[JSInvokable]` +`OnColumnResized`. It is the only object reference this category hands out, and +therefore the only one it can leak. + +### `DrylCitation` — interop by composition + +`DrylCitation` calls no JS. It composes `DrylPopover`, and every interop duty of +its panel — the body portal, the placement, the outside click, `Escape`, the +exit animation — belongs to that component and is specified in +`specs/E11 Surfaces/F1 DrylPopover.md`. The citation inherits both the behaviour +and the recorded debt. + +**`DrylTable` deliberately does not do this**, and the cost is recorded in +`F16 DrylTable/S2` and `S5`: its per-column filter surface and its +column-visibility menu are hand-built, rendered in place rather than portalled, +and therefore clipped inside a scrolling table — and each answers `Escape` only +if the user has focused it first, which nothing does when it opens. ## Services -none *(phase C)* +**None.** No component in this category injects a DRYL service, registers one, +or is registered by `AddDrylComponents()`. `DrylTable`'s state persistence goes +through the browser's storage via `dryl.storage` rather than through a service, +which is why it is scoped to a `PersistStateKey` and not to a user. ## Cleanup -none *(phase C)* +Eight components implement a disposal. + +| Component | Disposes | +|---|---| +| `DrylAvatar` | Unregisters itself from a surrounding `DrylAvatarGroup`. | +| `DrylTreeNode` | Unregisters itself from its `DrylTreeView`. | +| `DrylCodeBlock` | The aura lifecycle's timer, and the cancellation source behind the copy confirmation. | +| `DrylImage` | The aura lifecycle's timer. | +| `DrylStat` | The aura lifecycle's timer. | +| `DrylTimelineItem` | The aura lifecycle's timer. | +| `DrylTreeView` | Detaches the key-suppression listener. `IAsyncDisposable`. | +| `DrylTable` | The aura lifecycle's timer, the search debounce, the in-flight data-provider request, the view-transition helper, the resize listener and the object reference behind it. `IAsyncDisposable`. | + +Three patterns are worth naming because getting them wrong has broken this +repository before. + +**The aura lifecycle holds a timer.** `AuraLifecycle` keeps the AI aura mounted +for one `--dur-slow` beat after the state drops, so it dissolves rather than +snapping away, and the timer that does it must be disposed with the component. +Every component in this category that takes `Ai` disposes one. + +**Detaching is guarded by whether attaching happened.** `DrylTreeView` records +whether its first render actually attached, and skips the detach entirely when +it did not. Without that guard, a statically prerendered component throws +`InvalidOperationException` while being torn down, because there is no JS to +call. `DrylTable` guards its resize detach the same way. + +**A cancelled delay must not outlive its component.** `DrylCodeBlock`'s copy +confirmation and `DrylTable`'s search debounce both wait on a `Task.Delay` and +then call `StateHasChanged`. Both hold a cancellation source that is cancelled +*and* disposed on disposal, so neither can call into a component that is gone. +`DrylCodeBlock`'s is also cancelled by a second copy, so a rapid double press +does not let the first timer revert the second confirmation early. + +`DrylCodeBlock` and `DrylStat` additionally catch `JSDisconnectedException` — +and `DrylStat` `InvalidOperationException` as well, for the prerender pass — +around their own calls, because both can be triggered by a user action or a +render that races the circuit closing. From 3a52ae7bc4e21f34d0f37073cdc32c1efe61e34f Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 02:12:08 +0200 Subject: [PATCH 15/24] docs: the register of what the E5 specs found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-one components read against their code, ranked into five tiers and sequenced. Nothing fixed here — the specs land as one reviewable artifact and the fixes as another, as the plan said. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011yryrukUNNK1PMieud3Vef --- docs/2026-08-22-e5-findings-plan.md | 240 ++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/2026-08-22-e5-findings-plan.md diff --git a/docs/2026-08-22-e5-findings-plan.md b/docs/2026-08-22-e5-findings-plan.md new file mode 100644 index 0000000..614d39e --- /dev/null +++ b/docs/2026-08-22-e5-findings-plan.md @@ -0,0 +1,240 @@ +# Plan: what writing the `E5 Data` specs found + +**Branch:** not yet cut · **Base:** `spec/phase-c-e5-data` +(`2.24.3`, **published** — `v2.24.3` is tagged and on `origin/main`) + +Writing the twenty-one `E5 Data` specs meant reading every component in the +category against its code rather than its doc comments. This is the register of +what that reading turned up, ranked, so the fixes can be planned as their own +work rather than smuggled into the spec branch. Nothing here is fixed yet. + +Each entry names the spec that records it in full. The spec is the source; this +file is the queue. + +--- + +## Rank 1 — a rule's enforcement has a blind spot + +### 1.1 `DESIGN-01` greps stylesheets, and one component writes colours in markup + +`DrylTableKpi` paints its sparkline from four literal colour values written into +`DrylTableKpi.razor`. `DESIGN-01`'s Check line greps `code/*/**/*.razor.css` and +reads **clean**, because these live in a `.razor` file. Searching `.razor` files +under `code/` for colour literals returns exactly these four hits and nothing +else in the library. + +The visible consequence is that the tile ignores `DrylThemeProvider`: every +accent on a re-themed page follows the consumer's seed except this chart. The +stylesheet already contains the correct token-based rule for the same stroke — +it has never had an effect, being overridden by the inline `style` attribute on +the element it targets. + +- **Recorded in:** `specs/E5 Data/F17 DrylTableKpi.md` +- **The fix has two halves:** replace the four literals with the tokens, and + widen `DESIGN-01`'s Check to `.razor` as well as `.razor.css` so the next one + is caught. The second half is the one that matters. + +### 1.2 An icon name that does not exist, rendering as nothing + +`DrylFileUpload` asks `DrylIcon` for `UploadCloud`; the set has only `Upload`. +An unknown name renders a correctly sized, correctly stroked, entirely empty +`svg` — no exception, no console warning, no fallback glyph — so the drop zone's +32px leading icon has been blank. Comparing every `Name` passed to `DrylIcon` +under `code/` against the keys of `Icons` finds exactly this one mismatch out of +38 names in use. + +- **Recorded in:** `specs/E5 Data/F10 DrylIcon.md` +- **The fix has two halves:** correct the name (or add the icon), and add the + test that compares the two sets, which is the only form in which this class of + bug is catchable. + +--- + +## Rank 2 — accessibility claims the code does not honour + +### 2.1 `DrylTable` declares the grid role without the grid pattern + +`role="grid"` promises arrow-key movement between cells, `Home`/`End` within a +row and one tab stop for the widget. The table offers none of it. A +screen-reader user is told they are in a grid and finds none of the behaviour. +The plain table role is the honest claim for what the component does today. + +- **Recorded in:** `specs/E5 Data/F16 DrylTable/S9 Presentation, accessibility and AI.md` +- **Two routes:** change the role, or implement the pattern. The first is a + one-line honesty fix; the second is a feature. + +### 2.2 `DrylImage`'s AI state label is announced by nobody + +`Ai` set makes the frame a polite live region and writes the state into the +frame's `aria-label`. A live region announces changes to its *content*, not to +its label, and the content does not change when the state does — so "Generating +image… 40 %" reaches no screen reader. + +- **Recorded in:** `specs/E5 Data/F11 DrylImage.md` + +### 2.3 `DrylCodeBlock`'s copy button never announces that it copied + +Its `aria-label` is fixed, and an `aria-label` overrides the visible text — so +the label a screen reader reads stays "copy code" while the visible label reads +"Copied". The one user who most needs the confirmation is the one who does not +get it. + +- **Recorded in:** `specs/E5 Data/F7 DrylCodeBlock.md` + +### 2.4 `DrylAvatar`'s presence dot is silent + +`Status` is `aria-hidden` and contributes nothing to the accessible name, so a +screen-reader user cannot tell an online colleague from an offline one — which +is the only information the dot exists to carry. + +- **Recorded in:** `specs/E5 Data/F1 DrylAvatar.md` + +### 2.5 `DrylCodeBlock`'s scrollable code has no keyboard access + +The body scrolls horizontally and carries no `tabindex`, so a keyboard-only user +cannot scroll a long line into view — the classic WCAG 2.1.1 failure. Two nested +elements are scrollable, so the inner one is pointer-only. + +- **Recorded in:** `specs/E5 Data/F7 DrylCodeBlock.md` + +### 2.6 `DrylTreeNode`'s `Disabled` disables only selection + +A disabled node still takes focus, still counts as a stop in the arrow-key walk, +still expands and collapses, and can be the roving-`tabindex` target — while +reporting `aria-disabled` to assistive technology. + +- **Recorded in:** `specs/E5 Data/F21 DrylTreeNode.md` + +--- + +## Rank 3 — behaviour that is wrong rather than missing + +### 3.1 `DrylAvatar` remembers a failed image forever + +The load-error flag is never reset, so assigning a new, working `Src` to an +avatar whose previous URL failed keeps showing the fallback for the lifetime of +that instance. A list that reuses avatar instances across rows can show the +wrong person's initials. + +- **Recorded in:** `specs/E5 Data/F1 DrylAvatar.md` + +### 3.2 `DrylPagination`'s summary is not clamped + +The clamp that protects navigation does not protect the display path, so an +out-of-range `CurrentPage` renders "Showing 261–247 of 247" while the controls +behave correctly. + +- **Recorded in:** `specs/E5 Data/F13 DrylPagination.md` + +### 3.3 `DrylTreeView` cannot be deselected + +The view adopts an externally supplied `SelectedValue` only when it is non-null, +so setting the bound value back to `null` leaves the node highlighted and still +reported as selected. + +- **Recorded in:** `specs/E5 Data/F20 DrylTreeView.md` + +### 3.4 `DrylTable`'s "Select all rows" selects the page + +The header checkbox's accessible label says it selects all rows; it selects the +rows in the current view, which under paging is one page of them. + +- **Recorded in:** `specs/E5 Data/F16 DrylTable/S4 Selection.md` + +### 3.5 `DrylTable`'s view-transition names can collide silently + +Without a `RowIdSelector`, the name falls back to the item's hash code. A +duplicate name aborts the whole transition, so the symptom is not a wrong +animation but no animation at all, intermittently and without a message. + +- **Recorded in:** `specs/E5 Data/F16 DrylTable/S7 Row reordering and motion.md` + +### 3.6 `DrylAvatarGroup`'s cap hides the wrong avatar after a change + +Members are appended in registration order and removed by identity, so an avatar +added later always lands at the end. Remove the first participant and add +another, and the cap hides an avatar that is not the last one in the markup. + +- **Recorded in:** `specs/E5 Data/F2 DrylAvatarGroup.md` + +--- + +## Rank 4 — floating surfaces that were built twice + +`DrylTable`'s per-column filter surface and its column-visibility menu are +hand-built rather than `DrylPopover`s. Both are rendered in place, so both are +clipped inside a scrolling table; neither closes on an outside click; and each +answers `Escape` only if the user has focused it first, which nothing does when +it opens. That last one is the same defect already recorded against +`DrylPopover` itself, reproduced here because these surfaces were built +separately. + +- **Recorded in:** `specs/E5 Data/F16 DrylTable/S2 Search and filtering.md` and + `.../S5 Columns.md` +- **Note:** the right fix depends on `ideas/I4`, which is still waiting on a + maintainer decision about `DrylPopover`'s exit animation. Moving two more + surfaces onto that component before that is settled would multiply the + problem. + +--- + +## Rank 5 — documented debt with no user-visible symptom today + +These are recorded in their specs and listed here only so the register is +complete. None of them changes what a user sees right now. + +- **`DrylTableKpi` has no demo page and no `ComponentCatalog` entry** + (`CODE-20`, `REL-04`). This is the direct reason 1.1 survived: the chart with + the hardcoded colours has never been rendered on the docs site. + → `F17` +- **Nothing in this category is animated except six components.** `DrylCitation` + (chip hover), `DrylCodeBlock`, `DrylImage`, `DrylStat`, `DrylTimelineItem` and + `DrylTable` (aura and morphs), and `DrylTreeNode` (row and chevron). The other + fifteen have no enter, no exit and no state transition at all — including + `DrylTimeline`, whose content arrives over time, and `DrylTreeView`, whose main + gesture is expansion. `DESIGN-11` and `DESIGN-12`, recorded per component + rather than waived. +- **A dead class and a dead field.** `DrylImage` adds an image-specific class for + `AiState.Active` that no rule anywhere matches; `DrylTable` declares a + documented flag for the pinned-column re-measure that nothing reads or writes, + with a comment describing an intent the code does not implement. → `F11`, + `F16/S5` +- **Literal geometry in almost every component.** Sizes, type scales and + spacings written as raw lengths rather than tokens, usually in files whose + gaps and radii *are* tokens — half-converted rather than untouched. Recorded + per component; the fullest examples are `F1`, `F3` and `F19`. +- **`DrylPagination`'s size selector is a raw `select`**, not a `DrylSelect`, and + carries both a visible `label` and a competing `aria-label`. → `F13` +- **`DrylTable` reports misconfiguration with `Console.WriteLine`**, three times, + with no `ILogger` anywhere in the component. On Blazor Server that is the + server's console. → `F16/S1` +- **`FilterOperator` has ten members and the client pipeline implements two.** + The other eight are inert unless a `DataProvider` translates them. + → `_Api.md` +- **Two components carry neither `Class` nor `AdditionalAttributes`** — + `DrylDescriptionItem` and `DrylTreeNode`. → `F9`, `F21`, `_Api.md` +- **Sixteen of the twenty-one components have no test of their own.** The + category's tested components are `DrylBadge`, `DrylIcon` (three names), + `DrylPagination`, `DrylStat` (count-up only) and `DrylTable` (the morph, and + little else). Notably untested: `DrylSparkline`'s invariant formatting, which + is this repository's best-documented recurring failure and which the + component's own header comment calls out. + +--- + +## Suggested sequencing + +1. **1.1 and 1.2 together**, because both are one-line fixes whose second half + is a check that prevents the recurrence. They are also the two findings a + reader can verify in a minute. +2. **Rank 2 as one pass.** Six accessibility fixes, all small, all in different + components — the kind of work that is cheap in a batch and expensive one at a + time. +3. **Rank 3 individually**, each with the test that would have caught it. +4. **Rank 4 after `ideas/I4` is decided**, not before. +5. **Rank 5 as it is touched.** Recorded debt, not a queue. + +Every fix touches a component whose spec now exists, so every fix updates that +spec in the same commit (`SPEC-01`) and leaves it on `Implemented` (`SPEC-04`). +`` is bumped and a changelog entry cut with the first of them +(`REL-01`, `REL-02`). From 21e074be36b6135daef6929a88faa1baa0c85cf8 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 17:04:26 +0200 Subject: [PATCH 16/24] idea(I10): shared-element transitions, cut down to what is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of the user story ships already — IDrylViewTransition, the JS bridge, the ::view-transition-* vocabulary and the Glide/DepthGlass tiers. The idea is narrowed to the two real gaps and staged: the generic hull now, the route-level host as its own decision. Records the skeleton policy for a loading detail page, and why an automatic trigger is refused. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeoxNkuw3okKmUhzmYFiuV --- docs/2026-08-22-drylmorph-plan.md | 128 ++++++++++ ...transitions between overview and detail.md | 223 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 docs/2026-08-22-drylmorph-plan.md create mode 100644 ideas/I10 Shared-element transitions between overview and detail.md diff --git a/docs/2026-08-22-drylmorph-plan.md b/docs/2026-08-22-drylmorph-plan.md new file mode 100644 index 0000000..30b4df4 --- /dev/null +++ b/docs/2026-08-22-drylmorph-plan.md @@ -0,0 +1,128 @@ +# DrylMorph — implementation plan + +Idea: `ideas/I10 Shared-element transitions between overview and detail.md` +(`Ready`, step 1 of two). +Spec: `specs/E9 Layout/F17 DrylMorph.md` (`Modified`). + +Step 2 of the idea — the route-level transition host — is **not** in this plan. + +Target version: **2.25.0** (MINOR — new component, additive API). 2.24.3 has +shipped, so this is a fresh version block, not an addition to an existing one +(`REL-01`). + +--- + +## T1 — Spec and bookkeeping + +Files: +- `ideas/I10 Shared-element transitions between overview and detail.md` (done) +- `specs/E9 Layout/F17 DrylMorph.md` (done) +- `specs/E9 Layout/_Interop.md` (done) +- `harness/requirements.md` — `SPEC-02` table: `E9 Layout` 16 → 17, total + 127 → 128 +- `CLAUDE.md` — the `x/127 components covered` line → `x/128` + +Verify: `node scripts/check-harness-links.mjs` and +`node scripts/check-spec-coverage.mjs`. The coverage check exits non-zero +(phase C is unfinished); the evidence is that `DrylMorph.razor` is **not** in +its "without a spec" list once T2 lands, and that no structural error is +reported. + +Commit: `spec(E9): DrylMorph — the shared-element hull` + +## T2 — The component + +Files: +- `code/DRYL.Components/Components/Layout/DrylMorph.razor` (new) + +Shape (mirrors `DrylTypo`, which solves the same dynamic-tag problem): + +- `@inject IDrylViewTransition ViewTransition` +- parameters `Name`, `Style`, `As`, `Active`, `ChildContent`, `Class`, + `AdditionalAttributes` +- `private RenderFragment Render => builder => { builder.OpenElement(0, As); … }` + — a `.razor` cannot both carry markup and override `BuildRenderTree`, so the + element is built in a `RenderFragment` the way `DrylTypo.Render` is +- the inline style is built exactly as `DrylCard.VtStyle` builds it today +- `protected override void OnAfterRender(bool firstRender) => + ViewTransition.SignalRendered();` — unconditional, per the spec + +No stylesheet, no JS, no `IDisposable`. + +Verify: `dotnet build DRYL.slnx -c Release`. + +Commit: `feat(layout): DrylMorph — a transition ID for any content` + +## T3 — Tests + +Files: +- `tests/DRYL.Components.Tests/DrylMorphTests.cs` (new) +- `tests/DRYL.Components.Tests/ClassMergeTests.cs` (add `DrylMorph`) + +Cases, one per acceptance criterion group: tag rendering and `As`; the name +rendered / not rendered for null, empty, whitespace and `Active=false`; the +`DepthGlass` pair (`view-transition-class` + `data-vt-depth`) and their absence +on `Glide`; `SignalRendered` called on render, including while unnamed (fake +`IDrylViewTransition`); `Class` merge and attribute splat. + +Verify: `dotnet test DRYL.slnx -c Release`. + +Commit: `test(layout): DrylMorph` + +## T4 — DrylCard delegates + +Files: +- `code/DRYL.Components/Components/Surfaces/DrylCard.razor` +- `specs/E11 Surfaces/` — only if a `DrylCard` spec already exists (`SPEC-01`) + +`DrylCard.ViewTransitionName` / `ViewTransitionStyle` keep their exact public +behaviour; the inline-style construction moves to the shared helper `DrylMorph` +uses, so the string exists in one place. **Not** a wrap of `DrylCard`'s root in +a `DrylMorph` — that would add a box inside every card and change consumers' +layout. + +Verify: `dotnet build DRYL.slnx -c Release` and +`dotnet test DRYL.slnx -c Release` — `DrylCardViewTransitionTests.cs` is the +regression net and must stay green **unchanged**. + +Commit: `refactor(surfaces): DrylCard builds its morph style through the shared helper` + +## T5 — Release bookkeeping + +Files: +- `code/DRYL.Components/DRYL.Components.csproj` — `` 2.24.3 → 2.25.0 +- `CHANGELOG.md` — a `## [2.25.0]` block under `Added` +- `specs/E9 Layout/F17 DrylMorph.md` — `State: Modified` → `Implemented` + +Verify: `dotnet build DRYL.slnx -c Release`, +`node scripts/check-motion-tokens.mjs`, `node scripts/check-light-sync.mjs`, +`node scripts/validate-light-contrast.mjs`. + +Commit: `release: 2.25.0 — DrylMorph` + +## T6 — Website (separate repository) + +`../DRYL.Website` is its own repository (`CODE-20`: demos live there). Files: +- `DRYL.Website/Components/Pages/DemoMorph.razor` +- `DRYL.Website/Components/Examples/Morph/…` +- `DRYL.Website/Components/ComponentCatalog.cs` + +Verify: `dotnet build`, `dotnet test DRYL.Website.slnx`, and the page driven in +the browser in both color modes — the morph is the deliverable and a screenshot +of a static page proves nothing about it. + +Commit: in that repository, separately. + +--- + +## Risks carried into implementation + +- **Duplicate `view-transition-name`.** Two live instances sharing a name make + the browser skip the morph silently. Covered by `Active` and by T3. +- **Which service instance is signalled.** `DrylMorph` injects the DI-scoped + service; `DrylDialogProvider` and `DrylTable` deliberately run their own + instances and keep signalling those. Recorded in `specs/E9 Layout/_Interop.md` + so it is not "fixed" later. +- **The extra box.** The hull is a real element — `view-transition-name` does + not apply to `display: contents`. `As` keeps the DOM valid; T6 must show this + honestly rather than hide it. diff --git a/ideas/I10 Shared-element transitions between overview and detail.md b/ideas/I10 Shared-element transitions between overview and detail.md new file mode 100644 index 0000000..f1d4d3c --- /dev/null +++ b/ideas/I10 Shared-element transitions between overview and detail.md @@ -0,0 +1,223 @@ +# Shared-element transitions between overview and detail + +## Meta +- **State:** Ready + +## Problem + +Raised by the Product Owner on 2026-08-22 as a user story: + +> Als .NET-Entwickler möchte ich eine Blazor-Komponente nutzen können, die es +> mir erlaubt, mit minimalem Code sanfte Shared-Element-Transitions zwischen +> einer Übersicht und einer Detailansicht umzusetzen, ohne mich selbst um +> komplexe Animationslogik oder Timing kümmern zu müssen. Die Komponente soll +> mir erlauben, einfach eine Transition-ID zu vergeben und vordefinierte +> Motion-Token zu nutzen, damit sich UI-Elemente beim Navigieren flüssig und +> konsistent in die Detailansicht überführen. + +The named pain is real and specific: **the timing contract, not the animation.** +A consumer who wants a card to morph into a detail view today has to know that +`view-transition-name` must be unique at snapshot time, that the mutation has to +run inside `document.startViewTransition`, and that the browser must be told +when Blazor's render actually reached the DOM. That is the "komplexe +Animationslogik und Timing" the story wants to be rid of. + +## What already exists + +*(Tech Lead, before any solution is proposed — `IDEA-05` order.)* + +The library is much further along here than the story assumes, and the idea has +to be cut against that, not against a blank page: + +- **`IDrylViewTransition`** (`code/DRYL.Components/Motion/`) — a scoped service + that runs a Blazor state change inside a same-document view transition, with + a documented `SignalRendered()` contract, and a morph-free fallback for + prerender, unsupported browsers and `prefers-reduced-motion`. +- **`dryl.viewTransition`** (`wwwroot/js/dryl.js`) — the JS bridge, including + the lazily injected `#dryl-merge` filter and swallowed skip-rejections. +- **The full morph vocabulary in `dryl.css`** — `::view-transition-group(*)` on + `--dur-slow` / `--ease-viscous`, the `dryl-depth` tier + (`dryl-depth-clarify`), and the reduced-motion opt-out. +- **`DrylViewTransitionStyle`** — the two tiers (`Glide`, `DepthGlass`), whose + own XML docs already name "shared-element morphs such as card→dialog". +- **Three call sites already wired**: `DrylCard.ViewTransitionName` + + `ViewTransitionStyle`, the `DrylDialog` / `DrylDialogProvider` handoff + (`DialogOptions.HandoffStyle`), and `DrylTable`'s row reorder. + +So the motion vocabulary, the timing contract and the fallbacks are **done**. +Nothing in the story asks for a new token, a new duration, a new easing or a new +`AiState`. What is missing is narrower than the story implies — and one part of +it is harder. + +## The two gaps + +**Gap 1 — the shared element is only available on `DrylCard`.** +`ViewTransitionName` exists on exactly one component. Anything else — a table +row, an image, a heading, a `
` of the consumer's own — has to hand-write +the inline style, and there is no place to hang the `dryl-depth` class or the +`data-vt-depth` marker the JS looks for. This gap is small, cheap and squarely +what "einfach eine Transition-ID vergeben" asks for. + +**Gap 2 — "beim Navigieren" is not covered at all, and is the hard half.** +`IDrylViewTransition.RunAsync` takes a mutate delegate that ends in +`StateHasChanged()` on *one* component that then reports its own +`OnAfterRender`. A real route change (`NavigationManager.NavigateTo`) is a +different shape: the `Router` tears down the overview page and builds the detail +page, and the component that must call `SignalRendered()` is a component that +did not exist when the transition started. + +Three consequences, decided rather than discovered: + +1. **A loading detail page freezes the overview.** The view transition holds the + old frame until the new DOM is committed. If the detail page loads its data + in `OnInitializedAsync`, either the whole app freezes for the duration of + that load, or the morph lands on an empty skeleton. There is no third + option — this is a property of the API, not of the implementation. *Settled: + morph onto the skeleton (see Decisions).* +2. **Blazor Server pays circuit latency for this.** A route change is a round + trip. On WASM the freeze is a few frames; on Server it is the RTT. The + skeleton policy is what keeps this bounded. +3. **Cross-document view transitions do not apply.** `@view-transition + { navigation: auto }` needs a real document navigation. Interactive Blazor + does not do one, so the same-document path is the only path. + +## Solution Idea + +Settled on 2026-08-22: **Option C — staged.** Step 1 is the generic hull; the +route-level host is a second, separate step that builds on it. + +### Step 1 — `DrylMorph`, the generic shared-element hull + +A wrapper component that turns any content into a morph target: + +- `Name` — the transition ID. The one thing the story asks for. +- `Style` — `DrylViewTransitionStyle`, default `Glide`. +- `As` — the rendered tag, default `div`, so the hull stays valid inside lists + and tables (`li`, `tr`, `article`, `section`, …). +- `Active` — default `true`; set `false` on the entries of a long overview that + are not the morph target. +- `ChildContent`, plus the merged `Class` parameter and attribute splatting the + library's other wrappers carry. + +It renders the `view-transition-name`, the `view-transition-class: dryl-depth` +and the `data-vt-depth` marker `DrylCard` renders today — and it reports its own +`OnAfterRender` to `IDrylViewTransition.SignalRendered()`, so the consumer never +writes that line. + +### Step 2 — a route-level transition host *(not yet scoped)* + +A component wrapping the `Router`'s content that hooks +`NavigationManager.RegisterLocationChangingHandler`, starts the transition +there, and lets the detail page's own `DrylMorph` (or its skeleton) close the +loop. Its behaviour rests on the skeleton policy decided below. It gets its own +scoping pass before anything is written. + +## Scope + +- **In scope (step 1):** + - A new `DrylMorph` component with `Name`, `Style`, `As`, `Active`, + `ChildContent`, merged `Class` and attribute splatting. + - `DrylMorph` reporting its render to `IDrylViewTransition.SignalRendered()` + from `OnAfterRender`, unconditionally — half of today's timing contract + disappears from consumer code. + - `DrylCard` delegating its `ViewTransitionName` / `ViewTransitionStyle` + rendering to the same logic, so the inline-style construction exists once. + - A demo page and `ComponentCatalog` entry showing overview → detail on one + route (the shape that works today and is merely undiscoverable). +- **Out of scope (step 1):** + - The route-level host. Decided in principle, scoped separately (step 2). + - Any new token, duration, easing, `AiState` or dependency. + - A second animation vocabulary — `DrylMorph` renders only what `dryl.css` + already defines. + - Automatic detection of which element is "the" morph target. Rejected as not + reliably implementable. + - A fully automatic trigger parameter (`On="@selectedId"`). Rejected: the hull + cannot see when *other* components finished rendering, so it cannot honestly + own the start of the transition. + - Cross-document (`@view-transition { navigation: auto }`) transitions. + +## Impact + +*(Tech Lead, `IDEA-05`.)* + +### Harness + +- **Step 1:** no new token, no new animation/duration/easing, no new `AiState`, + no new dependency — it renders values `dryl.css` already defines and + `DrylCard` already uses. **No blocker.** +- **Step 2:** no new visual vocabulary either. The skeleton policy is new + *behaviour* and belongs in its spec before it is code. **No harness blocker.** + +### Specs + +- No `E{n} Motion` category exists. Settled against `SPEC-02` (a component's + category follows its source folder): the hull is a content wrapper a consumer + places on a page, exactly like `DrylReveal` — the library's other motion + wrapper, which lives in `Components/Layout/`. So `DrylMorph.razor` goes to + `code/DRYL.Components/Components/Layout/` and its spec to + `specs/E9 Layout/F17 DrylMorph.md`, appended after the sixteen existing + Layout components (`SPEC-02`: numbers are appended, never inserted). + `E1 Foundation` was considered and rejected: its source folder is + `Components/Providers/`, which holds what a consumer mounts *once in the + layout* — `DrylMorph` is placed per page, many times over. +- The category table in `harness/requirements.md` needs `E9 Layout` raised from + 16 to 17 and the total from 127 to 128 in the same commit, and the `127` + named in `CLAUDE.md`'s verification section with it. +- The view-transition service is documented in the `_Interop.md` files of the + categories that consume it (`E3 AI`, `E5 Data`, `E6 Dialogs`); + `specs/E9 Layout/_Interop.md` gains it too. +- Touches `specs/E11 Surfaces/` (`DrylCard` delegates) and + `specs/E6 Dialogs/_Interop.md` (the handoff description). + +### Public API + +- **Step 1:** one new component, five parameters. Additive → MINOR (`REL-01`). + `DrylCard.ViewTransitionName` stays; it is post-1.0 API and is not renamed, + only re-implemented on top of the shared logic. +- **Step 2:** one further component plus its policy parameter. Additive, MINOR. + +### Code + +- A new component under `code/DRYL.Components/Components/`; + `DrylCard.razor` refactored to delegate. +- **Risk — duplicate names.** Two elements carrying the same + `view-transition-name` at snapshot time make the browser skip the morph + silently (`dryl.viewTransition` swallows the skip-rejection by design). This + is the reason `Active` exists, and it needs a test rather than a screenshot. +- **Risk — which service instance is being signalled.** `DrylMorph` injects the + DI-scoped `IDrylViewTransition`, but `DrylDialogProvider` and `DrylTable` + deliberately run their own `DrylViewTransition` instances. Those two keep + signalling themselves; a `DrylMorph` inside them signals the scoped service, + where the call is a documented no-op when no transition is in flight. No + conflict, but it must be written down so it is not "fixed" later. +- **Risk — the extra box.** The hull is a real element; `display: contents` + cannot carry a `view-transition-name`. `As` keeps the DOM valid, but the hull + still participates in the parent's layout, which the demo page must show + honestly. + +## Decisions + +- 2026-08-22 (Tech Lead): the story is **not** taken at face value as "build a + shared-element component". Most of what it asks for ships already; the idea is + cut down to the two gaps above before any option is chosen. +- 2026-08-22 (Product Owner): **Option C — staged.** The generic hull first, the + route-level host as a second, separately scoped step. +- 2026-08-22 (Product Owner): while a detail page loads, the transition + **morphs onto the skeleton**. The UI is never frozen waiting for data; the + detail page renders its `DrylSkeleton` and fills in afterwards. +- 2026-08-22 (Product Owner): the hull **takes over the reporting half** of the + timing contract — the consumer calls `RunAsync`, `SignalRendered` is the + hull's job. +- 2026-08-22 (Product Owner, delegated to the Tech Lead): the hull renders a + **real element with a configurable tag (`As`, default `div`)** rather than a + fixed `div` or a cascaded style the child must splat itself — it keeps the DOM + valid in lists and tables without pushing work back onto the consumer. +- 2026-08-22 (Product Owner, delegated to the Tech Lead): long overviews are + handled by an **`Active` parameter** (default `true`), not by naming every + entry forever and not by automatic detection — the consumer keeps control, and + duplicate names stay preventable. + +## Open Points + +*(none — awaiting the Product Owner's explicit confirmation of this final +version, the last box of `IDEA-06`, before the state moves to `Ready`.)* From 9eb8f901b1cd7ed8d02e430484fd52db1bc48431 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 17:04:44 +0200 Subject: [PATCH 17/24] =?UTF-8?q?feat(layout):=20DrylMorph=20=E2=80=94=20a?= =?UTF-8?q?=20transition=20ID=20for=20any=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared-element treatment was reachable on DrylCard alone; everything else had to hand-write the inline view-transition-name with nowhere to hang the DepthGlass class or the marker the JS bridge keys on. DrylMorph is the generic hull: Name, Style, As, Active, and a rendered element that carries no class, colour or frost of its own. It also takes over the half of the timing contract that is easy to forget — it reports every render to IDrylViewTransition, so SignalRendered() never has to be written by hand. Starting a transition stays with the consumer: the hull cannot see when the rest of the page finished rendering. The attribute construction moves to ViewTransitionAttributes, which DrylCard now builds through as well, so what a morph target looks like exists once. DrylCard's public behaviour is unchanged. Spec: specs/E9 Layout/F17 DrylMorph.md (Modified — the demo page and the ComponentCatalog entry live in DRYL.Website and are still outstanding). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeoxNkuw3okKmUhzmYFiuV --- CHANGELOG.md | 5 + CLAUDE.md | 2 +- .../Components/Layout/DrylMorph.razor | 112 +++++++++++ .../Components/Surfaces/DrylCard.razor | 17 +- code/DRYL.Components/DRYL.Components.csproj | 2 +- .../Motion/ViewTransitionAttributes.cs | 36 ++++ harness/requirements.md | 10 +- specs/E9 Layout/F17 DrylMorph.md | 182 ++++++++++++++++++ specs/E9 Layout/_Interop.md | 13 +- 9 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 code/DRYL.Components/Components/Layout/DrylMorph.razor create mode 100644 code/DRYL.Components/Motion/ViewTransitionAttributes.cs create mode 100644 specs/E9 Layout/F17 DrylMorph.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c804a45..11c3288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ Version bump guide: ## [Unreleased] +## [2.25.0] — 2026-08-22 + +### Added +- `DrylMorph` — **a transition ID for any content.** Shared-element transitions were available on exactly one component: `DrylCard` took a `ViewTransitionName`, and everything else — a list row, an image, a heading, a plain `div` — had to hand-write the inline `view-transition-name`, with no place to hang the `DepthGlass` tier's transition class or the marker the JS bridge keys on. `DrylMorph` is the generic hull: wrap the same `Name` around the card in an overview and around the heading of the detail view, and the browser morphs position, size and opacity from one to the other instead of cutting between two screens. `Style` picks the tier (`Glide` or `DepthGlass`), `As` chooses the rendered tag so the hull is valid where it sits (`li` in a list, `tr` in a table), and `Active` lets a long overview name only the entry being opened — a duplicate name at snapshot time makes the browser skip the morph silently, so a hundred permanently-named rows is the one shape to avoid. The hull also takes over the half of the timing contract that is easy to forget: it reports every render to `IDrylViewTransition`, so **`SignalRendered()` never has to be written by hand**. Starting the transition stays with you — `IDrylViewTransition.RunAsync(...)` — because the hull cannot see when the rest of your page has finished rendering and will not pretend otherwise. It renders one element and nothing else: no class, no colour, no frost; the morph's duration, easing and merge are the existing `::view-transition-*` rules. Nothing changes for existing code — `DrylCard.ViewTransitionName` behaves exactly as before and now builds its markup through the same shared helper. + ## [2.24.3] — 2026-08-20 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 56283b7..9a56cb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,7 @@ repository the evidence is: `dotnet build DRYL.slnx -c Release`, `node scripts/check-spec-coverage.mjs`, `node scripts/check-motion-tokens.mjs`, and both color modes checked by eye. The coverage check exits non-zero until every component has a spec; during -phase C its `x/127 components covered` line is the progress meter, and a rising +phase C its `x/128 components covered` line is the progress meter, and a rising number is the evidence — not a green exit. If a step was skipped, say so. If tests fail, say so with the output. diff --git a/code/DRYL.Components/Components/Layout/DrylMorph.razor b/code/DRYL.Components/Components/Layout/DrylMorph.razor new file mode 100644 index 0000000..395ff02 --- /dev/null +++ b/code/DRYL.Components/Components/Layout/DrylMorph.razor @@ -0,0 +1,112 @@ +@namespace DRYL.Components +@using DRYL.Components.Motion +@inject IDrylViewTransition ViewTransition + +@* ───────────────────────────────────────────────────────── + DrylMorph — a transition ID for any content. + + Marks its content as a shared element: content that exists in two + views and should travel between them instead of disappearing and + reappearing. Wrap the same Name around the card in an + overview and around the heading of the detail view, and the browser + morphs position, size and opacity from one to the other while the + switch runs inside a view transition. + + The consumer starts the transition through IDrylViewTransition; the + hull reports its own render back, so SignalRendered() never has to + be written by hand. + + Usage: + -- the overview -- + @foreach (var p in products) + { + + @p.Title + + } + + -- the detail: same Name, so the two are one object -- + +

@current.Title

+
+ + -- and the switch itself -- + await ViewTransition.RunAsync(() => { openId = p.Id; StateHasChanged(); }); + + Renders one element and nothing else — no styling, no colour, no + frost. The morph's duration, easing and DepthGlass merge are the + shared ::view-transition-* rules in dryl.css. + + view-transition-name has no effect on a display:contents box, so the + element is real and takes part in its parent's layout. That is what + As is for: be an li in a list, a tr in a table. + ───────────────────────────────────────────────────────── *@ + +@Render + +@code { + /// The transition ID. Two elements carrying the same name in the old and the + /// new view are morphed into one another. A name must be unique among the elements + /// live at the moment the transition starts — a duplicate makes the browser skip the + /// morph silently; see . + [Parameter] public string? Name { get; set; } + + /// How much of the morph vocabulary this element gets — + /// (default, viscous shape settle) or + /// (the full translucency-and-merge + /// choreography). Ignored while the element claims no name. + [Parameter] public DrylViewTransitionStyle Style { get; set; } = DrylViewTransitionStyle.Glide; + + /// The HTML tag rendered as this component's root. Defaults to "div"; + /// set it so the hull is valid where it sits ("li", "tr", + /// "article", …). + [Parameter] public string As { get; set; } = "div"; + + /// Whether this instance currently claims . Set false on the + /// entries of an overview that are not the morph target, so a long list neither + /// duplicates a name nor pays for a snapshot per entry. + [Parameter] public bool Active { get; set; } = true; + + /// The content that morphs. + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Extra CSS class(es) on the rendered element. The hull renders no class of + /// its own, so this is the only class it carries. + [Parameter] public string? Class { get; set; } + + /// Pass-through HTML attributes on the rendered element. + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary? AdditionalAttributes { get; set; } + + // Null unless this instance is actively claiming a name — an inactive or unnamed + // hull renders no view-transition attribute at all and costs nothing. + private string? EffectiveName => Active ? Name : null; + + private string? VtStyle => ViewTransitionAttributes.Style(EffectiveName, Style); + + private string? VtDepthMarker => ViewTransitionAttributes.DepthMarker(EffectiveName, Style); + + // A .razor cannot both carry markup and choose its tag at runtime, so the element is + // built here — the same shape DrylTypo uses for its As parameter. + private RenderFragment Render => builder => + { + builder.OpenElement(0, As); + if (!string.IsNullOrWhiteSpace(Class)) builder.AddAttribute(1, "class", Class); + var style = VtStyle; + if (style is not null) builder.AddAttribute(2, "style", style); + var depth = VtDepthMarker; + if (depth is not null) builder.AddAttribute(3, "data-vt-depth", depth); + builder.AddMultipleAttributes(4, AdditionalAttributes); + builder.AddContent(5, ChildContent); + builder.CloseElement(); + }; + + // The half of the timing contract that is easy to forget: the browser holds the old + // frame until the mutated state has reached the DOM, and this is what tells it that + // it has. Unconditional by contract — a cheap no-op when no transition is in flight, + // and reported even while unnamed, so an instance that is only the *destination* of a + // morph still closes the loop. + protected override void OnAfterRender(bool firstRender) => ViewTransition.SignalRendered(); +} diff --git a/code/DRYL.Components/Components/Surfaces/DrylCard.razor b/code/DRYL.Components/Components/Surfaces/DrylCard.razor index b0358b4..2af3ea5 100644 --- a/code/DRYL.Components/Components/Surfaces/DrylCard.razor +++ b/code/DRYL.Components/Components/Surfaces/DrylCard.razor @@ -128,19 +128,14 @@ _aura.Sync(EffectiveAi, () => InvokeAsync(StateHasChanged)); } - private bool VtDepth => - !string.IsNullOrWhiteSpace(ViewTransitionName) - && ViewTransitionStyle == DrylViewTransitionStyle.DepthGlass; - - // Marker attribute the JS bridge keys on to lazily inject the #dryl-merge filter. - private string? VtDepthMarker => VtDepth ? "" : null; + // What makes an element a morph target is built in one place, shared with + // DrylMorph — the marker attribute included, which is what the JS bridge keys + // on to lazily inject the #dryl-merge filter. + private string? VtDepthMarker => + DRYL.Components.Motion.ViewTransitionAttributes.DepthMarker(ViewTransitionName, ViewTransitionStyle); private string? VtStyle => - string.IsNullOrWhiteSpace(ViewTransitionName) - ? null - : VtDepth - ? $"view-transition-name: {ViewTransitionName}; view-transition-class: dryl-depth" - : $"view-transition-name: {ViewTransitionName}"; + DRYL.Components.Motion.ViewTransitionAttributes.Style(ViewTransitionName, ViewTransitionStyle); private string CssClass { diff --git a/code/DRYL.Components/DRYL.Components.csproj b/code/DRYL.Components/DRYL.Components.csproj index 007c6ef..784c36a 100644 --- a/code/DRYL.Components/DRYL.Components.csproj +++ b/code/DRYL.Components/DRYL.Components.csproj @@ -5,7 +5,7 @@ DRYL.Components - 2.24.3 + 2.25.0 DRYL — Blazor Component Library DRYL is a dark, glassy, AI-native UI component library for Blazor Server and Blazor WebAssembly. Token-driven, accessible by default, with a shared AI-state visual vocabulary (Active / Thinking / Streaming / Generated) across every surface — and zero JavaScript framework dependencies. blazor;blazor-components;ui;components;razor;dark;glassmorphism;ai;design-system;blazor-server;blazor-webassembly diff --git a/code/DRYL.Components/Motion/ViewTransitionAttributes.cs b/code/DRYL.Components/Motion/ViewTransitionAttributes.cs new file mode 100644 index 0000000..aca1c50 --- /dev/null +++ b/code/DRYL.Components/Motion/ViewTransitionAttributes.cs @@ -0,0 +1,36 @@ +namespace DRYL.Components.Motion; + +/// +/// Builds the two attribute values that make an element a shared-element morph +/// target: the inline view-transition-name (plus the +/// view-transition-class of the +/// tier) and the data-vt-depth marker the JS bridge keys on to inject the +/// merge filter lazily. +/// +/// +/// One place, so DrylMorph and DrylCard cannot drift apart on what +/// a morph target looks like. The morph's own duration, easing and filter live +/// in the ::view-transition-* rules in dryl.css — nothing here +/// names a value. +/// +internal static class ViewTransitionAttributes +{ + /// True when claims a transition name and the + /// tier was asked for. + public static bool IsDepth(string? name, DrylViewTransitionStyle style) => + !string.IsNullOrWhiteSpace(name) && style == DrylViewTransitionStyle.DepthGlass; + + /// The inline style claiming , or null when the + /// element claims no name and should stay inert. + public static string? Style(string? name, DrylViewTransitionStyle style) => + string.IsNullOrWhiteSpace(name) + ? null + : IsDepth(name, style) + ? $"view-transition-name: {name}; view-transition-class: dryl-depth" + : $"view-transition-name: {name}"; + + /// The data-vt-depth marker value — an empty attribute on the + /// DepthGlass tier, and null (so Blazor omits the attribute) otherwise. + public static string? DepthMarker(string? name, DrylViewTransitionStyle style) => + IsDepth(name, style) ? "" : null; +} diff --git a/harness/requirements.md b/harness/requirements.md index c2400e5..bacd4f1 100644 --- a/harness/requirements.md +++ b/harness/requirements.md @@ -72,14 +72,14 @@ specs/ | `E6` | Dialogs | `code/DRYL.Components/Dialogs/` | 4 | | `E7` | Feedback | `code/DRYL.Components/Components/Feedback/` | 8 | | `E8` | Inputs | `code/DRYL.Components/Components/Inputs/` | 23 | -| `E9` | Layout | `code/DRYL.Components/Components/Layout/` | 16 | +| `E9` | Layout | `code/DRYL.Components/Components/Layout/` | 17 | | `E10` | Navigation | `code/DRYL.Components/Components/Navigation/` | 12 | | `E11` | Surfaces | `code/DRYL.Components/Components/Surfaces/` | 8 | | `E12` | Agent Runtime | `code/DRYL.Components.Agents/Agents/`, `/Display/` | 5 | | `E13` | Agent Tools | `code/DRYL.Components.Agents/Tools/` | 3 | | `E14` | Agent Canvas | `code/DRYL.Components.Agents/Canvas/` | 2 | | `E15` | Agent Inputs | `code/DRYL.Components.Agents/Field/`, `/CommandPalette/`, `/Voice/`, `/Generation/` | 5 | -| | | **Total** | **127** | +| | | **Total** | **128** | **A category may be componentless** — it then carries `_Api.md` and `_Interop.md` and nothing else. No category is currently in that position, but @@ -105,7 +105,7 @@ files reading "none", would be ceremony without return. The component counts are a statement of fact at the time of writing, not a budget — a new component raises its category's count and the total. Only the **total** is re-derived rather than trusted: `scripts/check-spec-coverage.mjs` -counts `Dryl*.razor` under `code/` and reports `x/127`. The per-category counts +counts `Dryl*.razor` under `code/` and reports `x/128`. The per-category counts are maintained by hand and are documentation, so a move between categories means editing this table in the same commit. - **F** = **one component, one file**. A `Dryl*.razor` maps to exactly one @@ -165,8 +165,8 @@ checkable in **two directions**: captured twice. The second direction is also the progress meter for phase C: it answers -"x of 127 components covered" directly — `scripts/check-spec-coverage.mjs` -prints exactly that line and exits non-zero until it reads `127/127`. +"x of 128 components covered" directly — `scripts/check-spec-coverage.mjs` +prints exactly that line and exits non-zero until it reads `128/128`. #### `Source` format diff --git a/specs/E9 Layout/F17 DrylMorph.md b/specs/E9 Layout/F17 DrylMorph.md new file mode 100644 index 0000000..570d689 --- /dev/null +++ b/specs/E9 Layout/F17 DrylMorph.md @@ -0,0 +1,182 @@ +# DrylMorph + +## Meta +- **State:** Modified +- **Source:** code/DRYL.Components/Components/Layout/DrylMorph.razor + +## User Story + +As a Blazor developer, I want to give a piece of content a transition ID and +have it morph into its counterpart in another view, so that moving from an +overview to a detail reads as one continuous object instead of two unrelated +screens — without me writing animation or timing code. + +## Description + +`DrylMorph` marks a piece of content as a **shared element**: content that +exists in two views and should travel between them rather than disappear and +reappear. Wrapping the same `Name` around a card in an overview and around the +heading of the detail view is the whole contract — when the switch between the +two runs inside a view transition, the browser morphs position, size and opacity +from one to the other. + +It is the generic form of something the library already does in two places: +`DrylCard` accepts a `ViewTransitionName`, and a dialog opened from a card takes +over that card's shape. `DrylMorph` makes the same treatment available to +anything — a list row, an image, a heading, a plain `div` — and is what those +call sites express their own morph with. + +The component supplies the **naming and the reporting** halves of the morph. The +**starting** half stays with `IDrylViewTransition`, which the consumer calls to +run the state change: the hull cannot see when other components have finished +rendering, so it never claims to own the moment a transition begins. What it +does own is the half that is easy to forget — reporting its render back, so the +browser knows when the new view has reached the DOM. A consumer who uses +`DrylMorph` never writes `SignalRendered()`. + +The component renders one element and nothing else: no wrapper of its own +around the content, no styling, no color. `view-transition-name` has no effect +on a `display: contents` box, so the element is real and participates in its +parent's layout; `As` exists so it can be the *right* element in a list, a table +or an article rather than always a `div`. + +Everything the morph looks like — its duration, its easing, the `DepthGlass` +merge — is the shared view-transition vocabulary in `dryl.css`. This component +adds no visual of its own and cannot be styled into a second one. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Name` | `string?` | `null` | The transition ID. Two elements sharing it in the old and the new view are morphed into one another. | +| `Style` | `DrylViewTransitionStyle` | `Glide` | How much of the morph vocabulary the element gets — `Glide` or `DepthGlass`. | +| `As` | `string` | `"div"` | The HTML tag rendered as the component's root. | +| `Active` | `bool` | `true` | Whether this instance currently claims `Name`. Set `false` on the entries of an overview that are not the morph target. | +| `ChildContent` | `RenderFragment?` | `null` | The content that morphs. | +| `Class` | `string?` | `null` | CSS class(es) on the rendered element. The hull renders no class of its own, so this is the only one it carries. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +`DrylViewTransitionStyle` and `IDrylViewTransition` belong to no single +component and no single category — they are Foundation surface and are due to be +documented in [`../E1 Foundation/_Api.md`](../E1%20Foundation/_Api.md) when that +scaffold is filled. This category's use of the service is recorded in +[`_Interop.md`](_Interop.md). + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Rendering the element + +- The component renders exactly one element and nothing around it. +- The rendered element's tag is the value of `As`. +- `As` defaults to `"div"`. +- `ChildContent` is rendered inside that element. +- `Class` is rendered as the element's `class`; the component has no class of its own for it to be merged with or to override. +- A consumer's splatted `class` attribute binds to `Class` and is rendered, rather than being dropped. +- `AdditionalAttributes` are applied to the rendered element. +- The component renders no class, color, spacing or border of its own, so it + changes nothing about how its content looks. + +### Claiming a transition name + +- A `Name` holding a non-whitespace value renders `view-transition-name` with + that value on the element. +- A `Name` that is `null`, empty or whitespace renders no + `view-transition-name`, so an unnamed hull is inert. +- `Active` set to `false` renders no `view-transition-name`, whatever `Name` + holds. +- `Active` defaults to `true`. +- Toggling `Active` from `false` to `true` on an already-rendered instance + renders the `view-transition-name` without the element being recreated. + +### The morph tiers + +- `Style` defaults to `DrylViewTransitionStyle.Glide`. +- `Style` accepts exactly the two values of `DrylViewTransitionStyle`. +- `Style` set to `DepthGlass` renders `view-transition-class: dryl-depth` on the + element in addition to the name. +- `Style` set to `DepthGlass` renders the `data-vt-depth` marker attribute, so + the JS bridge injects the merge filter the tier needs. +- `Style` set to `Glide` renders neither `view-transition-class` nor + `data-vt-depth`. +- Neither marker is rendered while the element claims no name, so an inert hull + costs nothing. + +### Reporting the render + +- The component reports every one of its renders to + `IDrylViewTransition.SignalRendered()`. +- The report is made unconditionally, without the component checking whether a + transition is in flight. +- The component reports its render even while it claims no name, so an instance + that is only the *destination* of a morph still closes the timing loop. +- A consumer using `DrylMorph` never calls `SignalRendered()` itself. + +### Behaviour where the morph cannot run + +- The component renders its element and its content unchanged during prerender. +- The component makes no JS interop call of its own, so it has nothing to + dispose and nothing that can fail on a disconnected circuit. +- The component behaves identically when the browser has no View Transition API: + the markup is inert rather than broken, and the state change still happens + (`IDrylViewTransition` falls back to applying it directly). +- The component renders the same markup under `prefers-reduced-motion`; the + reduced-motion opt-out lives in the shared vocabulary in `dryl.css`, not in + the component. + +### Keyboard and accessibility + +- The component renders no `role` and no ARIA attribute of its own, so it never + changes what its content is announced as. +- The component is not focusable and adds no stop to the tab order. +- `As` lets the hull be the element its context requires (`li` inside a list, + `article` in a feed), so wrapping content never produces invalid or + meaningless structure. +- The component binds no key handler and intercepts no key event. + +### Appearance + +- The component names no color, length, duration or easing (`DESIGN-01`); the + morph's duration and easing come from `--dur-slow` and `--ease-viscous` in the + shared `::view-transition-group(*)` rule. +- The component adds no stylesheet of its own — the entire morph vocabulary is + the existing `::view-transition-*` rules in `dryl.css`. +- The component paints no frost, being a transparent hull rather than a surface + (`DESIGN-06`). +- The component renders no accent, so `DESIGN-08` has nothing to apply to. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): the hull is transparent by contract — it + renders no surface of its own, so there is nothing for an aura to sit on. A + surface that *is* AI-driven carries its own `Ai` inside the hull, where the + aura belongs, and morphs with it. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — the component renders no color at all; the morph's + colors are the snapshots of the content itself. Verified by + `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs` remaining unaffected, and by eye in + both modes on the demo page. +- **Enter/exit animation** — the component *is* the motion: its entire purpose + is the morph between two views. It deliberately has no enter or exit animation + of its own, because an animated hull would fight the morph it exists to + enable — content that also needs to enter or leave composes `DrylPresence` + inside it. This is the written exception `DESIGN-11` allows. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the hull is semantically invisible: it adds no + role, no focus stop and no announcement, and `As` exists so it does not force + a `div` into places where a `div` is wrong. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — **outstanding.** Planned as + `DRYL.Website/Components/Pages/DemoMorph.razor`, showing an overview → detail + morph on one route in both tiers. Demos live in the `DRYL.Website` repository + (`CODE-20`), so this lands there; `State` stays `Modified` until it does. +- **`ComponentCatalog`** — **outstanding**, in the same repository: to be + registered as `"Morph"` / `morph` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. diff --git a/specs/E9 Layout/_Interop.md b/specs/E9 Layout/_Interop.md index cd8fed0..12a5a5c 100644 --- a/specs/E9 Layout/_Interop.md +++ b/specs/E9 Layout/_Interop.md @@ -12,8 +12,17 @@ none *(phase C)* ## Services -none *(phase C)* +| Service | Lifetime | Registered by | Used by | +|---|---|---|---| +| `IDrylViewTransition` | scoped | `AddDrylComponents()` | `DrylMorph` — injected, and signalled from every render so the browser learns when the new view reached the DOM. `DrylMorph` never *starts* a transition; that stays with the consumer. | + +*(the rest: phase C)* ## Cleanup -none *(phase C)* +`DrylMorph` makes no interop call of its own, holds no `IJSObjectReference` and +registers no listener, so it has nothing to dispose (`CODE-05`). Signalling a +service it does not own imposes no cleanup duty: `SignalRendered()` is a no-op +when no transition is in flight. + +*(the rest: phase C)* From 1e8ae3fbb3640fe6c44dc923e0a5c657e8c942b8 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 17:04:44 +0200 Subject: [PATCH 18/24] test(layout): DrylMorph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventeen cases across the four criterion groups: the rendered tag and As, the name claimed or withheld (null, empty, whitespace, Active=false, and activating an existing instance), the DepthGlass pair and its absence on Glide, and the unconditional report to IDrylViewTransition — including from an unnamed hull, which is the destination half of a morph. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeoxNkuw3okKmUhzmYFiuV --- tests/DRYL.Components.Tests/DrylMorphTests.cs | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 tests/DRYL.Components.Tests/DrylMorphTests.cs diff --git a/tests/DRYL.Components.Tests/DrylMorphTests.cs b/tests/DRYL.Components.Tests/DrylMorphTests.cs new file mode 100644 index 0000000..1f9bc5d --- /dev/null +++ b/tests/DRYL.Components.Tests/DrylMorphTests.cs @@ -0,0 +1,246 @@ +using Bunit; +using DRYL.Components; +using DRYL.Components.Motion; +using Microsoft.Extensions.DependencyInjection; + +namespace DRYL.Components.Tests; + +/// +/// Tests for , the generic shared-element hull: it renders +/// one element of the requested tag, claims a view-transition-name only +/// while it is named and active, tags the DepthGlass tier, and reports every +/// render back to so a consumer never writes +/// SignalRendered(). +/// +public class DrylMorphTests : BunitContext +{ + /// Counts what the hull reports, and asserts nothing else — the real + /// service's own contract is covered where it is implemented. + private sealed class CountingViewTransition : IDrylViewTransition + { + public int Signals { get; private set; } + public Task RunAsync(Action mutate) { mutate(); return Task.CompletedTask; } + public Task RunAsync(Func mutate) => mutate(); + public void SignalRendered() => Signals++; + } + + private CountingViewTransition UseFakeTransition() + { + var fake = new CountingViewTransition(); + Services.AddSingleton(fake); + return fake; + } + + // ---------------------------------------------------------------- element + + [Fact] + public void Renders_a_single_div_by_default() + { + UseFakeTransition(); + + var cut = Render(ps => ps.AddChildContent("x")); + + var root = cut.Nodes.OfType().Single(); + Assert.Equal("DIV", root.TagName); + Assert.Equal("x", root.InnerHtml); + } + + [Fact] + public void As_chooses_the_rendered_tag() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.As, "article") + .AddChildContent("x")); + + Assert.Equal("ARTICLE", cut.Nodes.OfType().Single().TagName); + } + + [Fact] + public void Renders_no_class_of_its_own() + { + UseFakeTransition(); + + var cut = Render(ps => ps.AddChildContent("x")); + + Assert.False(cut.Find("div").HasAttribute("class")); + } + + [Fact] + public void Class_parameter_is_rendered() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.Class, "col-span-2") + .AddChildContent("x")); + + Assert.Equal("col-span-2", cut.Find("div").GetAttribute("class")); + } + + [Fact] + public void Splatted_class_binds_to_the_typed_parameter() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .AddUnmatched("class", "mt-4") + .AddChildContent("x")); + + Assert.Equal("mt-4", cut.Find("div").GetAttribute("class")); + } + + [Fact] + public void Additional_attributes_are_splatted_onto_the_element() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .AddUnmatched("data-testid", "hull") + .AddChildContent("x")); + + Assert.Equal("hull", cut.Find("div").GetAttribute("data-testid")); + } + + // ------------------------------------------------------------ the name + + [Fact] + public void Name_renders_view_transition_name() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.Name, "product-42") + .AddChildContent("x")); + + var style = cut.Find("div").GetAttribute("style"); + Assert.Contains("view-transition-name: product-42", style); + Assert.DoesNotContain("dryl-depth", style); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void A_blank_name_renders_no_style(string? name) + { + UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.Name, name) + .AddChildContent("x")); + + var root = cut.Find("div"); + Assert.False(root.HasAttribute("style")); + Assert.False(root.HasAttribute("data-vt-depth")); + } + + [Fact] + public void Inactive_renders_no_name_even_when_one_is_set() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.Name, "product-42") + .Add(p => p.Active, false) + .Add(p => p.Style, DrylViewTransitionStyle.DepthGlass) + .AddChildContent("x")); + + var root = cut.Find("div"); + Assert.False(root.HasAttribute("style")); + Assert.False(root.HasAttribute("data-vt-depth")); + } + + [Fact] + public void Activating_an_existing_instance_renders_the_name() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.Name, "product-42") + .Add(p => p.Active, false) + .AddChildContent("x")); + + Assert.False(cut.Find("div").HasAttribute("style")); + + cut.Render(ps => ps.Add(p => p.Active, true)); + + Assert.Contains("view-transition-name: product-42", cut.Find("div").GetAttribute("style")); + } + + // ------------------------------------------------------------- the tiers + + [Fact] + public void DepthGlass_adds_the_transition_class_and_the_marker() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.Name, "product-42") + .Add(p => p.Style, DrylViewTransitionStyle.DepthGlass) + .AddChildContent("x")); + + var root = cut.Find("div"); + Assert.Contains("view-transition-class: dryl-depth", root.GetAttribute("style")); + Assert.True(root.HasAttribute("data-vt-depth")); + } + + [Fact] + public void Glide_adds_neither_the_transition_class_nor_the_marker() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.Name, "product-42") + .Add(p => p.Style, DrylViewTransitionStyle.Glide) + .AddChildContent("x")); + + var root = cut.Find("div"); + Assert.DoesNotContain("view-transition-class", root.GetAttribute("style")); + Assert.False(root.HasAttribute("data-vt-depth")); + } + + [Fact] + public void DepthGlass_without_a_name_stays_inert() + { + UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.Style, DrylViewTransitionStyle.DepthGlass) + .AddChildContent("x")); + + var root = cut.Find("div"); + Assert.False(root.HasAttribute("style")); + Assert.False(root.HasAttribute("data-vt-depth")); + } + + // --------------------------------------------------------- the reporting + + [Fact] + public void Every_render_is_reported_to_the_view_transition_service() + { + var fake = UseFakeTransition(); + + var cut = Render(ps => ps + .Add(p => p.Name, "product-42") + .AddChildContent("x")); + + Assert.True(fake.Signals >= 1); + + var before = fake.Signals; + cut.Render(ps => ps.Add(p => p.Name, "product-43")); + + Assert.True(fake.Signals > before); + } + + [Fact] + public void An_unnamed_hull_still_reports_its_render() + { + var fake = UseFakeTransition(); + + Render(ps => ps.AddChildContent("x")); + + Assert.True(fake.Signals >= 1); + } +} From d6af0a080b51ae2e96b2bc333f2537f4bb15e68b Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 17:27:09 +0200 Subject: [PATCH 19/24] fix(motion): let a morph's incoming snapshot settle before the shape does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A view-transition group morphs by animating width and height — the transform is identical at both ends — so both snapshots are bitmaps stretched for the whole --dur-slow, and when the pseudo-tree disappears they are replaced by sharp DOM in one frame. Measured on the DrylMorph demo: a card growing into a detail panel is stretched threefold, and the swap was landing while the new snapshot was still cross-fading. That is what read as a stumble at the end. ::view-transition-new(*) now fades in on --dur-med, so the final stretch of the movement shows one settled image and the swap lands on a picture that has stopped changing. Verified in the browser: Glide's fade-in is 240ms against the group's 420ms; the DepthGlass tier, which already worked this way through dryl-depth-clarify, is unchanged. Also records two properties of the API in F17 rather than leaving them to be re-investigated: the bitmap stretch itself, and the hover settle that fires when a morph ends under a resting pointer (verified absent with the pointer parked away). Version stays 2.25.0 — that block has not shipped yet (REL-01). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeoxNkuw3okKmUhzmYFiuV --- CHANGELOG.md | 3 +++ code/DRYL.Components/wwwroot/dryl.css | 14 ++++++++++++++ specs/E9 Layout/F17 DrylMorph.md | 28 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c3288..ffa8163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Version bump guide: ## [2.25.0] — 2026-08-22 +### Changed +- **View transitions settle before they end.** Every morph the library runs — `DrylMorph`, `DrylCard`'s `ViewTransitionName`, the dialog handoff, `DrylTable`'s row reorder, the canvas — brings its incoming snapshot in on `--dur-med` instead of the browser's default full-length cross-fade. The reason is what the browser actually animates: a view-transition group morphs by scaling `width` and `height`, so both snapshots are bitmaps being stretched for the whole `--dur-slow`, and when the transition ends they are swapped for sharp DOM in a single frame. Ending the fade early means that final stretch of movement shows one settled image, so the swap lands on a picture that has already stopped changing instead of on one still cross-fading — which is what read as a stumble at the very end of a morph. The `DepthGlass` tier already worked this way and is unchanged; this brings the default `Glide` tier in line with it. Nothing about the shape, the duration or the easing of the movement changes. + ### Added - `DrylMorph` — **a transition ID for any content.** Shared-element transitions were available on exactly one component: `DrylCard` took a `ViewTransitionName`, and everything else — a list row, an image, a heading, a plain `div` — had to hand-write the inline `view-transition-name`, with no place to hang the `DepthGlass` tier's transition class or the marker the JS bridge keys on. `DrylMorph` is the generic hull: wrap the same `Name` around the card in an overview and around the heading of the detail view, and the browser morphs position, size and opacity from one to the other instead of cutting between two screens. `Style` picks the tier (`Glide` or `DepthGlass`), `As` chooses the rendered tag so the hull is valid where it sits (`li` in a list, `tr` in a table), and `Active` lets a long overview name only the entry being opened — a duplicate name at snapshot time makes the browser skip the morph silently, so a hundred permanently-named rows is the one shape to avoid. The hull also takes over the half of the timing contract that is easy to forget: it reports every render to `IDrylViewTransition`, so **`SignalRendered()` never has to be written by hand**. Starting the transition stays with you — `IDrylViewTransition.RunAsync(...)` — because the hull cannot see when the rest of your page has finished rendering and will not pretend otherwise. It renders one element and nothing else: no class, no colour, no frost; the morph's duration, easing and merge are the existing `::view-transition-*` rules. Nothing changes for existing code — `DrylCard.ViewTransitionName` behaves exactly as before and now builds its markup through the same shared helper. diff --git a/code/DRYL.Components/wwwroot/dryl.css b/code/DRYL.Components/wwwroot/dryl.css index d3f2653..37aff91 100644 --- a/code/DRYL.Components/wwwroot/dryl.css +++ b/code/DRYL.Components/wwwroot/dryl.css @@ -5995,6 +5995,20 @@ pre { overflow-x: auto; } animation-timing-function: var(--ease-viscous); } +/* The incoming snapshot settles before the shape does. The group animates + width and height (that is what the UA morphs — the transform is identical + at both ends), so both snapshots are bitmaps being stretched for the whole + --dur-slow, and when the pseudo-tree disappears they are replaced by real, + sharp DOM in a single frame. Landing that swap while the new snapshot is + still cross-fading is what reads as a stumble at the very end of the morph. + Bringing the fade in on --dur-med leaves the last stretch of the movement + showing one settled image, so the swap happens on a picture that has + already stopped changing — the same "clarity arrives early" reasoning the + DepthGlass tier below is built on, applied to every morph. */ +::view-transition-new(*) { + animation-duration: var(--dur-med); +} + /* DepthGlass: mercury-like merge. A merge filter (blur + high-contrast, the "goo" technique) pulls the old/new snapshots together with a droplet's surface tension instead of a flat cross-fade. Transient — diff --git a/specs/E9 Layout/F17 DrylMorph.md b/specs/E9 Layout/F17 DrylMorph.md index 570d689..37f92e2 100644 --- a/specs/E9 Layout/F17 DrylMorph.md +++ b/specs/E9 Layout/F17 DrylMorph.md @@ -142,6 +142,9 @@ The component takes **no** `Ai` and no `Aura` — see "AI mode" below. shared `::view-transition-group(*)` rule. - The component adds no stylesheet of its own — the entire morph vocabulary is the existing `::view-transition-*` rules in `dryl.css`. +- The incoming snapshot finishes fading in on `--dur-med`, before the shape + finishes settling on `--dur-slow`, so the last stretch of a morph shows one + settled image. - The component paints no frost, being a transparent hull rather than a surface (`DESIGN-06`). - The component renders no accent, so `DESIGN-08` has nothing to apply to. @@ -156,6 +159,31 @@ The component takes **no** `Ai` and no `Aura` — see "AI mode" below. surface that *is* AI-driven carries its own `Ai` inside the hull, where the aura belongs, and morphs with it. +## Recorded gaps + +- **The morph stretches a bitmap, and the swap back to real DOM is a hard + cut.** The browser morphs a group by animating its `width` and `height` — the + transform is identical at both ends — so both snapshots are bitmaps being + scaled for the whole `--dur-slow`, and the moment the pseudo-tree disappears + they are replaced by sharp DOM in a single frame. Measured on the demo page, + a card growing into a detail panel is stretched by a factor of three. Landing + the incoming fade on `--dur-med` (see "Appearance") leaves the final stretch + showing a settled picture, which is as far as this can be taken without + leaving the API's own model. It is a property of the View Transition API, not + a defect in this component. +- **A morph that ends under a resting pointer lights up once more.** When the + revealed element sits where the pointer already is — the normal case, because + the pointer is still on the control that was pressed — its hover transitions + start in the frame the morph finishes, adding a `--dur-med` colour settle + after the movement is over. Verified in the browser: with the pointer parked + away from the reveal, nothing starts. This is correct browser behaviour and is + recorded here so it is recognised rather than re-investigated. +- **`SignalRendered` needs someone to render.** The hull closes the timing loop + from its own `OnAfterRender`, so a mutation that renders no `DrylMorph` at all + leaves the transition waiting. Every shape the component is built for renders + at least one hull in the same batch; a consumer who mutates something else + entirely still owns that half themselves. + ## Cross-cutting evidence (`SPEC-05`) - **Both color modes** — the component renders no color at all; the morph's From b4ee24727407a380410d32aa9a33e7fd2512983f Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 17:27:53 +0200 Subject: [PATCH 20/24] spec(E9): DrylMorph is Implemented; I10 Adopted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo page and the ComponentCatalog entry landed in DRYL.Website, which was the last of SPEC-05's six points still outstanding. I10 links the resulting spec and notes that step 2 — the route-level host — has none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeoxNkuw3okKmUhzmYFiuV --- ...ment transitions between overview and detail.md | 8 +++++++- specs/E9 Layout/F17 DrylMorph.md | 14 +++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ideas/I10 Shared-element transitions between overview and detail.md b/ideas/I10 Shared-element transitions between overview and detail.md index f1d4d3c..1869390 100644 --- a/ideas/I10 Shared-element transitions between overview and detail.md +++ b/ideas/I10 Shared-element transitions between overview and detail.md @@ -1,7 +1,7 @@ # Shared-element transitions between overview and detail ## Meta -- **State:** Ready +- **State:** Adopted ## Problem @@ -217,6 +217,12 @@ scoping pass before anything is written. entry forever and not by automatic detection — the consumer keeps control, and duplicate names stay preventable. +## Resulting specs + +- [`../specs/E9 Layout/F17 DrylMorph.md`](../specs/E9%20Layout/F17%20DrylMorph.md) — + step 1, implemented in 2.25.0. +- Step 2, the route-level transition host, has not been scoped and has no spec. + ## Open Points *(none — awaiting the Product Owner's explicit confirmation of this final diff --git a/specs/E9 Layout/F17 DrylMorph.md b/specs/E9 Layout/F17 DrylMorph.md index 37f92e2..7cabdc0 100644 --- a/specs/E9 Layout/F17 DrylMorph.md +++ b/specs/E9 Layout/F17 DrylMorph.md @@ -1,7 +1,7 @@ # DrylMorph ## Meta -- **State:** Modified +- **State:** Implemented - **Source:** code/DRYL.Components/Components/Layout/DrylMorph.razor ## User Story @@ -201,10 +201,10 @@ The component takes **no** `Ai` and no `Aura` — see "AI mode" below. role, no focus stop and no announcement, and `As` exists so it does not force a `div` into places where a `div` is wrong. - **AI mode** — explicitly no, with the reason under "AI mode" above. -- **Demo page** — **outstanding.** Planned as - `DRYL.Website/Components/Pages/DemoMorph.razor`, showing an overview → detail - morph on one route in both tiers. Demos live in the `DRYL.Website` repository - (`CODE-20`), so this lands there; `State` stays `Modified` until it does. -- **`ComponentCatalog`** — **outstanding**, in the same repository: to be - registered as `"Morph"` / `morph` in +- **Demo page** — `DRYL.Website/Components/Pages/DemoMorph.razor`, with the + examples `Components/Examples/Morph/OverviewToDetail.razor` (cards growing + into a detail panel, with a Glide/DepthGlass switch) and + `.../Morph/LongList.razor` (one name claimed by one row of twenty). Demos live + in the `DRYL.Website` repository (`CODE-20`). +- **`ComponentCatalog`** — registered as `"Morph"` / `morph` in `DRYL.Website/Components/ComponentCatalog.cs`, flagged not AI-capable. From 3b0386da0fcb9219bd80c4593997ff8fa40c8f26 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 17:56:08 +0200 Subject: [PATCH 21/24] =?UTF-8?q?feat(foundation):=20DrylRouteTransition?= =?UTF-8?q?=20=E2=80=94=20morph=20across=20a=20route=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of I10, now I11. DrylMorph covers a switch on one route; this covers /planets -> /planets/42, a NavLink, and the Back button. Mount it once beside DrylDialogProvider, put a DrylMorph with the same Name on both pages, and the object the user pressed travels with them. The mechanism is the part that took working out. Starting a transition inside a location-changing handler and awaiting it deadlocks: the transition waits for the new page's render, which cannot happen until the handler returns. But startViewTransition takes the old snapshot synchronously and only needs its callback to resolve once the new DOM is there — so the handler starts it, does not await it, and returns. Nothing is intercepted: no PreventNavigation, no cancel, no restart, so history and the Back button are untouched. Two guards, both measured in the browser: - The render signal is a latch, not an event. The destination can render before JS calls ApplyChange, and that render must not be lost. - The host reports the new route's first render itself, with Timeout underneath as the second net. Without that report, a destination carrying no DrylMorph held the old frame for the whole timeout — measured at 1460ms against 467ms now, which would have made every ordinary navigation in an app feel a second slow. Reporting on the route's first render is also the "morph onto the skeleton" policy: a page still loading morphs into its placeholder. IDrylViewTransition.BeginNavigation ships as a default interface implementation that does nothing, so existing implementers keep compiling (guarded by a test). E1 Foundation gets its first F-spec, and its _Api/_Interop scaffolds their first real content — the view-transition surface belongs to no single component. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeoxNkuw3okKmUhzmYFiuV --- CHANGELOG.md | 6 + CLAUDE.md | 2 +- .../Providers/DrylRouteTransition.razor | 99 +++++++++ code/DRYL.Components/DRYL.Components.csproj | 2 +- .../Motion/DrylViewTransition.cs | 63 +++++- .../Motion/IDrylViewTransition.cs | 20 ++ docs/2026-08-22-drylroutetransition-plan.md | 117 ++++++++++ harness/requirements.md | 10 +- ideas/I11 A route-level transition host.md | 167 ++++++++++++++ specs/E1 Foundation/F1 DrylRouteTransition.md | 161 ++++++++++++++ specs/E1 Foundation/_Api.md | 29 ++- specs/E1 Foundation/_Interop.md | 23 +- .../DrylRouteTransitionTests.cs | 204 ++++++++++++++++++ 13 files changed, 891 insertions(+), 12 deletions(-) create mode 100644 code/DRYL.Components/Components/Providers/DrylRouteTransition.razor create mode 100644 docs/2026-08-22-drylroutetransition-plan.md create mode 100644 ideas/I11 A route-level transition host.md create mode 100644 specs/E1 Foundation/F1 DrylRouteTransition.md create mode 100644 tests/DRYL.Components.Tests/DrylRouteTransitionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa8163..9d7665f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ Version bump guide: ## [Unreleased] +## [2.26.0] — 2026-08-22 + +### Added +- `DrylRouteTransition` — **a morph across a real route change.** `DrylMorph` (2.25.0) covers a switch that happens on one route; this covers `/planets` → `/planets/42`, a `NavLink`, and the browser's Back button. Mount it once beside `DrylDialogProvider`, put a `DrylMorph` with the same `Name` on both pages, and the object the user pressed travels with them — no router hook, no timing code. It works by starting the transition in a location-changing handler and then getting out of the way: it **never** calls `PreventNavigation`, never cancels and never restarts a navigation, so the history stack and the Back and Forward buttons behave exactly as they do without it. The old snapshot is taken when the handler runs; the transition is held open until a `DrylMorph` on the destination reports its render. The host also reports the new route's first render itself, so a destination carrying no hull completes immediately rather than waiting — and a destination still loading its data morphs onto its placeholder instead of freezing the page it came from. `Timeout` (one second by default) sits underneath as the second net, for a route that never renders at all: when it elapses the navigation completes without a morph, so the component holds a frame, never your application. `ShouldMorph` takes the target URI and excludes individual navigations (a sign-out, a jump to an unrelated part of the app); left unset, every internal navigation morphs. The component renders no markup. +- `IDrylViewTransition.BeginNavigation(TimeSpan)` — the entry point behind it, for a transition that a *coming navigation* completes rather than one the service mutates itself. It ships as a **default interface implementation that does nothing**, so if you implement `IDrylViewTransition` yourself, your code keeps compiling untouched and navigations simply do not morph until you override it. + ## [2.25.0] — 2026-08-22 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 9a56cb3..0107929 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,7 @@ repository the evidence is: `dotnet build DRYL.slnx -c Release`, `node scripts/check-spec-coverage.mjs`, `node scripts/check-motion-tokens.mjs`, and both color modes checked by eye. The coverage check exits non-zero until every component has a spec; during -phase C its `x/128 components covered` line is the progress meter, and a rising +phase C its `x/129 components covered` line is the progress meter, and a rising number is the evidence — not a green exit. If a step was skipped, say so. If tests fail, say so with the output. diff --git a/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor b/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor new file mode 100644 index 0000000..6789caf --- /dev/null +++ b/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor @@ -0,0 +1,99 @@ +@namespace DRYL.Components +@using DRYL.Components.Motion +@using Microsoft.AspNetCore.Components.Routing +@inject NavigationManager Nav +@inject IDrylViewTransition ViewTransition +@implements IDisposable + +@* ───────────────────────────────────────────────────────── + DrylRouteTransition — morph across a real route change. + + DrylMorph covers a switch that happens on one route. This covers + /planets to /planets/42, a NavLink, and the Back button: mount it + once next to DrylDialogProvider, put a DrylMorph with the same + Name on both pages, and the object the user pressed travels with + them. + + Usage (in MainLayout.razor, beside the other providers): + + + -- exclude a navigation from morphing -- + + + It never prevents, cancels or restarts a navigation — it starts the + transition and gets out of the way — so history and the browser's + Back and Forward buttons behave exactly as they would without it. + + Renders no markup. + ───────────────────────────────────────────────────────── *@ + +@code { + /// Decides whether a navigation morphs, given the URI it is heading to. + /// Null (the default) morphs every internal navigation. Return false to leave a + /// navigation alone — a sign-out, or a jump to a part of the app that shares + /// nothing with the current page. + [Parameter] public Func? ShouldMorph { get; set; } + + /// How long the previous frame may be held while waiting for the + /// destination page to report a render, before the navigation completes without a + /// morph. A destination that carries no , or that never + /// finishes rendering, must not leave the user looking at a frozen frame. + [Parameter] public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(1); + + private IDisposable? _registration; + private bool _awaitingRouteRender; + + // Registered from OnAfterRender, never OnInitialized: during prerender there is no + // JS to start a transition with, and a handler registered there would outlive + // nothing useful. + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + _registration ??= Nav.RegisterLocationChangingHandler(OnLocationChanging); + Nav.LocationChanged += OnLocationChanged; + return; + } + + if (_awaitingRouteRender) + { + _awaitingRouteRender = false; + // The floor under the timeout. A destination carrying a DrylMorph reports in + // the same batch and this changes nothing; a destination carrying none would + // otherwise hold the old frame for the whole of Timeout, which would make + // every ordinary navigation in an app feel a second slow. Reporting on the + // route's first render is also exactly the "morph onto the skeleton" policy: + // a page still loading its data morphs into its placeholder rather than + // freezing the page it came from. + ViewTransition.SignalRendered(); + } + } + + // Queues one render of this component behind the router's own, so the report above + // lands after the new route reached the DOM. + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + _awaitingRouteRender = true; + InvokeAsync(StateHasChanged); + } + + private ValueTask OnLocationChanging(LocationChangingContext context) + { + // Note what this does NOT do: no PreventNavigation, no await. The old snapshot + // is taken inside BeginNavigation; holding the handler open would deadlock, + // because the transition is waiting for a render that cannot happen until this + // returns. + if (ShouldMorph?.Invoke(context.TargetLocation) is not false) + { + ViewTransition.BeginNavigation(Timeout); + } + return ValueTask.CompletedTask; + } + + public void Dispose() + { + Nav.LocationChanged -= OnLocationChanged; + _registration?.Dispose(); + _registration = null; + } +} diff --git a/code/DRYL.Components/DRYL.Components.csproj b/code/DRYL.Components/DRYL.Components.csproj index 784c36a..581b1ca 100644 --- a/code/DRYL.Components/DRYL.Components.csproj +++ b/code/DRYL.Components/DRYL.Components.csproj @@ -5,7 +5,7 @@ DRYL.Components - 2.25.0 + 2.26.0 DRYL — Blazor Component Library DRYL is a dark, glassy, AI-native UI component library for Blazor Server and Blazor WebAssembly. Token-driven, accessible by default, with a shared AI-state visual vocabulary (Active / Thinking / Streaming / Generated) across every surface — and zero JavaScript framework dependencies. blazor;blazor-components;ui;components;razor;dark;glassmorphism;ai;design-system;blazor-server;blazor-webassembly diff --git a/code/DRYL.Components/Motion/DrylViewTransition.cs b/code/DRYL.Components/Motion/DrylViewTransition.cs index a5b6919..21f82cd 100644 --- a/code/DRYL.Components/Motion/DrylViewTransition.cs +++ b/code/DRYL.Components/Motion/DrylViewTransition.cs @@ -10,6 +10,16 @@ internal sealed class DrylViewTransition : IDrylViewTransition, IDisposable private TaskCompletionSource? _renderTcs; private Func? _pending; + // The navigation path (BeginNavigation). It has no mutate delegate — the Router + // does the mutating — so ApplyChange only waits for the destination page to + // render. _navRenderSeen is a latch rather than an event: the destination can + // render before JS gets round to calling ApplyChange, and that render must not + // be lost, or the old frame would be held until the timeout for no reason. + private bool _navPending; + private bool _navRenderSeen; + private TimeSpan _navTimeout; + private TaskCompletionSource? _navRenderTcs; + public DrylViewTransition(IJSRuntime js) { _js = js; @@ -41,12 +51,56 @@ public async Task RunAsync(Func mutate) if (Interlocked.Exchange(ref _pending, null) is { } missed) await missed(); } + /// + public void BeginNavigation(TimeSpan timeout) + { + _navTimeout = timeout; + _navRenderSeen = false; + _navRenderTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _navPending = true; + // Deliberately not awaited: the navigation must not wait for the morph, and + // awaiting here would deadlock — the transition waits for the new page's + // render, which cannot happen until the location-changing handler returns. + _ = StartForNavigationAsync(); + } + + private async Task StartForNavigationAsync() + { + try + { + await _js.InvokeVoidAsync("dryl.viewTransition.start", _selfRef); + } + catch (Exception ex) when (ex is JSException or InvalidOperationException) + { + // Circuit gone, stale dryl.js, or prerender — the navigation itself is + // unaffected; it simply happens without a morph. + } + finally + { + _navPending = false; + } + } + /// Invoked from JS inside document.startViewTransition's update /// callback (or directly on the fallback path). Applies the pending mutation and /// resolves once the consuming component reports the render reached the DOM. [JSInvokable] public async Task ApplyChange() { + // Navigation path: there is nothing to mutate, only a destination to wait for. + if (_navPending) + { + _navPending = false; + if (_navRenderSeen) return; // already rendered — take the new snapshot now + if (_navRenderTcs is { } tcs) + { + // The bail: a destination carrying no DrylMorph, or one that never + // finishes rendering, must not leave the user looking at a held frame. + await Task.WhenAny(tcs.Task, Task.Delay(_navTimeout)); + } + return; + } + var pending = Interlocked.Exchange(ref _pending, null); if (pending is null) return; // already applied — nothing to snapshot _renderTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -56,11 +110,18 @@ public async Task ApplyChange() await _renderTcs.Task; } - public void SignalRendered() => _renderTcs?.TrySetResult(); + public void SignalRendered() + { + _renderTcs?.TrySetResult(); + // Latch, not an event: this render may arrive before JS calls ApplyChange. + _navRenderSeen = true; + _navRenderTcs?.TrySetResult(); + } public void Dispose() { _renderTcs?.TrySetResult(); // unblock an in-flight ApplyChange + _navRenderTcs?.TrySetResult(); _selfRef.Dispose(); } } diff --git a/code/DRYL.Components/Motion/IDrylViewTransition.cs b/code/DRYL.Components/Motion/IDrylViewTransition.cs index d7eff43..68ae741 100644 --- a/code/DRYL.Components/Motion/IDrylViewTransition.cs +++ b/code/DRYL.Components/Motion/IDrylViewTransition.cs @@ -32,4 +32,24 @@ public interface IDrylViewTransition /// the mutated state has reached the DOM. Call this unconditionally from /// OnAfterRender — it is a cheap no-op when no transition is in flight. void SignalRendered(); + + /// + /// Starts a transition that a coming navigation completes, rather than one + /// this service mutates itself. Used by DrylRouteTransition from a + /// location-changing handler: the old snapshot is taken here, and the transition + /// is held open until a DrylMorph on the destination page reports its + /// render through . + /// + /// How long the old frame may be held before the transition + /// completes without a morph. A destination page that carries no DrylMorph, + /// or never finishes rendering, must not leave the user looking at a held frame. + /// + /// Unlike this does not await the morph — the + /// navigation must not be delayed by it, and awaiting here would deadlock: the + /// transition waits for the new page's render, which cannot happen until the + /// handler returns. + /// Ships with a do-nothing default implementation, so an existing implementer + /// of this interface keeps compiling and simply never morphs a navigation. + /// + void BeginNavigation(TimeSpan timeout) { } } diff --git a/docs/2026-08-22-drylroutetransition-plan.md b/docs/2026-08-22-drylroutetransition-plan.md new file mode 100644 index 0000000..13ace0d --- /dev/null +++ b/docs/2026-08-22-drylroutetransition-plan.md @@ -0,0 +1,117 @@ +# DrylRouteTransition — implementation plan + +Idea: `ideas/I11 A route-level transition host.md` (`Ready`). +Spec: `specs/E1 Foundation/F1 DrylRouteTransition.md` (`Modified`). + +Target version: **2.26.0** (MINOR — new component, additive API). 2.25.0 has not +shipped, but it is a released *block* in `CHANGELOG.md`; this is a separate +feature, so it gets its own block and its own bump (`REL-01`). + +--- + +## T1 — The service path + +Files: +- `code/DRYL.Components/Motion/IDrylViewTransition.cs` — add + `void BeginNavigation(TimeSpan timeout)` as a **default interface + implementation** that does nothing. +- `code/DRYL.Components/Motion/DrylViewTransition.cs` — implement it, and turn + the render signal into a **latch**. + +Shape: + +- `BeginNavigation(timeout)`: arm the latch (`_navRenderSeen = false`), then call + the JS bridge **without awaiting** — the old snapshot is taken here. +- `ApplyChange` (already `[JSInvokable]`): when a navigation is pending, there is + nothing to mutate. It waits for the latch, bounded by `timeout`, and returns. +- `SignalRendered`: unchanged for the mutate path; additionally sets the latch, + so a render arriving before `ApplyChange` is not lost. + +Verify: `dotnet build DRYL.slnx -c Release`. + +Commit: `feat(motion): a navigation entry point on IDrylViewTransition` + +## T2 — The component + +Files: +- `code/DRYL.Components/Components/Providers/DrylRouteTransition.razor` (new) + +- `@implements IDisposable`, `@inject NavigationManager`, `@inject + IDrylViewTransition` +- registers the handler in `OnAfterRender(firstRender)` — never in + `OnInitialized`, so prerender registers nothing (`patterns.md`) +- handler: consult `ShouldMorph(context.TargetLocation)`, then + `ViewTransition.BeginNavigation(Timeout)` and return. No `PreventNavigation`, + no await. +- renders no markup; disposes the handler registration. + +Verify: `dotnet build DRYL.slnx -c Release`. + +Commit: `feat(foundation): DrylRouteTransition — morph across a route change` + +## T3 — Tests + +Files: +- `tests/DRYL.Components.Tests/DrylRouteTransitionTests.cs` (new) + +Cases: renders no markup; registers on first render and unregisters on dispose; +`ShouldMorph` null/true/false decides whether `BeginNavigation` is called and +with which URI; `Timeout` is passed through; the latch — a render signalled +*before* `ApplyChange` still completes it; the bail — `ApplyChange` returns +within `Timeout` when nothing signals. + +The latch and the bail are tested against `DrylViewTransition` directly (its +`ApplyChange` is `[JSInvokable]` and callable), because they are the two defects +that would freeze a real app. + +Verify: `dotnet test DRYL.slnx -c Release`. + +Commit: `test(foundation): DrylRouteTransition and the navigation latch` + +## T4 — Spec, bookkeeping, release + +Files: +- `harness/requirements.md` — `E1 Foundation` 5 → 6, total 128 → 129 +- `CLAUDE.md` — the `x/128` line → `x/129` +- `specs/E1 Foundation/_Api.md` — the new interface member (its scaffold gains + its first real content) +- `specs/E1 Foundation/_Interop.md` — the service and its cleanup duty +- `specs/E1 Foundation/F1 DrylRouteTransition.md` — `State` → `Implemented` once + the website lands +- `code/DRYL.Components/DRYL.Components.csproj` — `` → 2.26.0 +- `CHANGELOG.md` — a `## [2.26.0]` block + +Verify: all of `node scripts/check-harness-links.mjs`, +`check-spec-coverage.mjs`, `check-motion-tokens.mjs`, `check-light-sync.mjs`, +`validate-light-contrast.mjs`, plus build and test. + +Commit: `release: 2.26.0 — DrylRouteTransition` + +## T5 — Website (separate repository) + +Files in `../DRYL.Website`: +- `Components/Pages/DemoRouteTransition.razor` — **two real routes** + (`/components/route-transition` and `/components/route-transition/{Id:int}`), + because a route morph cannot be demonstrated on one page +- `Components/ComponentCatalog.cs` — `"Route Transition"` / `route-transition` +- the host mounted in `Components/Layout/MainLayout.razor` (or wherever + `DrylDialogProvider` sits) + +Verify: `dotnet build`, `dotnet test DRYL.Website.slnx`, and driven in the +browser: a real route change morphs, the Back button morphs back and lands on +the right history entry, and a destination without a hull still navigates. + +--- + +## Risks carried into implementation + +- **The latch race.** `ApplyChange` and the destination's render can arrive in + either order. Getting this wrong holds the old frame forever. T3 covers it. +- **A stale signal.** `SignalRendered` is called by every `DrylMorph` on every + render, so a render between the handler and the actual route change would + complete the transition too early and flatten the morph. Blazor renders + nothing in that window in practice; recorded in the spec rather than guarded. +- **Mounting it twice.** Two hosts would start two transitions for one + navigation. The second `startViewTransition` is skipped by the browser, so the + failure mode is a missing morph rather than a broken app — but it is worth a + criterion. diff --git a/harness/requirements.md b/harness/requirements.md index bacd4f1..3d0fa5b 100644 --- a/harness/requirements.md +++ b/harness/requirements.md @@ -64,7 +64,7 @@ specs/ | E | Category | Source folder | Components | |---|---|---|---:| -| `E1` | Foundation | `code/DRYL.Components/Components/Providers/` | 5 | +| `E1` | Foundation | `code/DRYL.Components/Components/Providers/` | 6 | | `E2` | Actions | `code/DRYL.Components/Components/Actions/` | 3 | | `E3` | AI | `code/DRYL.Components/Components/AI/` | 8 | | `E4` | Charts | `code/DRYL.Components/Components/Data/Charts/` | 4 | @@ -79,7 +79,7 @@ specs/ | `E13` | Agent Tools | `code/DRYL.Components.Agents/Tools/` | 3 | | `E14` | Agent Canvas | `code/DRYL.Components.Agents/Canvas/` | 2 | | `E15` | Agent Inputs | `code/DRYL.Components.Agents/Field/`, `/CommandPalette/`, `/Voice/`, `/Generation/` | 5 | -| | | **Total** | **128** | +| | | **Total** | **129** | **A category may be componentless** — it then carries `_Api.md` and `_Interop.md` and nothing else. No category is currently in that position, but @@ -105,7 +105,7 @@ files reading "none", would be ceremony without return. The component counts are a statement of fact at the time of writing, not a budget — a new component raises its category's count and the total. Only the **total** is re-derived rather than trusted: `scripts/check-spec-coverage.mjs` -counts `Dryl*.razor` under `code/` and reports `x/128`. The per-category counts +counts `Dryl*.razor` under `code/` and reports `x/129`. The per-category counts are maintained by hand and are documentation, so a move between categories means editing this table in the same commit. - **F** = **one component, one file**. A `Dryl*.razor` maps to exactly one @@ -165,8 +165,8 @@ checkable in **two directions**: captured twice. The second direction is also the progress meter for phase C: it answers -"x of 128 components covered" directly — `scripts/check-spec-coverage.mjs` -prints exactly that line and exits non-zero until it reads `128/128`. +"x of 129 components covered" directly — `scripts/check-spec-coverage.mjs` +prints exactly that line and exits non-zero until it reads `129/129`. #### `Source` format diff --git a/ideas/I11 A route-level transition host.md b/ideas/I11 A route-level transition host.md new file mode 100644 index 0000000..9cc00b2 --- /dev/null +++ b/ideas/I11 A route-level transition host.md @@ -0,0 +1,167 @@ +# A route-level transition host + +## Meta +- **State:** Ready + +## Problem + +Step 2 of [`I10 Shared-element transitions between overview and detail.md`](I10%20Shared-element%20transitions%20between%20overview%20and%20detail.md), +which shipped step 1 as `DrylMorph` in 2.25.0 and deliberately left this half +unscoped: + +> Als .NET-Entwickler möchte ich … sanfte Shared-Element-Transitions zwischen +> einer Übersicht und einer Detailansicht umzusetzen … damit sich UI-Elemente +> **beim Navigieren** flüssig und konsistent in die Detailansicht überführen. + +`DrylMorph` covers the switch that happens *on one route*. A real route change — +`/planets` → `/planets/42`, a `NavLink`, a browser Back button — is not covered +at all today: the `Router` tears down the overview page and builds the detail +page, and the component that has to report the render is one that did not exist +when the transition started. + +## The mechanism, worked out before anything is promised + +*(Tech Lead. This is the part that decides whether the idea is buildable at +all, so it comes before the options.)* + +Blazor's `NavigationManager.RegisterLocationChangingHandler` runs **before** the +navigation and the navigation awaits it. That is the hook — but the obvious use +of it deadlocks: + +> start the transition inside the handler and await it → the transition's update +> callback waits for the new page's render → the render cannot happen until the +> handler returns → the handler is waiting on the transition. Deadlock. + +The way out is that `document.startViewTransition` **takes the old snapshot +synchronously at the call** and only needs its callback's promise to resolve +once the new DOM is in place. The DOM may change at any point in between. So: + +1. The handler calls the JS bridge **without awaiting it**, and returns + immediately. The old snapshot is now taken. +2. Blazor navigates and renders the new page. +3. The bridge invokes `ApplyChange` on .NET, which — unlike today's mutate-and- + wait — has nothing to mutate. It only waits for the new page's render. +4. A `DrylMorph` on the new page reports its render, the + callback resolves, the browser takes the new snapshot and morphs. + +Two consequences worth stating plainly: + +- **`PreventNavigation` is not needed.** The navigation is never intercepted or + restarted, so the Back/Forward buttons and the history stack are untouched. + That removes what would otherwise have been this idea's biggest risk. +- **Steps 3 and 4 can arrive in either order.** If the new page renders before + the bridge calls back, a plain "wait for the next render" waits forever. The + render signal has to be a **latch**, not an event. + +There is a third consequence that is an actual gap rather than a detail: today's +`IDrylViewTransition` is built around `RunAsync(mutate)`. Navigation has no +mutate delegate — the `Router` does the mutating. The service needs a second +entry point, and it has to be *that* service instance, because `DrylMorph` +reports to the DI-scoped one. + +## Solution + +**`DrylRouteTransition`** — a component mounted once in the layout, the way +`DrylDialogProvider` is. It registers a location-changing handler, starts the +transition there without awaiting it, and lets the destination page's +`DrylMorph` hulls close the loop. It renders nothing. + +- `ShouldMorph` — `Func?`. Given the target URI, decides whether + this navigation morphs. Null means every internal navigation does. +- `Timeout` — `TimeSpan`, the bail. When no render is reported within it, the + transition completes without a morph rather than holding the frame. + +`IDrylViewTransition` gains **one member with a default implementation** — +`BeginNavigation(TimeSpan)`, defaulting to a no-op. Existing implementers keep +compiling, and an implementation that does not override it simply never morphs +a navigation. + +## Scope + +- **In scope:** + - `DrylRouteTransition` with `ShouldMorph` and `Timeout`, rendering nothing. + - `IDrylViewTransition.BeginNavigation(TimeSpan)` as a default-implemented + member; the real implementation in `DrylViewTransition`. + - A **latching** render signal, so a render that arrives before the JS bridge + calls back is not lost. + - The bail: the transition completes morph-free when `Timeout` elapses with no + render reported. + - A demo page and `ComponentCatalog` entry showing a real route change. +- **Out of scope:** + - Cross-document (`@view-transition { navigation: auto }`) transitions. + - Any change to `DrylMorph`'s own behaviour — it already reports every render, + which is exactly what this needs. + - Intercepting, cancelling or restarting a navigation. The handler starts the + transition and gets out of the way; history and the Back button stay + untouched. + - Deciding *which elements* morph. That is the destination page's `DrylMorph` + hulls, as on one route. + +## Impact + +*(Tech Lead, `IDEA-05`.)* + +### Harness + +- No new token, no new duration, no new easing, no new `AiState`, no new + dependency — the morph is the vocabulary `dryl.css` already defines and + 2.25.0 just tuned. **No harness blocker.** +- The bail policy (what happens when no render is ever reported) is new + *behaviour*, not new visuals. It belongs in the spec before it is code. + +### Specs + +- A new component. Its category follows its source folder (`SPEC-02`). It is + mounted once in `Routes.razor` rather than placed on a page, which is exactly + what `E1 Foundation` / `Components/Providers/` is for — the same reasoning + that put `DrylMorph` in `E9 Layout` puts this one in Foundation. +- `E1 Foundation` currently has no `F{n}` specs at all (phase C has not reached + it), so this would be its first. +- Touches `specs/E9 Layout/F17 DrylMorph.md` (the hull becomes the thing that + closes a *navigation's* loop too) and `specs/E1 Foundation/_Interop.md`. + +### Public API + +- One new component with two parameters. Additive → MINOR (`REL-01`). +- **`IDrylViewTransition.BeginNavigation(TimeSpan)` as a default interface + implementation** (settled below), so the post-1.0 interface stays + non-breaking for implementers and the bump remains MINOR. + +### Code + +- `code/DRYL.Components/Motion/` — `DrylViewTransition` gains the navigation + path and a latching render signal. +- A new component under `code/DRYL.Components/Components/Providers/`. +- **Risk — the render latch.** Steps 3 and 4 above racing is the defect that + would show up as an app frozen behind a held frame. It needs a test. +- **Risk — nothing ever reports.** A destination page with no `DrylMorph` and no + host-side signal leaves the callback open and the UI holding the old frame. + This is why a bail policy is not optional. +- **Risk — Blazor Server latency.** Every route change is a round trip; the + frame is held for its duration. The skeleton policy from I10 bounds it only if + the destination page actually renders something quickly. + +## Decisions + +- 2026-08-22 (Product Owner, in I10): staged — the hull first, this second. +- 2026-08-22 (Product Owner, in I10): while a detail page loads, the transition + **morphs onto the skeleton**; the UI is never frozen waiting for data. +- 2026-08-22 (Tech Lead): the handler does **not** prevent or restart the + navigation, so history and the Back button stay untouched. The old snapshot is + taken in the handler; the callback is held open until the new page renders. +- 2026-08-22 (Product Owner): **every internal navigation morphs**, with a + `ShouldMorph` predicate to exclude individual ones. Wiring each navigation up + by hand would have cost the "minimaler Code" the original story asked for. +- 2026-08-22 (Product Owner): **a timeout is the bail.** When nothing reports a + render, the transition finishes morph-free; the UI never holds the old frame + longer than that. The host does *not* report its own render — that would + resolve the callback before the destination is really there and flatten the + morph. +- 2026-08-22 (Product Owner): the new interface member ships as a **default + interface implementation**, so nobody implementing `IDrylViewTransition` + breaks and the release stays MINOR. + +## Open Points + +*(none — awaiting the Product Owner's explicit confirmation of this final +version, the last box of `IDEA-06`, before the state moves to `Ready`.)* diff --git a/specs/E1 Foundation/F1 DrylRouteTransition.md b/specs/E1 Foundation/F1 DrylRouteTransition.md new file mode 100644 index 0000000..4e9fd3b --- /dev/null +++ b/specs/E1 Foundation/F1 DrylRouteTransition.md @@ -0,0 +1,161 @@ +# DrylRouteTransition + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Providers/DrylRouteTransition.razor + +## User Story + +As a Blazor developer, I want a route change to morph instead of cutting, so +that navigating from an overview page to a detail page carries the object the +user pressed across with them — without me hooking into the router or timing +anything myself. + +## Description + +`DrylRouteTransition` extends the shared-element morph across a **real route +change**. `DrylMorph` already covers a switch that happens on one route; this +covers `/planets` → `/planets/42`, a `NavLink`, and a Back button. + +A consumer mounts it once, next to `DrylDialogProvider` in the layout, and puts +a `DrylMorph` with the same `Name` on both pages. Nothing else is wired up. + +It works by starting the transition in a location-changing handler and then +getting out of the way. It never prevents, cancels or restarts a navigation, so +the history stack and the browser's Back and Forward buttons behave exactly as +they would without it. The old snapshot is taken when the handler runs; the +transition is then held open until a `DrylMorph` on the destination page reports +its render, at which point the browser takes the new snapshot and morphs. + +Because a destination page can fail to report — it may carry no `DrylMorph` at +all, or be waiting on data that never arrives — the component always carries a +bail. `Timeout` bounds how long the previous frame can be held; when it elapses +the navigation simply completes without a morph. The component holds the old +frame, never the application. + +The component renders no markup. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `ShouldMorph` | `Func?` | `null` | Given the target URI, decides whether that navigation morphs. `null` morphs every internal navigation. | +| `Timeout` | `TimeSpan` | 1 second | How long the old frame may be held waiting for the destination to report a render, before the navigation completes morph-free. | + +`IDrylViewTransition` gains one member for this, documented in +[`_Api.md`](_Api.md): + +| Member | Purpose | +|---|---| +| `BeginNavigation(TimeSpan timeout)` | Starts a transition that a coming navigation completes. Ships as a **default interface implementation** that does nothing, so an existing implementer of the interface keeps compiling and simply never morphs a navigation. | + +The component takes **no** `Ai` and no `Aura` — see "AI mode" below. + +## Acceptance Criteria + +### Mounting and rendering + +- The component renders no markup. +- The component registers its location-changing handler when it is first + rendered. +- The component removes its handler when it is disposed. +- A second instance mounted by mistake does not start two transitions for one + navigation. + +### Starting a transition + +- An internal navigation starts a view transition before the router changes the + route. +- The component does not await the transition inside the handler, so the + navigation is never delayed by it. +- The component never calls `PreventNavigation`, so a navigation always + proceeds. +- The browser's Back and Forward buttons navigate exactly as they do without the + component present. + +### Choosing which navigations morph + +- `ShouldMorph` left `null` morphs every internal navigation. +- `ShouldMorph` returning `false` for a target leaves that navigation with no + transition at all. +- `ShouldMorph` receives the target URI of the navigation. +- `ShouldMorph` returning `true` morphs that navigation. + +### Completing the transition + +- The transition completes when a `DrylMorph` on the destination page reports + its render. +- A render reported *before* the JS bridge asks for it still completes the + transition, rather than being lost. +- The component reports a render of its own once the new route has rendered, so + a destination carrying no `DrylMorph` completes the transition immediately + instead of waiting out `Timeout`. +- The component's own report is made after the route reached the DOM, never + before, so it does not pre-empt a destination that does carry a hull. +- A destination that is still loading its data completes the transition on its + first render, morphing onto whatever placeholder it shows rather than holding + the previous page. + +### The bail + +- `Timeout` defaults to one second. +- The transition completes without a morph when `Timeout` elapses with no render + reported at all — the second net under the component's own report, for a route + that never renders. +- The old frame is never held longer than `Timeout`. +- A navigation whose transition bailed still arrives at its destination. + +### Behaviour where the morph cannot run + +- The component renders nothing and registers nothing during prerender. +- Navigation behaves identically when the browser has no View Transition API: + the route change happens, morph-free. +- Navigation behaves identically under `prefers-reduced-motion`, since the + shared bridge already falls back to a direct apply. +- A disconnected circuit does not leave a navigation blocked. + +### Keyboard and accessibility + +- The component renders nothing, so it adds no focus stop, no landmark and no + announcement. +- The component does not move focus; where focus lands after a route change + stays the application's own concern (`FocusOnNavigate` and the like keep + working). +- The component changes nothing about how a link or a `NavLink` is operated by + keyboard. + +### Appearance + +- The component names no color, length, duration or easing (`DESIGN-01`); the + morph is the shared `::view-transition-*` vocabulary in `dryl.css`. +- The component adds no stylesheet of its own. +- The component renders nothing, so it has no appearance to differ between color + modes (`DESIGN-02`). + +### AI mode + +- The component takes no `Ai` parameter and renders no aura. +- The decision is deliberate (`AI-05`): the component has no surface. It renders + nothing at all, so there is nothing for an aura to sit on, and a navigation is + not an AI activity. A page that *is* AI-driven carries its own `Ai` on the + surfaces it morphs into. + +## Cross-cutting evidence (`SPEC-05`) + +- **Both color modes** — the component renders nothing; there is no per-mode + value to hold. `node scripts/check-light-sync.mjs` and + `node scripts/validate-light-contrast.mjs` are unaffected. The morph itself is + checked by eye in both modes on the demo page. +- **Enter/exit animation** — the component *is* the enter animation of a route. + It has none of its own and renders no element that could carry one; this is + the written exception `DESIGN-11` allows. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that focus handling is left alone: a component that + moved focus on every route change would fight `FocusOnNavigate`. +- **AI mode** — explicitly no, with the reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoRouteTransition.razor`, + which needs two real routes to demonstrate anything, so it carries an overview + route and a detail route rather than a switch on one page. +- **`ComponentCatalog`** — registered as `"Route Transition"` / + `route-transition` in `DRYL.Website/Components/ComponentCatalog.cs`, flagged + not AI-capable. diff --git a/specs/E1 Foundation/_Api.md b/specs/E1 Foundation/_Api.md index 14f580b..8241609 100644 --- a/specs/E1 Foundation/_Api.md +++ b/specs/E1 Foundation/_Api.md @@ -31,7 +31,34 @@ the exact spelling used in code. Until then this file claims nothing.* ## Motion primitives -*(phase C)* +*(mostly phase C. The view-transition surface is documented here because +`DrylRouteTransition` and `DrylMorph` both depend on it and neither owns it.)* + +### `DrylViewTransitionStyle` + +How much of the morph vocabulary a target gets. Both tiers glide on +`--ease-viscous`; only `DepthGlass` pays for the blur/merge pass. + +| Value | Meaning | +|---|---| +| `Glide` | Viscous easing only — the shape glides, no blur/merge pass. Cheap enough for high-frequency interactions. | +| `DepthGlass` | The full choreography — translucency pulse, mercury merge, decoupled clarity. For low-frequency, high-meaning merges. | + +### `IDrylViewTransition` + +Runs a change inside a same-document view transition. Registered scoped by +`AddDrylComponents()`. + +| Member | Purpose | +|---|---| +| `RunAsync(Action mutate)` | Runs `mutate` (which must end in `StateHasChanged()`) inside a view transition; completes when the morph has finished. | +| `RunAsync(Func mutate)` | Async-mutation overload. | +| `SignalRendered()` | Reports that a render reached the DOM. Called unconditionally; a no-op when no transition is in flight. `DrylMorph` calls it for its consumers. | +| `BeginNavigation(TimeSpan timeout)` | Starts a transition that a **coming navigation** completes, rather than one this service mutates itself. Ships as a **default interface implementation that does nothing**, so an existing implementer keeps compiling and simply never morphs a navigation. `timeout` bounds how long the old frame may be held. | + +All four fall back to applying the change directly — no snapshot, no morph — in +browsers without the View Transition API, during prerender, and when the user +prefers reduced motion. ## Token surface diff --git a/specs/E1 Foundation/_Interop.md b/specs/E1 Foundation/_Interop.md index fd126fd..38d95cb 100644 --- a/specs/E1 Foundation/_Interop.md +++ b/specs/E1 Foundation/_Interop.md @@ -7,12 +7,29 @@ library as a whole rather than to one category. ## Interop -none *(phase C)* +| Entry point | Used by | Purpose | +|---|---|---| +| `dryl.viewTransition.start` | `DrylViewTransition` (the service behind `IDrylViewTransition`) | Takes the old snapshot, asks .NET to apply its change, then lets the browser morph old → new. Falls back to a direct, morph-free apply when the API is missing or the user prefers reduced motion. | + +*(the rest: phase C)* ## Services -none *(phase C)* +| Service | Lifetime | Registered by | Used by | +|---|---|---|---| +| `IDrylViewTransition` | scoped | `AddDrylComponents()` | `DrylRouteTransition` — calls `BeginNavigation` from a location-changing handler. `DrylMorph` reports every render to the same instance, which is what completes a navigation's morph. | + +*(the rest: phase C)* ## Cleanup -none *(phase C)* +`DrylRouteTransition` disposes the registration returned by +`RegisterLocationChangingHandler` (`CODE-05`); without it a torn-down host would +keep starting transitions. It makes no interop call of its own and holds no +`IJSObjectReference`. + +`DrylViewTransition` owns a `DotNetObjectReference` to itself and disposes it, +releasing any in-flight wait first so a disposed circuit cannot leave a +navigation blocked. + +*(the rest: phase C)* diff --git a/tests/DRYL.Components.Tests/DrylRouteTransitionTests.cs b/tests/DRYL.Components.Tests/DrylRouteTransitionTests.cs new file mode 100644 index 0000000..36ef519 --- /dev/null +++ b/tests/DRYL.Components.Tests/DrylRouteTransitionTests.cs @@ -0,0 +1,204 @@ +using System.Diagnostics; +using Bunit; +using DRYL.Components; +using DRYL.Components.Motion; +using Microsoft.Extensions.DependencyInjection; + +namespace DRYL.Components.Tests; + +/// +/// Tests for and the navigation path it drives on +/// . Two of these guard defects that would freeze a +/// real application: the render latch (a destination that renders before JS calls +/// back) and the bail (a destination that never reports at all). +/// +public class DrylRouteTransitionTests : BunitContext +{ + private sealed class RecordingViewTransition : IDrylViewTransition + { + public List Begun { get; } = []; + public Task RunAsync(Action mutate) { mutate(); return Task.CompletedTask; } + public Task RunAsync(Func mutate) => mutate(); + public void SignalRendered() { } + public void BeginNavigation(TimeSpan timeout) => Begun.Add(timeout); + } + + /// An implementer that predates BeginNavigation — it must still compile + /// and behave, which is the whole point of the default implementation. + private sealed class LegacyViewTransition : IDrylViewTransition + { + public Task RunAsync(Action mutate) { mutate(); return Task.CompletedTask; } + public Task RunAsync(Func mutate) => mutate(); + public void SignalRendered() { } + } + + private RecordingViewTransition UseRecorder() + { + var fake = new RecordingViewTransition(); + Services.AddSingleton(fake); + return fake; + } + + private Microsoft.AspNetCore.Components.NavigationManager Nav => + Services.GetRequiredService(); + + // ------------------------------------------------------------------ mounting + + [Fact] + public void Renders_no_markup() + { + UseRecorder(); + + var cut = Render(); + + Assert.Equal(string.Empty, cut.Markup.Trim()); + } + + [Fact] + public void An_internal_navigation_begins_a_transition() + { + var fake = UseRecorder(); + Render(); + + Nav.NavigateTo("/planets/42"); + + Assert.Single(fake.Begun); + } + + [Fact] + public void No_transition_is_begun_after_dispose() + { + var fake = UseRecorder(); + var cut = Render(); + cut.Instance.Dispose(); + + Nav.NavigateTo("/planets/42"); + + Assert.Empty(fake.Begun); + } + + [Fact] + public void The_timeout_parameter_is_passed_through() + { + var fake = UseRecorder(); + Render(ps => ps.Add(p => p.Timeout, TimeSpan.FromMilliseconds(250))); + + Nav.NavigateTo("/planets/42"); + + Assert.Equal(TimeSpan.FromMilliseconds(250), Assert.Single(fake.Begun)); + } + + [Fact] + public void The_timeout_defaults_to_one_second() + { + var fake = UseRecorder(); + Render(); + + Nav.NavigateTo("/planets/42"); + + Assert.Equal(TimeSpan.FromSeconds(1), Assert.Single(fake.Begun)); + } + + // -------------------------------------------------------------- ShouldMorph + + [Fact] + public void ShouldMorph_returning_false_leaves_the_navigation_alone() + { + var fake = UseRecorder(); + Render(ps => ps.Add(p => p.ShouldMorph, _ => false)); + + Nav.NavigateTo("/logout"); + + Assert.Empty(fake.Begun); + } + + [Fact] + public void ShouldMorph_returning_true_morphs_the_navigation() + { + var fake = UseRecorder(); + Render(ps => ps.Add(p => p.ShouldMorph, _ => true)); + + Nav.NavigateTo("/planets/42"); + + Assert.Single(fake.Begun); + } + + [Fact] + public void ShouldMorph_receives_the_target_uri() + { + UseRecorder(); + var seen = new List(); + Render(ps => ps.Add(p => p.ShouldMorph, url => { seen.Add(url); return true; })); + + Nav.NavigateTo("/planets/42"); + + Assert.Contains(seen, u => u.Contains("/planets/42")); + } + + // ------------------------------------- the latch and the bail (freeze guards) + + [Fact] + public async Task A_render_signalled_before_ApplyChange_still_completes_it() + { + var svc = new DrylViewTransition(new NoopJsRuntime()); + svc.BeginNavigation(TimeSpan.FromSeconds(30)); + + // The destination rendered before JS got round to calling back. + svc.SignalRendered(); + + var sw = Stopwatch.StartNew(); + await svc.ApplyChange(); + sw.Stop(); + + // Had the signal been treated as an event rather than a latch, this would + // have sat here for the full 30 seconds. + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"ApplyChange waited {sw.Elapsed}"); + } + + [Fact] + public async Task ApplyChange_bails_when_nothing_ever_reports_a_render() + { + var svc = new DrylViewTransition(new NoopJsRuntime()); + svc.BeginNavigation(TimeSpan.FromMilliseconds(150)); + + var sw = Stopwatch.StartNew(); + await svc.ApplyChange(); // no SignalRendered ever arrives + sw.Stop(); + + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"ApplyChange waited {sw.Elapsed}"); + } + + [Fact] + public async Task A_render_arriving_after_ApplyChange_started_completes_it() + { + var svc = new DrylViewTransition(new NoopJsRuntime()); + svc.BeginNavigation(TimeSpan.FromSeconds(30)); + + var applying = svc.ApplyChange(); + await Task.Delay(20); + svc.SignalRendered(); + + var finished = await Task.WhenAny(applying, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.Same(applying, finished); + } + + [Fact] + public void A_legacy_implementer_still_satisfies_the_interface() + { + IDrylViewTransition legacy = new LegacyViewTransition(); + + // The default implementation: does nothing, throws nothing. + legacy.BeginNavigation(TimeSpan.FromSeconds(1)); + } + + /// A JS runtime that never resolves anything — BeginNavigation's bridge + /// call is fire-and-forget, so the tests above drive ApplyChange directly, exactly + /// as the browser does. + private sealed class NoopJsRuntime : Microsoft.JSInterop.IJSRuntime + { + public ValueTask InvokeAsync(string identifier, object?[]? args) => + new(default(TValue)!); + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) => + new(default(TValue)!); + } +} From 237bd9468cdaf3afe3c2f0248f9788907539c5c2 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 18:04:20 +0200 Subject: [PATCH 22/24] fix(layout): the collapsed sidebar rail was squeezing every icon to 5px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "why is the sidebar so ugly when collapsed". Measured: each 16px icon was rendering 5px wide — a column of smears rather than icons — and what was left sat off-centre. Two causes, both about space: - The nav rows kept the horizontal padding of their expanded state: 24px of padding inside a 29px row leaves 5px for a 16px icon. The rail already provides the padding, so the rows inside it give theirs up. - A classic scrollbar took another 10px of a 56px rail and pushed the content left with the remainder. On a rail that narrow a scrollbar is unusable as a control anyway, so it no longer takes space — wheel, trackpad and keyboard scrolling are untouched. Verified in the browser: icons back at 16x16, off-centre by 0.5px instead of 5.5px, in both colour modes. Labels stay hidden exactly as before, and the expanded sidebar is untouched. Version stays 2.26.0 — that block has not shipped (REL-01). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeoxNkuw3okKmUhzmYFiuV --- CHANGELOG.md | 3 +++ code/DRYL.Components/wwwroot/dryl.css | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7665f..c5c3bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Version bump guide: ## [2.26.0] — 2026-08-22 +### Fixed +- **The collapsed sidebar rail is legible again.** Every icon in a collapsed `DrylDrawer` was being squeezed to **5px wide** instead of 16 — a row of unrecognisable smears rather than icons. Two things were taking the space: the nav rows kept the horizontal padding of their expanded state (24px of it inside a 29px row), and a classic scrollbar claimed another quarter of a 56px rail, pushing what was left off-centre. On a rail the scrollbar is unusable as a control anyway, so it no longer takes space there — wheel, trackpad and keyboard scrolling are untouched — and the rows give up their side padding to the rail that already provides it. Icons are back at their full size, centred, and the labels stay hidden exactly as before. Nothing changes while the sidebar is expanded. + ### Added - `DrylRouteTransition` — **a morph across a real route change.** `DrylMorph` (2.25.0) covers a switch that happens on one route; this covers `/planets` → `/planets/42`, a `NavLink`, and the browser's Back button. Mount it once beside `DrylDialogProvider`, put a `DrylMorph` with the same `Name` on both pages, and the object the user pressed travels with them — no router hook, no timing code. It works by starting the transition in a location-changing handler and then getting out of the way: it **never** calls `PreventNavigation`, never cancels and never restarts a navigation, so the history stack and the Back and Forward buttons behave exactly as they do without it. The old snapshot is taken when the handler runs; the transition is held open until a `DrylMorph` on the destination reports its render. The host also reports the new route's first render itself, so a destination carrying no hull completes immediately rather than waiting — and a destination still loading its data morphs onto its placeholder instead of freezing the page it came from. `Timeout` (one second by default) sits underneath as the second net, for a route that never renders at all: when it elapses the navigation completes without a morph, so the component holds a frame, never your application. `ShouldMorph` takes the target URI and excludes individual navigations (a sign-out, a jump to an unrelated part of the app); left unset, every internal navigation morphs. The component renders no markup. - `IDrylViewTransition.BeginNavigation(TimeSpan)` — the entry point behind it, for a transition that a *coming navigation* completes rather than one the service mutates itself. It ships as a **default interface implementation that does nothing**, so if you implement `IDrylViewTransition` yourself, your code keeps compiling untouched and navigations simply do not morph until you override it. diff --git a/code/DRYL.Components/wwwroot/dryl.css b/code/DRYL.Components/wwwroot/dryl.css index 37aff91..84fd267 100644 --- a/code/DRYL.Components/wwwroot/dryl.css +++ b/code/DRYL.Components/wwwroot/dryl.css @@ -1569,8 +1569,29 @@ a:hover { text-decoration: underline; text-underline-offset: 4px; } .sidebar.is-collapsed .sidebar-collapse-hide { display: none; } + /* Centring alone is not enough: the row keeps its expanded horizontal padding, + which on a rail this narrow leaves less room than the icon needs and squashes + it — 16px of icon in 5px of space. The rail is the padding now, so the rows + inside it give theirs up and each icon gets its full width back. */ .sidebar.is-collapsed .nav-item, - .sidebar.is-collapsed .nav-section-toggle { justify-content: center; } + .sidebar.is-collapsed .nav-section-toggle { + justify-content: center; + padding-left: 0; + padding-right: 0; + } + /* The icon is the whole row here — it may never be shrunk to fit. */ + .sidebar.is-collapsed .nav-item > svg, + .sidebar.is-collapsed .nav-item .ico, + .sidebar.is-collapsed .nav-section-toggle > svg { + flex: 0 0 auto; + min-width: 16px; + } + /* A classic scrollbar claims a quarter of a rail this narrow and pushes every + icon off-centre with the remainder. On a rail it is also unusable as a + control, so it stops taking space here — the rail still scrolls by wheel, + trackpad and keyboard. */ + .sidebar.is-collapsed .sidebar-content { scrollbar-width: none; } + .sidebar.is-collapsed .sidebar-content::-webkit-scrollbar { width: 0; height: 0; } } /* Static mode: never becomes an overlay, stays an in-flow column at any width. */ From 10325bda04084d89aeede8ec7faaa5a2a42a1d32 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 22 Aug 2026 19:00:52 +0200 Subject: [PATCH 23/24] feat!: replace the View Transition API with FLIP everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: DRYL 3.0.0. IDrylViewTransition, DrylViewTransition, DrylViewTransitionStyle, DrylCard.ViewTransitionName/ViewTransitionStyle and window.dryl.viewTransition are removed. CHANGELOG.md carries the migration table; every replacement is a mechanical rename. The browser API replaced the live page with snapshots for the duration of a transition and swapped back at the end — measured as a 1718x1248 snapshot of the whole viewport for a change to one card. That swap is visible everywhere at once: text antialiasing shifts, backdrop-filter surfaces recomposite. It read as the entire page flickering every time one element moved, and no CSS could switch it off. A MutationObserver ruled out the alternative explanation: Blazor performed 33 mutations for that click and never touched the shell. FLIP does the opposite. Measure where things are, let Blazor render, measure again, animate the difference on the real elements. The page stays live. Verified side by side in the browser before the decision, and again after: 0 snapshots and 0 calls to the API, against 10 pseudo-elements before. - IDrylMorph + DrylMorphEngine, with dryl.morph (capture/play) in dryl.js. Targets announce themselves through data-dryl-morph rather than being registered from .NET — an ElementReference cannot be passed inside a nested object, and the DOM already knows where everything is. - DrylMorphStyle replaces DrylViewTransitionStyle; DepthGlass is rebuilt as blur and translucency travelling with the real element. - Content is counter-scaled for the length of the move, so type keeps its proportions while the shape grows. A snapshot could never do that. - All call sites moved: DrylCard (its two parameters give way to the hull), the dialog handoff, DrylTable's row reorder — FLIP is the classic technique for exactly that — DrylCanvas and DrylCanvasWorkspace. - The engine also survives JSDisconnectedException now, which the old one did not catch; a torn-down circuit must never lose a state change. - Sixteen spec files updated with the code (SPEC-01), and the ::view-transition block in dryl.css is gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TeoxNkuw3okKmUhzmYFiuV --- CHANGELOG.md | 26 ++++ .../Canvas/DrylAiCanvas.razor | 12 +- .../Tools/DrylUiTools.cs | 8 +- .../Components/AI/DrylCanvas.razor | 23 ++- .../Components/AI/DrylCanvasWorkspace.razor | 12 +- .../Components/Data/DrylTable.razor | 38 ++--- .../Components/Layout/DrylMorph.razor | 81 +++++----- .../Providers/DrylRouteTransition.razor | 9 +- .../Components/Surfaces/DrylCard.razor | 26 ---- code/DRYL.Components/DRYL.Components.csproj | 2 +- code/DRYL.Components/Dialogs/DialogOptions.cs | 9 +- code/DRYL.Components/Dialogs/DrylDialog.razor | 62 +++----- .../Dialogs/DrylDialogProvider.razor | 30 ++-- code/DRYL.Components/DrylMorphStyle.cs | 19 +++ .../DrylViewTransitionStyle.cs | 18 --- .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../DRYL.Components/Motion/DrylMorphEngine.cs | 105 +++++++++++++ .../Motion/DrylViewTransition.cs | 127 --------------- code/DRYL.Components/Motion/IDrylMorph.cs | 50 ++++++ .../Motion/IDrylViewTransition.cs | 55 ------- .../Motion/ViewTransitionAttributes.cs | 36 ----- code/DRYL.Components/wwwroot/dryl.css | 79 ++-------- code/DRYL.Components/wwwroot/js/dryl.js | 147 +++++++++++++----- ...place the View Transition API with FLIP.md | 147 ++++++++++++++++++ specs/E1 Foundation/F1 DrylRouteTransition.md | 16 +- specs/E1 Foundation/_Api.md | 18 ++- specs/E1 Foundation/_Interop.md | 7 +- .../F3 DrylCanvas/S5 Layout and expand.md | 6 +- specs/E3 AI/F3 DrylCanvas/_Component.md | 4 +- specs/E3 AI/F8 DrylCanvasWorkspace.md | 10 +- specs/E3 AI/_Interop.md | 2 +- .../S7 Row reordering and motion.md | 6 +- .../S9 Presentation, accessibility and AI.md | 2 +- specs/E5 Data/F16 DrylTable/_Component.md | 2 +- specs/E5 Data/_Interop.md | 2 +- specs/E6 Dialogs/F2 DrylDialogProvider.md | 6 +- specs/E6 Dialogs/_Api.md | 2 +- specs/E6 Dialogs/_Interop.md | 8 +- specs/E9 Layout/F17 DrylMorph.md | 78 +++++----- specs/E9 Layout/_Interop.md | 2 +- .../Agents/Canvas/DrylAiCanvasWowTests.cs | 23 +-- .../Canvas/DrylCanvasWorkspaceTests.cs | 4 +- .../DrylCardViewTransitionTests.cs | 49 ------ .../DrylMorphEngineTests.cs | 84 ++++++++++ tests/DRYL.Components.Tests/DrylMorphTests.cs | 51 +++--- .../DrylRouteTransitionTests.cs | 68 +++----- tests/DRYL.Components.Tests/DrylTableTests.cs | 30 ++-- .../DrylViewTransitionTests.cs | 59 ------- 48 files changed, 857 insertions(+), 807 deletions(-) create mode 100644 code/DRYL.Components/DrylMorphStyle.cs delete mode 100644 code/DRYL.Components/DrylViewTransitionStyle.cs create mode 100644 code/DRYL.Components/Motion/DrylMorphEngine.cs delete mode 100644 code/DRYL.Components/Motion/DrylViewTransition.cs create mode 100644 code/DRYL.Components/Motion/IDrylMorph.cs delete mode 100644 code/DRYL.Components/Motion/IDrylViewTransition.cs delete mode 100644 code/DRYL.Components/Motion/ViewTransitionAttributes.cs create mode 100644 ideas/I12 Replace the View Transition API with FLIP.md delete mode 100644 tests/DRYL.Components.Tests/DrylCardViewTransitionTests.cs create mode 100644 tests/DRYL.Components.Tests/DrylMorphEngineTests.cs delete mode 100644 tests/DRYL.Components.Tests/DrylViewTransitionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c3bc4..e00e2e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,32 @@ Version bump guide: ## [Unreleased] +## [3.0.0] — 2026-08-22 + +### Removed +- **The View Transition API is gone from DRYL.** Every morph in the library now runs on FLIP instead. The browser API replaced the **live page** with snapshots for the duration of a transition and swapped back to live rendering at the end — measured as a 1718 × 1248 px snapshot of the whole viewport for a change to one card. That swap is visible everywhere at once: text antialiasing shifts, `backdrop-filter` surfaces recomposite, gradients re-raster. It looked like the entire page flickering every time one element moved, and no amount of CSS could switch it off. FLIP does the opposite: measure where things are, let Blazor render, measure again, and animate the difference on the real elements. The page stays live and only what actually moved is animated — verified side by side in the browser: **0 snapshots and 0 calls to the API** afterwards, against 10 pseudo-elements before. + +**Removed API — what to use instead:** + +| Removed | Replacement | +|---|---| +| `IDrylViewTransition` | `IDrylMorph` — same `RunAsync(mutate)` shape and the same `SignalRendered()` contract. `BeginNavigation(TimeSpan)` becomes `BeginNavigationAsync(TimeSpan)`. | +| `DrylViewTransitionStyle` | `DrylMorphStyle`, with the same `Glide` and `DepthGlass` values. | +| `DrylCard.ViewTransitionName`, `DrylCard.ViewTransitionStyle` | Wrap the card in ``. The card is a surface; being a morph target is the hull's job, and this way any content can be one. | +| `DialogOptions.HandoffStyle` typed as `DrylViewTransitionStyle` | Same property, now typed `DrylMorphStyle`. `AnimateHandoff` is unchanged. | +| `window.dryl.viewTransition` | `window.dryl.morph` (`capture` / `play`). Only relevant if you called the bridge yourself. | +| The `::view-transition-*` block in `dryl.css` | Nothing to replace — the movement is created by the engine, not by stylesheet rules. | + +Everything else is a rename you can follow mechanically. If you never touched these names, the only change you will notice is that morphs look better. + +### Changed +- `DrylMorph` — marks its target with `data-dryl-morph` instead of rendering a `view-transition-name`, and its `Style` parameter takes `DrylMorphStyle`. The parameters, their defaults and their meaning are otherwise unchanged. +- `DrylTable` — row reordering runs on FLIP, which is the classic technique for exactly this: each row is measured, the list re-renders, and every row that moved travels to its new position. Rows carry `data-dryl-morph` instead of a per-row `view-transition-name`. +- The dialog handoff, `DrylCanvas` and `DrylCanvasWorkspace` all move over to the engine. Their behaviour is unchanged; their movement no longer freezes the page around them. +- `DepthGlass` is rebuilt on FLIP: the surface passes through translucency and blur while it travels and arrives clear, instead of two snapshots merging through a filter. + + + ## [2.26.0] — 2026-08-22 ### Fixed diff --git a/code/DRYL.Components.Agents/Canvas/DrylAiCanvas.razor b/code/DRYL.Components.Agents/Canvas/DrylAiCanvas.razor index 286595e..6c805f5 100644 --- a/code/DRYL.Components.Agents/Canvas/DrylAiCanvas.razor +++ b/code/DRYL.Components.Agents/Canvas/DrylAiCanvas.razor @@ -2,7 +2,7 @@ @using DRYL.Components.Ai @using DRYL.Components.Canvas @using DRYL.Components.Motion -@inject IDrylViewTransition ViewTransition +@inject IDrylMorph Morph @implements IDisposable @* ───────────────────────────────────────────────────────── @@ -15,7 +15,7 @@ * the AI aura that breathes with the run state and flashes on completion * a DrylAiIndicator in the header doubling as a live element counter * the aria-live announcements (building / updated / ready / failed) - * the view-transition morph when a second create_artifact replaces the + * the morph when a second create_artifact replaces the whole artifact — the old tree morphs into the new one instead of snapping * forwarding the measured body width to the run, so the next generation is authored for the space the artifact actually has @@ -93,7 +93,7 @@ // The CanvasSpec instance the DOM currently shows. A different instance arriving through // OnChange means a whole new artifact replaced the old one — the one case that gets a - // view-transition morph (see HandleChange). + // morph (see HandleChange). private CanvasSpec? _renderedSpec; protected override void OnParametersSet() @@ -134,12 +134,12 @@ private void HandleChange() => InvokeAsync(async () => { // The C# state has already moved on, but the DOM still shows the old tree — exactly - // the window document.startViewTransition needs. A brand-new CanvasSpec instance means + // the morph engine needs. A brand-new CanvasSpec instance means // a second create_artifact replaced the artifact wholesale; morph old into new instead // of letting the old tree vanish untransitioned. The very first artifact has no "old" // picture worth morphing, so it keeps the node-by-node reveal choreography. // Except when the swap came from a view switch: DrylCanvasWorkspace owns that morph, and - // nesting two view transitions loses one of the mutations. + // nesting two morphs loses one of the mutations. var switched = Run?.ConsumeSwapMorphSuppression() == true; var swap = !switched && _renderedSpec is not null && !ReferenceEquals(_renderedSpec, Run?.Spec); @@ -147,7 +147,7 @@ if (swap) { - await ViewTransition.RunAsync(() => + await Morph.RunAsync(() => { _renderedSpec = Run?.Spec; StateHasChanged(); diff --git a/code/DRYL.Components.Agents/Tools/DrylUiTools.cs b/code/DRYL.Components.Agents/Tools/DrylUiTools.cs index d850de9..47079aa 100644 --- a/code/DRYL.Components.Agents/Tools/DrylUiTools.cs +++ b/code/DRYL.Components.Agents/Tools/DrylUiTools.cs @@ -61,7 +61,7 @@ private async Task AskChoiceImpl( var dialogOptions = new DialogOptions { AnimateHandoff = true, - HandoffStyle = DrylViewTransitionStyle.DepthGlass, + HandoffStyle = DrylMorphStyle.DepthGlass, }; var reference = await _dialogs.ShowAsync("Choose", p, dialogOptions); var result = await Await(reference, ct); @@ -85,7 +85,7 @@ private async Task AskMultiChoiceImpl( var dialogOptions = new DialogOptions { AnimateHandoff = true, - HandoffStyle = DrylViewTransitionStyle.DepthGlass, + HandoffStyle = DrylMorphStyle.DepthGlass, }; var reference = await _dialogs.ShowAsync("Choose", p, dialogOptions); var result = await Await(reference, ct); @@ -103,7 +103,7 @@ private async Task RequestPermissionImpl( var dialogOptions = new DialogOptions { AnimateHandoff = true, - HandoffStyle = DrylViewTransitionStyle.DepthGlass, + HandoffStyle = DrylMorphStyle.DepthGlass, }; var showTask = _dialogs.ShowConfirmAsync("Permission required", message, "Allow", "Deny", dialogOptions); @@ -128,7 +128,7 @@ private async Task AskTextImpl( var dialogOptions = new DialogOptions { AnimateHandoff = true, - HandoffStyle = DrylViewTransitionStyle.DepthGlass, + HandoffStyle = DrylMorphStyle.DepthGlass, }; var reference = await _dialogs.ShowAsync("Question", p, dialogOptions); var result = await Await(reference, ct); diff --git a/code/DRYL.Components/Components/AI/DrylCanvas.razor b/code/DRYL.Components/Components/AI/DrylCanvas.razor index a83d010..fc85f9e 100644 --- a/code/DRYL.Components/Components/AI/DrylCanvas.razor +++ b/code/DRYL.Components/Components/AI/DrylCanvas.razor @@ -5,7 +5,7 @@ @using Microsoft.Extensions.Logging @inject IJSRuntime JS @inject IServiceProvider Services -@inject IDrylViewTransition ViewTransition +@inject IDrylMorph Morph @implements IAsyncDisposable @* ───────────────────────────────────────────────────────── @@ -30,7 +30,7 @@ ───────────────────────────────────────────────────────── *@ -
@Overlay @@ -211,11 +211,11 @@ private string? _selectionAnnouncement; private bool _reorderAttached; - // Per-instance view-transition-name. Must be document-globally unique while in the DOM: - // a duplicate name voids the entire transition, so two canvases on one page each get + // Per-instance morph name. Must be document-globally unique while in the DOM: a + // duplicate name would move the wrong canvas, so two canvases on one page each get // their own counter value. - private static int _vtSeq; - private readonly string _vtName = FormattableString.Invariant($"dryl-canvas-{Interlocked.Increment(ref _vtSeq)}"); + private static int _morphSeq; + private readonly string _morphName = FormattableString.Invariant($"dryl-canvas-{Interlocked.Increment(ref _morphSeq)}"); /// The shared per-canvas context — form state, pulse tracker and data binder. /// Exposed so a wrapper (e.g. DrylAiCanvas) can read the live field values. @@ -489,7 +489,7 @@ // Unconditional, cheap no-op when no transition is in flight — this is what tells the // browser the "new" state has reached the DOM and it may take the second snapshot. // It also covers a transition a wrapper started around a spec swap. - ViewTransition.SignalRendered(); + Morph.SignalRendered(); if (!firstRender) return; try @@ -549,7 +549,7 @@ // The morph between inline and fullscreen is a view transition, so the CSS only has to // describe the destination, never the movement. private Task ToggleExpandAsync() => - ViewTransition.RunAsync(() => + Morph.RunAsync(() => { _expanded = !_expanded; StateHasChanged(); @@ -569,11 +569,8 @@ // the attribute entirely and the CSS fallback takes over. private string? PopoverMode => _expanded ? "manual" : null; - // Rendered on the root so an expand — and any morph a wrapper runs around a spec swap — - // moves the whole surface. dryl-depth adds the mercury merge; data-vt-depth is the marker - // the JS bridge keys on to lazily inject the #dryl-merge filter. - private string VtStyle => FormattableString.Invariant( - $"view-transition-name: {_vtName}; view-transition-class: dryl-depth"); + // The name sits on the root so an expand — and any morph a wrapper runs around a spec + // swap — moves the whole surface; the depth marker gives it the DepthGlass pass. private string RootCssClass { diff --git a/code/DRYL.Components/Components/AI/DrylCanvasWorkspace.razor b/code/DRYL.Components/Components/AI/DrylCanvasWorkspace.razor index 967a6b3..245f041 100644 --- a/code/DRYL.Components/Components/AI/DrylCanvasWorkspace.razor +++ b/code/DRYL.Components/Components/AI/DrylCanvasWorkspace.razor @@ -2,7 +2,7 @@ @using DRYL.Components.Canvas @using DRYL.Components.Motion @inject IJSRuntime JS -@inject IDrylViewTransition ViewTransition +@inject IDrylMorph Morph @inject IServiceProvider Services @implements IAsyncDisposable @@ -14,7 +14,7 @@ back to what they had. The workspace keeps those views side by side as chips and shows exactly one of them large. - Switching runs through IDrylViewTransition, so the surface morphs into the + Switching runs through IDrylMorph, so the surface morphs into the other view instead of snapping (one state change, one movement), while the shared [data-dryl-ink] indicator glides between the chips — the same primitive DrylTabs uses. @@ -259,7 +259,7 @@ // The morph belongs to the workspace, not to whatever sits in the body: nesting two view // transitions loses one of the mutations (DrylAiCanvas skips its own swap morph for a // switch, see DrylCanvasRun.ConsumeSwapMorphSuppression). - return ViewTransition.RunAsync(() => + return Morph.RunAsync(() => { Workspace.Activate(id); StateHasChanged(); @@ -287,14 +287,14 @@ () => FormattableString.Invariant($"Restored version {index + 1} of {Versions.Count}")); } - // A history step is a state change, so it is a movement: the same view-transition layer the + // A history step is a state change, so it is a movement: the same mow-transition layer the // view switch uses morphs the artifact instead of blinking it away. private async Task HistoryStep(Func step, Func announce) { if (Workspace is null) return; var moved = false; - await ViewTransition.RunAsync(() => + await Morph.RunAsync(() => { moved = step(); if (moved) StateHasChanged(); @@ -371,7 +371,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { // Tells the browser the switched-to view has reached the DOM (a cheap no-op otherwise). - ViewTransition.SignalRendered(); + Morph.SignalRendered(); if (!ShowBar) { diff --git a/code/DRYL.Components/Components/Data/DrylTable.razor b/code/DRYL.Components/Components/Data/DrylTable.razor index c625635..229ed9b 100644 --- a/code/DRYL.Components/Components/Data/DrylTable.razor +++ b/code/DRYL.Components/Components/Data/DrylTable.razor @@ -418,7 +418,7 @@ @ @{ var rowIndex = ReorderColumnVisible ? _view.IndexOf(item) : -1; } - /// Stable per-row id used to build each row's view-transition-name when + /// Stable per-row id used to build each row's morph name when /// is on. Falls back to GetHashCode() — stable /// for records and immutable rows, but pass an explicit selector (e.g. a database /// key) when row instances mutate in place. Ids must be unique per row; the value @@ -858,8 +858,8 @@ private int? _dragOverIndex; private int? _pendingFocusGrip; private ElementReference _rootEl; - private DrylViewTransition? _rowTransition; - private bool _rowTransitionBusy; + private DrylMorphEngine? _rowMorph; + private bool _rowMorphBusy; // Inline editing — one row at a time. _editBuffer is the item handed to the column // EditTemplates: a clone when CloneRow is set (so cancel reverts), otherwise the live item. @@ -940,18 +940,20 @@ (Ai == AiState.Streaming || Ai == AiState.Generated) && !Virtualize && GroupBy is null && DataProvider is null; - // Rows carry a view-transition-name whenever either morph mode could run, so the - // ::view-transition-group morph has stable per-row targets to glide between. + // Rows carry a morph name whenever either morph mode could run, so the engine has + // stable per-row targets to glide between. FLIP is the classic technique for a list + // reorder: each row is measured, the list re-renders, and every row that moved + // travels from its old position to its new one. private bool VtMorphActive => AnimateReorderActive || StreamGlideActive; - // Per-instance scope so view-transition-names stay document-globally unique when + // Per-instance scope so morph names stay document-globally unique when // several morph-enabled tables share a page — RowVtId only guarantees uniqueness // within one table, but the name must be unique across the whole document or the - // browser aborts the transition ("duplicate view-transition-name"). + // engine would move the wrong row (a duplicate name). private readonly string _vtScope = "t" + Guid.NewGuid().ToString("N")[..8]; - private string? RowVtStyle(TItem item) => - VtMorphActive ? $"view-transition-name: {_vtScope}-row-{RowVtId(item)}" : null; + private string? RowMorphName(TItem item) => + VtMorphActive ? $"{_vtScope}-row-{RowVtId(item)}" : null; private string RowVtId(TItem item) { @@ -961,7 +963,7 @@ return SanitizeVtName(raw); } - // view-transition-name must be a CSS custom-ident: keep [A-Za-z0-9_-], map the + // A morph name is an attribute value, but it is kept ident-safe: [A-Za-z0-9_-], map the // rest to '_'. The "tbl-row-" prefix guarantees a valid ident start. private static string SanitizeVtName(string raw) { @@ -982,16 +984,16 @@ // transitions serialise, so overlapping starts would only skip anyway. private async Task RunRowTransitionAsync(Func mutate) { - if (!VtMorphActive || _rowTransitionBusy) + if (!VtMorphActive || _rowMorphBusy) { await mutate(); return; } - _rowTransition ??= new DrylViewTransition(JS); - _rowTransitionBusy = true; + _rowMorph ??= new DrylMorphEngine(JS); + _rowMorphBusy = true; try { - await _rowTransition.RunAsync(async () => + await _rowMorph.RunAsync(async () => { await mutate(); StateHasChanged(); @@ -999,7 +1001,7 @@ } finally { - _rowTransitionBusy = false; + _rowMorphBusy = false; } } @@ -1143,7 +1145,7 @@ { // Tell an in-flight view transition that the mutated state reached the DOM // (no-op when none is running). - _rowTransition?.SignalRendered(); + _rowMorph?.SignalRendered(); if (firstRender && !string.IsNullOrEmpty(PersistStateKey)) { @@ -2109,7 +2111,7 @@ catch (Microsoft.JSInterop.JSException) { /* circuit gone */ } catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ } } - _rowTransition?.Dispose(); + _rowMorph?.Dispose(); _dotNetRef?.Dispose(); } } diff --git a/code/DRYL.Components/Components/Layout/DrylMorph.razor b/code/DRYL.Components/Components/Layout/DrylMorph.razor index 395ff02..a550103 100644 --- a/code/DRYL.Components/Components/Layout/DrylMorph.razor +++ b/code/DRYL.Components/Components/Layout/DrylMorph.razor @@ -1,6 +1,6 @@ @namespace DRYL.Components @using DRYL.Components.Motion -@inject IDrylViewTransition ViewTransition +@inject IDrylMorph Morph @* ───────────────────────────────────────────────────────── DrylMorph — a transition ID for any content. @@ -8,65 +8,63 @@ Marks its content as a shared element: content that exists in two views and should travel between them instead of disappearing and reappearing. Wrap the same Name around the card in an - overview and around the heading of the detail view, and the browser - morphs position, size and opacity from one to the other while the - switch runs inside a view transition. + overview and around the heading of the detail view, and it glides + from one to the other while the page around it stays untouched. - The consumer starts the transition through IDrylViewTransition; the - hull reports its own render back, so SignalRendered() never has to - be written by hand. + The consumer starts the morph through IDrylMorph; the hull reports + its own render back, so SignalRendered() never has to be written by + hand. Usage: -- the overview -- @foreach (var p in products) { - + @p.Title } -- the detail: same Name, so the two are one object -- + Style="DrylMorphStyle.DepthGlass">

@current.Title

-- and the switch itself -- - await ViewTransition.RunAsync(() => { openId = p.Id; StateHasChanged(); }); + await Morph.RunAsync(() => { openId = p.Id; StateHasChanged(); }); Renders one element and nothing else — no styling, no colour, no - frost. The morph's duration, easing and DepthGlass merge are the - shared ::view-transition-* rules in dryl.css. + frost. The movement's duration and easing are the shared motion + tokens; the hull adds no visual of its own. - view-transition-name has no effect on a display:contents box, so the - element is real and takes part in its parent's layout. That is what - As is for: be an li in a list, a tr in a table. + The element is real and takes part in its parent's layout, because + it is the thing being measured and moved. That is what As is + for: be an li in a list, a tr in a table. ───────────────────────────────────────────────────────── *@ @Render @code { - /// The transition ID. Two elements carrying the same name in the old and the - /// new view are morphed into one another. A name must be unique among the elements - /// live at the moment the transition starts — a duplicate makes the browser skip the - /// morph silently; see . + /// The morph ID. Two elements carrying the same name before and after a + /// state change are treated as the same object, and the second is animated from + /// where the first was. A name must be unique among the elements live at the moment + /// the morph starts; see . [Parameter] public string? Name { get; set; } - /// How much of the morph vocabulary this element gets — - /// (default, viscous shape settle) or - /// (the full translucency-and-merge - /// choreography). Ignored while the element claims no name. - [Parameter] public DrylViewTransitionStyle Style { get; set; } = DrylViewTransitionStyle.Glide; + /// How much of the "Depth Glass" vocabulary this element gets while it + /// moves — (default) or + /// . Ignored while the element claims no + /// name. + [Parameter] public DrylMorphStyle Style { get; set; } = DrylMorphStyle.Glide; /// The HTML tag rendered as this component's root. Defaults to "div"; /// set it so the hull is valid where it sits ("li", "tr", /// "article", …). [Parameter] public string As { get; set; } = "div"; - /// Whether this instance currently claims . Set false on the - /// entries of an overview that are not the morph target, so a long list neither - /// duplicates a name nor pays for a snapshot per entry. + /// Whether this instance currently claims . Set false on + /// the entries of an overview that are not the morph target, so a long list neither + /// duplicates a name nor pays to be measured. [Parameter] public bool Active { get; set; } = true; /// The content that morphs. @@ -81,12 +79,13 @@ public IDictionary? AdditionalAttributes { get; set; } // Null unless this instance is actively claiming a name — an inactive or unnamed - // hull renders no view-transition attribute at all and costs nothing. - private string? EffectiveName => Active ? Name : null; + // hull is not a morph target and is never measured. + private string? EffectiveName => + Active && !string.IsNullOrWhiteSpace(Name) ? Name : null; - private string? VtStyle => ViewTransitionAttributes.Style(EffectiveName, Style); - - private string? VtDepthMarker => ViewTransitionAttributes.DepthMarker(EffectiveName, Style); + // Marker the JS engine keys on to give this target the DepthGlass treatment. + private string? DepthMarker => + EffectiveName is not null && Style == DrylMorphStyle.DepthGlass ? "" : null; // A .razor cannot both carry markup and choose its tag at runtime, so the element is // built here — the same shape DrylTypo uses for its As parameter. @@ -94,19 +93,19 @@ { builder.OpenElement(0, As); if (!string.IsNullOrWhiteSpace(Class)) builder.AddAttribute(1, "class", Class); - var style = VtStyle; - if (style is not null) builder.AddAttribute(2, "style", style); - var depth = VtDepthMarker; - if (depth is not null) builder.AddAttribute(3, "data-vt-depth", depth); + var name = EffectiveName; + if (name is not null) builder.AddAttribute(2, "data-dryl-morph", name); + var depth = DepthMarker; + if (depth is not null) builder.AddAttribute(3, "data-dryl-morph-depth", depth); builder.AddMultipleAttributes(4, AdditionalAttributes); builder.AddContent(5, ChildContent); builder.CloseElement(); }; - // The half of the timing contract that is easy to forget: the browser holds the old - // frame until the mutated state has reached the DOM, and this is what tells it that - // it has. Unconditional by contract — a cheap no-op when no transition is in flight, + // The half of the contract that is easy to forget: the engine cannot measure the new + // geometry until the mutated state has reached the DOM, and this is what tells it + // that it has. Unconditional by contract — a cheap no-op when no morph is in flight, // and reported even while unnamed, so an instance that is only the *destination* of a // morph still closes the loop. - protected override void OnAfterRender(bool firstRender) => ViewTransition.SignalRendered(); + protected override void OnAfterRender(bool firstRender) => Morph.SignalRendered(); } diff --git a/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor b/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor index 6789caf..35632dc 100644 --- a/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor +++ b/code/DRYL.Components/Components/Providers/DrylRouteTransition.razor @@ -2,7 +2,7 @@ @using DRYL.Components.Motion @using Microsoft.AspNetCore.Components.Routing @inject NavigationManager Nav -@inject IDrylViewTransition ViewTransition +@inject IDrylMorph Morph @implements IDisposable @* ───────────────────────────────────────────────────────── @@ -65,7 +65,7 @@ // route's first render is also exactly the "morph onto the skeleton" policy: // a page still loading its data morphs into its placeholder rather than // freezing the page it came from. - ViewTransition.SignalRendered(); + Morph.SignalRendered(); } } @@ -85,7 +85,10 @@ // returns. if (ShouldMorph?.Invoke(context.TargetLocation) is not false) { - ViewTransition.BeginNavigation(Timeout); + // Deliberately not awaited: the navigation must not wait for the morph, and + // awaiting here would deadlock — the morph waits for the new page's render, + // which cannot happen until this handler returns. + _ = Morph.BeginNavigationAsync(Timeout); } return ValueTask.CompletedTask; } diff --git a/code/DRYL.Components/Components/Surfaces/DrylCard.razor b/code/DRYL.Components/Components/Surfaces/DrylCard.razor index 2af3ea5..70bbe18 100644 --- a/code/DRYL.Components/Components/Surfaces/DrylCard.razor +++ b/code/DRYL.Components/Components/Surfaces/DrylCard.razor @@ -27,8 +27,6 @@
@if (_aura.Present) { @@ -88,23 +86,7 @@ /// Extra CSS class(es) merged onto the card's own classes. [Parameter] public string? Class { get; set; } - /// - /// Marks this card as a shared-element morph endpoint for view transitions - /// (): renders view-transition-name - /// with this value. Must be a valid CSS identifier (letters, digits, -, - /// _) and unique among elements simultaneously in the DOM — a duplicate - /// name voids the entire transition. Supply a stable per-instance id, same - /// discipline as @key. Null (default) opts out. - /// - [Parameter] public string? ViewTransitionName { get; set; } - /// - /// How much "Depth Glass" a morph of this card gets — - /// (default, viscous shape settle only) or - /// (adds the translucency pulse + mercury merge; reserve for rare, high-meaning merges). - /// Ignored unless is set. - /// - [Parameter] public DrylViewTransitionStyle ViewTransitionStyle { get; set; } = DrylViewTransitionStyle.Glide; private ElementReference _el; private bool _tracking; @@ -128,14 +110,6 @@ _aura.Sync(EffectiveAi, () => InvokeAsync(StateHasChanged)); } - // What makes an element a morph target is built in one place, shared with - // DrylMorph — the marker attribute included, which is what the JS bridge keys - // on to lazily inject the #dryl-merge filter. - private string? VtDepthMarker => - DRYL.Components.Motion.ViewTransitionAttributes.DepthMarker(ViewTransitionName, ViewTransitionStyle); - - private string? VtStyle => - DRYL.Components.Motion.ViewTransitionAttributes.Style(ViewTransitionName, ViewTransitionStyle); private string CssClass { diff --git a/code/DRYL.Components/DRYL.Components.csproj b/code/DRYL.Components/DRYL.Components.csproj index 581b1ca..02cd703 100644 --- a/code/DRYL.Components/DRYL.Components.csproj +++ b/code/DRYL.Components/DRYL.Components.csproj @@ -5,7 +5,7 @@ DRYL.Components - 2.26.0 + 3.0.0 DRYL — Blazor Component Library DRYL is a dark, glassy, AI-native UI component library for Blazor Server and Blazor WebAssembly. Token-driven, accessible by default, with a shared AI-state visual vocabulary (Active / Thinking / Streaming / Generated) across every surface — and zero JavaScript framework dependencies. blazor;blazor-components;ui;components;razor;dark;glassmorphism;ai;design-system;blazor-server;blazor-webassembly diff --git a/code/DRYL.Components/Dialogs/DialogOptions.cs b/code/DRYL.Components/Dialogs/DialogOptions.cs index 76eb5c3..4ef02af 100644 --- a/code/DRYL.Components/Dialogs/DialogOptions.cs +++ b/code/DRYL.Components/Dialogs/DialogOptions.cs @@ -31,8 +31,7 @@ public sealed class DialogOptions /// /// When true, a dialog opened while a sibling is still closing (the sequential /// "agent handoff" pattern — see the Sequential demo) morphs into the new one via - /// the browser's - /// View Transition API (): + /// the browser's morph engine (): /// the dialog shell glides to its new size/position while its title, body and /// footer cross-fade independently, instead of the default CSS cross-fade /// (predecessor plays its exit while the successor enters). Off by default — @@ -45,12 +44,12 @@ public sealed class DialogOptions /// /// Morph tier for the transition. Defaults to - /// — a dialog handoff is exactly + /// — a dialog handoff is exactly /// the rare, high-meaning merge that tier is for: the mercury-merge + translucency /// pulse makes the content swap read as a deliberate change even when the dialog's /// size barely moves (e.g. two confirm dialogs of similar length), instead of - /// looking like a plain text cross-fade. Set to + /// looking like a plain text cross-fade. Set to /// for the cheaper shape-only morph. Ignored unless is true. /// - public DrylViewTransitionStyle HandoffStyle { get; set; } = DrylViewTransitionStyle.DepthGlass; + public DrylMorphStyle HandoffStyle { get; set; } = DrylMorphStyle.DepthGlass; } diff --git a/code/DRYL.Components/Dialogs/DrylDialog.razor b/code/DRYL.Components/Dialogs/DrylDialog.razor index cd16b7a..b019034 100644 --- a/code/DRYL.Components/Dialogs/DrylDialog.razor +++ b/code/DRYL.Components/Dialogs/DrylDialog.razor @@ -19,15 +19,15 @@ role="dialog" aria-modal="true" aria-labelledby="@_titleId" - style="@RootVtStyle" - data-vt-depth="@VtDepthMarker" + data-dryl-morph="@MorphName("")" + data-dryl-morph-depth="@MorphDepthMarker" @attributes="AdditionalAttributes"> @if (ShowHeader) { -
+
@if (!string.IsNullOrEmpty(Icon)) { @@ -50,13 +50,13 @@
} -
+
@ChildContent
@if (ActionContent is not null) { -