From 4cf3c9ab11280326717cff1eec30d641c20faf49 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 16:16:20 +0200 Subject: [PATCH 01/15] idea(I8): raise a regression net for what the library looks like 1066 tests assert markup; none of them can see a colour. For a library whose product is its appearance that is the gap in the middle, and the popover spec already records it in its own words as 'no regression net'. Three findings on 2026-08-20 alone rested entirely on measurements taken by hand in a browser: the portal's discarded scroll, Block's 420/383/38, and I7 A's inverted loudness. Each was real, each is fixed, none is guarded. Draft, not Ready: three open points are the Product Owner's. The first is a CODE-03 sign-off that IDEA-05 says belongs in this dialogue rather than in a commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QoXQ4L9XzgZJv5X9sZZb1L --- ...ion net for what the library looks like.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 ideas/I8 A regression net for what the library looks like.md diff --git a/ideas/I8 A regression net for what the library looks like.md b/ideas/I8 A regression net for what the library looks like.md new file mode 100644 index 0000000..27a811a --- /dev/null +++ b/ideas/I8 A regression net for what the library looks like.md @@ -0,0 +1,211 @@ +# A regression net for what the library looks like + +## Meta +- **State:** Draft + +## Problem + +**Nothing in this repository checks what DRYL looks like.** 1066 tests assert +markup: classes, attributes, rendered children, the state machine behind an exit +animation. Not one of them can see a colour, a size, a shadow or a layout. For a +library whose product *is* its appearance, that is the gap in the middle. + +It is not hypothetical, and the repository already says so in its own words. +`specs/E11 Surfaces/F1 DrylPopover.md` carries, under **Recorded gaps**: + +> **Most of this file has no regression net.** … the portal, the placement, the +> dismissal, the focus behaviour, the trigger's ARIA claim — still rests on +> reading the code and on measurement in a browser, because bUnit executes no +> `dryl.js` and manages no real focus. Tests that claimed otherwise would be +> lying, which is why there are none. + +Three separate findings in the single session of 2026-08-20 rested entirely on +measurements taken by hand in a browser and recorded in prose: + +| Finding | What proved it | What guards it now | +|---|---|---| +| The portal discarded the scroll state of what it moved | `scrollTop` 0 vs. 310/854 at `/components/timepicker` | nothing | +| `Block` stretches, the caret keeps its width | 420 / 383 / 38 px measured | nothing | +| A toggled `Bold` read *quieter* than an untoggled one (`I7 A`) | by eye, both modes | nothing | + +Each was real, each was fixed, and **each can silently come back tomorrow.** A +change to one token in `dryl.css` ripples through every component in the +library; nothing would report it. That is the shape of the risk: not a component +breaking loudly, but the whole system drifting quietly. + +**What happens today without the feature.** Visual correctness is established by +whoever is at the keyboard opening the docs site and looking — which is the +maintainer, or an agent that will not be in the room next week. `DESIGN-02` says +both colour modes are checked by eye and has no exception route, so every change +that touches appearance costs a manual pass, and every manual pass is as good as +the attention on it that day. + +**Target role:** the maintainer, as the reviewer who has to believe a change is +safe; and any agent working on the library, which today has no way to prove a +CSS change did not move something three components away. + +## Solution Idea + +Render the docs site in CI, screenshot it, diff against approved baselines, fail +the PR on an unexplained difference — and make updating a baseline a deliberate, +reviewable act. + +The shape is conventional. The two decisions that are *not* conventional, and +that this idea exists to settle, are below. + +### The hard part: DRYL moves, on purpose + +Rule 2.12 — **every component is deliberately animated** — is what makes this +harder here than in an ordinary component library. Measured in `code/`: + +- **147 `infinite` animations** across the CSS. +- Only **12** of the `prefers-reduced-motion: reduce` blocks in `dryl.css` + actually set `animation: none`; the other guards drop a transition, hide a + pseudo-element or shorten something. + +So **`prefers-reduced-motion` alone does not freeze the page**, and a screenshot +taken at an arbitrary moment of a 6-second `ai-comet-spin` is a coin toss. +Playwright's `animations: "disabled"` does freeze it — finite animations are +fast-forwarded to their end state, infinite ones are cancelled to their initial +state — which makes the shot reproducible. + +**The price is worth naming out loud rather than discovering later: this net +catches composition, not motion.** Layout, colour, spacing, radius, shadow, +glass, the shape of a state — all covered. Whether the comet actually spins, +whether the exit animation runs, whether a glide lands where it should — not +covered, and still resting on browser measurement. An honest name for this +feature is *appearance regression*, not *visual regression*, and it should not be +sold to a future reader as more than it is. + +### The second hard part: DRYL is made of glass + +`backdrop-filter` blur is the one CSS feature whose rasterisation differs most +between platforms, GPUs and driver versions — and it is on `--glass-fx-float` +and `--glass-fx-flow`, which is to say on most floating and in-flow surfaces in +the library. A baseline captured on the maintainer's Windows machine will not +match a Linux CI runner, and may not match the same runner after an image +update. + +That is survivable but it dictates the design: **baselines are captured by the +CI container and only by it**, never locally, and the container is pinned. The +local workflow becomes "push, let CI produce the diff", which is slower than +running it on your own machine and is the price of the numbers meaning anything. +A per-pixel tolerance is the usual escape hatch; it should be set *low* and +argued, because a tolerance wide enough to absorb GPU differences is also wide +enough to absorb the accent hairline that `I6` spent a whole idea on. + +## Scope + +- **In scope:** + - A screenshot suite driving the docs site (`DRYL.Website`) against the + working-tree library, in **both colour modes** (`DESIGN-02`). + - Baselines stored, reviewed and updated deliberately. + - A CI job that fails a pull request on an unapproved difference, with the + diff image reachable from the run. + - The decision of which repository owns it and what it gates. + +- **Out of scope:** + - **Motion.** See above — frozen frames cannot assert an animation, and + pretending otherwise would be the same lie the popover spec refused to tell. + - **Accessibility.** Contrast, ARIA and keyboard belong to the a11y audit + already on the backlog; a screenshot proves none of them. + - **Responsive breakpoints beyond whatever widths are chosen here.** The + responsive foundation is container-query-first and deserves its own + treatment rather than a bolt-on. + - **`DRYL.Portfolio`.** It vendors GSAP and three.js and is a consumer, not + the library. + - Retrofitting baselines as *specifications*. A baseline records what the + library looks like today, including its current defects; it is a change + detector, not a statement that the current appearance is correct. + +## Impact + +- **Harness:** one blocker, and it is smaller than it first looks. + `CODE-03` forbids external **runtime** dependencies — "zero npm packages, zero + JS frameworks", with `Markdig` as the single approved exception — and its + documented check is `rg -n ' Date: Thu, 20 Aug 2026 16:26:26 +0200 Subject: [PATCH 02/15] idea(I8): withdraw the CODE-03 blocker, keep the build decision under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Product Owner pointed out that Playwright is already driving this UI daily, which is true and which the first draft did not account for. The distinction that survives: an MCP server lives in an agent's harness and leaves no trace in the repository, while a binds every clone and every CI run. The distinction that does not: that CODE-03 reaches this at all. That rule is about what ships, and its check reads code/*/*.csproj, never tests/. What is left is a build decision rather than a sign-off, and it has a better answer than an exception: the suite becomes its own project outside DRYL.slnx with its own CI job, so 'dotnet test DRYL.slnx' stays self-contained and the CLAUDE.md stage 5 evidence bar is not touched. Recorded rather than quietly edited away — a withdrawn blocker is worth as much to a later reader as an approved one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QoXQ4L9XzgZJv5X9sZZb1L --- ...ion net for what the library looks like.md | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/ideas/I8 A regression net for what the library looks like.md b/ideas/I8 A regression net for what the library looks like.md index 27a811a..1676607 100644 --- a/ideas/I8 A regression net for what the library looks like.md +++ b/ideas/I8 A regression net for what the library looks like.md @@ -120,19 +120,31 @@ enough to absorb the accent hairline that `I6` spent a whole idea on. ## Impact -- **Harness:** one blocker, and it is smaller than it first looks. - `CODE-03` forbids external **runtime** dependencies — "zero npm packages, zero - JS frameworks", with `Markdig` as the single approved exception — and its - documented check is `rg -n '
+ What is real is a **build decision**, and it is smaller and more concrete: + `Microsoft.Playwright` downloads browser binaries (~150 MB) on first use, and + without them the tests **fail** rather than skip. If the screenshot suite + joined `tests/DRYL.Components.Tests`, then `dotnet test DRYL.slnx` — the + command `CLAUDE.md` stage 5 names as the evidence bar, and the command CI + runs — would stop being self-contained on a fresh clone, and every + contributor would pay that for a one-line change. +

+ **So it does not join it.** The suite becomes its own project, deliberately + **not** listed in `DRYL.slnx` (which holds exactly three today: the two + libraries and the test project), driven by its own CI job. Then the evidence + bar is untouched, `CODE-03`'s check is untouched, nothing reaches a `.nupkg`, + and the whole cost is one CI job that pulls a browser. No new token, no new + animation, no new `AiState`, and nothing for a maintainer to sign off that a + build layout does not already answer. - **Specs:** none is contradicted, and one is directly served — `specs/E11 Surfaces/F1 DrylPopover.md` names this feature by description as the route that would cover its untestable ground. If this lands, that recorded gap @@ -188,17 +200,24 @@ components before knowing whether the approach survives the glass problem at all - 2026-08-20: Raised as an idea rather than started as work, per `IDEA-01`. It is a new capability, it is not specified anywhere, and it carries a `CODE-03` question that `IDEA-05` says belongs in this dialogue and not in a commit. +- 2026-08-20: The `CODE-03` question was raised by the Tech Lead and then + withdrawn by it, after the Product Owner pointed out that Playwright is + already driving this UI daily. The distinction that survives: the MCP server + lives in an agent's harness and leaves no trace in the repository, while a + `` binds every clone and every CI run. The distinction that + did **not** survive: that this is a `CODE-03` matter at all. Recorded because + a withdrawn blocker is worth as much to a later reader as an approved one. - 2026-08-20: Chosen from the backlog over three alternatives (phase C specs, `DrylAiScope` cascading, the a11y audit) by the Product Owner, on the argument that three findings in one session rested on unrepeatable manual measurement. ## Open Points -1. **`CODE-03` sign-off.** `Microsoft.Playwright` in `tests/` — a `Microsoft.*` - .NET NuGet, outside the rule's documented check, shipped to no consumer, and - costing a ~150 MB browser download in CI plus a `dotnet test` that no longer - runs offline out of the box. Approved, approved with conditions, or refused? - Everything else here depends on this answer. +1. ~~**`CODE-03` sign-off.**~~ **Closed on 2026-08-20** — see `## Impact`. The + rule does not reach a test-time NuGet, and the real question underneath it + (may `dotnet test` require a browser?) is answered by keeping the suite out + of `DRYL.slnx` rather than by an exception. Confirmation that this is the + wanted resolution is still the Product Owner's, but nothing else waits on it. 2. **Which shape** — 1, 2 or 3 above. Equivalently: *should a merge to `main` here be blockable by a screenshot diff?* Given that such a merge publishes to NuGet, my answer is yes, but it makes the net load-bearing on release day and From 0aaf67fa80d4860377eb366d4117f772ddcb7208 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 20:53:34 +0200 Subject: [PATCH 03/15] spec(E11): one bare path per Source line (SPEC-03) --- specs/E11 Surfaces/F1 DrylPopover.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/E11 Surfaces/F1 DrylPopover.md b/specs/E11 Surfaces/F1 DrylPopover.md index 1d45a7f..0a9d276 100644 --- a/specs/E11 Surfaces/F1 DrylPopover.md +++ b/specs/E11 Surfaces/F1 DrylPopover.md @@ -5,7 +5,7 @@ - **Source:** code/DRYL.Components/Components/Surfaces/DrylPopover.razor code/DRYL.Components/Components/Surfaces/DrylPopover.razor.css code/DRYL.Components/Components/Surfaces/PopoverPlacement.cs - code/DRYL.Components/wwwroot/js/dryl.js (the `dryl.popover` module) + code/DRYL.Components/wwwroot/js/dryl.js ## User Story From 48af29a755c4df5ddc698d7935c78696aa411cef Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 21:01:15 +0200 Subject: [PATCH 04/15] spec(E4): the shared half of the chart family, in writing The four charts share two record types, two base classes and one palette. _Api.md now carries that contract - ChartSeries, ChartSegment, the two base classes, the ShowLegend and ValueFormat rules and the six-slot ceiling. _Interop.md records the category's zero-JS position and the one cleanup duty it does have (the AI aura's exit timer). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- specs/E4 Charts/_Api.md | 139 ++++++++++++++++++++++++++++++++++-- specs/E4 Charts/_Interop.md | 36 ++++++++-- 2 files changed, 164 insertions(+), 11 deletions(-) diff --git a/specs/E4 Charts/_Api.md b/specs/E4 Charts/_Api.md index 5cb41d9..78ec81f 100644 --- a/specs/E4 Charts/_Api.md +++ b/specs/E4 Charts/_Api.md @@ -5,11 +5,138 @@ part of the data contract the 1.0 freeze binds. **Source folder:** `code/DRYL.Components/Components/Data/Charts/` -*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`).* +The category holds four components — `DrylLineChart`, `DrylBarChart`, +`DrylAreaChart` and `DrylDonutChart` — and they share more than they own. Two +record types carry the data, two base classes carry the parameters, and one +palette carries the colors. Everything below is the shared half; a component +spec adds only what is genuinely its own. -## Shared types +`DrylSparkline` is **not** in this category. It lives in `E5 Data` because its +source file does (`SPEC-02` derives the category from the path), and it shares +none of the types below. -*(phase C)* +## `ChartSeries` + +One data series for the three cartesian charts. A `sealed record`. + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Name` | `string` | — | Series name, shown in the legend and in tooltip rows. | +| `Data` | `IReadOnlyList` | — | The series values, one per category. | +| `ColorSlot` | `int?` | `null` | Pinned palette slot (1–6). Defaults to the series' position in the list. | + +## `ChartSegment` + +One segment of `DrylDonutChart`. A `sealed record`. + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Label` | `string` | — | Segment name, shown in the legend and the tooltip. | +| `Value` | `double` | — | Segment value; its share of the total drives the sweep angle. | +| `ColorSlot` | `int?` | `null` | Pinned palette slot (1–6). Defaults to the segment's position in the list. | + +`ColorSlot` exists for one reason on both types: a series or segment keeps its +color when the ones before it are filtered away. Without it, hiding the first +series silently recolors every remaining one. + +## `DrylChartBase` + +The abstract base every chart in this category derives from, directly +(`DrylDonutChart`) or through `DrylCartesianChartBase`. It derives from +`DrylAiAware`, so the `Ai` and `Aura` parameters below are the library's +standard AI opt-in (`AI-03`), not chart-specific. + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Height` | `int` | `260` | Chart height in pixels. Width always fills the container. | +| `ShowLegend` | `bool?` | `null` | Legend visibility. `null` is automatic — see below. | +| `ValueFormat` | `string?` | `null` | Display format for axis ticks and tooltip values — see below. | +| `AriaLabel` | `string?` | `null` | Accessible summary label. Defaults to a generated description. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the chart's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the chart root. | +| `Ai` | `AiState` | `AiState.None` | Inherited from `DrylAiAware`. AI ambient state; an explicit value wins over a surrounding `DrylAiScope`. | +| `Aura` | `AiAura?` | `null` | Inherited from `DrylAiAware`. Pins the aura variant; `null` inherits the scope. | + +`Height` is a raw `int` of pixels rather than a token, and that is deliberate: +the caller sizes a chart to its slot in *their* layout, which no library token +can know. It reaches CSS as the `--chart-h` custom property, never as a +hardcoded rule. + +### `ShowLegend` + +`null` — the default — resolves per component, because the useful default is +not the same for all four: + +- The cartesian charts show the legend for two or more series and hide it for + one, since a single series is already named by the surrounding title. +- `DrylDonutChart` shows it always: its segments have no axis to label them, + so without the legend the colors mean nothing. + +`true` and `false` are absolute in both cases. + +### `ValueFormat` + +One string, two accepted shapes: + +- A .NET format string applied to the value directly — `"N0"`, `"C0"`, `"0.0"`. +- A template containing the `{value}` placeholder, where the formatted number is + substituted in place — `"{value}%"`, `"€{value} k"`. The placeholder may carry + an inner .NET format after a colon: `"{value:0.0}"`. + +`null` formats with `"0.##"`. Display values are **culture-aware on purpose** — +they are read by a human. The numbers written into SVG path data, percentages +and custom properties are a separate concern and always invariant. + +An unparseable format never throws. A model-invented specifier — `"K"` for +thousands, `"{value:Q}"` — falls back to the default numeric format, because a +chart showing an unstyled number beats a chart that takes the circuit down +mid-render. + +## `DrylCartesianChartBase` + +Derives from `DrylChartBase`; the base of `DrylLineChart`, `DrylBarChart` and +`DrylAreaChart`. Adds the axis half of the contract. + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Series` | `IReadOnlyList?` | `null` | The series to plot. | +| `Labels` | `IReadOnlyList?` | `null` | Category labels for the x-axis and the tooltip titles. | +| `ShowXAxis` | `bool` | `true` | Show the x-axis label row. | +| `ShowYAxis` | `bool` | `true` | Show the y-axis tick column. | +| `ShowGridLines` | `bool` | `true` | Show horizontal gridlines at the y-ticks. | +| `YMin` | `double?` | `null` | Fixed lower bound of the y-range. `null` is automatic. | +| `YMax` | `double?` | `null` | Fixed upper bound of the y-range. `null` is automatic. | + +Shared behaviour the three cartesian charts inherit rather than restate: + +- **The y-range** is taken from the data, widened to "nice" round tick values, + and overridden per bound by `YMin`/`YMax` where they are set. +- **Missing labels** fall back to the 1-based category index, so `Labels` is + optional and a short `Labels` list is not an error. +- **X labels are thinned** so they never collide; every category still gets its + own hover column and tooltip. +- **A zero line** is drawn only when the range spans zero. +- **`AriaLabel` defaults** to the series names, comma-separated. + +## The palette + +Series and segment colors come from the six chart slots — `--chart-1` … +`--chart-6` in `code/DRYL.Components/wwwroot/dryl.css` — resolved from +`ColorSlot` where it is set and from list position otherwise. Slots 1 and 2 are +derived from the theme accents so a themed app's charts follow it; slots 3–6 are +fixed anchors, tuned per color mode. + +**Beyond slot 6 the color stops.** A seventh series renders in `--fg-dim` +instead of cycling back to slot 1, because two series in the same color is a +misread chart, and a muted one is visibly "not in the palette". Six is the +documented ceiling of the vocabulary, not an accident of the token list. + +## Internal, not public API + +`ChartFrame`, `CartesianLayout`, `AxisTick`, `TooltipRow`, `LegendItem`, +`HoverColumn` and `ChartMath` live under +`code/DRYL.Components/Components/Data/Charts/Internal/` in the +`DRYL.Components.Internal` namespace. Several are `public` only because Blazor +requires a component `[Parameter]`'s type to be public. They carry no +compatibility promise and no spec of their own; the behaviour they implement is +specified as behaviour of the four components. diff --git a/specs/E4 Charts/_Interop.md b/specs/E4 Charts/_Interop.md index fe0d6e2..3f247f5 100644 --- a/specs/E4 Charts/_Interop.md +++ b/specs/E4 Charts/_Interop.md @@ -4,16 +4,42 @@ 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.* - ## Interop -none *(phase C)* +**none** — and this is a property of the category, not a gap in it. + +All four charts render from server-computed geometry into SVG paths and +percent-positioned HTML. Hover crosshairs, tooltips, the donut's outward lift +and every entrance animation are CSS. No chart calls `IJSRuntime`, imports a +module from `dryl.js`, measures an element or observes a resize. + +Two consequences worth stating, because they are what the decision bought: + +- **Static prerendering renders a complete chart.** There is no post-render + pass that fills anything in, so no chart needs the `_attached` dispose guard + the JS-interop components carry (`CODE-05`). +- **Hovering costs no roundtrip.** On Blazor Server, moving the pointer across a + chart produces no circuit traffic at all — the tooltip is a CSS + `:hover`/`:focus-visible` state, not an event handler. + +Responsiveness follows the same rule. The plot area is percent-based and the +donut sizes itself against a container query on its own root; neither needs a +resize observer. ## Services -none *(phase C)* +**none.** No chart injects a service, and the category registers none. The AI +aura resolves through the `DrylAiScope` cascading parameter, which is a +component cascade rather than DI. ## Cleanup -none *(phase C)* +Every chart derives from `DrylChartBase`, which implements `IDisposable` for one +reason: the shared `AuraLifecycle` that keeps the AI aura mounted for one +`--dur-slow` beat after the state drops to `AiState.None`, so it dissolves +instead of snapping. `DrylChartBase.Dispose` disposes it, cancelling the pending +exit timer. + +A subclass that needs its own disposal overrides nothing today; if one ever +does, it must keep the base disposal — dropping it leaks a timer that will call +back into a component that is gone. From b55cf57be0773f295959a72c1d7a7e9a3df14fda Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 21:01:15 +0200 Subject: [PATCH 05/15] spec(E4): all four chart components, reverse-engineered (19/127) F1 DrylLineChart, F2 DrylBarChart, F3 DrylAreaChart, F4 DrylDonutChart. Each carries its own criteria only; the shared contract stays in _Api.md. State: Implemented - the criteria were written from the code and the CSS, not from the doc comments, and 1066 tests stay green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- specs/E4 Charts/F1 DrylLineChart.md | 194 +++++++++++++++++++++++++ specs/E4 Charts/F2 DrylBarChart.md | 200 ++++++++++++++++++++++++++ specs/E4 Charts/F3 DrylAreaChart.md | 190 ++++++++++++++++++++++++ specs/E4 Charts/F4 DrylDonutChart.md | 207 +++++++++++++++++++++++++++ 4 files changed, 791 insertions(+) create mode 100644 specs/E4 Charts/F1 DrylLineChart.md create mode 100644 specs/E4 Charts/F2 DrylBarChart.md create mode 100644 specs/E4 Charts/F3 DrylAreaChart.md create mode 100644 specs/E4 Charts/F4 DrylDonutChart.md diff --git a/specs/E4 Charts/F1 DrylLineChart.md b/specs/E4 Charts/F1 DrylLineChart.md new file mode 100644 index 0000000..05a1a86 --- /dev/null +++ b/specs/E4 Charts/F1 DrylLineChart.md @@ -0,0 +1,194 @@ +# DrylLineChart + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/Charts/DrylLineChart.razor + +## User Story + +As a Blazor developer building a dashboard on DRYL, I want to hand a list of +series and a list of labels to a component and get a readable multi-series line +chart with axes, gridlines, a legend and hover tooltips, so that I can show a +trend over time without taking on a charting library or writing a line of +JavaScript. + +## Description + +`DrylLineChart` plots one or more `ChartSeries` as lines over a shared category +axis. It is the plainest member of the chart family and the reference for the +other two cartesian charts: it contributes the line marks and nothing else, and +inherits the entire frame — y-ticks, gridlines, x-labels, hover columns, +crosshair, tooltips and legend — from `DrylCartesianChartBase` and the shared +chart frame. + +The rendering is hybrid on purpose. The lines are SVG in a viewBox stretched to +the plot area with non-scaling strokes, so the geometry is resolution +independent while the stroke keeps a constant visual weight at any aspect ratio. +Everything that is text — ticks, labels, legend, tooltip — is ordinary HTML +outside that SVG, so it never distorts with the stretch. The whole component +runs without JavaScript; see [`_Interop.md`](_Interop.md) for what that buys. + +Two parameters are its own: `Smooth` swaps the straight segments for a spline, +and `ShowMarkers` puts a dot on every data point. Everything else the consumer +touches is the shared contract in [`_Api.md`](_Api.md). + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Smooth` | `bool` | `false` | Draw the line as a Catmull-Rom spline instead of straight segments. | +| `ShowMarkers` | `bool` | `false` | Mark every data point with a dot. | + +Inherited from `DrylCartesianChartBase`: `Series`, `Labels`, `ShowXAxis`, +`ShowYAxis`, `ShowGridLines`, `YMin`, `YMax`. +Inherited from `DrylChartBase`: `Height`, `ShowLegend`, `ValueFormat`, +`AriaLabel`, `Class`, `AdditionalAttributes`, `Ai`, `Aura`. +Both are specified in [`_Api.md`](_Api.md); the criteria below cover only what +this component decides. + +The component exposes no `EventCallback` and no `RenderFragment`: it displays +data, it does not collect input. + +## Acceptance Criteria + +### Rendering + +- The component renders nothing when `Series` is `null`. +- The component renders nothing when `Series` is empty. +- The component renders nothing when every series in `Series` has an empty + `Data` list. +- The component renders one line per entry in `Series`. +- The component renders each line in its series' palette color, resolved as + described in [`_Api.md`](_Api.md). +- The component renders a line for a series whose `Data` is shorter than the + longest series, covering only the categories that series has. +- The line marks carry no pointer events, so they never intercept a hover + intended for the column beneath them. +- `Class` is merged onto the chart root's own classes. +- `AdditionalAttributes` are applied to the chart root. + +### Line shape + +- `Smooth` defaults to `false`. +- The line is drawn as straight segments between consecutive points when + `Smooth` is `false`. +- The line is drawn as a spline through the points when `Smooth` is `true`. +- A series of fewer than three points is drawn as straight segments even when + `Smooth` is `true`, because a spline through two points is that same segment. +- The spline passes through every data point rather than approximating it, so + reading a value off the curve stays honest. + +### Markers + +- `ShowMarkers` defaults to `false`. +- The component renders no markers when `ShowMarkers` is `false`. +- The component renders one marker per data point of every series when + `ShowMarkers` is `true`. +- Each marker is filled in its series' palette color. +- Each marker carries a ring in `--bg`, so it stays legible where it sits on top + of another series' line. +- Markers are decorative and are `aria-hidden`: the values they mark are already + announced by the hover column (`UX-07`). + +### Numbers and locale + +- Every number written into SVG path data is formatted with the invariant + culture, so path data parses under any thread culture. +- Every number written into a CSS custom property or an inline style is + formatted with the invariant culture. +- Tick and tooltip values are formatted culture-aware, per `ValueFormat` in + [`_Api.md`](_Api.md). + +### Motion + +- The lines wipe in from left to right on mount, over `--dur-slow` with + `--ease-out`. +- The wipe leaves no element clipped once it has finished. +- Markers fade in with `--dur-med` and `--ease-out`. +- Markers fade in staggered from left to right, roughly tracking the line wipe + above them. +- The crosshair fades in and out with `--dur-fast` and `--ease-out`. +- The tooltip fades and slides in with `--dur-fast` and `--ease-out`. +- All chart animations and transitions are switched off under + `prefers-reduced-motion: reduce`, leaving a complete, legible chart. +- The component has no exit animation of its own; a chart that is mounted + conditionally is wrapped in `DrylPresence` by its host (`DESIGN-12`). + + The marker stagger step and the wipe's clipping slack are written as literals + in `code/DRYL.Components/wwwroot/dryl.css`. `DESIGN-10` binds the durations and + easings, which are tokens; the per-item delay step is a stagger increment + rather than a duration. Recorded here as documented debt, not as compliance. + +### Keyboard and accessibility + +- The chart root carries `role="group"`. +- The chart root carries an accessible label: `AriaLabel` when set, the + comma-separated series names otherwise. +- Every category has a hover column that is reachable by `Tab`. +- A focused hover column shows the same crosshair and tooltip that hovering it + shows, so the keyboard path is not a second-class one. +- A focused hover column carries a visible focus ring in `--accent-line`. +- Every hover column carries an accessible label naming its category and, per + series, the series name and its formatted value. +- The SVG line marks are `aria-hidden`, so the data is announced once — through + the columns — and not twice. +- The y-axis, x-axis and gridlines are `aria-hidden`: they are scaffolding for + the eye, and the values they carry are in the column labels. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The series colors come from the six chart palette slots (`--chart-1` … + `--chart-6`), and a series beyond the sixth renders in `--fg-dim` rather than + cycling (see [`_Api.md`](_Api.md)). +- Gridlines use `--line` and the zero line `--line-strong`, so the zero line + reads as the stronger of the two. +- The tooltip is a floating panel: `--panel-solid` fill, `--line-strong` border, + `--shadow-md`, `--r-sm` radius. +- The tooltip flips to the other side of the crosshair for columns in the right + part of the plot, so it never leaves the chart. +- Tooltip and axis values are rendered with tabular figures, so digits do not + jitter as the pointer moves between columns. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). +- The component renders no frost: it is an in-flow surface on the page's ground, + and the only floating surface it owns is the tooltip (`DESIGN-06`). + +### AI mode + +- `Ai` defaults to `AiState.None`, and the chart then renders exactly as a + non-AI chart does — no ring, no glow, no added padding. +- An explicit `Ai` value wins over a surrounding `DrylAiScope`. +- `Ai` left unset inherits the state of a surrounding `DrylAiScope`. +- While the effective state is not `AiState.None`, the chart root becomes a + contained AI panel — a `--glass-1` ground with `--r-lg` corners and padding — + so the aura ring traces a rounded surface instead of a bare rectangle. +- The aura variant follows `Aura` when set and the surrounding scope otherwise. +- 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. +- `AiState.Generated` retires itself to `AiState.None` without the host having to + hand it back. + +## 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`. The chart palette carries a tuned + set per mode in both LIGHT-TOKEN-SET copies; the component itself defines no + mode-specific rule. +- **Enter/exit animation** — the line wipe and the marker stagger are the + entrance; the exit belongs to `DrylPresence` on the host's side, per the + "Motion" criteria above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + hover columns are the accessible representation of the data. +- **AI mode** — yes, via `DrylAiAware`. The chart is a natural place for an + agent to show that it is producing or has just produced a figure, and the + criteria under "AI mode" say what that looks like. +- **Demo page** — `DRYL.Website/Components/Pages/DemoLineChart.razor`, with the + examples `Components/Examples/LineChart/Basic.razor`, + `.../SmoothMarkers.razor` and `.../AiStates.razor`. +- **`ComponentCatalog`** — registered as `"Line Chart"` / `line-chart` in + `DRYL.Website/Components/ComponentCatalog.cs`. diff --git a/specs/E4 Charts/F2 DrylBarChart.md b/specs/E4 Charts/F2 DrylBarChart.md new file mode 100644 index 0000000..690abe6 --- /dev/null +++ b/specs/E4 Charts/F2 DrylBarChart.md @@ -0,0 +1,200 @@ +# DrylBarChart + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/Charts/DrylBarChart.razor + +## User Story + +As a Blazor developer building a dashboard on DRYL, I want to plot my series as +columns — side by side, or stacked into one column per category — so that I can +compare discrete categories instead of showing a trend, without leaving the +chart vocabulary the rest of my page already uses. + +## Description + +`DrylBarChart` renders each `ChartSeries` as a column per category. It shares +the whole frame with the other cartesian charts — y-ticks, gridlines, x-labels, +hover columns, crosshair, tooltips and legend — and contributes only the bars. + +It differs from its two siblings in where it places a category on the x-axis. +Lines and areas sit *on* the category positions; bars sit in *bands* around +them, centred in an equal share of the plot width. The same decision brings +zero into the y-range unconditionally: a bar is read as a length from the +baseline, so a range that does not contain zero would make every bar a lie. + +`Stacked` turns the grouped columns into one column per category. Negative +values are not supported when stacking and are clamped to zero — stacking mixed +signs has no single honest reading, and the clamp is the documented behaviour +rather than an exception thrown at the consumer. + +The bars are percent-positioned HTML rather than SVG, which is what lets them +grow, round only at the data end and brighten on hover with plain CSS. The +component runs without JavaScript; see [`_Interop.md`](_Interop.md). + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Stacked` | `bool` | `false` | Stack the series into one column per category instead of grouping them side by side. | + +Inherited from `DrylCartesianChartBase`: `Series`, `Labels`, `ShowXAxis`, +`ShowYAxis`, `ShowGridLines`, `YMin`, `YMax`. +Inherited from `DrylChartBase`: `Height`, `ShowLegend`, `ValueFormat`, +`AriaLabel`, `Class`, `AdditionalAttributes`, `Ai`, `Aura`. +Both are specified in [`_Api.md`](_Api.md); the criteria below cover only what +this component decides. + +The component exposes no `EventCallback` and no `RenderFragment`. + +## Acceptance Criteria + +### Rendering + +- The component renders nothing when `Series` is `null`. +- The component renders nothing when `Series` is empty. +- The component renders nothing when every series in `Series` has an empty + `Data` list. +- The component renders one band per category. +- Bands are of equal width and together fill the plot area. +- Each category sits at the centre of its band, so a bar is centred over its + x-label. +- Each bar is filled in its series' palette color, resolved as described in + [`_Api.md`](_Api.md). +- A bar is never wider than a fixed maximum, so a chart of two categories shows + two bars rather than two blocks. +- Adjacent bars within a band are separated by a gap in the surface color. +- `Class` is merged onto the chart root's own classes. +- `AdditionalAttributes` are applied to the chart root. + +### Grouped mode + +- `Stacked` defaults to `false`. +- Grouped mode renders one bar per series per category, side by side within the + band. +- A bar grows from the zero line to its value. +- A bar for a negative value grows downward from the zero line. +- A bar for a negative value is rounded at its lower end and square at the zero + line, mirroring the positive case. +- A series that carries no value for a category is drawn at zero height there, + so the remaining bars keep their slots and the band stays aligned with its + neighbours. + +### Stacked mode + +- Stacked mode renders one bar per category, composed of one segment per series. +- Each segment's height is that series' share of the category's total. +- Segments are ordered so the first series sits at the baseline. +- A negative value contributes zero to the stack and renders no segment. +- A series whose value is zero for a category renders no segment for it. +- Only the topmost segment of a stack is rounded; the ones below it are square, + so the column reads as one bar rather than as a row of pills. +- Adjacent segments are separated by a gap in the surface color. +- The y-range covers the tallest stack, not the largest single value. + +### Y-range + +- Zero is always inside the y-range, whether or not the data reaches it. +- `YMin` and `YMax` each override their bound of the automatic range when set. +- Tick values are rounded to readable numbers rather than to the raw data + extremes. + +### Numbers and locale + +- Every number written into a CSS custom property or an inline style is + formatted with the invariant culture, so the style parses under any thread + culture. +- Tick and tooltip values are formatted culture-aware, per `ValueFormat` in + [`_Api.md`](_Api.md). + +### Motion + +- Bars grow from their baseline on mount, over `--dur-slow` with + `--ease-spring`. +- Bars grow staggered from left to right, so the chart builds up rather than + appearing at once. +- Stacked segments grow with the same duration, easing and stagger as grouped + bars, so the two modes read as one component. +- Hovering a band brightens its bars over `--dur-fast` with `--ease-out`. +- The crosshair fades in and out with `--dur-fast` and `--ease-out`. +- The tooltip fades and slides in with `--dur-fast` and `--ease-out`. +- All chart animations and transitions are switched off under + `prefers-reduced-motion: reduce`, leaving a complete, legible chart. +- The component has no exit animation of its own; a chart that is mounted + conditionally is wrapped in `DrylPresence` by its host (`DESIGN-12`). + + The bar stagger step, the maximum bar width, the inter-bar gap and the bar's + corner radius are written as literals in + `code/DRYL.Components/wwwroot/dryl.css`. `DESIGN-10` binds the durations and + easings, which are tokens; the per-item delay step is a stagger increment + rather than a duration. Recorded here as documented debt, not as compliance. + +### Keyboard and accessibility + +- The chart root carries `role="group"`. +- The chart root carries an accessible label: `AriaLabel` when set, the + comma-separated series names otherwise. +- Every category has a hover column that is reachable by `Tab`. +- A focused hover column shows the same crosshair and tooltip that hovering it + shows. +- A focused hover column carries a visible focus ring in `--accent-line`. +- Every hover column carries an accessible label naming its category and, per + series, the series name and its formatted value. +- The bars are `aria-hidden`, so the data is announced once — through the + columns — and not twice. +- The stacked mode announces the per-series values, not the stack totals, so a + screen reader hears the same numbers the tooltip shows. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The series colors come from the six chart palette slots (`--chart-1` … + `--chart-6`), and a series beyond the sixth renders in `--fg-dim` rather than + cycling (see [`_Api.md`](_Api.md)). +- Gridlines use `--line` and the zero line `--line-strong`. +- The tooltip is a floating panel: `--panel-solid` fill, `--line-strong` border, + `--shadow-md`, `--r-sm` radius. +- The tooltip flips to the other side of the crosshair for columns in the right + part of the plot, so it never leaves the chart. +- Tooltip and axis values are rendered with tabular figures. +- A bar is a saturated fill of a palette color, not of an accent: the palette + slots are data colors, and using one is not the accent-as-surface `DESIGN-08` + forbids. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). +- The component renders no frost; the only floating surface it owns is the + tooltip (`DESIGN-06`). + +### AI mode + +- `Ai` defaults to `AiState.None`, and the chart then renders exactly as a + non-AI chart does. +- An explicit `Ai` value wins over a surrounding `DrylAiScope`. +- `Ai` left unset inherits the state of a surrounding `DrylAiScope`. +- While the effective state is not `AiState.None`, the chart root becomes a + contained AI panel — a `--glass-1` ground with `--r-lg` corners and padding. +- The aura variant follows `Aura` when set and the surrounding scope otherwise. +- 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. +- `AiState.Generated` retires itself to `AiState.None` without the host having to + hand it back. + +## 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`. The chart palette carries a tuned + set per mode in both LIGHT-TOKEN-SET copies; the component defines no + mode-specific rule. +- **Enter/exit animation** — the staggered bar growth is the entrance; the exit + belongs to `DrylPresence` on the host's side, per the "Motion" criteria above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. +- **AI mode** — yes, via `DrylAiAware`, on the same terms as the rest of the + chart family; the criteria under "AI mode" say what it looks like. +- **Demo page** — `DRYL.Website/Components/Pages/DemoBarChart.razor`, with the + examples `Components/Examples/BarChart/Grouped.razor` and `.../Stacked.razor`. +- **`ComponentCatalog`** — registered as `"Bar Chart"` / `bar-chart` in + `DRYL.Website/Components/ComponentCatalog.cs`. diff --git a/specs/E4 Charts/F3 DrylAreaChart.md b/specs/E4 Charts/F3 DrylAreaChart.md new file mode 100644 index 0000000..f183f99 --- /dev/null +++ b/specs/E4 Charts/F3 DrylAreaChart.md @@ -0,0 +1,190 @@ +# DrylAreaChart + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/Charts/DrylAreaChart.razor + +## User Story + +As a Blazor developer building a dashboard on DRYL, I want the same multi-series +line chart with a soft fill down to the baseline, so that a volume over time +reads as a mass rather than as a thin stroke, without me hand-building gradients +and baseline paths. + +## Description + +`DrylAreaChart` is `DrylLineChart` with a filled body: the same line, the same +frame, plus a translucent area from the line down to the zero baseline. It +inherits its axes, gridlines, hover columns, tooltips and legend from +`DrylCartesianChartBase` and the shared chart frame, and contributes the area +path and its gradient. + +The fill is a vertical fade **of the series' own color** — opaque at the line, +almost gone at the baseline. That is a deliberate limit: identity beats +decoration, so a multi-series area chart never gets cross-hue gradients that +would make two series share a middle color and stop being tellable apart. + +The areas are not stacked. Each series is drawn to the baseline independently +and they overlap; the fills are faint enough to read through each other. A +consumer who needs stacked volumes uses `DrylBarChart` with `Stacked`. + +The component runs without JavaScript; see [`_Interop.md`](_Interop.md). + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Smooth` | `bool` | `false` | Draw the line and the area's upper edge as a Catmull-Rom spline instead of straight segments. | + +Inherited from `DrylCartesianChartBase`: `Series`, `Labels`, `ShowXAxis`, +`ShowYAxis`, `ShowGridLines`, `YMin`, `YMax`. +Inherited from `DrylChartBase`: `Height`, `ShowLegend`, `ValueFormat`, +`AriaLabel`, `Class`, `AdditionalAttributes`, `Ai`, `Aura`. +Both are specified in [`_Api.md`](_Api.md); the criteria below cover only what +this component decides. + +The component has no `ShowMarkers` parameter: markers would sit on a filled +body, where they add noise rather than precision. A chart that needs point +markers is a `DrylLineChart`. + +The component exposes no `EventCallback` and no `RenderFragment`. + +## Acceptance Criteria + +### Rendering + +- The component renders nothing when `Series` is `null`. +- The component renders nothing when `Series` is empty. +- The component renders nothing when every series in `Series` has an empty + `Data` list. +- The component renders one line per entry in `Series`. +- The component renders one filled area per entry in `Series`. +- Each line is stroked in its series' palette color, resolved as described in + [`_Api.md`](_Api.md). +- Each area is closed down to the zero baseline of the current y-range. +- Each area is drawn beneath its own line, so the stroke stays crisp on top of + the fill. +- Areas are drawn independently and may overlap; they are not stacked. +- The marks carry no pointer events, so they never intercept a hover intended + for the column beneath them. +- `Class` is merged onto the chart root's own classes. +- `AdditionalAttributes` are applied to the chart root. + +### Line and area shape + +- `Smooth` defaults to `false`. +- The line and the area's upper edge are drawn as straight segments when + `Smooth` is `false`. +- The line and the area's upper edge are drawn as a spline when `Smooth` is + `true`. +- The area's upper edge follows exactly the same path as the line, so the fill + never separates from its stroke. +- A series of fewer than three points is drawn as straight segments even when + `Smooth` is `true`. + +### The fill + +- Each area is filled with a vertical gradient of its own series color, from + more opaque at the top to nearly transparent at the baseline. +- The gradient uses one hue per series: no series is filled with a blend of two + palette colors. +- The fill is faint enough that a line drawn behind another series' area stays + visible. +- Each series' gradient definition carries an id unique to the component + instance, so two area charts on one page never claim each other's fills. + +### Numbers and locale + +- Every number written into SVG path data is formatted with the invariant + culture, so path data parses under any thread culture. +- Every number written into a CSS custom property or an inline style is + formatted with the invariant culture. +- Tick and tooltip values are formatted culture-aware, per `ValueFormat` in + [`_Api.md`](_Api.md). + +### Motion + +- The lines and areas wipe in from left to right on mount, over `--dur-slow` + with `--ease-out`. +- Line and area wipe together as one mark, so the fill never lags behind its + stroke. +- The wipe leaves no element clipped once it has finished. +- The crosshair fades in and out with `--dur-fast` and `--ease-out`. +- The tooltip fades and slides in with `--dur-fast` and `--ease-out`. +- All chart animations and transitions are switched off under + `prefers-reduced-motion: reduce`, leaving a complete, legible chart. +- The component has no exit animation of its own; a chart that is mounted + conditionally is wrapped in `DrylPresence` by its host (`DESIGN-12`). + + The gradient's two stop opacities and the wipe's clipping slack are written as + literals in the component and in + `code/DRYL.Components/wwwroot/dryl.css`. `DESIGN-01` governs colors, and the + colors here are tokens — the opacities modulating them are not covered by a + token today. Recorded as documented debt, not as compliance. + +### Keyboard and accessibility + +- The chart root carries `role="group"`. +- The chart root carries an accessible label: `AriaLabel` when set, the + comma-separated series names otherwise. +- Every category has a hover column that is reachable by `Tab`. +- A focused hover column shows the same crosshair and tooltip that hovering it + shows. +- A focused hover column carries a visible focus ring in `--accent-line`. +- Every hover column carries an accessible label naming its category and, per + series, the series name and its formatted value. +- The SVG marks are `aria-hidden`, so the data is announced once — through the + columns — and not twice. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The series colors come from the six chart palette slots (`--chart-1` … + `--chart-6`), and a series beyond the sixth renders in `--fg-dim` rather than + cycling (see [`_Api.md`](_Api.md)). +- Gridlines use `--line` and the zero line `--line-strong`. +- The tooltip is a floating panel: `--panel-solid` fill, `--line-strong` border, + `--shadow-md`, `--r-sm` radius. +- The tooltip flips to the other side of the crosshair for columns in the right + part of the plot, so it never leaves the chart. +- Tooltip and axis values are rendered with tabular figures. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). +- The component renders no frost; the only floating surface it owns is the + tooltip (`DESIGN-06`). + +### AI mode + +- `Ai` defaults to `AiState.None`, and the chart then renders exactly as a + non-AI chart does. +- An explicit `Ai` value wins over a surrounding `DrylAiScope`. +- `Ai` left unset inherits the state of a surrounding `DrylAiScope`. +- While the effective state is not `AiState.None`, the chart root becomes a + contained AI panel — a `--glass-1` ground with `--r-lg` corners and padding. +- The aura variant follows `Aura` when set and the surrounding scope otherwise. +- 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. +- `AiState.Generated` retires itself to `AiState.None` without the host having to + hand it back. + +## 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`. The chart palette carries a tuned + set per mode in both LIGHT-TOKEN-SET copies; the component defines no + mode-specific rule. The fill is a transparency of the series color, so it + follows the mode with it rather than assuming a ground. +- **Enter/exit animation** — the left-to-right wipe of line and fill is the + entrance; the exit belongs to `DrylPresence` on the host's side, per the + "Motion" criteria above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. +- **AI mode** — yes, via `DrylAiAware`, on the same terms as the rest of the + chart family; the criteria under "AI mode" say what it looks like. +- **Demo page** — `DRYL.Website/Components/Pages/DemoAreaChart.razor`, with the + example `Components/Examples/AreaChart/Basic.razor`. +- **`ComponentCatalog`** — registered as `"Area Chart"` / `area-chart` in + `DRYL.Website/Components/ComponentCatalog.cs`. diff --git a/specs/E4 Charts/F4 DrylDonutChart.md b/specs/E4 Charts/F4 DrylDonutChart.md new file mode 100644 index 0000000..19e5c23 --- /dev/null +++ b/specs/E4 Charts/F4 DrylDonutChart.md @@ -0,0 +1,207 @@ +# DrylDonutChart + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Data/Charts/DrylDonutChart.razor + code/DRYL.Components/Components/Data/Charts/ChartSegment.cs + +## User Story + +As a Blazor developer building a dashboard on DRYL, I want to show how a whole +splits into parts, with the headline number sitting in the middle of the ring, +so that a share-of-total reads at a glance without me computing angles, arcs or +percentages myself. + +## Description + +`DrylDonutChart` plots a list of `ChartSegment` as a ring of proportional +segments. It is the one member of the chart family with no axes: it derives from +`DrylChartBase` directly rather than from `DrylCartesianChartBase`, and shares +with its siblings the sizing, legend, value formatting, palette and AI aura, but +none of the axis machinery. + +`InnerRadius` scales the hole. At its default the component is a donut; at `0` +it is a pie, and the `CenterContent` slot is then ignored because there is no +hole to put anything in. That slot is what makes the donut a KPI surface rather +than only a proportion display — the usual content is a `DrylStat` naming the +total the segments add up to. + +Two details are load-bearing and easy to get wrong when reimplementing it. The +separators between segments are a **stroke** in the surface color, not angular +padding: angular padding wedges to a point at the centre and looks broken on a +pie, while a stroke keeps the same visible gap at every radius. And a lone +segment covering the entire circle is drawn just short of a full turn, because +an arc whose start and end angle are equal draws nothing at all. + +The component runs without JavaScript; see [`_Interop.md`](_Interop.md). + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Segments` | `IReadOnlyList?` | `null` | The segments to plot. Zero and negative values are skipped. | +| `InnerRadius` | `double` | `0.65` | Hole radius as a fraction of the outer radius, clamped to 0–0.9. `0` renders a pie. | +| `CenterContent` | `RenderFragment?` | `null` | Content rendered inside the hole. Ignored while `InnerRadius` is `0`. | + +Inherited from `DrylChartBase`: `Height`, `ShowLegend`, `ValueFormat`, +`AriaLabel`, `Class`, `AdditionalAttributes`, `Ai`, `Aura`. They are specified +in [`_Api.md`](_Api.md); the criteria below cover only what this component +decides. + +The component takes no `Labels` and no `Series`: a segment carries its own label +in `ChartSegment.Label`, and there is no category axis to label. + +The component exposes no `EventCallback`. Segments are focusable and +hoverable, but they are not selectable — the chart displays a split, it does not +collect a choice. + +## Acceptance Criteria + +### Rendering + +- The component renders nothing when `Segments` is `null`. +- The component renders nothing when `Segments` is empty. +- The component renders nothing when no segment has a value greater than zero. +- The component renders one arc per segment whose value is greater than zero. +- A segment whose value is zero or negative is skipped entirely — no arc, no + legend entry, no tooltip. +- Each arc's sweep is that segment's share of the total of the plotted segments. +- The arcs together cover the full circle, starting at twelve o'clock and + running clockwise. +- A single plotted segment is drawn as a closed ring rather than as nothing. +- Each arc is filled in its segment's palette color, resolved from `ColorSlot` + where set and from the segment's position in `Segments` otherwise. +- A skipped segment does not shift the colors of the segments after it, because + the position used is the position in `Segments`. +- `Class` is merged onto the chart root's own classes. +- `AdditionalAttributes` are applied to the chart root. + +### The hole and the centre slot + +- `InnerRadius` defaults to a donut, not a pie. +- `InnerRadius` is clamped to the range 0–0.9, so a value outside it degrades + instead of producing an inverted or invisible ring. +- `InnerRadius` set to `0` renders a filled pie. +- `CenterContent` is rendered inside the hole when `InnerRadius` is greater than + `0`. +- `CenterContent` is not rendered when `InnerRadius` is `0`. +- The centre slot is sized to the hole, so its content never covers the ring. +- The centre slot does not intercept pointer events except on its own content, + so hovering across the hole still reaches the segment beneath. + +### Sizing + +- The wheel is square: its height and width are equal at any container width. +- The wheel never exceeds the width of its container, so a donut in a narrow + column shrinks instead of overflowing. +- The tooltip anchors and the hole size ride along with that shrink, staying + aligned with the arcs. + +### Legend + +- The legend is shown by default, because the segments have no axis to name + them. +- `ShowLegend` set to `false` hides the legend. +- The legend lists one entry per plotted segment, in the order the segments are + plotted. +- Each legend entry shows a swatch in that segment's color next to its label. + +### Values and locale + +- A tooltip shows the segment's label, its formatted value and its share of the + total as a percentage. +- Values are formatted culture-aware, per `ValueFormat` in + [`_Api.md`](_Api.md). +- Every number written into SVG path data is formatted with the invariant + culture, so path data parses under any thread culture. +- Every number written into a CSS custom property or an inline style is + formatted with the invariant culture. + +### Motion + +- Segments fade and scale in on mount, over `--dur-med` with `--ease-out`. +- Segments enter staggered in plot order, so the ring builds up rather than + appearing at once. +- Hovering a segment lifts it outward along its own mid-angle, over `--dur-fast` + with `--ease-spring`. +- Focusing a segment produces the same outward lift as hovering it. +- The tooltip fades and slides in with `--dur-fast` and `--ease-out`. +- All animations and transitions are switched off under + `prefers-reduced-motion: reduce`, leaving a complete, legible chart. +- The component has no exit animation of its own; a chart that is mounted + conditionally is wrapped in `DrylPresence` by its host (`DESIGN-12`). + + The segment stagger step, the lift distance and the separator stroke width are + written as literals. `DESIGN-10` binds the durations and easings, which are + tokens; the per-item delay step is a stagger increment rather than a duration. + Recorded here as documented debt, not as compliance. + +### Keyboard and accessibility + +- The chart root carries `role="group"`. +- The chart root carries an accessible label: `AriaLabel` when set, the + comma-separated segment labels otherwise. +- Every plotted segment is reachable by `Tab`. +- A focused segment shows the same tooltip that hovering it shows, so the + keyboard path is not a second-class one. +- A focused segment is outlined in `--accent-line`, replacing the surface-colored + separator stroke on that segment only. +- Every segment carries `role="img"` and an accessible label naming its label, + its formatted value and its percentage of the total. +- The tooltip itself is `aria-hidden`, so its content is announced once — through + the segment's own label — and not twice. +- The legend is not focusable: it repeats what the segments already announce. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The segment colors come from the six chart palette slots (`--chart-1` … + `--chart-6`), and a segment beyond the sixth renders in `--fg-dim` rather than + cycling (see [`_Api.md`](_Api.md)). +- Segments are separated by a stroke in `--bg`, giving a gap of constant width at + every radius. +- The tooltip is a floating panel: `--panel-solid` fill, `--line-strong` border, + `--shadow-md`, `--r-sm` radius. +- The tooltip is centred on the segment's mid-angle anchor, so it points at the + segment it describes. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). +- The component renders no frost; the only floating surface it owns is the + tooltip (`DESIGN-06`). + +### AI mode + +- `Ai` defaults to `AiState.None`, and the chart then renders exactly as a + non-AI chart does. +- An explicit `Ai` value wins over a surrounding `DrylAiScope`. +- `Ai` left unset inherits the state of a surrounding `DrylAiScope`. +- While the effective state is not `AiState.None`, the chart root becomes a + contained AI panel — a `--glass-1` ground with `--r-lg` corners and padding. +- The aura variant follows `Aura` when set and the surrounding scope otherwise. +- 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. +- `AiState.Generated` retires itself to `AiState.None` without the host having to + hand it back. + +## 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`. The separator stroke is `--bg` + rather than a literal, so the gaps follow the mode instead of assuming a dark + ground. +- **Enter/exit animation** — the staggered segment bloom is the entrance; the + exit belongs to `DrylPresence` on the host's side, per the "Motion" criteria + above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. Each + segment is its own tab stop and announces its own share. +- **AI mode** — yes, via `DrylAiAware`, on the same terms as the rest of the + chart family; the criteria under "AI mode" say what it looks like. +- **Demo page** — `DRYL.Website/Components/Pages/DemoDonutChart.razor`, with the + examples `Components/Examples/DonutChart/Basic.razor` and + `.../CenterSlot.razor`. +- **`ComponentCatalog`** — registered as `"Donut Chart"` / `donut-chart` in + `DRYL.Website/Components/ComponentCatalog.cs`. From e6b8ec7117884591a0c9132cb9e147c4116f1969 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 21:17:57 +0200 Subject: [PATCH 06/15] spec(E6): the dialog category, reverse-engineered (23/127) _Api.md carries DialogSize, DialogOptions, DialogParameters, DialogResult and the three interfaces the service and the provider meet over. _Interop.md carries dryl.modal, the exit-listener pair, the view-transition handoff and the provider's disposal duties - including why the exit watchdog is a rule and not a precaution. F1 DrylDialog is the frame, F2 DrylDialogProvider is the modality, F3/F4 are the two dialogs the service shows for ShowConfirmAsync/ShowAlertAsync. Written from the code, the CSS and the modal JS. Recorded gaps rather than glossed: no aria-describedby on the two built-ins, role=dialog where alertdialog fits, and the literal dialog widths. Also fixes a wrong rule citation in E4 F1 (UX-07 is about animation, not about decorative markup). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- specs/E4 Charts/F1 DrylLineChart.md | 2 +- specs/E6 Dialogs/F1 DrylDialog.md | 205 +++++++++++++++++++ specs/E6 Dialogs/F2 DrylDialogProvider.md | 231 ++++++++++++++++++++++ specs/E6 Dialogs/F3 DrylConfirmDialog.md | 144 ++++++++++++++ specs/E6 Dialogs/F4 DrylAlertDialog.md | 129 ++++++++++++ specs/E6 Dialogs/_Api.md | 149 +++++++++++++- specs/E6 Dialogs/_Interop.md | 75 ++++++- 7 files changed, 924 insertions(+), 11 deletions(-) create mode 100644 specs/E6 Dialogs/F1 DrylDialog.md create mode 100644 specs/E6 Dialogs/F2 DrylDialogProvider.md create mode 100644 specs/E6 Dialogs/F3 DrylConfirmDialog.md create mode 100644 specs/E6 Dialogs/F4 DrylAlertDialog.md diff --git a/specs/E4 Charts/F1 DrylLineChart.md b/specs/E4 Charts/F1 DrylLineChart.md index 05a1a86..fde3f73 100644 --- a/specs/E4 Charts/F1 DrylLineChart.md +++ b/specs/E4 Charts/F1 DrylLineChart.md @@ -88,7 +88,7 @@ data, it does not collect input. - Each marker carries a ring in `--bg`, so it stays legible where it sits on top of another series' line. - Markers are decorative and are `aria-hidden`: the values they mark are already - announced by the hover column (`UX-07`). + announced by the hover column. ### Numbers and locale diff --git a/specs/E6 Dialogs/F1 DrylDialog.md b/specs/E6 Dialogs/F1 DrylDialog.md new file mode 100644 index 0000000..cd012eb --- /dev/null +++ b/specs/E6 Dialogs/F1 DrylDialog.md @@ -0,0 +1,205 @@ +# DrylDialog + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Dialogs/DrylDialog.razor + code/DRYL.Components/Dialogs/DialogSize.cs + +## User Story + +As a Blazor developer writing a dialog for my app, I want to wrap my content in +a ready-made frame with a title, a close button and a footer for my buttons, and +close it from my own code with a result, so that I write only what is specific +to my dialog and nothing about modality, layering or dismissal. + +## Description + +`DrylDialog` is the **frame**, not the modality. It renders the glass panel a +dialog lives in — header with title, icon and close button, a scrolling body, an +optional footer — and it is what a consumer places at the root of their own +dialog component. What is around it, above the page, is +[`F2 DrylDialogProvider`](F2%20DrylDialogProvider.md): backdrop, layer, focus +trap, scroll lock and the `Escape` key are all the provider's, not this +component's. + +The two halves meet in one cascading value. The provider cascades an +`IDrylDialogInstance`, and `DrylDialog` reads it for the things the *caller* +decided rather than the dialog author: the title passed to `ShowAsync`, the size +preset, whether the close button is shown, the frame's AI state. Every one of +those has a local parameter that wins over the instance, so the same component +also works standalone, with no provider and no instance at all — useful for a +dialog rendered inline in a demo, and the reason none of its parameters is +required. + +Closing is not this component's decision either. The close button calls +`Cancel()` on the instance; a dialog that wants to close with a result calls +`Instance.Close(...)` from its own code. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Title` | `string?` | `null` | Title text. Falls back to the instance's title. | +| `TitleContent` | `RenderFragment?` | `null` | Custom title slot; replaces the plain `Title` string. | +| `Icon` | `string?` | `null` | Leading icon shown before the title, by `DrylIcon` name. | +| `ChildContent` | `RenderFragment?` | `null` | The dialog body. | +| `ActionContent` | `RenderFragment?` | `null` | Footer content, typically buttons. The footer is absent without it. | +| `ShowHeader` | `bool` | `true` | Render the header at all. | +| `ShowCloseButton` | `bool?` | `null` | Overrides `DialogOptions.ShowCloseButton`. `null` defers to the caller. | +| `Ai` | `AiState` | `AiState.None` | AI state of the frame. `AiState.None` defers to the instance. | +| `Aura` | `AiAura?` | `null` | Pins the aura variant; `null` inherits a surrounding `DrylAiScope`. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the dialog's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the dialog root. | +| `Instance` | `IDrylDialogInstance?` | `null` | Cascading. Supplied by `DrylDialogProvider`; absent when the dialog is rendered standalone. | + +`DialogSize` is not a parameter of this component. The size is the *caller's* +decision, made in `DialogOptions` at `ShowAsync` time, and the dialog author does +not override it — a dialog that hardcoded its own width would ignore what the +call site asked for. `DialogSize` and `DialogOptions` are specified in +[`_Api.md`](_Api.md). + +The component exposes no `EventCallback`: closing goes through the instance, not +through a callback the host would have to wire. + +## Acceptance Criteria + +### Structure + +- The dialog root carries `role="dialog"`. +- The dialog root carries `aria-modal="true"`. +- The component renders a header, a body and — when `ActionContent` is set — a + footer, in that order. +- The component renders no footer when `ActionContent` is `null`. +- The component renders no header when `ShowHeader` is `false`. +- The body renders `ChildContent`. +- The body scrolls on its own when the content exceeds the dialog's height, so + the header and footer stay in place. +- `Class` is merged onto the dialog root's own classes. +- `AdditionalAttributes` are applied to the dialog root. +- The extra class from `DialogOptions.Class` is applied to the dialog root as + well, and independently of `Class`. + +### Title and icon + +- The header renders `Title` when it is set. +- The header renders the instance's title when `Title` is `null`. +- The header renders `TitleContent` instead of any title string when it is set. +- The header renders a leading `DrylIcon` when `Icon` is set. +- The header renders no icon slot when `Icon` is `null` or empty. + +### Closing + +- `ShowCloseButton` defaults to deferring to `DialogOptions.ShowCloseButton`. +- The close button is shown when neither `ShowCloseButton` nor the instance's + option is set, so a dialog is dismissible unless someone says otherwise. +- `ShowCloseButton` set to `false` hides the close button even when the caller's + options ask for it. +- Activating the close button cancels the dialog through the instance. +- Activating the close button does nothing when no instance is cascaded, rather + than throwing. +- The component itself never removes itself from the DOM: closing is a request + to the provider, which owns the exit animation. + +### Size + +- The dialog carries the class of `DialogOptions.Size` from its instance. +- The dialog falls back to the `DialogSize.Medium` class when no instance is + cascaded. +- Each of the four `DialogSize` values maps to its own class, so the four widths + are selectable and none shares a rule with another. +- A fullscreen dialog fills the viewport, and the padding that insets the other + sizes is removed for it. + +### Standalone use + +- The component renders without an `Instance`, with no exception thrown. +- Every value the component reads from the instance has a local parameter or a + documented fallback, so a standalone dialog is fully configurable. + +### Motion + +- The dialog enters with a scale-and-lift over `--dur-med` with `--ease-spring`. +- The exit animation belongs to the provider's layer, over `--dur-med` with + `--ease-in-out`, and mirrors the entrance. +- Both are switched off under `prefers-reduced-motion: reduce`, leaving the + dialog fully usable. + +### Keyboard and accessibility + +- The dialog root is labelled by its own title element through + `aria-labelledby`, so a screen reader announces the dialog by name. +- The title element's id is unique per component instance, so two dialogs on + screen at once do not label each other. +- The close button carries an accessible label of its own, since it renders an + icon and no text (`UX-05`). +- The header icon is decorative and adds no second announcement of the title. +- The focus trap, the `Escape` key and the return of focus on close are the + provider's (`F2`); this component adds no key handling of its own. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The dialog is a floating surface: `--panel-grad` fill with `--glass-fx-float` + frost, the pairing `DESIGN-06` requires for something that floats above the + page. +- The border is `--line-strong` and the corner radius `--r-lg`. +- The elevation is `--shadow-lg`, with an accent-derived glow rather than an + accent fill (`DESIGN-08`). +- The footer is separated from the body by a `--line` border, not by a change of + background. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). +- Below the mobile breakpoint a non-fullscreen dialog docks to the bottom edge + as a sheet — full width, rounded at the top only — and a fullscreen dialog is + unaffected. +- A dialog that reaches the bottom edge keeps its footer clear of the device's + safe area, so the confirm button is never under a home indicator. + +### AI mode + +- `Ai` defaults to `AiState.None`. +- An explicit `Ai` value wins over the instance's state. +- `Ai` left at `AiState.None` renders the instance's state, so the caller can + drive the frame through `DialogOptions.Ai` and `IDrylDialogInstance.SetAi`. +- The aura variant follows `Aura` when set and a surrounding `DrylAiScope` + otherwise. +- The frame renders the shared aura vocabulary — ring, glow, wash — rather than + a dialog-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 AI state changes nothing about the dialog's layout, so content does not + reflow when an operation starts or ends. + +## Recorded debt + +- The dialog widths, the maximum height and the mobile breakpoint are written as + literals in `code/DRYL.Components/wwwroot/dryl.css`. `DESIGN-01` covers colors, + radii, shadows, durations and easings, which are tokens here; the four widths + are not covered by a token today. Recorded as debt, not as compliance. +- `AdditionalAttributes` and `Class` reach the same root element, and a `class` + entry splatted through `AdditionalAttributes` would clobber the component's + own classes. `Class` is the supported way to add one. + +## 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`. The frost and the panel fill are + the mode-dependent tokens; the component defines no mode-specific rule. +- **Enter/exit animation** — the entrance is this component's; the exit runs on + the provider's layer and is specified there, because the provider owns the + moment of removal (`DESIGN-12`). +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above, plus + the provider's focus trap and `Escape` handling in `F2`. +- **AI mode** — yes. A dialog is where an agent's work is most often waited on, + so the frame carries the aura and the instance can change it mid-flight + through `SetAi`. +- **Demo page** — `DRYL.Website/Components/Pages/DemoDialog.razor`, with the + examples `Components/Examples/Dialog/Sizes.razor`, `.../CustomTyped.razor`, + `.../ConfirmAlert.razor`, `.../Sequential.razor` and `.../HumanInMiddle.razor`. +- **`ComponentCatalog`** — registered as `"Dialog"` / `dialog` in + `DRYL.Website/Components/ComponentCatalog.cs`, with an explicit source-URL + override because the component sits outside `Components/`. diff --git a/specs/E6 Dialogs/F2 DrylDialogProvider.md b/specs/E6 Dialogs/F2 DrylDialogProvider.md new file mode 100644 index 0000000..7877713 --- /dev/null +++ b/specs/E6 Dialogs/F2 DrylDialogProvider.md @@ -0,0 +1,231 @@ +# DrylDialogProvider + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Dialogs/DrylDialogProvider.razor + code/DRYL.Components/Dialogs/DrylDialogService.cs + code/DRYL.Components/Dialogs/DrylDialogReference.cs + code/DRYL.Components/Dialogs/IDrylDialogService.cs + code/DRYL.Components/Dialogs/IDrylDialogReference.cs + code/DRYL.Components/Dialogs/IDrylDialogInstance.cs + +## User Story + +As a Blazor developer building an app on DRYL, I want to place one component in +my root layout and then open dialogs from anywhere by calling a service, so that +opening a dialog is a line of C# in the code that needs it, instead of a piece of +markup, a boolean and a conditional block in whichever page happens to be +showing. + +## Description + +`DrylDialogProvider` is the host. It is mounted **once**, in the root layout, +takes no parameters, and renders nothing at all until someone calls the dialog +service. From then on it owns everything that is not the dialog's own content: +the backdrop, one full-viewport layer per dialog, the cascaded +`IDrylDialogInstance`, the focus trap, the body scroll lock, the `Escape` key, +and the exit animation with the moment of removal at its end. + +Its shape follows from one decision: **one shared backdrop, one layer per +dialog.** Two dialogs on screen — a stack, or a handoff mid-swap — must not +double the dark wash and the blur, which is exactly what a backdrop per dialog +would do. So the backdrop is the container, and each dialog gets its own layer +inside it. + +The choreography for sequential dialogs is the part worth reading twice. Agent +flows produce chains: close A, immediately open B. The default is a cross-fade — +the backdrop persists and only its opacity transitions, A plays its exit while B +enters a beat later. A caller who sets `DialogOptions.AnimateHandoff` gets +something stronger instead: A is finalized at once, and its removal together +with B's mount is wrapped in a single browser view transition, so the shell +glides to the new size while title, body and footer cross-fade independently. + +Removal is deliberately defensive. An entry stays mounted through its exit +animation and leaves when JS reports the animation ended — but a lost report +would leave an invisible full-viewport overlay eating every click on the page. +A C#-side watchdog finalizes the entry regardless, and the exiting layer stops +taking pointer events immediately. That is a scar, not a precaution: see +[`_Interop.md`](_Interop.md). + +## Public API + +The component takes **no parameters**. Its API is the service it hosts — +`IDrylDialogService`, `IDrylDialogReference` and `IDrylDialogInstance`, all +specified in [`_Api.md`](_Api.md). + +Usage is two lines in an app: `AddDrylComponents()` in startup, and +`` in the root layout. + +## Acceptance Criteria + +### Mounting and lifecycle + +- The component renders nothing while no dialog is open — no backdrop, no + layer, no overlay. +- The component subscribes to the service's added, close-requested and updated + events when it initializes. +- The component unsubscribes from all three when it is disposed. +- Calling `ShowAsync` renders the requested dialog type without the caller + placing any markup. +- The parameters passed to `ShowAsync` reach the dialog component as its own + `[Parameter]` values. +- Each dialog is rendered with a key derived from its instance id, so adding or + removing one never re-uses another's component state. +- A dialog's `IDrylDialogInstance` is cascaded to the rendered component. +- The cascade is not fixed, so a state change on the instance — an AI state, for + example — reaches the dialog. + +### Layering + +- All open dialogs share exactly one backdrop. +- Each dialog is rendered in its own layer above that backdrop. +- Two dialogs on screen at once produce one dark wash and one blur, not two. +- The backdrop stays mounted while any dialog is open, including across a + close-then-open sequence. +- A fullscreen dialog reaches the viewport edges: the inset that centres the + other sizes is removed for it, on both the backdrop and the layer. + +### Dismissal + +- A click on a dialog's layer outside the dialog cancels that dialog when + `DialogOptions.CloseOnBackdropClick` is set. +- A click on the layer does nothing when `CloseOnBackdropClick` is `false`. +- A click inside the dialog never reaches the layer, so interacting with the + content cannot dismiss it. +- `Escape` cancels the dialog whose layer has focus when + `DialogOptions.CloseOnEscape` is set. +- `Escape` does nothing when `CloseOnEscape` is `false`. +- `Escape` reaches the dialog the user is in, not merely the topmost one. +- Closing resolves the caller's awaited `Result` exactly once, whether the + dialog closed itself, was cancelled by `Escape`, by the backdrop or + programmatically. + +### Focus and scroll + +- Opening a dialog moves focus into it. +- Opening a dialog focuses its first focusable element, or the layer itself when + it holds none, so focus is never left behind on the page. +- `Tab` and `Shift+Tab` cycle within the open dialog and do not reach the page + behind it. +- Focus that has escaped the layer is pulled back into it on the next `Tab`. +- Closing a dialog returns focus to the element that had it before the dialog + opened. +- Closing a dialog does **not** return focus when a follow-up dialog has already + taken it, so a handoff does not break the successor's trap. +- Opening a dialog locks scrolling of the page behind it. +- Scrolling is unlocked when the last dialog closes, not when the first one + does. + +### Exit and removal + +- A closing dialog plays its exit animation before it is removed from the render + tree. +- The entry is removed as soon as the exit animation ends. +- The entry is removed even if the animation's end is never reported, through a + watchdog on the component's own side. +- An exiting layer takes no pointer events, so a fading or stuck overlay can + never swallow a click. +- The backdrop fades out only once every remaining dialog is exiting. +- The backdrop's exit is a transition rather than an animation, so a fade + interrupted by a new dialog reverses from its current opacity instead of + restarting. +- Removing an entry detaches its JS listeners and disposes the object references + it handed to JS. + +### Sequential dialogs + +- A dialog opened while a sibling is still exiting enters with a short delay, so + the swap reads as a sequence rather than a collision. +- That delay is `--dur-fast`, and the incoming dialog holds its start state until + it elapses instead of flashing at full opacity first. +- A dialog opened with `AnimateHandoff` while a predecessor is exiting morphs + into it through a single view transition instead of the cross-fade. +- The predecessor is finalized immediately in that case, and never plays its own + exit animation. +- The predecessor's removal and the successor's mount are applied as one render, + so the transition captures one before-state and one after-state. +- A dialog opened with `AnimateHandoff` while no predecessor is exiting opens + normally. +- The morph falls back to the cross-fade in browsers without view-transition + support, during prerender, and under reduced motion. +- `DialogOptions.HandoffStyle` selects the morph tier, and defaults to the + glass-merge tier rather than the shape-only one. +- A handoff transition uses a transition instance of the provider's own, so it + is unaffected by any other view transition running in the host application. + +### Robustness + +- Every JS call is guarded against a disconnected circuit, so a client that has + gone away cannot throw out of a render or a disposal. +- Disposal detaches the interop of every layer still mounted, cancels every + watchdog and disposes every object reference and the transition instance. +- A second close request for a dialog that is already exiting is ignored rather + than starting a second exit. + +### Keyboard and accessibility + +- The dialog's `role="dialog"` and `aria-modal` come from `DrylDialog` (`F1`); + the provider adds the behaviour that makes them true — the trap, the scroll + lock and `Escape`. +- Each layer is focusable programmatically but takes no tab stop of its own, so + it can receive focus when a dialog has no focusable content without adding a + stop when it does. +- The page behind an open dialog is unreachable by keyboard. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The backdrop derives from `--backdrop` and frosts what is behind it, which is + the one place in the library where a full-viewport blur is warranted + (`DESIGN-07`). +- The backdrop sits at `--z-modal`. +- 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**, deliberately (`AI-05`). It is + chrome with no content of its own: the AI state belongs to the dialog frame + inside it, where `DialogOptions.Ai` and `IDrylDialogInstance.SetAi` put it, and + an aura on a full-viewport backdrop would ring the entire screen rather than + the surface doing the work. +- The provider re-renders when an instance's AI state changes, so `SetAi` from + inside a dialog reaches the frame. + +## Recorded debt + +- The backdrop's blur radius is written as a literal in + `code/DRYL.Components/wwwroot/dryl.css` rather than as `--glass-fx-float`. The + backdrop is not a glass panel — it is the wash behind one — and no token covers + it today. Recorded as debt, not as compliance. +- The exit watchdog's grace period is a literal in the component. It is a + robustness timeout rather than an animation duration, so `DESIGN-10` does not + bind it, but it must stay longer than the exit animation it backs up: a + shortened `--dur-med` would not break it, a lengthened one would. +- The view-transition name used for a handoff is a fixed string, so only one + handoff chain may be mid-transition per provider. That matches the sequential, + non-stacked pattern the option is for; a second simultaneous chain voids its + own morph rather than misbehaving. + +## 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`. `--backdrop` is mode-dependent and + defined in both LIGHT-TOKEN-SET copies; the component defines no mode-specific + rule. +- **Enter/exit animation** — the backdrop fades in and out, and the provider is + the component that makes the dialog's *exit* possible at all by holding the + entry mounted until the animation has finished (`DESIGN-12`). +- **Keyboard and a11y** — the "Keyboard and accessibility" and "Focus and + scroll" criteria above. This component is where the library's modal keyboard + contract is implemented. +- **AI mode** — an explicit **no**, with its reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Pages/DemoDialog.razor`; every + example on that page runs through this provider, and + `Components/Examples/Dialog/Sequential.razor` is the handoff case. +- **`ComponentCatalog`** — covered by the `"Dialog"` / `dialog` entry in + `DRYL.Website/Components/ComponentCatalog.cs`. The provider has no entry of its + own and should not: it is not a component a reader browses for and places on a + page, it is the one-line mount that page documents. diff --git a/specs/E6 Dialogs/F3 DrylConfirmDialog.md b/specs/E6 Dialogs/F3 DrylConfirmDialog.md new file mode 100644 index 0000000..836ebe4 --- /dev/null +++ b/specs/E6 Dialogs/F3 DrylConfirmDialog.md @@ -0,0 +1,144 @@ +# DrylConfirmDialog + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Dialogs/DrylConfirmDialog.razor + +## User Story + +As a Blazor developer building an app on DRYL, I want to ask the user a yes/no +question in one awaited line of C#, so that guarding a destructive action does +not cost me a component, a parameter bag and a footer full of buttons every time. + +## Description + +`DrylConfirmDialog` is the dialog behind `IDrylDialogService.ShowConfirmAsync`. +It is a two-button question: a message, a cancel and a confirm. A consumer does +not place it, name it or pass parameters to it — they call `ShowConfirmAsync` +and await a `DialogResult`. The component exists so that the convenience method +has something to show, and so that every confirmation in an app built on DRYL +looks and answers the same way. + +It is a `DrylDialog` with content, and nothing more. The frame, the size, the +title, the close button, the focus trap and the dismissal all come from `F1` and +`F2`; this component contributes a paragraph and two buttons. + +The two answers are not symmetrical, and that is the point of specifying it: +confirming closes with a positive result, while cancelling — the button, the +close button, `Escape` or the backdrop — all produce the same cancelled result. +A caller therefore has exactly one thing to check. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Message` | `string` | `""` | The question shown in the body. | +| `ConfirmLabel` | `string` | `"Confirm"` | Label of the confirming button. | +| `CancelLabel` | `string` | `"Cancel"` | Label of the cancelling button. | +| `Instance` | `IDrylDialogInstance` | — | Cascading, supplied by `DrylDialogProvider`. Required. | + +The parameters are set by `ShowConfirmAsync` from its own arguments; they are +public because the service passes them through a `DialogParameters` bag, not +because the component is meant to be placed by hand. + +The dialog's title is not a parameter of this component: it is the title passed +to `ShowConfirmAsync`, which reaches the header through the instance. + +## Acceptance Criteria + +### Content + +- The component renders a `DrylDialog` as its frame. +- The body renders `Message` and nothing else. +- The footer renders exactly two buttons. +- The cancelling button precedes the confirming one, so the safe answer is not + the one under the cursor's resting position on the primary. +- The confirming button renders `ConfirmLabel`. +- The cancelling button renders `CancelLabel`. +- The confirming button is rendered as the primary action. +- The cancelling button is rendered as a quiet action, so the two answers are + not equally loud. + +### Answering + +- Activating the confirming button closes the dialog with a non-cancelled + result. +- Activating the cancelling button closes the dialog with a cancelled result. +- The header's close button produces the same cancelled result as the cancelling + button. +- `Escape` produces the same cancelled result, when the caller's options allow + it (`F2`). +- A backdrop click produces the same cancelled result, when the caller's options + allow it (`F2`). +- `ShowConfirmAsync` returns only once one of those has happened. +- The awaited result is produced exactly once, however the dialog was dismissed. + +### Defaults from the service + +- `ShowConfirmAsync` opens the dialog at `DialogSize.Small` when the caller + passes no options. +- `ShowConfirmAsync` uses the caller's `DialogOptions` unchanged when they pass + one, including its size. +- The confirm and cancel labels default to English one-word labels, and a caller + who needs another language passes them. + +### Motion + +- The dialog enters and exits with the frame's own animation (`F1`, `F2`); the + component adds no motion of its own and needs none — it is content inside an + animated shell. + +### Keyboard and accessibility + +- Both buttons are reachable by `Tab` and operable by `Enter` and `Space`, as + ordinary buttons. +- Focus lands inside the dialog when it opens, on the first focusable element + (`F2`). +- Neither button is auto-confirmed by `Enter` from elsewhere in the dialog: a + confirmation is answered deliberately, not by a stray keystroke. +- The dialog is announced by its title, through the frame's labelling (`F1`). + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The message is rendered in `--fg-muted`, the body text color of the frame. +- The component branches on no color mode and holds no mode-assuming value + (`DESIGN-02`). + +### AI mode + +- The component takes **no `Ai` parameter**, deliberately (`AI-05`). A + confirmation is a question the user answers, not an operation the AI performs; + the frame around it can still carry an aura through `DialogOptions.Ai` when a + caller wants one, which is the right level for it. + +## Recorded gaps + +- The body paragraph carries an inline `style` with a literal margin. It is + layout rather than color, and it exists because the component contributes bare + text into a body it does not style. Recorded as debt. +- The message is not referenced by `aria-describedby` on the dialog root. It is + read as dialog content by screen readers today, but the explicit association + is missing. Recorded as a gap, shared with `F4 DrylAlertDialog`. +- `Instance` is declared non-nullable and the component would fail if it were + rendered outside `DrylDialogProvider`. That is true by construction — the + service is the only thing that shows it — but it is not enforced. + +## 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`. +- **Enter/exit animation** — inherited from the frame and the provider's layer; + the exception `DESIGN-11` allows is not needed, because the component is + animated — by the shell it is rendered into (`F1`, `F2`). +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above, plus + the recorded `aria-describedby` gap. +- **AI mode** — an explicit **no**, with its reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Examples/Dialog/ConfirmAlert.razor` + on `Components/Pages/DemoDialog.razor`. +- **`ComponentCatalog`** — covered by the `"Dialog"` / `dialog` entry in + `DRYL.Website/Components/ComponentCatalog.cs`. It has no entry of its own and + should not: it is not placed by a consumer, it is the shape + `ShowConfirmAsync` takes, and that method is documented on the dialog page. diff --git a/specs/E6 Dialogs/F4 DrylAlertDialog.md b/specs/E6 Dialogs/F4 DrylAlertDialog.md new file mode 100644 index 0000000..2724ba4 --- /dev/null +++ b/specs/E6 Dialogs/F4 DrylAlertDialog.md @@ -0,0 +1,129 @@ +# DrylAlertDialog + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Dialogs/DrylAlertDialog.razor + +## User Story + +As a Blazor developer building an app on DRYL, I want to tell the user something +and wait until they have seen it, in one awaited line of C#, so that a message +that must not be missed does not cost me a component of its own. + +## Description + +`DrylAlertDialog` is the dialog behind `IDrylDialogService.ShowAlertAsync`: a +message and a single acknowledging button. It is the one-answer sibling of +[`F3 DrylConfirmDialog`](F3%20DrylConfirmDialog.md), and everything said there +about being service-driven applies here — a consumer calls the service, they do +not place this component. + +The difference from a toast is the point of it. A toast informs and disappears; +an alert **blocks until acknowledged**. Choosing between them is a choice about +whether the user may miss the message, and this component is the "may not" half. + +Its result is deliberately uninteresting: whatever the user does — the button, +the close button, `Escape`, the backdrop — the caller's await returns and the +flow continues. There is nothing to branch on, and the spec says so rather than +leaving a caller to check a `Canceled` flag that carries no meaning here. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Message` | `string` | `""` | The message shown in the body. | +| `OkLabel` | `string` | `"OK"` | Label of the acknowledging button. | +| `Instance` | `IDrylDialogInstance` | — | Cascading, supplied by `DrylDialogProvider`. Required. | + +The dialog's title is not a parameter of this component: it is the title passed +to `ShowAlertAsync`, which reaches the header through the instance. + +## Acceptance Criteria + +### Content + +- The component renders a `DrylDialog` as its frame. +- The body renders `Message` and nothing else. +- The footer renders exactly one button. +- The button renders `OkLabel`. +- The button is rendered as the primary action. + +### Acknowledging + +- Activating the button closes the dialog with a non-cancelled result. +- `ShowAlertAsync` returns only once the dialog has closed. +- `ShowAlertAsync` returns whichever way the dialog was dismissed, so a caller + never has to branch on the outcome. +- The awaited result is produced exactly once, however the dialog was dismissed. +- The dialog cannot be dismissed by any means the caller's `DialogOptions` have + switched off, so an alert that must be acknowledged explicitly is configurable + through `CloseOnEscape` and `CloseOnBackdropClick`. + +### Defaults from the service + +- `ShowAlertAsync` opens the dialog at `DialogSize.Small` when the caller passes + no options. +- `ShowAlertAsync` uses the caller's `DialogOptions` unchanged when they pass + one, including its size. +- The acknowledging label defaults to an English one, and a caller who needs + another language passes it. + +### Motion + +- The dialog enters and exits with the frame's own animation (`F1`, `F2`); the + component adds no motion of its own and needs none — it is content inside an + animated shell. + +### Keyboard and accessibility + +- The button is reachable by `Tab` and operable by `Enter` and `Space`, as an + ordinary button. +- Focus lands inside the dialog when it opens, on its first focusable element — + the header's close button while it is shown, the acknowledging button + otherwise (`F2`). +- `Tab` cycles within the dialog and never reaches the page behind it (`F2`). +- The dialog is announced by its title, through the frame's labelling (`F1`). + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The message is rendered in `--fg-muted`, the body text color of the frame. +- The component branches on no color mode and holds no mode-assuming value + (`DESIGN-02`). + +### AI mode + +- The component takes **no `Ai` parameter**, deliberately (`AI-05`). An alert + reports a finished fact; there is no ongoing operation for an aura to + describe. A caller who is announcing the end of an AI operation sets + `DialogOptions.Ai` and the frame carries it, which is the right level for it. + +## Recorded gaps + +- The body paragraph carries an inline `style` with a literal margin, for the + same reason as in `F3`. Recorded as debt. +- The message is not referenced by `aria-describedby` on the dialog root, and + the dialog carries `role="dialog"` rather than `role="alertdialog"`. For a + component whose whole purpose is that the message is not missed, the more + specific role is the better fit. Recorded as a gap; changing it is a behaviour + change and belongs in an idea, not in a spec edit (`SPEC-01`). +- `Instance` is declared non-nullable and the component would fail if it were + rendered outside `DrylDialogProvider`. True by construction, not enforced. + +## 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`. +- **Enter/exit animation** — inherited from the frame and the provider's layer; + no exception is needed, the component is animated by the shell it renders into + (`F1`, `F2`). +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above, plus + the recorded role and `aria-describedby` gaps. +- **AI mode** — an explicit **no**, with its reason under "AI mode" above. +- **Demo page** — `DRYL.Website/Components/Examples/Dialog/ConfirmAlert.razor` + on `Components/Pages/DemoDialog.razor`. +- **`ComponentCatalog`** — covered by the `"Dialog"` / `dialog` entry in + `DRYL.Website/Components/ComponentCatalog.cs`. It has no entry of its own and + should not: it is the shape `ShowAlertAsync` takes, documented on that page. diff --git a/specs/E6 Dialogs/_Api.md b/specs/E6 Dialogs/_Api.md index 287ad5a..751e3bb 100644 --- a/specs/E6 Dialogs/_Api.md +++ b/specs/E6 Dialogs/_Api.md @@ -5,11 +5,148 @@ part of the data contract the 1.0 freeze binds. **Source folder:** `code/DRYL.Components/Dialogs/` -*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`).* +The category is service-driven and that shapes its API. A consumer does not +place a dialog on a page; they mount `DrylDialogProvider` once in the root +layout, inject `IDrylDialogService`, and call `ShowAsync` for a dialog *type*. +The types below are what travels between those two ends. The component specs +describe behaviour; this file describes the contract. -## Shared types +Everything here lives in the `DRYL.Components.Dialogs` namespace, except +`DrylDialog`, `DrylDialogProvider`, `DrylAlertDialog` and `DrylConfirmDialog` +themselves. -*(phase C)* +## `DialogSize` + +Width preset of a dialog. An `enum`. + +| Value | Meaning | +|---|---| +| `Small` | Compact — confirmations and alerts. | +| `Medium` | The default — forms and content. | +| `Large` | Wide — rich content, multi-column layouts. | +| `Fullscreen` | Fills the viewport — immersive flows. | + +Each value maps to one CSS class on the dialog. The widths themselves live in +`code/DRYL.Components/wwwroot/dryl.css` and are documented in +[`../../harness/tokens.md`](../../harness/tokens.md), never repeated in a spec +(`SPEC-07`). + +## `DialogOptions` + +Per-call configuration, passed to `ShowAsync`. A `sealed class` with settable +properties; every one has a default, so `new DialogOptions()` is a valid call. + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Size` | `DialogSize` | `DialogSize.Medium` | Width preset. | +| `CloseOnEscape` | `bool` | `true` | `Escape` cancels the dialog. | +| `CloseOnBackdropClick` | `bool` | `true` | A click outside the dialog cancels it. | +| `ShowCloseButton` | `bool` | `true` | The header shows a close button. | +| `Ai` | `AiState` | `AiState.None` | Initial AI state of the dialog frame. | +| `Class` | `string?` | `null` | Extra CSS class applied to the dialog container. | +| `AnimateHandoff` | `bool` | `false` | Morph into a predecessor that is still closing, instead of cross-fading. | +| `HandoffStyle` | `DrylViewTransitionStyle` | `DrylViewTransitionStyle.DepthGlass` | Morph tier for `AnimateHandoff`. Ignored while it is `false`. | + +`AnimateHandoff` is the one option that is not about a single dialog. It +addresses the sequential pattern an agent produces — close A, immediately open +B — and is opt-in per call. Keep it consistent across every step of a chain: a +step that does not set it simply falls back to the cross-fade for that step. + +## `DialogParameters` + +The parameter bag forwarded to the dialog component. Keys are the component's +`[Parameter]` property names. + +| Member | Purpose | +|---|---| +| `this[string name]` | Get or set a parameter. Setting `null` removes it. | +| `Add(string, object?)` | Add or replace a parameter; returns the bag, so calls chain. Adding `null` removes the key. | +| `Contains(string)` | Whether a parameter of that name is set. | +| `Count` | Number of parameters set. | +| `ToDictionary()` | Materialize for `DynamicComponent.Parameters`. | + +`null` is treated as "not set" rather than as a value, on both the indexer and +`Add`. A dialog parameter that must be able to be `null` therefore carries its +own default on the component rather than relying on the bag to deliver one. + +## `DialogResult` + +The outcome of a dialog. A `sealed class` with a private constructor and static +factories — the two outcomes are constructed, never assembled field by field. + +| Member | Purpose | +|---|---| +| `Canceled` | `true` when the dialog was dismissed without a confirmed outcome. | +| `Data` | Payload returned on confirmation; `null` when canceled. | +| `DialogResult.Ok()` | Confirmed, no payload. | +| `DialogResult.Ok(object?)` | Confirmed with a payload. | +| `DialogResult.Ok(T)` | Confirmed with a typed payload. | +| `DialogResult.Cancel()` | Canceled. | +| `DataAs()` | Read `Data` as `T`, or `default` when it is not a `T`. | + +`DataAs` never throws on a type mismatch. A dialog that returns the wrong +payload type yields `default`, which the caller can handle, rather than an +`InvalidCastException` from inside an awaited result. + +## `IDrylDialogService` + +The caller-facing API. Registered as **scoped** — one per Blazor circuit — by +`AddDrylComponents()`. + +| Member | Purpose | +|---|---| +| `ShowAsync(string?, DialogParameters?, DialogOptions?)` | Show a dialog of type `TDialog`; returns its `IDrylDialogReference`. `TDialog` is constrained to `IComponent`. | +| `ShowConfirmAsync(string, string, string, string, DialogOptions?)` | Show a confirmation with a cancel and a confirm button; awaits and returns the `DialogResult`. | +| `ShowAlertAsync(string, string, string, DialogOptions?)` | Show an alert with a single button; awaits and returns the `DialogResult`. | +| `OnDialogInstanceAdded` | Raised when a dialog is requested. Subscribed by `DrylDialogProvider`. | +| `OnDialogCloseRequested` | Raised when a dialog closes, so the provider can retire it. | +| `OnDialogInstanceUpdated` | Raised when a dialog's own state changes and the provider must re-render. | + +The three events are the seam between the service and the provider. They are +part of the public interface because the provider is an ordinary component that +subscribes to them, not because a consumer is expected to. + +`ShowAsync` returns as soon as the dialog is requested; `ShowConfirmAsync` and +`ShowAlertAsync` return only when the user has answered. That difference is +deliberate: the generic call hands back a handle the caller may keep, close +programmatically or await later, while the two convenience calls exist precisely +to be awaited in one line. + +`ShowConfirmAsync` and `ShowAlertAsync` default to `DialogSize.Small` when the +caller passes no options — a one-line question does not deserve a form-sized +frame. A supplied `DialogOptions` is taken as given, including its `Size`. + +## `IDrylDialogReference` + +The handle `ShowAsync` returns to the caller. + +| Member | Purpose | +|---|---| +| `Id` | Stable id of this dialog instance. | +| `Result` | A `Task` that completes when the dialog closes. | +| `Close(DialogResult)` | Close the dialog programmatically with a result. | +| `Cancel()` | Cancel the dialog programmatically. | + +## `IDrylDialogInstance` + +The same object, seen from inside the dialog component, cascaded by +`DrylDialogProvider`. + +| Member | Purpose | +|---|---| +| `Id` | Stable id of this dialog instance. | +| `Title` | The title supplied at `ShowAsync` time. | +| `Options` | The `DialogOptions` the dialog was opened with. | +| `Ai` | The dialog frame's current AI state; starts at `DialogOptions.Ai`. | +| `Close(DialogResult)` | Close with a result. | +| `Cancel()` | Cancel — equivalent to closing with `DialogResult.Cancel()`. | +| `SetAi(AiState)` | Change the frame's AI state at runtime and re-render. | + +One object implements both interfaces, so the caller's handle and the dialog's +instance are the same identity seen from two sides. `Close` is idempotent: the +first call completes `Result`, later ones are ignored, so a dialog that both +answers and is dismissed does not complete twice. + +`SetAi` is what makes a dialog an AI surface for the length of an operation: the +dialog component calls it while it works, and the frame's aura follows without +the dialog re-rendering its own chrome. diff --git a/specs/E6 Dialogs/_Interop.md b/specs/E6 Dialogs/_Interop.md index ebf54e8..67d3092 100644 --- a/specs/E6 Dialogs/_Interop.md +++ b/specs/E6 Dialogs/_Interop.md @@ -4,16 +4,83 @@ 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.* +All of it belongs to `DrylDialogProvider`. `DrylDialog`, `DrylConfirmDialog` and +`DrylAlertDialog` call no JS at all: the frame is CSS, and everything below is +the provider's doing. ## Interop -none *(phase C)* +| Entry point | Called by | Purpose | +|---|---|---| +| `dryl.modal.attach(layer, dotNetRef, options)` | `DrylDialogProvider` | Installs the key handler, the focus trap and the body scroll lock on one dialog layer. | +| `dryl.modal.detach(layer)` | `DrylDialogProvider` | Removes them again and hands focus back. | +| `dryl.motion.onExit(layer, dotNetRef, options)` | `DrylDialogProvider` | Reports the end of the dialog's exit animation, so the entry can leave the render tree only once it is invisible. | +| `dryl.motion.clearExit(layer)` | `DrylDialogProvider` | Drops that listener. | +| `IDrylViewTransition.RunAsync` | `DrylDialogProvider` | Wraps a handoff — the predecessor's removal plus the successor's mount — in one browser view transition. | + +`dryl.modal.attach` carries three duties that are easy to read as one: + +- **`Escape`** invokes back into the dialog's own reference, so the key reaches + the dialog the user is looking at rather than the topmost one. It is installed + only when `DialogOptions.CloseOnEscape` is set. +- **The focus trap** cycles `Tab` and `Shift+Tab` inside the layer, and pulls + focus back in when it has escaped. A layer with nothing focusable takes focus + itself rather than letting `Tab` walk out. +- **The scroll lock** is reference-counted across layers, so a stack of dialogs + locks the body once and unlocks it when the last one is gone. + +`dryl.modal.detach` restores focus to the element that had it before the dialog +opened — but only when focus is still inside the closing dialog or has been lost +to the body. A follow-up dialog may already own it, and stealing it back would +break that dialog's trap. + +The view transition uses a **dedicated** `DrylViewTransition` instance rather +than the DI-scoped one, so a dialog handoff is independent of whatever else in +the host application is mid-transition. It falls back to the plain CSS +cross-fade in browsers without View Transition support, during prerender, and +under reduced motion. ## Services -none *(phase C)* +| Service | Lifetime | Registered by | +|---|---|---| +| `IDrylDialogService` | scoped | `AddDrylComponents()` | +| `IDrylViewTransition` | scoped | `AddDrylComponents()` | + +Scoped means one per Blazor circuit, which is what makes the service's dialog +list per-user rather than per-server. `DrylDialogProvider` consumes both by +injection; the category registers nothing itself. + +The provider is also the reason the service's three events are public: it +subscribes to them in `OnInitialized` and unsubscribes in `DisposeAsync`. A +provider that is disposed without unsubscribing would keep a dead component +alive through the scoped service for the life of the circuit. ## Cleanup -none *(phase C)* +`DrylDialogProvider` implements `IAsyncDisposable`, and its disposal is not +optional bookkeeping — every item below is a leak, a stuck overlay or a dead +circuit if it is dropped: + +- **The three service subscriptions** are removed. +- **Every `DotNetObjectReference`** the provider handed to JS — the escape + bridge and the exit bridge, per entry — is disposed. +- **`dryl.modal.detach` and `dryl.motion.clearExit`** run for every layer still + mounted, so no listener outlives its component. +- **The exit watchdog** of every entry is cancelled and disposed. +- **The lazily created `DrylViewTransition`** is disposed. +- Every JS call is wrapped against `JSDisconnectedException`: on Blazor Server a + circuit can be gone before disposal runs, and a throw there would take the + teardown down with it. + +**The watchdog is a rule, not a detail.** A dialog's entry stays mounted through +its exit animation and is removed when `dryl.motion.onExit` reports the end. If +that report is lost — an interop race, DOM churn, a reduced-motion edge — the +entry would stay in the tree as an invisible, click-eating full-viewport +overlay. A C#-side timer finalizes the entry regardless, and the exiting layer +carries `pointer-events: none` as the second net. Never rely on `animationend` +alone. + +`DrylDialog` itself has exactly one cleanup duty and no JS: it implements +`IDisposable` for the `AuraLifecycle` that keeps its AI aura mounted for one +`--dur-slow` beat after the state drops, and disposes it with the component. From 4176ba04063bb39b8afcd8a0ecf0df2760e5ebfc Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 22:22:33 +0200 Subject: [PATCH 07/15] spec(E7): the shared half of the feedback category, in writing _Api.md is mostly an inventory, because the eight components share almost nothing with each other - what they share is with the rest of the library. It records the one thing that is genuinely category-wide and genuinely awkward: three enums live at namespace level and five are nested, ProgressSize sits in a file named after another enum, and four separate size enums exist for what a reader would call one concept. All of it is bound by the 1.0 freeze, so it is recorded rather than corrected. _Interop.md is the unusual one. No component in the category obtains an IJSRuntime. DrylTooltip has an interop surface it never calls - one set of delegated document listeners drives every tooltip on the page, which is why a toolbar of triggers costs nothing and why a tooltip works during prerender. DrylNotifications gets its interop by composing DrylPopover. The cleanup that matters is the one subscription: a bell that does not unsubscribe from the scoped notification service is kept alive by it for the rest of the circuit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- specs/E7 Feedback/_Api.md | 243 +++++++++++++++++++++++++++++++++- specs/E7 Feedback/_Interop.md | 94 ++++++++++++- 2 files changed, 327 insertions(+), 10 deletions(-) diff --git a/specs/E7 Feedback/_Api.md b/specs/E7 Feedback/_Api.md index f7edfbd..8eefd47 100644 --- a/specs/E7 Feedback/_Api.md +++ b/specs/E7 Feedback/_Api.md @@ -5,11 +5,242 @@ part of the data contract the 1.0 freeze binds. **Source folder:** `code/DRYL.Components/Components/Feedback/` -*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`). The category holds eight components — +`DrylAlert`, `DrylTooltip`, `DrylSpinner`, `DrylSkeleton`, `DrylProgress`, +`DrylEmptyState`, `DrylErrorBoundary` and `DrylNotifications` — and unlike +`E4 Charts` they share almost nothing with each other. What they share is with +the rest of the library: seven of the eight take `Ai` and `Aura` and render the +one aura vocabulary specified in `E3 AI`. -## Shared types +So this file is mostly an inventory of per-component types. It is worth having +anyway, because the eight components disagree about **where a type is +declared**, and the 1.0 freeze binds that disagreement into the call sites of +every consumer. -*(phase C)* +## Where the enums live — and why it is not uniform + +Three enums are declared at namespace level in their own file; five are nested +inside the component that uses them. The difference is visible to a consumer, +because a nested enum must be written qualified: + +```razor + + +``` + +| Type | Declared in | Written as | +|---|---|---| +| `ProgressVariant` | `code/DRYL.Components/Components/Feedback/ProgressVariant.cs` | `ProgressVariant.Success` | +| `ProgressSize` | `code/DRYL.Components/Components/Feedback/ProgressVariant.cs` | `ProgressSize.Large` | +| `EmptyStateSize` | `code/DRYL.Components/Components/Feedback/EmptyStateSize.cs` | `EmptyStateSize.Small` | +| `DrylAlert.AlertKind` | `DrylAlert.razor` | `DrylAlert.AlertKind.Danger` | +| `DrylSpinner.SpinnerVariant` | `DrylSpinner.razor` | `DrylSpinner.SpinnerVariant.Dots` | +| `DrylSpinner.SpinnerSize` | `DrylSpinner.razor` | `DrylSpinner.SpinnerSize.Large` | +| `DrylSkeleton.SkeletonVariant` | `DrylSkeleton.razor` | `DrylSkeleton.SkeletonVariant.Card` | +| `DrylSkeleton.SkeletonSize` | `DrylSkeleton.razor` | `DrylSkeleton.SkeletonSize.Small` | +| `DrylTooltip.TooltipPlacement` | `DrylTooltip.razor` | `DrylTooltip.TooltipPlacement.Bottom` | + +`ProgressSize` is the one that shows this is history rather than design: it sits +in `ProgressVariant.cs`, a file named after the *other* enum. Recorded as a +fact, not corrected — moving a public type between declaration sites is a source +break for every consumer that wrote it qualified, and the freeze is in force. + +Two of the three namespace-level enums have nested twins with the same job: +`ProgressSize`, `EmptyStateSize`, `DrylSpinner.SpinnerSize` and +`DrylSkeleton.SkeletonSize` are four separate enums for what a reader would call +one concept. They are **not** interchangeable, and no component accepts another +component's size type. + +## `DrylAlert.AlertKind` + +Semantic variant of an alert. Nested in `DrylAlert`. + +| Member | Notes | +|---|---| +| `Info` | The default of `DrylAlert.Kind`. Also the fallback for any unmatched value. | +| `Success` | | +| `Warning` | Announced assertively — see `F1`. | +| `Danger` | Announced assertively — see `F1`. Used by `DrylErrorBoundary` for its fallback surface. | +| `Ai` | Semantic AI provenance, chosen **independently** of the `Ai` parameter. | + +`Kind` and `Ai` answer different questions and neither implies the other: +`Kind` says what the message *is*, `Ai` says whether something is *happening*. +A `Warning` alert can carry `AiState.Thinking` while its check is still running, +and an `AlertKind.Ai` alert can sit at `AiState.None` once its content is final. + +## `ProgressVariant` + +Color treatment of a `DrylProgress` fill. Namespace-level. + +| Member | Notes | +|---|---| +| `Accent` | The default of `DrylProgress.Variant`. The accent gradient. | +| `Success` | The `--success` semantic. | +| `Warning` | The `--warning` semantic. | +| `Danger` | The `--danger` semantic. | + +`Accent` is the only member that maps to no modifier class of its own — it is +the unmodified bar. The three others each add one. + +## `ProgressSize` + +Rendered thickness of a `DrylProgress` track. Namespace-level, declared in +`ProgressVariant.cs`. + +| Member | Notes | +|---|---| +| `Small` | | +| `Medium` | The default of `DrylProgress.Size`. | +| `Large` | | + +## `EmptyStateSize` + +Overall size of a `DrylEmptyState`. Namespace-level. Two members, not three — +the odd one out among the size enums. + +| Member | Notes | +|---|---| +| `Small` | Compact, for an empty state inside a panel or a dropdown. `DrylNotifications` uses it for its own empty inbox. | +| `Medium` | The default of `DrylEmptyState.Size`. Full-page or large-card. | + +## `DrylSpinner.SpinnerVariant` + +Visual style of a spinner. Nested in `DrylSpinner`. + +| Member | Notes | +|---|---| +| `Ring` | The default of `DrylSpinner.Variant`. A rotating gradient arc. | +| `Dots` | Three dots in a sequential wave. The only variant whose host is a pill rather than a circle. | +| `Pulse` | Concentric rings expanding out of a core. | + +## `DrylSpinner.SpinnerSize` + +| Member | Notes | +|---|---| +| `Small` | | +| `Medium` | The default of `DrylSpinner.Size`. | +| `Large` | | + +Each member sets the wrapper's size custom properties; every child dimension is +derived from them, so a size change never means editing a second value. + +## `DrylSkeleton.SkeletonVariant` + +Shape of a placeholder. Nested in `DrylSkeleton`. + +| Member | Notes | +|---|---| +| `Line` | The default of `DrylSkeleton.Variant`. One bar, width overridable through `Width`. | +| `Text` | `Lines` stacked bars with varied widths. | +| `Avatar` | One circle. | +| `Card` | Composite: header avatar, two header lines, an image block, a body block. | +| `Image` | One wide rectangle. | +| `Custom` | Renders `ChildContent` instead of a built-in shape. | + +`Custom` is the one member that makes the component's CSS classes part of its +public contract: a consumer's own layout uses `skel`, `skel-circle` and +`skel-rect` to get the shimmer, and those class names are therefore as frozen as +the parameters. + +## `DrylSkeleton.SkeletonSize` + +| Member | Notes | +|---|---| +| `Small` | | +| `Medium` | The default of `DrylSkeleton.Size`. | +| `Large` | | + +## `DrylTooltip.TooltipPlacement` + +Preferred side of the trigger. Nested in `DrylTooltip`. + +| Member | Notes | +|---|---| +| `Top` | The default of `DrylTooltip.Placement`. | +| `Bottom` | | +| `Left` | | +| `Right` | | + +A **preference**, not an instruction: the bubble flips to the opposite side when +the viewport has no room on the preferred one (`_Interop.md`). + +## `DrylNotification` + +One entry in the notification inbox. A `sealed class` — mutable, so `Read` can +be toggled in place. Identity is `Id`. + +Declared in `code/DRYL.Components/Notifications/DrylNotification.cs`, outside the +category's source folder. `SPEC-02` derives a *component's* category from its +path; a supporting type has no such rule, and this one is specified here because +`DrylNotifications` is the only thing that consumes it. + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Id` | `string` | a new GUID | Stable identity. `init`-only. Set it yourself to de-duplicate. | +| `Title` | `string` | `string.Empty` | Headline. | +| `Message` | `string?` | `null` | Supporting line under the title. | +| `Icon` | `string?` | `null` | `DrylIcon` name for the leading chip. | +| `Timestamp` | `DateTimeOffset` | `DateTimeOffset.Now` | Drives the relative "x ago" label. | +| `Read` | `bool` | `false` | Unread entries show the accent dot and count toward the badge. | +| `Ai` | `AiState` | `AiState.None` | AI provenance. Anything but `AiState.None` gives the row the shared aura. | + +`Id` being `init`-only and defaulted is what makes `@key` stable across +re-renders; a caller who wants idempotent pushes supplies their own. + +## `IDrylNotificationService` + +The service-driven half of the inbox. Registered scoped by +`AddDrylComponents()` — see [`_Interop.md`](_Interop.md). + +| Member | Signature | Purpose | +|---|---|---| +| `Notifications` | `IReadOnlyList` | Every entry, newest first. | +| `UnreadCount` | `int` | Entries whose `Read` is `false`. | +| `Add` | `DrylNotification Add(DrylNotification notification)` | Adds an entry at the front and returns it. | +| `Add` | `DrylNotification Add(string title, string? message = null, string? icon = null, AiState ai = AiState.None)` | Builds and adds an entry. | +| `MarkRead` | `void MarkRead(string id)` | Marks one entry read. | +| `MarkAllRead` | `void MarkAllRead()` | Marks every entry read. | +| `Remove` | `void Remove(string id)` | Removes one entry. | +| `Clear` | `void Clear()` | Removes every entry. | +| `OnChanged` | `event Action?` | Raised when the list changes, so a bound component re-renders. | + +`OnChanged` is raised only on an **actual** change: marking an already-read entry +read, removing an unknown id or clearing an empty list raise nothing. That is +what keeps a bell in an app shell from re-rendering on every no-op call. + +The default implementation is `internal`; a consumer replacing the service +registers their own `IDrylNotificationService` and never names the class. + +## The AI parameters + +Six of the eight components — `DrylAlert`, `DrylSpinner`, `DrylSkeleton`, +`DrylProgress`, `DrylEmptyState` and `DrylErrorBoundary` — 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 two remaining components are the exceptions, each for +its own reason: `DrylNotifications` takes `Aura` but no `Ai`, because the state +belongs to each `DrylNotification` rather than to the inbox around them; and +`DrylTooltip` takes neither, because it renders no surface of its own to put an +aura on. + +## `Class` and `AdditionalAttributes` + +All eight 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. That is verified for two of the +eight in `tests/DRYL.Components.Tests/ClassMergeTests.cs`. diff --git a/specs/E7 Feedback/_Interop.md b/specs/E7 Feedback/_Interop.md index 3bacfca..c02482b 100644 --- a/specs/E7 Feedback/_Interop.md +++ b/specs/E7 Feedback/_Interop.md @@ -4,16 +4,102 @@ 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.* +The category is unusually quiet here. **No component in it calls JS from C#** — +there is no `IJSRuntime` injection anywhere under +`code/DRYL.Components/Components/Feedback/`. `DrylSpinner`, `DrylSkeleton`, +`DrylProgress`, `DrylAlert`, `DrylEmptyState` and `DrylErrorBoundary` are CSS and +markup end to end. That is why none of them needs a prerender guard: there is no +`OnAfterRenderAsync` interop to guard. + +Two components are the exceptions, and neither is an exception in the usual way. ## Interop -none *(phase C)* +| Entry point | Reached by | Purpose | +|---|---|---| +| `dryl.tooltip` (delegated document listeners) | `DrylTooltip`, **without calling it** | Shows, positions and hides the tooltip bubble. | +| `dryl.tooltip.hide()` | any consumer, optional | Hides the current bubble immediately. | +| `dryl.popover.*` | `DrylNotifications`, transitively | Opening, portalling and positioning the inbox panel. | + +### `DrylTooltip` — an interop surface with no interop call + +`DrylTooltip` renders a wrapper carrying `data-tt` and `data-tt-placement` and +nothing else. It never obtains an `IJSRuntime`, never registers, never +unregisters. A single set of **delegated document listeners**, installed once by +`dryl.tooltip` in `code/DRYL.Components/wwwroot/js/dryl.js` and guarded by +`window.__drylTooltipBound`, drives every tooltip on the page by finding the +nearest `[data-tt]` ancestor of the event target. + +The consequences are the point of the design: + +- A tooltip costs **no** per-instance interop, which is what makes it usable in + a table cell or a toolbar with dozens of triggers. +- A tooltip works during prerender, because there is nothing to attach. +- A trigger removed from the DOM mid-hover cannot leak a listener, because it + never had one. `pointerout` and the `isConnected` guard in the placement + routine cover the bubble. + +The bubble itself is a **body-level portal**: one lazily created `div.tt-portal` +appended to `document.body`, `position: fixed`, reused by every tooltip and +never removed. It escapes ancestor `overflow` and `backdrop-filter` clipping — +the reason a tooltip inside a glass card or an app bar is visible at all. + +Placement is resolved in JS, not in CSS: the routine measures the bubble +off-screen, flips to the opposite side when the preferred one has no viewport +room, and finally clamps the result into the viewport on both axes. The bubble +is revealed on the next animation frame so its enter transition runs rather than +being skipped. + +`dryl.tooltip` exposes exactly one function, `hide()`. Nothing in the library +calls it; it exists so an application that moves content under a stationary +pointer can dismiss a bubble that would otherwise linger. + +### `DrylNotifications` — interop by composition + +`DrylNotifications` calls no JS either. It composes `DrylPopover`, and every +interop duty of the inbox 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 inbox inherits both the +behaviour and the recorded debt. ## Services -none *(phase C)* +| Service | Lifetime | Registered by | +|---|---|---| +| `IDrylNotificationService` | scoped | `AddDrylComponents()` | + +Scoped means one per Blazor circuit, which is what makes an inbox per-user +rather than per-server. + +`DrylNotifications` resolves it in an unusual way, and deliberately: it injects +`IServiceProvider` and asks for `IDrylNotificationService` with `GetService`, +tolerating `null`. A required-service injection would make the component +unusable for anyone who did not call `AddDrylComponents()`, and unusable in +controlled mode, where the service is genuinely not wanted. The service is also +not resolved at all when `Items` is set — controlled mode never touches it. + +No other component in the category injects anything. ## Cleanup -none *(phase C)* +Six components implement `IDisposable` for the same single duty: `AuraLifecycle` +keeps the AI aura mounted for one `--dur-slow` beat after the state drops so it +dissolves rather than snapping away, and it holds a timer that must be disposed +with the component. `DrylAlert`, `DrylSpinner`, `DrylSkeleton`, `DrylProgress`, +`DrylEmptyState` and `DrylNotifications` each dispose theirs. + +`DrylNotifications` carries one duty more, and it is the one that matters: +**it unsubscribes from `IDrylNotificationService.OnChanged`.** The service is +scoped and outlives any single component; a bell disposed without unsubscribing +would be kept alive by the service for the rest of the circuit and would call +`StateHasChanged` on a dead component on the next push. The subscription is +taken in `OnInitialized` only in service-driven mode, and the disposal is +null-safe for controlled mode where it was never taken. + +`DrylTooltip` and `DrylErrorBoundary` implement no disposal and need none: +neither holds a timer, a subscription or a JS reference. `DrylErrorBoundary` +forwards `Ai` and `Aura` to the `DrylAlert` it renders, and that alert disposes +its own aura lifecycle. + +Nothing in the category hands a `DotNetObjectReference` to JS, so nothing in the +category can leak one. From 96ceb113c03ea33a780c9da52e5564333c200ae2 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 22:22:45 +0200 Subject: [PATCH 08/15] spec(E7): all eight feedback components, reverse-engineered (31/127) F1 DrylAlert, F2 DrylTooltip, F3 DrylSpinner, F4 DrylSkeleton, F5 DrylProgress, F6 DrylEmptyState, F7 DrylErrorBoundary, F8 DrylNotifications. Written from the code, the CSS and the tooltip JS - not from the doc comments. Recorded gaps rather than glossed, and several of them are real: DrylProgress clamps its fill but not its ARIA value, so 120 of 100 shows a full bar and tells a screen reader "120"; the skeleton's base shimmer keeps running under prefers-reduced-motion because the calming rules only cover the AI mutations; DrylNotifications writes Read onto the consumer's own object in controlled mode; its unread dot's aria-label sits on a bare span and may never be announced; DrylAlert renders a dismiss button that does nothing when no handler is attached. Two AI-mode decisions are written down as decisions. DrylTooltip gets no Ai and no Aura: it renders no surface of its own, and its bubble is one page-level element shared by every tooltip. DrylNotifications takes Aura but no Ai, because the state belongs to each entry rather than to the inbox around them. State: Implemented for all eight - the criteria were written against the code, and 1066 tests stay green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- specs/E7 Feedback/F1 DrylAlert.md | 202 ++++++++++++++++ specs/E7 Feedback/F2 DrylTooltip.md | 175 ++++++++++++++ specs/E7 Feedback/F3 DrylSpinner.md | 188 +++++++++++++++ specs/E7 Feedback/F4 DrylSkeleton.md | 223 +++++++++++++++++ specs/E7 Feedback/F5 DrylProgress.md | 229 ++++++++++++++++++ specs/E7 Feedback/F6 DrylEmptyState.md | 181 ++++++++++++++ specs/E7 Feedback/F7 DrylErrorBoundary.md | 218 +++++++++++++++++ specs/E7 Feedback/F8 DrylNotifications.md | 278 ++++++++++++++++++++++ 8 files changed, 1694 insertions(+) create mode 100644 specs/E7 Feedback/F1 DrylAlert.md create mode 100644 specs/E7 Feedback/F2 DrylTooltip.md create mode 100644 specs/E7 Feedback/F3 DrylSpinner.md create mode 100644 specs/E7 Feedback/F4 DrylSkeleton.md create mode 100644 specs/E7 Feedback/F5 DrylProgress.md create mode 100644 specs/E7 Feedback/F6 DrylEmptyState.md create mode 100644 specs/E7 Feedback/F7 DrylErrorBoundary.md create mode 100644 specs/E7 Feedback/F8 DrylNotifications.md diff --git a/specs/E7 Feedback/F1 DrylAlert.md b/specs/E7 Feedback/F1 DrylAlert.md new file mode 100644 index 0000000..418dd05 --- /dev/null +++ b/specs/E7 Feedback/F1 DrylAlert.md @@ -0,0 +1,202 @@ +# DrylAlert + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Feedback/DrylAlert.razor + +## User Story + +As a Blazor developer, I want to put a notice on the page that says what kind of +notice it is — informational, successful, a warning, a failure, or something an +AI produced — so that a reader recognises its weight before reading a word of +it, and a screen-reader user is interrupted only when the message is worth an +interruption. + +## Description + +`DrylAlert` is the in-flow feedback banner: an icon chip, an optional bold +title, a body, and an optional dismiss button. It stays where it is placed — +unlike `DrylToast`, which floats and expires — so it suits a form error, a +policy notice at the top of a page, or a provenance line above AI-generated +content. + +It carries **two independent axes** and that is its defining trait. `Kind` says +what the message is; `Ai` says whether something is happening to it. Neither +implies the other: a `Warning` alert can sit at `AiState.Thinking` while the +check that produced it is still running, and an `AlertKind.Ai` alert can sit at +`AiState.None` once its content is final. Both are specified in +[`_Api.md`](_Api.md). + +The component is also the fallback surface of +[`F7 DrylErrorBoundary`](F7%20DrylErrorBoundary.md), which renders it as +`AlertKind.Danger` and forwards its own `Ai` and `Aura` into it. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Kind` | `DrylAlert.AlertKind` | `AlertKind.Info` | Semantic variant; picks the icon and the accent. | +| `Title` | `string?` | `null` | Bold headline above the body. | +| `Icon` | `string?` | `null` | Icon override by `DrylIcon` name. `""` suppresses the icon. | +| `Dismissible` | `bool` | `false` | Renders a dismiss button. | +| `OnDismiss` | `EventCallback` | — | Raised when the dismiss button is activated. | +| `ChildContent` | `RenderFragment?` | `null` | Body content. | +| `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 alert's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the alert root. | + +`Dismissible` and `OnDismiss` are separate on purpose: the button appears +because the alert is dismissible, not because someone is listening. An alert +with `Dismissible` and no handler is a valid, if inert, configuration — see the +recorded gap below. + +## Acceptance Criteria + +### Structure + +- The component renders a single root element carrying the alert classes. +- The root carries the modifier class of its `Kind`, one per value. +- `AlertKind.Info` is the class used for any value the variant switch does not + match, so an unmapped value degrades to the neutral notice. +- The component renders an icon chip when an icon resolves. +- The component renders no icon chip when `Icon` is the empty string. +- The component renders a title element when `Title` is non-empty. +- The component renders no title element when `Title` is `null` or empty. +- The component renders a body element when `ChildContent` is set. +- The component renders no body element when `ChildContent` is `null`. +- The title and the body sit in one content region that takes the space left by + the icon and the dismiss button. +- The body region shrinks rather than overflowing when its text is longer than + the available width. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. + +### Icon resolution + +- `Icon` set to a non-empty value wins over the icon implied by `Kind`. +- `Icon` set to the empty string suppresses the icon entirely, including for a + `Kind` that would otherwise supply one. +- `Icon` left `null` resolves the icon from `Kind`. +- Each `AlertKind` value resolves to an icon name, and `AlertKind.Info` supplies + the fallback for an unmatched value. +- The icon chip is hidden from assistive technology, because the `Kind` it + depicts is already carried by the alert's role. + +### Dismissal + +- The dismiss button is rendered only when `Dismissible` is `true`. +- Activating the dismiss button raises `OnDismiss`. +- Activating the dismiss button when no handler is attached does nothing and + throws nothing. +- The component never removes itself from the DOM: unmounting the alert is the + host's decision, taken in response to `OnDismiss`. +- The dismiss button is a `type="button"`, so an alert inside a form cannot + submit it. +- A dismissible alert reserves the space its button occupies, so adding + `Dismissible` does not reflow the text. + +### Keyboard and accessibility + +- An alert of `AlertKind.Danger` or `AlertKind.Warning` carries `role="alert"`. +- Every other `Kind` carries `role="status"`. +- An alert of `AlertKind.Danger` or `AlertKind.Warning` carries + `aria-live="assertive"`, so a failure interrupts what the screen reader is + saying. +- Every other `Kind` carries `aria-live="polite"`, so a routine notice waits its + turn. +- The dismiss button carries an accessible label of its own, since it renders an + icon and no text (`UX-05`). +- The dismiss button is reachable by `Tab` and activated by `Enter` and `Space`, + because it is a native button and the component adds no key handling. +- The dismiss button shows a visible focus ring under `:focus-visible`. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The alert sits in the flow and therefore pairs `--glass-1` with + `--glass-fx-flow`, the pairing `DESIGN-06` requires for an in-flow surface. +- The border is `--line-strong` and the corner radius `--r-md`. +- Each `Kind` tints only its icon chip — background and text stay neutral — so + the accent is an indicator rather than the fill of a large surface + (`DESIGN-08`). +- `AlertKind.Success`, `Warning` and `Danger` derive their chip from the + matching semantic token. +- `AlertKind.Info` and `AlertKind.Ai` derive their chip from `--accent-soft` and + `--accent-ico`. +- The body text is `--fg-muted`, so the title reads as the louder of the two. +- 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 dismiss button transitions its background and its text color over + `--dur-fast` with `--ease-out` on hover. +- The alert has no mount animation of its own: it is an in-flow element the host + places, and a host that wants it to animate in wraps it in `DrylPresence` + (`DESIGN-12`). +- Under `prefers-reduced-motion: reduce` the alert remains fully legible and + fully dismissible. + +### AI mode + +- `Ai` defaults to `AiState.None`, so AI styling is opt-in. +- `Ai` is independent of `Kind`: every combination of the two renders. +- The aura variant follows `Aura` when set and a surrounding `DrylAiScope` + otherwise. +- The alert renders the shared aura vocabulary — ring, comet, glow, wash — + rather than an alert-specific AI treatment (`AI-02`). +- While an aura is present the alert's own border recedes to `--accent-line`, so + the rotating gradient ring dominates instead of competing with a second edge. +- 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, including on a second entry after leaving. +- The AI state changes nothing about the alert's layout, so text does not reflow + when an operation starts or ends. + +## Recorded gaps + +- **`Dismissible` without `OnDismiss` is inert.** The button renders, is + focusable and is announced, and pressing it does nothing visible. The + component cannot fix this alone — it does not own its own mounting — but a + consumer meets a control that lies about being actionable. +- **The dismiss button's label is fixed English** (`"Dismiss notification"`), + with no parameter to change it. Every other string on the component comes from + the consumer. +- **No tests of its own.** `DrylAlert` is covered by one assertion in + `tests/DRYL.Components.Tests/ClassMergeTests.cs` — that a splatted `class` + merges — and by nothing else. None of the criteria above is guarded by a test. +- **Literal type sizes and paddings.** The alert's font sizes, the icon chip's + dimensions and the banner's padding are literals in + `code/DRYL.Components/wwwroot/dryl.css`. `DESIGN-01` covers colors, radii, + shadows, durations and easings, which are tokens here; type scale is not + covered by a token today. Recorded as debt, not as compliance. +- **`AdditionalAttributes` and `Class` reach the same root element.** A `class` + entry splatted through `AdditionalAttributes` would clobber the component's + own classes; `Class` is the supported way to add one. + +## 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`. The glass fill, the frost and the + semantic chips 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: an in-flow banner is mounted and unmounted by its host, + which wraps it in `DrylPresence` when it should animate. The component's own + motion is the dismiss button's hover transition and the AI aura. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + role/`aria-live` split by `Kind` is the substantive decision: only a failure + or a warning interrupts. +- **AI mode** — yes, on its own axis. An alert is where AI provenance is stated + in words, so it carries both the semantic `AlertKind.Ai` and the ambient `Ai` + state, and neither is derived from the other. +- **Demo page** — `DRYL.Website/Components/Pages/DemoAlert.razor`, with the + examples `Components/Examples/Alert/Varianten.razor`, `.../NoTitle.razor`, + `.../NoIcon.razor`, `.../Dismissible.razor`, `.../AiStates.razor` and + `.../Lifecycle.razor`. +- **`ComponentCatalog`** — registered as `"Alert"` / `alerts` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. diff --git a/specs/E7 Feedback/F2 DrylTooltip.md b/specs/E7 Feedback/F2 DrylTooltip.md new file mode 100644 index 0000000..e44ddeb --- /dev/null +++ b/specs/E7 Feedback/F2 DrylTooltip.md @@ -0,0 +1,175 @@ +# DrylTooltip + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Feedback/DrylTooltip.razor + +## User Story + +As a Blazor developer, I want to wrap any trigger in a tooltip and get a bubble +that appears on hover and on keyboard focus, never gets clipped by the glass +card it sits in, and costs nothing per instance, so that I can label a toolbar +full of icon buttons without thinking about portals, positioning or interop. + +## Description + +`DrylTooltip` is a wrapper, not a bubble. It renders one inline element around +`ChildContent` and states two facts on it as data attributes: the text, and the +preferred side. Everything else — creating the bubble, measuring it, flipping +it, revealing it, hiding it — is done by delegated document listeners that are +installed once per page, and are specified in [`_Interop.md`](_Interop.md). + +That split is the reason the component has no lifecycle at all. It obtains no +`IJSRuntime`, registers nothing and disposes nothing, so a page may hold +hundreds of tooltips without hundreds of interop calls, and a tooltip works +unchanged during prerender. + +The bubble is **decorative** and hidden from assistive technology. It repeats +text the trigger is expected to carry itself: an icon-only button labels itself +with `AriaLabel`, and the tooltip shows the same words to people using a +pointer. A tooltip is therefore never the only place a label exists. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Text` | `string` | `string.Empty` | Bubble text. `[EditorRequired]`. | +| `Placement` | `DrylTooltip.TooltipPlacement` | `TooltipPlacement.Top` | Preferred side of the trigger. | +| `ChildContent` | `RenderFragment?` | `null` | The trigger. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the wrapper's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the wrapper. | + +The component takes no `Ai` and no `Aura`, and that is a decision rather than an +omission — see the cross-cutting evidence below. + +## Acceptance Criteria + +### Structure + +- The component renders one inline wrapper element around `ChildContent`. +- The wrapper carries `Text` in its tooltip data attribute. +- The wrapper carries the string form of `Placement` in its placement data + attribute. +- Each `TooltipPlacement` value maps to its own placement string. +- `TooltipPlacement.Top` supplies the placement string for any value the switch + does not match. +- The wrapper displays inline, so wrapping a trigger does not change the + trigger's position in a row. +- `Class` is merged onto the wrapper's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the wrapper. +- The component adds no element inside the wrapper: the trigger's own markup is + what renders. + +### The bubble + +- A bubble appears when the pointer enters the wrapper or anything inside it. +- A bubble appears when focus moves into the wrapper, so a keyboard user reaches + the same information as a pointer user. +- The bubble shows the wrapper's current `Text`. +- No bubble appears when `Text` is empty. +- The bubble hides when the pointer leaves the wrapper. +- The bubble hides when focus leaves the wrapper. +- The bubble hides on pointer-down, so it does not linger over the thing the + user just clicked. +- The bubble hides on scroll and on viewport resize, rather than staying at a + stale position. +- The bubble hides when its trigger is removed from the DOM while hovered. +- One bubble exists per page, and a second tooltip reuses it rather than adding + another. +- The bubble is rendered outside every component's subtree, so no ancestor's + clipping or frost can cut it off. + +### Placement + +- The bubble is placed on the side named by `Placement` when the viewport has + room for it there. +- The bubble flips to the opposite side when the preferred side has no room. +- The bubble is clamped into the viewport on both axes after placement, so it is + never partly off-screen. +- The bubble is centred on the trigger along the axis it is not offset on. +- The bubble is measured before it is revealed, so it is never seen at an + intermediate position. + +### Keyboard and accessibility + +- The bubble is hidden from assistive technology and adds no second announcement + of the trigger's own label. +- The bubble does not receive pointer events, so it can never intercept a click + meant for what is underneath it. +- The wrapper adds no `tabindex`, so the tooltip does not insert a stop into the + tab order; the trigger keeps whatever focusability it had. +- The component installs no key handler, so `Escape` inside the trigger reaches + whatever the trigger does with it. +- A trigger that conveys information only through its tooltip is a consumer + error: the trigger carries its own accessible name, and the tooltip repeats it + (`UX-05`). + +### Appearance + +- Every color the bubble renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The bubble is a floating surface on an opaque fill — `--panel-solid` — and + therefore carries no frost, the case `DESIGN-07` reserves for an opaque ground. +- The bubble's border is `--line-strong` and its elevation is `--shadow-md`. +- The bubble's text is `--fg` and does not wrap, so a short label stays on one + line. +- The bubble branches on no color mode and holds no mode-assuming value, so the + same bubble serves light and dark (`DESIGN-02`). + +### Motion + +- The bubble fades and lifts in over `--dur-fast` with `--ease-out`. +- The bubble lifts from below when it is placed above the trigger, and from + above when it is placed below, so the motion points away from what it labels. +- The bubble fades out over the same duration when it hides, rather than + disappearing instantly. +- Both transitions are switched off under `prefers-reduced-motion: reduce`, + leaving the bubble fully readable. + +## Recorded gaps + +- **The bubble is not the trigger's accessible name.** A consumer who wraps an + icon-only control and gives it no label of its own ships a control that is + unnamed for screen-reader and touch users. The component cannot detect this; + it is documented in the component's usage comment and stated here. +- **No open delay.** The bubble appears on the first `pointerover`, so moving + the pointer across a toolbar flashes every bubble on the way. A delay would be + a new motion value and therefore a maintainer decision (`DESIGN-10`). +- **No touch affordance.** Touch fires no `pointerover` that stays, so a tooltip + is effectively pointer- and keyboard-only. +- **`Text` is `[EditorRequired]` but defaults to `string.Empty`**, so omitting + it is a compiler warning rather than an error, and the component silently + renders no bubble. +- **The bubble's padding, radius and font size are literals** in + `code/DRYL.Components/wwwroot/dryl.css`, not tokens. Colors, shadows, + durations and easings are tokens. Recorded as debt, not as compliance. +- **No tests of its own.** None of the criteria above is guarded by a test; the + flip, the clamp and the hide-on-removal path are verified in the browser only. + +## 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`. `--panel-solid` and + `--line-strong` are the mode-dependent tokens; the component defines no + mode-specific rule. +- **Enter/exit animation** — both present, on the bubble: a fade-and-lift in, + and a fade out driven by the same transition. The wrapper itself has nothing + to animate. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the tooltip opens on focus as well as on hover, + and that the bubble is decorative rather than an accessible name. +- **AI mode** — **no**, deliberately. The component renders no surface of its + own: its wrapper is a transparent inline box and its bubble is a shared, + page-level element that would have to carry the aura for every tooltip at + once. An aura here would either be invisible or would attach to the wrong + thing. A trigger that needs to show AI activity carries `Ai` itself — the + button, the field or the card it sits on (`AI-05`). +- **Demo page** — `DRYL.Website/Components/Pages/DemoTooltip.razor`, with the + examples `Components/Examples/Tooltip/Placements.razor`, + `.../IconOnly.razor` and `.../LongText.razor`. +- **`ComponentCatalog`** — registered as `"Tooltip"` / `tooltip` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged as not AI-capable, + which matches the decision above. Its one-line description there still reads + "CSS-only hover tooltip", which has not been true since the bubble became a + JS-driven body portal; a correction belongs to `DRYL.Website`, not here. diff --git a/specs/E7 Feedback/F3 DrylSpinner.md b/specs/E7 Feedback/F3 DrylSpinner.md new file mode 100644 index 0000000..a937fa8 --- /dev/null +++ b/specs/E7 Feedback/F3 DrylSpinner.md @@ -0,0 +1,188 @@ +# DrylSpinner + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Feedback/DrylSpinner.razor + code/DRYL.Components/Components/Feedback/DrylSpinner.razor.css + +## User Story + +As a Blazor developer, I want a compact loading indicator that fits inline in a +button, a row or a panel, and that changes its rhythm when the work behind it is +an AI thinking rather than a request loading, so that a user can feel what kind +of wait they are in without reading a label. + +## Description + +`DrylSpinner` is the smallest of the category's indicators: an inline-flex box +sized by one token, holding one of three animations. `Ring` is a rotating +gradient arc, `Dots` is a three-dot wave, `Pulse` is concentric rings expanding +from a core. + +Its distinguishing behaviour is that **the AI state changes the spinner's own +rhythm, not only its aura.** Every other component in the category treats `Ai` +as a decoration applied around unchanged content. Here the loop itself +retimes — slower while a model is thinking, faster while it is streaming, and +stopped once it is done — so the same spinner communicates a different kind of +waiting in the same space. + +The size system is one custom property per size on the wrapper. Every child +dimension derives from it, which is why a variant can change shape without any +size value being written twice. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Variant` | `DrylSpinner.SpinnerVariant` | `SpinnerVariant.Ring` | Visual style. | +| `Size` | `DrylSpinner.SpinnerSize` | `SpinnerSize.Medium` | Physical size. | +| `Label` | `string?` | `null` | Accessible label. `null` uses a state-aware default. | +| `Ai` | `AiState` | `AiState.None` | Ambient AI state; also retimes the spinner's own loop. | +| `Aura` | `AiAura?` | `null` | Pins the aura variant; `null` inherits a surrounding `DrylAiScope`. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the spinner's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +## Acceptance Criteria + +### Structure + +- The component renders a single root element carrying the wrapper classes. +- The root carries the modifier class of its `Size`, one per value. +- The root carries the modifier class of its `Variant`, one per value. +- `SpinnerVariant.Ring` supplies the class for any value the switch does not + match, so an unmapped value still renders a spinner. +- `SpinnerVariant.Ring` renders one arc element. +- `SpinnerVariant.Dots` renders exactly three dot elements. +- `SpinnerVariant.Pulse` renders two ring elements and one core element. +- The root is inline-flex, so a spinner sits on the text baseline row of a + button or a label without breaking the line. +- The root does not shrink when placed in a flex row that runs out of space. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. + +### Size + +- Each `SpinnerSize` value sets its own size custom property on the root. +- Every child dimension derives from that property, so no variant carries a + second copy of a size value. +- Each `SpinnerSize` value sets its own track thickness, so a small ring stays + proportionate rather than looking heavy. +- `SpinnerVariant.Dots` renders as a pill wider than it is tall, so the aura + ring wraps a capsule rather than being cropped to a circle. +- Every other variant renders inside a circle. + +### Keyboard and accessibility + +- The root carries `role="status"`. +- The root carries `aria-live="polite"`, so a spinner appearing mid-page does + not interrupt what the screen reader is saying. +- The root carries an accessible label. +- `Label` set wins over the state-aware default. +- `Label` left `null` yields a label describing the current `Ai` state, so a + screen-reader user learns that a model is thinking rather than only that + something is loading. +- `Label` left `null` with `Ai` at `AiState.None` yields the plain loading + label. +- The spinner is not focusable and adds no stop to the tab order, because it is + an indicator and not a control. +- The spinner's moving parts are hidden from assistive technology: the state is + carried by the label, not by the animation. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The spinner 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. +- `SpinnerVariant.Ring` draws its arc from `--accent-a` and `--accent-b`, with + the centre masked out so only the track is painted. +- `SpinnerVariant.Dots` alternates its dots between `--accent-a` and + `--accent-b`. +- `SpinnerVariant.Pulse` draws its rings from `--accent-a` and `--accent-b` and + its core from `--accent-grad`. +- The accent appears as a thin arc, three small dots or a small core — 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 + +- Every variant animates continuously while mounted. +- `SpinnerVariant.Dots` staggers its three dots, so the wave reads as a sequence + rather than three things blinking together. +- `SpinnerVariant.Pulse` staggers its two rings, so a second ring is always + mid-flight when the first expires. +- The looping durations are chosen for the rhythm each variant needs and are + deliberately not `--dur-*`: that scale governs transitions and one-shots, + while continuous motion is free of it (`DESIGN-10`). +- The rotating variant uses `linear` timing, which `DESIGN-10` requires for + anything that rotates — an eased rotation stutters once per revolution. +- Wherever an easing is applied at all, it is an easing token rather than a bare + keyword (`DESIGN-10`). +- Under `prefers-reduced-motion: reduce` every loop stops. +- Under `prefers-reduced-motion: reduce` each variant is left in a legible + resting state rather than blank, so the indicator still reads as present. + +### 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 spinner renders the shared aura vocabulary — ring, comet, glow, wash — + rather than a spinner-specific AI treatment (`AI-02`). +- `AiState.Thinking` slows the spinner's own loop relative to its default. +- `AiState.Streaming` speeds the spinner's own loop relative to its default. +- `AiState.Active` sets a rate between the two, so an idle-but-engaged model + reads as different from both. +- `AiState.Generated` stops the loop and leaves the spinner in a settled state, + so a finished operation stops asking for attention. +- Each of the three variants implements all four of those rate changes, so the + signal does not depend on which variant a consumer picked. +- The rate change is carried by the animation's duration alone, so the spinner's + geometry does not shift when the state changes. +- 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. +- `AiState.Active` is signalled by an extra class the shared aura helper + deliberately omits, because the generic aura treats `Active` as ring-only and + the spinner needs the rate cue as well. + +## Recorded gaps + +- **A dozen and a half loose loop durations.** `DESIGN-10` leaves the duration + of continuous motion free, so none of them is a violation — but three variants + times "default plus four AI states", with the pulse variant timing its rings + and its core separately, means sixteen hand-picked seconds values in + `DrylSpinner.razor.css` with nothing relating them to each other. A fifth + `AiState` would add three more the same way, and the ratios between "slower + while thinking" and "faster while streaming" differ per variant. +- **`AiState.Active` needs an extra class the shared helper does not emit.** The + component appends `ai-active` itself, so the AI-state contract of this + component is one class wider than the shared vocabulary. Anything reusing the + helper alone would miss the `Active` rate cue. +- **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`. `--accent-a`, `--accent-b`, + `--accent-grad` and `--glow-accent` 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: a spinner *is* an animation, mounted and unmounted by its + host, which wraps it in `DrylPresence` when its appearance should be animated + too. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is the state-aware default label: the AI state is + announced in words, not only in motion. +- **AI mode** — yes, and further than anywhere else in the category: the + component's own loop retimes per state, which is the cue a user reads before + they read the aura. +- **Demo page** — `DRYL.Website/Components/Pages/DemoSpinner.razor`, with the + examples `Components/Examples/Spinner/Variants.razor`, `.../Sizes.razor`, + `.../AiMode.razor`, `.../InContext.razor` and `.../Live.razor`. +- **`ComponentCatalog`** — registered as `"Spinner"` / `spinners` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. diff --git a/specs/E7 Feedback/F4 DrylSkeleton.md b/specs/E7 Feedback/F4 DrylSkeleton.md new file mode 100644 index 0000000..c149d96 --- /dev/null +++ b/specs/E7 Feedback/F4 DrylSkeleton.md @@ -0,0 +1,223 @@ +# DrylSkeleton + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Feedback/DrylSkeleton.razor + code/DRYL.Components/Components/Feedback/DrylSkeleton.razor.css + +## User Story + +As a Blazor developer, I want a placeholder that has the shape of the content it +is standing in for, and that shows me the difference between "still fetching" +and "an AI is writing into this space right now", so that a user waiting on a +model sees the answer taking shape rather than a generic loading block. + +## Description + +`DrylSkeleton` renders shimmer blocks in the rough shape of the content that is +coming: one bar, a paragraph of bars, a circle, an image rectangle, or a +composite card that combines all of them. The shimmer itself is the library's +`skel` primitive; this component adds the size system, the shapes, and the AI +behaviour. + +The AI behaviour is what makes it AI-native rather than AI-decorated. The three +states each change the shimmer, not just the frame around it: `Thinking` +accelerates it, `Streaming` recolors it from a neutral sweep to a violet-cyan +one — the placeholder itself signals that model output is flowing into it — and +`Generated` fades the blocks out so real content can take their place. + +`SkeletonVariant.Custom` makes the component's block classes part of its public +contract: a consumer builds their own layout out of `skel`, `skel-circle` and +`skel-rect`, and inherits the shimmer, the sizes and every AI mutation above. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Variant` | `DrylSkeleton.SkeletonVariant` | `SkeletonVariant.Line` | Shape of the placeholder. | +| `Size` | `DrylSkeleton.SkeletonSize` | `SkeletonSize.Medium` | Size of the blocks. | +| `Lines` | `int` | `3` | Bars rendered by `Text`, and in the body block of `Card`. | +| `Width` | `string?` | `null` | CSS width for `Line`. `null` fills the container. | +| `Label` | `string?` | `null` | Accessible label. `null` uses a state-aware default. | +| `ChildContent` | `RenderFragment?` | `null` | Layout for `SkeletonVariant.Custom`. | +| `Ai` | `AiState` | `AiState.None` | Ambient AI state; also mutates the shimmer. | +| `Aura` | `AiAura?` | `null` | Pins the aura variant; `null` inherits a surrounding `DrylAiScope`. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the skeleton's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +`Width` is a raw CSS value rather than a typed size, deliberately: a placeholder +is matched to the content it replaces, and that content's width is the +consumer's, not the library's. + +## Acceptance Criteria + +### Structure + +- The component renders a single root element carrying the wrapper classes. +- The root carries the modifier class of its `Size`, one per value. +- The root carries the modifier class of its `Variant`, one per value. +- `SkeletonVariant.Line` supplies the class for any value the switch does not + match. +- `SkeletonVariant.Line` renders one bar. +- `SkeletonVariant.Text` renders exactly `Lines` bars. +- `SkeletonVariant.Avatar` renders one circle. +- `SkeletonVariant.Image` renders one rectangle. +- `SkeletonVariant.Card` renders a header row, a rectangle and a body block, in + that order. +- `SkeletonVariant.Card` renders an avatar and two bars in its header row. +- `SkeletonVariant.Card` caps its body block at five bars however large `Lines` + is, so a card placeholder cannot grow unbounded. +- `SkeletonVariant.Custom` renders `ChildContent` and no built-in shape. +- `Lines` set to zero or below renders no bars and throws nothing. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. + +### Shape and size + +- Each `SkeletonSize` value sets its own bar height, circle diameter, rectangle + height and header-avatar diameter on the root. +- Every block derives its dimensions from those properties, so a size change + never means editing a second value. +- `Width` set applies to the bar of `SkeletonVariant.Line`. +- `Width` left `null` makes that bar fill its container. +- The bars of `SkeletonVariant.Text` vary in width, so a paragraph placeholder + does not read as a stack of identical rectangles. +- The last bar of a multi-bar `Text` block is visibly shorter than the ones + above it, the way a real last line of a paragraph is. +- A single-bar `Text` block is not shortened, because there is no paragraph for + it to end. +- `SkeletonVariant.Avatar` sizes its root to the circle, so it can sit in a flex + row beside text without stretching. +- `SkeletonVariant.Card` and `SkeletonVariant.Image` round their root, so the + AI ring traces the shape of the content rather than a rectangle around it. + +### Keyboard and accessibility + +- The root carries `role="status"`. +- The root carries `aria-live="polite"`, so a placeholder appearing mid-page + does not interrupt what the screen reader is saying. +- The root carries an accessible label. +- `Label` set wins over the state-aware default. +- `Label` left `null` yields a label describing the current `Ai` state, so a + screen-reader user learns that a model is writing rather than only that + something is loading. +- `Label` left `null` with `Ai` at `AiState.None` yields the plain loading + label. +- The skeleton is not focusable and adds no stop to the tab order. +- The shimmer blocks carry no text, so nothing is announced twice. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The blocks paint `--glass-1` and sweep through `--glass-3`, so a placeholder + reads as an absence of content rather than as content. +- The component paints no surface of its own around the blocks while `Ai` is + `AiState.None` — no fill, no border, no frost — so it inherits whatever ground + it is placed on and `DESIGN-06` has nothing to apply to. +- The rectangle and the card use `--r-md` and `--r-lg`; the circles are fully + round. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### Motion + +- Every block shimmers continuously while mounted. +- The shimmer is carried by a sliding strip inside each block rather than by an + animated background position, so it stays on the compositor. +- Consecutive bars in a text block and in a card header shimmer at staggered + offsets, so the group reads as a wave rather than as one flash. +- The looping duration is chosen for the rhythm the shimmer needs and is + deliberately not `--dur-*`: that scale governs transitions and one-shots, + while continuous motion is free of it (`DESIGN-10`). +- Wherever an easing is applied, it is an easing token rather than a bare + keyword. +- Under `prefers-reduced-motion: reduce` the staggered offsets are dropped, so + every bar shimmers in phase and nothing chases anything. + +### 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 skeleton renders the shared aura vocabulary — ring, comet, glow, wash — + rather than a skeleton-specific AI treatment (`AI-02`). +- The root gains a glass frame while an aura is present, so the rotating ring + has a shape to trace instead of wrapping loose blocks. +- `AiState.Thinking` accelerates the shimmer relative to its default. +- `AiState.Streaming` recolors the shimmer from the neutral sweep to a gradient + built from `--ai-a` and `--ai-b`, so the placeholder itself says that model + output is arriving. +- `AiState.Streaming` also tints the blocks' resting fill, so the part of a + block the sweep has not reached is not left neutral. +- `AiState.Generated` fades the blocks out over `--dur-slow`, so real content + replaces a dissolving placeholder rather than a disappearing one. +- The faded-out blocks stay in the layout, so the swap to real content does not + jump. +- Every shimmer mutation targets the sliding strip rather than the block itself, + so an AI state change never repaints a large surface. +- Under `prefers-reduced-motion: reduce` the accelerated and the recolored + shimmer both fall back to the base rate, so an AI state no longer speeds + anything up. +- Under `prefers-reduced-motion: reduce` `AiState.Generated` settles the blocks + at a dimmed opacity instead of animating them out. +- 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. +- `AiState.Active` is signalled by an extra class the shared aura helper + deliberately omits, so the skeleton can respond to the idle-but-engaged state + as well. + +## Recorded gaps + +- **The size values are literals** in + `code/DRYL.Components/Components/Feedback/DrylSkeleton.razor.css` — four + dimensions times three sizes. `DESIGN-01` covers colors, radii, shadows, + durations and easings, which are tokens here; a placeholder's block heights + are not covered by a token today. Recorded as debt, not as compliance. +- **The bar widths are a hardcoded eight-element cycle** in `DrylSkeleton.razor`. + A `Text` block of nine bars repeats the first width at position nine, and the + sequence is identical for every skeleton on the page — two paragraphs side by + side shimmer in exactly the same shape. +- **`Lines` is unbounded for `SkeletonVariant.Text`.** Only the card body caps + it at five; a `Text` skeleton renders as many bars as it is given. +- **The shimmer stagger covers six bars.** A text block's seventh bar and beyond + shimmer in phase with the first, because the offsets are written per + `nth-child` rather than derived from an index. +- **`SkeletonVariant.Custom` freezes three CSS class names** — `skel`, + `skel-circle` and `skel-rect` — into the public contract. They are as bound by + the 1.0 freeze as the parameters, and nothing in the build enforces that. +- **The base shimmer keeps running under `prefers-reduced-motion: reduce`.** The + reduced-motion block calms the AI mutations and drops the stagger, but the + `skel` primitive's own sweep is untouched, so a user who asked for less motion + still gets a continuously moving placeholder. The primitive is shared and its + rule lives in `dryl.css`, so the fix is not this component's alone — recorded + here because this is where the shimmer is most of the screen (`UX-06`). +- **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`. `--glass-1`, `--glass-3`, + `--ai-a` and `--ai-b` are the mode-dependent tokens; the component defines no + mode-specific rule. +- **Enter/exit animation** — the exit is the component's own and is the point of + it: `AiState.Generated` fades the blocks out over `--dur-slow` instead of the + host yanking them. There is no enter animation, the written exception + `DESIGN-11` allows for a placeholder whose whole body is already a continuous + animation; a host that wants it to appear gradually wraps it in + `DrylPresence`. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is the state-aware default label: "AI streaming content" + is announced in words, not only in the shimmer's color. +- **AI mode** — yes, and it drives the primitive rather than decorating it. The + violet-cyan streaming shimmer is the component's reason to exist as a DRYL + component rather than a generic placeholder. +- **Demo page** — `DRYL.Website/Components/Pages/DemoSkeleton.razor`, with the + examples `Components/Examples/Skeleton/Variants.razor`, `.../Sizes.razor`, + `.../CardAvatar.razor`, `.../Custom.razor`, `.../AiMode.razor` and + `.../Lifecycle.razor`. +- **`ComponentCatalog`** — registered as `"Skeleton"` / `skeleton` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. diff --git a/specs/E7 Feedback/F5 DrylProgress.md b/specs/E7 Feedback/F5 DrylProgress.md new file mode 100644 index 0000000..d34784b --- /dev/null +++ b/specs/E7 Feedback/F5 DrylProgress.md @@ -0,0 +1,229 @@ +# DrylProgress + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Feedback/DrylProgress.razor + code/DRYL.Components/Components/Feedback/ProgressVariant.cs + +## User Story + +As a Blazor developer, I want a linear progress bar that takes my own scale +rather than forcing me to convert to percent, that can also say "working, no +idea how long", and that reads the same to a screen reader as it does on screen, +so that I can show an upload, a quota or a model's progress without writing any +of the arithmetic or the ARIA myself. + +## Description + +`DrylProgress` is a track with a fill, optionally preceded by a label row that +carries a caption on the leading edge and the percentage on the trailing one. +`Value` is expressed on the consumer's own scale — three of five steps is +`Value="3" Max="5"` — and the component derives the percentage, clamps it and +formats it. + +Two structural decisions are worth naming. The percentage is computed and +rendered with `InvariantCulture`, because a fill width of `33,33%` is not a CSS +length and would silently collapse the bar under a German locale; there is a +regression test for exactly that. And the AI aura is hosted on a wrapper +*around* the track rather than on the track itself, because the track clips its +own overflow to keep the fill inside its rounded ends — an aura painted there +would be cut off at the same edge. + +`Indeterminate` replaces the fill with a sweep and removes the value from the +component's ARIA, so nothing claims a number that does not exist. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Value` | `double` | `0` | Current value, on the consumer's scale. | +| `Max` | `double` | `100` | Upper bound of that scale. | +| `Indeterminate` | `bool` | `false` | Shows a sweep instead of a fill. | +| `Variant` | `ProgressVariant` | `ProgressVariant.Accent` | Color treatment of the fill. | +| `Size` | `ProgressSize` | `ProgressSize.Medium` | Track thickness. | +| `ShowLabel` | `bool` | `false` | Shows the percentage on the trailing edge of the label row. | +| `LabelText` | `string?` | `null` | Plain-text caption on the leading edge. | +| `Label` | `RenderFragment?` | `null` | Custom caption; overrides `LabelText`. | +| `AriaLabel` | `string?` | `null` | Accessible label for the bar. | +| `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 progress field's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +`Label` and `LabelText` are two parameters for one slot because the common case +is a string and the general case is markup. `ShowLabel` is independent of both: +a bar may show its percentage with no caption, a caption with no percentage, +both, or neither. + +## Acceptance Criteria + +### Structure + +- The component renders a root field containing an optional label row and the + track. +- The label row is rendered when `Label` is set. +- The label row is rendered when `LabelText` is non-empty. +- The label row is rendered when `ShowLabel` is `true` and `Indeterminate` is + `false`. +- The label row is not rendered when none of those three conditions holds, so a + bare bar occupies only the track's height. +- The caption slot renders `Label` when it is set. +- The caption slot renders `LabelText` when `Label` is `null` and `LabelText` is + non-empty. +- The percentage is rendered only when `ShowLabel` is `true` and `Indeterminate` + is `false`. +- The caption sits on the leading edge and the percentage on the trailing edge + of the label row. +- The track wraps the fill and clips it, so the fill cannot spill past the + track's rounded ends. +- The aura host sits outside the track, so the aura is not clipped by that + same rule. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. + +### Value and scale + +- The rendered percentage is `Value` relative to `Max`. +- A `Value` above `Max` renders as a full bar rather than overflowing. +- A `Value` below zero renders as an empty bar rather than a negative fill. +- A `Max` of zero or below renders as an empty bar and throws nothing. +- The percentage in the label row is rounded to whole percent. +- The fill's width keeps two decimal places, so a long bar does not visibly + quantise. +- Both are formatted with the invariant culture, so a locale whose decimal + separator is a comma still produces a valid CSS length and a stable label. + +### Variants and size + +- Each `ProgressVariant` value renders its own fill treatment. +- `ProgressVariant.Accent` is the unmodified fill and adds no modifier class. +- `ProgressVariant.Success`, `Warning` and `Danger` each derive their fill from + the matching semantic token. +- Each `ProgressSize` value renders its own track thickness. +- `ProgressSize.Medium` is the unmodified track and adds no modifier class. +- The track's height is the only thing `Size` changes, so a caption's type size + does not move with it. + +### Indeterminate + +- `Indeterminate` replaces the fixed fill with a sweep that traverses the track. +- `Indeterminate` renders no percentage, even when `ShowLabel` is `true`. +- `Indeterminate` still renders the label row when a caption is supplied, so a + bar can say what it is working on without claiming how far along it is. +- Switching from indeterminate to determinate stops the sweep and shows the + fill. + +### Keyboard and accessibility + +- The track carries `role="progressbar"`. +- The track carries a minimum of zero. +- The track carries `Max` as its maximum, formatted with the invariant culture. +- A determinate bar carries `Value` as its current value, formatted with the + invariant culture. +- An indeterminate bar carries no current value, so assistive technology + announces it as busy rather than as a number. +- The track carries `AriaLabel` as its accessible label. +- The bar is not focusable and adds no stop to the tab order, because it is an + indicator and not a control. +- The percentage text is rendered as text, so it is announced without depending + on the ARIA value. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The track paints `--glass-2` and the accent fill paints `--accent-grad`. +- The fill carries a glow derived from its own color rather than a second + surface (`DESIGN-08`). +- The track and the fill are `--r-pill`, so a partially filled bar has rounded + ends on both sides. +- The component paints no frost: the track is a small in-flow element on + whatever ground it sits, and `DESIGN-07` reserves frost for surfaces that can + show it. +- The caption is `--fg-muted` and the percentage is `--fg`, so the number is the + louder of the two. +- The percentage is set in `--font-mono`, so a rising number does not shift the + label row's width digit by digit. +- The component branches on no color mode and holds no mode-assuming value, so + the same markup serves light and dark (`DESIGN-02`). + +### Motion + +- A change of `Value` animates the fill's width over `--dur-slow` with + `--ease-out`, so progress glides rather than jumping. +- The indeterminate sweep animates continuously while mounted. +- The indeterminate sweep animates its position by transform rather than by + width, so it stays on the compositor. +- The indeterminate bar carries no width transition, so the sweep is not fought + by the determinate glide. +- Under `prefers-reduced-motion: reduce` the indeterminate sweep stops and the + bar rests as a full track, so a busy state is still visible without motion. + +### 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 bar renders the shared aura vocabulary — ring, comet, glow, wash — rather + than a progress-specific AI treatment (`AI-02`). +- The aura traces the track's pill shape rather than a rectangle around it. +- The aura is independent of `Variant` and `Indeterminate`, so any combination + renders. +- 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 AI state changes nothing about the bar's layout, so the label row does not + move when an operation starts or ends. + +## Recorded gaps + +- **`AriaLabel` has no default.** A bar with no `AriaLabel` and no caption is + announced as a progress bar with a number and no subject. Every other + indicator in this category falls back to a state-aware label; this one does + not. +- **The ARIA value is not clamped.** The fill is clamped into 0…100 %, but the + reported current value is `Value` as given. A `Value` of 120 against a `Max` + of 100 shows a full bar and tells a screen reader "120 of 100" — the two + halves of the criterion "a value above `Max` renders as full" disagree. +- **The percentage is not announced as it changes.** The label row carries no + live region, so a screen-reader user hears the value only when they navigate + to the bar. `role="progressbar"` covers the value; the visible text is + silent. +- **The track heights are literals** in + `code/DRYL.Components/wwwroot/dryl.css` — one per `ProgressSize`. `DESIGN-01` + covers colors, radii, shadows, durations and easings, which are tokens here; + a track's thickness is not covered by a token today. Recorded as debt, not as + compliance. +- **The label row's type sizes are literals** in the same file. +- **The indeterminate sweep's width and travel are literals**, so its rhythm + cannot be retuned from a token. +- **Only two of its criteria are guarded by tests**: the invariant-culture fill + width in `tests/DRYL.Components.Tests/GlobalizationTests.cs`, and the class + merge in `tests/DRYL.Components.Tests/ClassMergeTests.cs`. Nothing else above + is covered. + +## 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`, `--accent-grad` and + the three semantic fills are the mode-dependent tokens; the component defines + no mode-specific rule. +- **Enter/exit animation** — the determinate fill's width glide is the + component's own state animation, and the indeterminate sweep is continuous. + There is no mount animation, the written exception `DESIGN-11` allows for an + in-flow indicator its host mounts; a host that wants it to appear gradually + wraps it in `DrylPresence`. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above, + including the deliberate absence of an ARIA value while indeterminate. The + missing default label is recorded as a gap rather than glossed. +- **AI mode** — yes. A progress bar is the natural place to show a model's + long-running work, and the aura is hosted outside the clipping track so it is + actually visible there. +- **Demo page** — `DRYL.Website/Components/Pages/DemoProgress.razor`, with the + examples `Components/Examples/Progress/Determinate.razor`, + `.../Indeterminate.razor`, `.../Variants.razor`, `.../Sizes.razor` and + `.../AiMode.razor`. +- **`ComponentCatalog`** — registered as `"Progress"` / `progress` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. diff --git a/specs/E7 Feedback/F6 DrylEmptyState.md b/specs/E7 Feedback/F6 DrylEmptyState.md new file mode 100644 index 0000000..35deb3f --- /dev/null +++ b/specs/E7 Feedback/F6 DrylEmptyState.md @@ -0,0 +1,181 @@ +# DrylEmptyState + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Feedback/DrylEmptyState.razor + code/DRYL.Components/Components/Feedback/DrylEmptyState.razor.css + code/DRYL.Components/Components/Feedback/EmptyStateSize.cs + +## User Story + +As a Blazor developer, I want the "there is nothing here" case to look like a +designed state rather than a blank rectangle, with an icon, an explanation and +somewhere to go next, so that an empty table or an unmatched search reads as an +answer instead of as a failure. + +## Description + +`DrylEmptyState` is a centred column: an icon in a round chip, a headline, a +description, and a slot for the buttons that offer a way out. Every part is +optional, so the same component covers the two-line version inside a dropdown +and the full version on an empty page. + +`Size` has two values rather than three, and that is the shape of the problem: +an empty state is either the main thing on the screen or a note inside +something else. `DrylNotifications` uses the small one for its own empty inbox. + +While `Ai` is set the component grows a glass frame it does not otherwise have. +An empty state paints no surface at rest — it is text on whatever ground it sits +on — and the rotating gradient ring needs a shape to trace, so the frame appears +with the aura and leaves with it. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Icon` | `string?` | `null` | Icon name shown above the title. | +| `Title` | `string?` | `null` | Headline. | +| `Description` | `string?` | `null` | Supporting text. | +| `ChildContent` | `RenderFragment?` | `null` | Richer description content, rendered after `Description`. | +| `ActionContent` | `RenderFragment?` | `null` | Call-to-action slot, typically buttons. | +| `Size` | `EmptyStateSize` | `EmptyStateSize.Medium` | Overall size. | +| `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 empty state's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the root element. | + +`Description` and `ChildContent` share one region rather than replacing each +other: a consumer can pass a plain sentence, markup, or a sentence followed by +markup. + +## Acceptance Criteria + +### Structure + +- The component renders a single root element carrying the empty-state classes. +- The root carries the modifier class of `EmptyStateSize.Small`. +- `EmptyStateSize.Medium` is the unmodified root and adds no modifier class. +- The component renders an icon chip when `Icon` is non-empty. +- The component renders no icon chip when `Icon` is `null` or empty. +- The component renders a title element when `Title` is non-empty. +- The component renders no title element when `Title` is `null` or empty. +- The component renders a description region when `Description` is non-empty. +- The component renders a description region when `ChildContent` is set. +- The description region renders `Description` and then `ChildContent`, so both + may be supplied at once. +- The component renders an action region when `ActionContent` is set. +- The component renders no action region when `ActionContent` is `null`. +- The icon, the title, the description and the actions render in that order. +- A component with none of its four content parameters set renders an empty + centred block and throws nothing. +- `Class` is merged onto the root's own classes rather than replacing them. +- `AdditionalAttributes` are applied to the root. + +### Layout + +- The content is centred on both axes and the text is centre-aligned. +- The description's line length is capped, so a long explanation wraps into a + readable column rather than spanning the container. +- The action region wraps when it holds more buttons than fit on one line. +- The action region centres its buttons. +- `EmptyStateSize.Small` reduces the block's padding, the gap between its parts + and the icon chip's size relative to `EmptyStateSize.Medium`. +- `EmptyStateSize.Small` reduces the title's type size relative to + `EmptyStateSize.Medium`. +- The icon rendered inside the chip is smaller at `EmptyStateSize.Small`. + +### Keyboard and accessibility + +- The root carries `role="region"`. +- The root carries `Title` as its accessible label when `Title` is non-empty. +- The root carries a fallback accessible label when `Title` is `null` or empty, + so the region is never announced unnamed. +- The icon chip is decorative and adds no second announcement of the title. +- The component adds no `tabindex` and no key handling: the only focusable + things inside it are whatever `ActionContent` contains, and they keep their + own behaviour. +- The action buttons are reached by `Tab` in the order they were written. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The component paints no surface of its own while `Ai` is `AiState.None` — no + fill, no border, no frost — so it inherits whatever ground it is placed on and + `DESIGN-06` has nothing to apply to. +- The icon chip paints `--glass-2` with a `--line` border, so the icon reads as + seated rather than floating. +- The title is `--fg`, the description is `--fg-dim` and the block's own color is + `--fg-muted`, so the headline is the loudest of the three. +- The icon chip is fully round and the icon inside it is `--fg-dim`, so the + empty state is quiet rather than alarming. +- 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 component has no motion of its own: it is a static block, and its only + moving parts are the AI aura and whatever `ActionContent` brings. +- Nothing in the component moves under `prefers-reduced-motion: reduce`, because + nothing moves without it either. + +### 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 empty state renders the shared aura vocabulary — ring, comet, glow, wash — + rather than an empty-state-specific AI treatment (`AI-02`). +- While an aura is present the root gains a `--glass-1` fill, a `--line` border + and a `--r-lg` radius, so the rotating ring has a shape to trace. +- That frame is removed again when the aura is gone, so an empty state at rest + is still surfaceless. +- 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 AI state changes the root's fill and border but not its size, so the block + does not reflow when an operation starts or ends. + +## Recorded gaps + +- **The frame appearing with the aura is a visible jump.** The fill, the border + and the radius are switched by a class with no transition, so entering AI mode + snaps a box around the text. Everything else about the aura fades. +- **The icon chip's dimensions and the type sizes are literals** in + `code/DRYL.Components/Components/Feedback/DrylEmptyState.razor.css`. + `DESIGN-01` covers colors, radii, shadows, durations and easings, which are + tokens here; a chip's diameter and a headline's type size are not covered by a + token today. Recorded as debt, not as compliance. +- **The description's maximum line length is a literal** in the same file. +- **The fallback accessible label is fixed English** (`"Empty"`), with no + parameter to change it. Every other string on the component comes from the + consumer. +- **`role="region"` on a decorative block is heavy.** A landmark is added for + every empty state on a page, including the small in-panel ones, and each shows + up in a screen reader's landmark list. +- **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`. `--glass-1`, `--glass-2`, + `--line` and the three foreground steps 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: an empty state is what remains when content is absent, it + is mounted and unmounted by its host, and a host that wants it to fade in + wraps it in `DrylPresence`. The frame that appears with the aura is the one + place this shows as a gap, and it is recorded above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the block labels itself from its own title, so a + screen-reader user hears which region is empty. +- **AI mode** — yes. "Nothing here yet" is exactly the state a model is asked to + fill, so the component supports showing the work in the space the result will + occupy. +- **Demo page** — `DRYL.Website/Components/Pages/DemoEmptyState.razor`, with the + examples `Components/Examples/EmptyState/Default.razor`, `.../Minimal.razor`, + `.../Small.razor` and `.../AiMode.razor`. +- **`ComponentCatalog`** — registered as `"Empty State"` / `empty-state` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. diff --git a/specs/E7 Feedback/F7 DrylErrorBoundary.md b/specs/E7 Feedback/F7 DrylErrorBoundary.md new file mode 100644 index 0000000..debaa71 --- /dev/null +++ b/specs/E7 Feedback/F7 DrylErrorBoundary.md @@ -0,0 +1,218 @@ +# DrylErrorBoundary + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Feedback/DrylErrorBoundary.razor + code/DRYL.Components/Components/Feedback/DrylErrorBoundary.razor.css + +## User Story + +As a Blazor developer, I want a section of my page that survives its own +exceptions and says so in the library's own visual language, with a retry that +actually recovers, so that one failing panel does not leave a white unstyled +block in the middle of a glass layout — or take the whole page with it. + +## Description + +`DrylErrorBoundary` wraps Blazor's built-in `ErrorBoundary` and replaces its +default markup. When the protected content throws during a render or a lifecycle +method, the boundary shows a danger [`F1 DrylAlert`](F1%20DrylAlert.md) carrying +a title, an optional description, an optional collapsible exception panel, and a +retry button. + +Retry is a two-step sequence, and the order is the whole design: the consumer's +`OnRetry` runs **first**, then the boundary recovers. A boundary that recovered +first would re-render the same failing content and throw again immediately; +running the callback first is what gives the consumer the chance to clear the +condition — reset the state, re-issue the request — before the child is asked to +render again. + +`ShowDetails` is the parameter that must not be left on in production: it +reveals the full exception text, stack trace included. The component makes it +opt-in, defaults it off, and says so in its own documentation; it cannot enforce +it. + +The component is AI-aware by forwarding rather than by rendering: `Ai` and +`Aura` are passed into the alert, so a failed AI block reads in the same aura +vocabulary that was on screen while the model was working. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `ChildContent` | `RenderFragment?` | `null` | The protected content. | +| `Title` | `string` | `"Something went wrong"` | Heading on the fallback surface. | +| `Description` | `string?` | a generic retry hint | Explanatory line under the title. | +| `ShowRetry` | `bool` | `true` | Renders the retry button. | +| `RetryText` | `string` | `"Try again"` | Label of the retry button. | +| `OnRetry` | `EventCallback` | — | Raised on retry, **before** the boundary recovers. | +| `ShowDetails` | `bool` | `false` | Reveals a collapsible panel with the full exception text. | +| `FallbackContent` | `RenderFragment?` | `null` | Full override of the fallback surface. | +| `MaximumErrorCount` | `int` | `100` | Errors tolerated before the boundary stops recovering. | +| `Ai` | `AiState` | `AiState.None` | Forwarded to the fallback alert. | +| `Aura` | `AiAura?` | `null` | Forwarded to the fallback alert. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the fallback alert's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the fallback surface. | + +| Method | Signature | Purpose | +|---|---|---| +| `Recover` | `void Recover()` | Recovers the boundary from consumer code, without a user gesture. | + +`Recover` is public because the failure a boundary caught is often cleared by +something other than a button — a reconnect, a new selection, a background +retry — and the host needs a way to say so. + +## Acceptance Criteria + +### While nothing has thrown + +- The component renders `ChildContent` and nothing of its own. +- The component adds no wrapper element around `ChildContent`, so it does not + disturb the layout it is placed in. + +### After a throw + +- An exception thrown while rendering `ChildContent` is caught rather than + propagated. +- The fallback surface is rendered in place of `ChildContent`. +- The fallback surface is a danger alert, so a failure is announced assertively + by the alert's own role (`F1`). +- The fallback renders `Title` as its heading. +- The fallback renders `Description` when it is non-empty. +- The fallback renders no description element when `Description` is `null` or + empty. +- `FallbackContent` set replaces the entire built-in surface. +- `FallbackContent` receives the caught exception. +- `FallbackContent` set suppresses the title, the description, the details + toggle and the retry button, because all four belong to the surface it + replaced. +- The boundary stops recovering after `MaximumErrorCount` errors, so a child + that throws on every render cannot loop forever. + +### Retry + +- The retry button is rendered only when `ShowRetry` is `true`. +- The retry button renders `RetryText` as its label. +- Activating retry raises `OnRetry` before the boundary recovers. +- The boundary recovers after `OnRetry` has completed, so an asynchronous + handler finishes clearing the condition before the child re-renders. +- Activating retry recovers the boundary even when no handler is attached. +- Recovering re-renders `ChildContent`, and a child that no longer throws is + shown again. +- Recovering collapses the details panel, so a second failure does not open + onto the previous exception's text. +- `Recover` called from consumer code has the same effect as the button, minus + the callback. + +### Exception details + +- The details toggle is rendered only when `ShowDetails` is `true`. +- The details panel is collapsed on first render. +- Activating the toggle expands the panel, and activating it again collapses it. +- The expanded panel renders the caught exception's full text. +- The toggle's label states which action it will perform, so it does not read + the same in both states. +- The panel scrolls within a bounded height, so a long stack trace does not + push the retry button off the screen. +- The exception text wraps rather than scrolling sideways, so a long type name + stays readable. +- Nothing about the exception is rendered while `ShowDetails` is `false`, so the + default configuration leaks nothing. + +### Keyboard and accessibility + +- The details toggle carries `aria-expanded`, reflecting the panel's state. +- The details toggle is a `type="button"`, so a boundary inside a form cannot + submit it. +- The details toggle is reachable by `Tab` and activated by `Enter` and `Space`, + because it is a native button and the component adds no key handling. +- The details toggle shows a visible focus ring under `:focus-visible`. +- The retry button is a `DrylButton` and keeps that component's keyboard + behaviour. +- The failure is announced by the alert's assertive live region, so a + screen-reader user is told the section failed rather than finding it silently + replaced. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The fallback surface's fill, frost, border and accent are the alert's, not + this component's (`F1`). +- The exception panel paints `--glass-1` with a `--line` border and `--r-sm`, so + it reads as a deeper layer inside the alert. +- The exception text and the details toggle are set in `--font-mono`, so a stack + trace is legible as code. +- The details toggle is `--fg-dim` at rest and `--fg-muted` on hover, so a + developer-only affordance does not compete with the retry button. +- The details toggle's focus ring is `--accent-line`. +- 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 details toggle transitions its color over `--dur-fast` with `--ease-out`. +- The toggle's chevron rotates a quarter turn over `--dur-fast` with + `--ease-out` when the panel opens, so the control shows its own state. +- Both transitions are switched off under `prefers-reduced-motion: reduce`, + leaving the toggle fully operable. + +### AI mode + +- `Ai` defaults to `AiState.None`, so AI styling is opt-in. +- `Ai` is forwarded to the fallback alert unchanged. +- `Aura` is forwarded to the fallback alert unchanged. +- The component renders no aura of its own, so there is exactly one aura on the + fallback surface (`AI-02`). +- The AI state has no effect while nothing has thrown, because the component + renders nothing of its own then. + +## Recorded gaps + +- **The exception panel appears and disappears without motion.** It is a + conditional render with no `DrylPresence` around it, so expanding the details + snaps a block of text into the layout while the chevron beside it animates + (`DESIGN-12`). +- **`Class` and `AdditionalAttributes` reach the fallback surface, not the + boundary.** While nothing has thrown, both are inert — there is no element of + this component's own to put them on. A consumer styling the boundary is + styling only its failure state. +- **`ShowDetails` cannot be enforced.** A consumer who hardcodes it to `true` + ships stack traces to end users. The default is off and the documentation says + why, which is as far as the component can go. +- **The two toggle labels and the default title and description are fixed + English**, with `Title` and `Description` overridable and the toggle's two + states not. +- **Only render-time exceptions are caught.** This is Blazor's own boundary + semantics — an exception from an event handler or a background task is not a + render exception and never reaches the fallback — but a consumer reading the + component's name will expect more than it can deliver. +- **The type sizes and the panel's maximum height are literals** in + `code/DRYL.Components/Components/Feedback/DrylErrorBoundary.razor.css`. + `DESIGN-01` covers colors, radii, shadows, durations and easings, which are + tokens here. Recorded as debt, not as compliance. +- **No tests of its own.** None of the criteria above is guarded by a test — + including the retry ordering, which is the component's central claim. + +## 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` and the + foreground steps are the mode-dependent tokens; the component defines no + mode-specific rule. +- **Enter/exit animation** — the component's own motion is the details toggle's + color transition and its chevron rotation. The fallback surface's appearance + is a replacement of the protected content, which the boundary does not + animate; the missing motion on the details panel is recorded as a gap above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decision is that the failure inherits the alert's assertive live + region rather than appearing silently. +- **AI mode** — yes, by forwarding. A model that failed should not be reported + in a different visual language than the one it worked in, and rendering a + second aura here would put two on the same surface. +- **Demo page** — `DRYL.Website/Components/Pages/DemoErrorBoundary.razor`, with + the examples `Components/Examples/ErrorBoundary/Basic.razor`, + `.../Custom.razor` and `.../AiDetails.razor`. +- **`ComponentCatalog`** — registered as `"Error Boundary"` / `error-boundary` + in `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. diff --git a/specs/E7 Feedback/F8 DrylNotifications.md b/specs/E7 Feedback/F8 DrylNotifications.md new file mode 100644 index 0000000..45579cd --- /dev/null +++ b/specs/E7 Feedback/F8 DrylNotifications.md @@ -0,0 +1,278 @@ +# DrylNotifications + +## Meta +- **State:** Implemented +- **Source:** code/DRYL.Components/Components/Feedback/DrylNotifications.razor + code/DRYL.Components/Components/Feedback/DrylNotifications.razor.css + code/DRYL.Components/Notifications/DrylNotification.cs + code/DRYL.Components/Notifications/IDrylNotificationService.cs + code/DRYL.Components/Notifications/DrylNotificationService.cs + +## User Story + +As a Blazor developer building an app shell, I want a bell in my header that +shows how many unread notifications there are and opens an inbox when clicked, +fed either by a service I can push to from a background job or by a list I hold +myself, so that an agent finishing a task twenty minutes from now can tell the +user without me building an inbox. + +## Description + +`DrylNotifications` is two things in one component: a bell trigger with an +unread badge, and a popover panel listing the entries. The panel is a +[`DrylPopover`](../E11%20Surfaces/F1%20DrylPopover.md) — the portal, the +placement, the outside click and `Escape` are that component's, not this one's. + +It has **two modes**, and which one is active is decided by a single question: +was `Items` supplied? + +- **Service-driven** (`Items` is `null`) — the component resolves + `IDrylNotificationService`, subscribes to its change event, renders what the + service holds and mutates the service directly. A background job, an agent + completion or a SignalR message pushes an entry and the bell updates live. +- **Controlled** (`Items` is set) — the component renders that list and raises + a callback for every action instead of mutating anything. The service is not + resolved at all. + +The service is resolved leniently rather than required, so a consumer who never +called `AddDrylComponents()` gets an empty inbox rather than an exception at +render time. + +Each entry carries its own `AiState`, which is what makes the inbox AI-native: +"your report was generated" arrives with the aura the report was written under. +The state belongs to the entry and is fixed for its lifetime — entries are added +and removed, never transitioned — which is why the component takes `Aura` but +no `Ai` of its own. + +## Public API + +| Member | Type | Default | Purpose | +|---|---|---|---| +| `Items` | `IReadOnlyList?` | `null` | Controlled list. Set it to leave service-driven mode. | +| `OnItemClick` | `EventCallback` | — | Raised when a row is activated, after it is marked read. | +| `OnMarkRead` | `EventCallback` | — | Controlled mode: a row should be marked read. | +| `OnMarkAllRead` | `EventCallback` | — | Controlled mode: "Mark all read" was pressed. | +| `OnRemove` | `EventCallback` | — | Controlled mode: a row was dismissed. | +| `OnClear` | `EventCallback` | — | Controlled mode: "Clear all" was pressed. | +| `Title` | `string` | `"Notifications"` | Panel heading. | +| `EmptyTitle` | `string` | `"All caught up"` | Empty-state heading. | +| `EmptyText` | `string` | `"No new notifications."` | Empty-state description. | +| `Placement` | `PopoverPlacement` | `PopoverPlacement.BottomEnd` | Where the panel opens relative to the bell. | +| `MaxBadgeCount` | `int` | `99` | Counts above this show as "N+". | +| `ShowClearAll` | `bool` | `true` | Shows the "Clear all" action. | +| `MaxHeight` | `string` | `"360px"` | Maximum height of the scrolling list. | +| `AriaLabel` | `string` | `"Notifications"` | Accessible label of the panel. | +| `Aura` | `AiAura?` | `null` | Aura variant for AI entries; `null` inherits a surrounding `DrylAiScope`. | +| `Class` | `string?` | `null` | Extra CSS class(es) merged onto the anchor's own classes. | +| `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the anchor. | + +`DrylNotification` and `IDrylNotificationService` are specified in +[`_Api.md`](_Api.md). + +## Acceptance Criteria + +### Mode selection + +- `Items` left `null` puts the component in service-driven mode. +- `Items` set puts the component in controlled mode. +- Service-driven mode resolves `IDrylNotificationService` and renders its + entries. +- Service-driven mode renders an empty inbox rather than throwing when no + service is registered. +- Controlled mode does not resolve the service at all. +- Service-driven mode mutates the service and raises none of the four + state-changing callbacks. +- Controlled mode raises the callbacks and mutates nothing but the row's own + read flag. +- The mode is decided per render from `Items`, so a consumer cannot end up in + both. + +### The bell + +- The bell renders a badge when at least one entry is unread. +- The bell renders no badge when every entry is read. +- The badge shows the unread count. +- The badge shows the count followed by a plus sign when the count exceeds + `MaxBadgeCount`. +- The bell reflects the panel's open state with a modifier class. +- Activating the bell opens the panel, and activating it again closes it. + +### The panel + +- The panel renders `Title` as its heading. +- The panel renders the entries newest first. +- The panel renders a "Mark all read" action when at least one entry is unread. +- The panel renders no "Mark all read" action when every entry is read. +- The panel renders a "Clear all" action when `ShowClearAll` is `true` and at + least one entry exists. +- The panel renders no "Clear all" action when `ShowClearAll` is `false`. +- The panel renders an empty state when there are no entries, using + `EmptyTitle` and `EmptyText`. +- The panel renders the small size of the empty state, so an in-panel placeholder + does not fill the popover. +- The list scrolls within `MaxHeight` rather than growing the panel past it. +- The panel opens at `Placement` relative to the bell. +- Each row is keyed by its notification's identity, so re-rendering the list + does not reuse one row's state for another entry. + +### A row + +- A row renders its notification's title. +- A row renders its notification's message when the message is non-empty. +- A row renders no message element when the message is `null` or empty. +- A row renders a relative time derived from its notification's timestamp. +- A row's relative time falls back to an absolute date once the entry is more + than a week old. +- A row's relative time is formatted with the invariant culture, so the month + abbreviation does not change with the host's locale. +- A future timestamp renders as the present rather than as a negative age. +- A row renders its notification's icon when one is set. +- A row with no icon and no AI provenance renders the bell icon. +- A row with no icon and any AI provenance renders the sparkle icon. +- An unread row carries an unread modifier class and an unread dot. +- A read row carries neither. + +### Actions + +- Activating a row marks it read when it was unread. +- Activating a row raises `OnItemClick` whether or not it was already read. +- Activating a row marks it read before `OnItemClick` is raised, so a handler + sees the new state. +- Activating "Mark all read" marks every entry read. +- Activating a row's dismiss control removes that entry. +- Activating "Clear all" removes every entry. +- Every action works in both modes, differing only in whether it mutates the + service or raises a callback. + +### Keyboard and accessibility + +- The bell carries an accessible label naming the unread count when there is + one, so a screen-reader user hears the badge rather than only seeing it. +- The bell carries a plain accessible label when nothing is unread. +- The bell carries `aria-haspopup` and `aria-expanded`, and the expanded state + tracks the panel. +- The badge is hidden from assistive technology, because its number is already + in the bell's label. +- The panel is announced as a dialog labelled by `AriaLabel`. +- Every row is a native button, so it is reached by `Tab` and activated by + `Enter` and `Space`. +- Each dismiss control carries an accessible label naming the entry it + dismisses, so a screen-reader user is not offered a list of identical + "Dismiss" buttons. +- The unread dot carries a text alternative, so the read state is not conveyed + by color alone. +- Every control in the panel shows a visible focus indicator under + `:focus-visible`. +- `Escape` and the outside click that close the panel are the popover's (`F1` in + `E11 Surfaces`); this component adds no key handling of its own. + +### Appearance + +- Every color the component renders comes from a token; the component names no + literal color (`DESIGN-01`). +- The bell paints `--glass-1` with a `--line` border and `--r-md`. +- The bell's border becomes `--accent-line` while the panel is open, so the + trigger shows that it is the source of what is on screen (`DESIGN-08`). +- The badge paints `--accent-grad` and is ringed in `--bg-1`, so it stays + readable over the bell's own border. +- The badge's count is set in `--font-mono`, so a rising number does not shift + the badge's width digit by digit. +- An unread row paints `--accent-soft` and keeps it on hover, so hovering does + not make an unread row look read. +- A read row paints nothing at rest and `--glass-1` on hover. +- The panel's own fill and frost are the popover's, not this component's. +- 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 bell transitions its border, its color and its background over + `--dur-fast` with `--ease-out` on hover. +- A row transitions its background over `--dur-fast` with `--ease-out` on hover. +- A row's dismiss control is invisible at rest and fades in over `--dur-fast` + when the row is hovered or the control itself is focused, so the list is calm + until it is being worked on. +- The panel's own enter and exit animation is the popover's (`F1` in + `E11 Surfaces`). + +### AI mode + +- A row whose notification's `Ai` is `AiState.None` renders no aura. +- A row whose notification's `Ai` is anything else renders the shared aura + vocabulary — ring, comet, glow (`AI-02`). +- `AiState.Thinking`, `AiState.Streaming` and `AiState.Generated` each map to + their own aura state class. +- `AiState.Active` renders the base aura with no state class, matching how the + shared vocabulary treats the idle-but-engaged state. +- The aura variant follows `Aura` when set and a surrounding `DrylAiScope` + otherwise. +- A row's aura ring and glow are drawn inside the row's rounded bounds, so the + scrolling list's straight edges cannot clip a glow into a square around a + rounded card. +- The component itself takes no `Ai`: the state belongs to each entry, and the + inbox around them is not doing the work. + +## Recorded gaps + +- **AI rows have no aura lifecycle.** Every other AI-capable component in the + category keeps the aura mounted for one `--dur-slow` beat after the state + drops. Here the aura classes are computed straight from the entry's own state, + which never transitions, so there is nothing to fade — but an entry whose `Ai` + a consumer mutates in place snaps instead of dissolving. +- **No completion wash on a row.** The one-shot `AiState.Generated` wash is + driven by a re-anchoring tick the shared helper supplies; the per-row aura + markup is hand-written and carries no such tick, so a `Generated` row shows + the aura state without the wash that announces it elsewhere. +- **In controlled mode the component writes to the consumer's object.** + Activating an unread row sets `Read` on the supplied `DrylNotification` before + raising `OnMarkRead`. A controlled component that mutates its input is a + surprise, and a consumer holding an immutable snapshot will find it changed + under them. +- **The unread dot's text alternative may not be announced.** It is an + `aria-label` on a plain `span` with no role, and a generic element without one + is not reliably named by assistive technology. The read state is inside the + row's button, so a screen-reader user may hear the title and the time but not + that the entry is unread (`UX-05`). +- **The relative time never re-renders on its own.** "just now" stays "just now" + until something else causes a render; there is no timer. +- **The relative time is computed from local now against the entry's own + offset**, so a timestamp taken in another time zone is aged correctly but an + entry created with a wrong offset silently reads wrong. +- **Six labels are fixed English** — "Mark all read", "Clear all + notifications", "Unread", "Dismiss {title}", "Notifications, N unread" and the + bell's plain label — with no parameters to change them, while `Title`, + `EmptyTitle`, `EmptyText` and `AriaLabel` are overridable. +- **The panel's width, the bell's size and the type sizes are literals** in + `code/DRYL.Components/Components/Feedback/DrylNotifications.razor.css`. + `DESIGN-01` covers colors, radii, shadows, durations and easings, which are + tokens here. Recorded as debt, not as compliance. +- **`MaxHeight` is a raw CSS string**, unvalidated, passed straight through to + `DrylScrollArea`. +- **The list has no virtualisation.** Every entry the service holds is rendered, + and the service never trims: a long-lived circuit that pushes on every agent + completion grows the inbox without bound. +- **No tests of its own.** None of the criteria above is guarded by a test — + including the two-mode split, which is the component's central claim. + +## 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`, + `--accent-soft`, `--accent-grad` and `--bg-1` are the mode-dependent tokens; + the component defines no mode-specific rule. +- **Enter/exit animation** — the panel's are the popover's, specified in + `E11 Surfaces/F1`. The component's own motion is the bell's hover transition, + the row hover and the dismiss control's fade-in. Individual rows do not + animate in or out, which is recorded above. +- **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The + substantive decisions are the count in the bell's own label and the per-entry + dismiss labels. +- **AI mode** — yes, per entry rather than per component. An inbox is where + asynchronous AI work lands, and provenance belongs to the individual + notification. +- **Demo page** — `DRYL.Website/Components/Pages/DemoNotifications.razor`, with + the examples `Components/Examples/Notifications/ServiceDriven.razor`, + `.../Controlled.razor` and `.../AiEntries.razor`. +- **`ComponentCatalog`** — registered as `"Notifications"` / `notifications` in + `DRYL.Website/Components/ComponentCatalog.cs`, flagged AI-capable. From c75c80cf57713cce2ed11815ffd8bb94f641c524 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 22:45:40 +0200 Subject: [PATCH 09/15] docs: record the plan for the five defects the E7 specs turned up Four fixes in the library and one wrong sentence in the website catalog. The sixth item raised alongside them - an Ai parameter on DrylTooltip - is a new feature touching the AI visual vocabulary and goes to the idea stage instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- docs/2026-08-20-e7-findings-plan.md | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/2026-08-20-e7-findings-plan.md diff --git a/docs/2026-08-20-e7-findings-plan.md b/docs/2026-08-20-e7-findings-plan.md new file mode 100644 index 0000000..639cc22 --- /dev/null +++ b/docs/2026-08-20-e7-findings-plan.md @@ -0,0 +1,156 @@ +# Plan: the five defects the E7 specs turned up + +**Branch:** `idea/i8-appearance-regression` (continuing) · **Base:** `96ceb11` +(`2.24.2`, **published** — `v2.24.2` is tagged and on `origin/main`) + +Writing the Feedback specs meant reading eight components against their code +rather than their doc comments, and five things came out of that reading that +are defects rather than debt. This plan fixes them. One task per commit, each +with its own verification. + +Because `2.24.2` is published, task 1 bumps `` to **`2.24.3`** and cuts +the changelog block; tasks 2–4 add their entries to that same block and leave +the version alone (`REL-01`). All four are fixes, so PATCH carries the stack. + +Every task touches a component whose spec now exists, so every task updates that +spec in the same commit (`SPEC-01`) and leaves it on `Implemented` — spec and +code changed together in one session goes straight there, without `Modified` as +an intermediate state (`SPEC-04`). + +The sixth item raised in the same conversation — giving `DrylTooltip` an `Ai` +parameter — is **not** in this plan. It is a new feature touching the AI visual +vocabulary, so it belongs in the idea stage (`IDEA-01`, `AI-04`), and it is +opened as `ideas/I9 AI tooltip.md` instead. + +--- + +## Task 1 — `DrylProgress` reports the value it actually draws + +- **Files:** `code/DRYL.Components/Components/Feedback/DrylProgress.razor` · + `specs/E7 Feedback/F5 DrylProgress.md` · + `code/DRYL.Components/DRYL.Components.csproj` · `CHANGELOG.md` +- **The defect:** the fill is clamped into 0…100 %, the reported ARIA value is + not. `Value="120" Max="100"` draws a full bar and tells a screen reader + "120 of 100"; `Value="-5"` draws an empty bar and reports `-5`. The two halves + of the same component disagree about what is on screen, and the half a + sighted user cannot check is the wrong one. +- **The fix:** report the clamped value. The clamp already exists for the + percentage; the ARIA value is derived from the same clamped number instead of + from the raw parameter. Formatting stays invariant-culture. +- **Not in scope:** `Max <= 0`. The bar renders empty for it today and will + report `0` of that `Max`; changing what a non-positive scale means is a + behaviour decision, not this fix. +- **Verify:** `dotnet build DRYL.slnx -c Release`; a new bUnit test asserting + the reported value for an over-range, an in-range and a negative `Value`; + `dotnet test DRYL.slnx -c Release`. + +## Task 2 — the shimmer stops when the user asked for less motion + +- **Files:** `code/DRYL.Components/wwwroot/dryl.css` · + `code/DRYL.Components/Components/Feedback/DrylSkeleton.razor.css` · + `specs/E7 Feedback/F4 DrylSkeleton.md` · `CHANGELOG.md` +- **The defect:** `DrylSkeleton`'s reduced-motion block calms the AI mutations + and drops the stagger, and it was easy to read that as the component honouring + `UX-06`. It does not: the `skel` primitive's own sweep lives in `dryl.css` and + is untouched by any reduced-motion rule, so a user who asked for less motion + gets a placeholder that is *entirely* moving — and on a loading page that is + most of the screen. +- **Where it belongs:** in the primitive, not in the component. `DrylImage` + renders a `DrylSkeleton` for its own loading state, so both are covered by one + fix, and any future consumer of `skel` inherits it. +- **The fix:** under `prefers-reduced-motion: reduce`, the sliding strip is not + painted at all, leaving the block as a flat token surface. The AI streaming + state keeps its violet-cyan **color** as a static tint on the block itself, so + the one thing the shimmer was saying — model output is arriving here — is not + lost with the motion that said it. +- **Not in scope:** the base shimmer's rate with motion on. `DESIGN-10` leaves + continuous motion free and 1.4 s is a chosen rhythm. +- **Verify:** `node scripts/check-motion-tokens.mjs`; + `node scripts/check-light-sync.mjs`; both color modes with reduced motion + forced in the browser, on `/components/skeleton` and `/components/image`. + +## Task 3 — a controlled `DrylNotifications` stops writing to its input + +- **Files:** + `code/DRYL.Components/Components/Feedback/DrylNotifications.razor` · + `specs/E7 Feedback/F8 DrylNotifications.md` · `CHANGELOG.md` · + (`DRYL.Website`) `Components/Examples/Notifications/Controlled.razor` +- **Two defects, one component:** + 1. **It mutates the consumer's object.** Activating an unread row sets `Read` + on the supplied `DrylNotification` *and then* raises `OnMarkRead`. A + controlled component whose whole contract is "you own the state, I raise + callbacks" writing to that state is a surprise, and a consumer holding a + snapshot finds it changed under them. The website's own controlled example + has a no-op `OnMarkRead` with a comment explaining that the component + already did it — the demo documents the bug. + 2. **The unread state may never be announced.** The dot carries an + `aria-label` on a bare `span` with no role. A generic element without one + is not reliably named, so a screen-reader user hears a row's title and time + and not that it is unread — the state the whole bell exists to convey + (`UX-05`). +- **The fix:** in controlled mode the component raises `OnMarkRead` and writes + nothing; service-driven mode is unchanged, because there the service *is* the + state. The unread marker moves into the row's own accessible name as + visually-hidden text, and the dot becomes decorative. +- **Consequence for the website:** the controlled example must now set `Read` + itself. That is the point — it becomes an example of controlled mode instead + of an example of relying on the component to cheat. +- **Verify:** `dotnet build`; new bUnit tests — controlled mode leaves `Read` + untouched and raises the callback, service-driven mode still marks read; the + unread row's accessible name contains the marker; + `dotnet test DRYL.slnx -c Release`; then in `DRYL.Website`, + `dotnet test DRYL.Website.slnx`. + +## Task 4 — a dismiss button that dismisses + +- **Files:** `code/DRYL.Components/Components/Feedback/DrylAlert.razor` · + `specs/E7 Feedback/F1 DrylAlert.md` · `CHANGELOG.md` +- **The defect:** `` with no `OnDismiss` renders a + button that is focusable, announced and inert. The alert never removes itself, + so with nobody listening the control does nothing at all — a control that + lies about being actionable. +- **The options considered:** + 1. *Render the button only when a handler is attached.* Rejected: it silently + drops a control the consumer explicitly asked for, and `Dismissible` would + mean two different things depending on an unrelated parameter. + 2. *Always self-hide, and also raise the callback.* Rejected: a host that + unmounts the alert on `OnDismiss` gets the same effect twice, and an alert + that hides itself has no way back — a host re-showing the same instance + would find it invisible. + 3. **Self-hide only when no handler is attached.** Chosen. The rule is one + sentence: if nobody is listening, the button still does the obvious thing; + if someone is, they own the lifecycle exactly as today. +- **The fix:** option 3, plus the state resets when `Dismissible` is turned off + and on again, so the alert is recoverable without remounting. +- **Verify:** `dotnet build`; new bUnit tests — dismiss with no handler removes + the alert, dismiss with a handler raises it and leaves the alert mounted; + `dotnet test DRYL.slnx -c Release`. + +## Task 5 — the catalog stops calling the tooltip CSS-only + +- **Files:** (`DRYL.Website`) `Components/ComponentCatalog.cs` +- **The defect:** the catalog's one-line description reads "CSS-only hover + tooltip — 4 placements, wraps any trigger." The bubble has been a JS-driven + body-level portal for a long time, which is exactly why it survives the glass + cards it is used inside. The line is on the components overview and in the + search index, so it is the first thing a reader is told about the component, + and it is false. +- **The fix:** describe what it is. No library code, no version bump — this is + `DRYL.Website` only. +- **Verify:** `dotnet test DRYL.Website.slnx`; the overview page and the search + result read correctly in the browser. + +--- + +## What is deliberately left alone + +- **`DrylProgress` has no default `AriaLabel`.** A real gap, recorded in `F5`, + but giving it a default is a behaviour decision about what a nameless bar + should be called — not a defect with one obvious fix. +- **The fixed English strings** across five components. A localisation surface + is a feature, not a patch. +- **`DrylNotifications` has no virtualisation and the service never trims.** + Both are real and both are design decisions the maintainer should make. +- **The literal type sizes, chip diameters and panel widths** in six of the + eight components. `DESIGN-01` does not cover type scale today; inventing a + token set for it is a `DESIGN-03` proposal, not a fix. From 60730273cc07751a7992aa84007700aaac7ffbc6 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 22:48:19 +0200 Subject: [PATCH 10/15] fix(progress): announce the value the bar actually draws The fill was clamped into the track and aria-valuenow was not. Value=120 against Max=100 drew a full bar and told a screen reader "120 of 100"; a negative value drew an empty bar and reported the negative number. The half a sighted user cannot check was the wrong one. The fill width, the percentage label and the reported value now all come from one clamped number, so the drawn bar and the announced value cannot disagree. Max <= 0 is unchanged: the bar renders empty and reports zero. Seven tests, and they are the component's first of its own - it had two assertions in shared test files and nothing else. Spec F5 updated in the same commit (SPEC-01): four criteria under "Value and scale" and "Keyboard and accessibility", and the recorded gap that named this defect is gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- CHANGELOG.md | 5 ++ .../Components/Feedback/DrylProgress.razor | 9 ++- code/DRYL.Components/DRYL.Components.csproj | 2 +- specs/E7 Feedback/F5 DrylProgress.md | 29 ++++---- .../DrylProgressTests.cs | 72 +++++++++++++++++++ 5 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 tests/DRYL.Components.Tests/DrylProgressTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 73df1a6..aae9ae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ Version bump guide: ## [Unreleased] +## [2.24.3] — 2026-08-20 + +### Fixed +- `DrylProgress` — **the value announced to a screen reader is now the value the bar actually draws.** The fill was clamped into the track and `aria-valuenow` was not, so the two halves of the same component disagreed whenever `Value` fell outside `0..Max`: `Value="120" Max="100"` drew a full bar and reported "120 of 100", and a negative value drew an empty bar and reported the negative number. The half a sighted user cannot check was the wrong one. The fill width, the percentage label and the reported value are now all derived from one clamped number, so a bar cannot show one thing and say another. No API change; a bar whose `Value` was always in range renders and reports exactly as before. + ## [2.24.2] — 2026-08-20 ### Changed diff --git a/code/DRYL.Components/Components/Feedback/DrylProgress.razor b/code/DRYL.Components/Components/Feedback/DrylProgress.razor index 7079139..738e50d 100644 --- a/code/DRYL.Components/Components/Feedback/DrylProgress.razor +++ b/code/DRYL.Components/Components/Feedback/DrylProgress.razor @@ -109,19 +109,24 @@ public void Dispose() => _aura.Dispose(); + // The value the bar actually draws. Everything the component reports — the fill + // width, the percentage and aria-valuenow — is derived from this one number, so a + // Value outside 0..Max cannot make the drawn bar and the announced value disagree. + private double ClampedValue => Max <= 0 ? 0 : Math.Clamp(Value, 0, Max); + private double Percent { get { if (Max <= 0) return 0; - return Math.Clamp(Value / Max * 100, 0, 100); + return ClampedValue / Max * 100; } } private string PctText => FormattableString.Invariant($"{Percent:0}%"); private string? AriaValueNow => - Indeterminate ? null : Value.ToString(CultureInfo.InvariantCulture); + Indeterminate ? null : ClampedValue.ToString(CultureInfo.InvariantCulture); private string? BarStyle => Indeterminate ? null : FormattableString.Invariant($"width: {Percent:0.##}%"); diff --git a/code/DRYL.Components/DRYL.Components.csproj b/code/DRYL.Components/DRYL.Components.csproj index bf2dc78..007c6ef 100644 --- a/code/DRYL.Components/DRYL.Components.csproj +++ b/code/DRYL.Components/DRYL.Components.csproj @@ -5,7 +5,7 @@ DRYL.Components - 2.24.2 + 2.24.3 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/specs/E7 Feedback/F5 DrylProgress.md b/specs/E7 Feedback/F5 DrylProgress.md index d34784b..4217057 100644 --- a/specs/E7 Feedback/F5 DrylProgress.md +++ b/specs/E7 Feedback/F5 DrylProgress.md @@ -118,8 +118,14 @@ both, or neither. - The track carries `role="progressbar"`. - The track carries a minimum of zero. - The track carries `Max` as its maximum, formatted with the invariant culture. -- A determinate bar carries `Value` as its current value, formatted with the - invariant culture. +- A determinate bar carries the value it draws as its current value, formatted + with the invariant culture. +- A `Value` above `Max` is reported as `Max`, so the bar never announces more + than the scale it was given. +- A `Value` below zero is reported as zero. +- The reported value, the rendered percentage and the fill's width are derived + from the same clamped number, so what is drawn and what is announced cannot + disagree. - An indeterminate bar carries no current value, so assistive technology announces it as busy rather than as a number. - The track carries `AriaLabel` as its accessible label. @@ -182,10 +188,6 @@ both, or neither. announced as a progress bar with a number and no subject. Every other indicator in this category falls back to a state-aware label; this one does not. -- **The ARIA value is not clamped.** The fill is clamped into 0…100 %, but the - reported current value is `Value` as given. A `Value` of 120 against a `Max` - of 100 shows a full bar and tells a screen reader "120 of 100" — the two - halves of the criterion "a value above `Max` renders as full" disagree. - **The percentage is not announced as it changes.** The label row carries no live region, so a screen-reader user hears the value only when they navigate to the bar. `role="progressbar"` covers the value; the visible text is @@ -198,10 +200,12 @@ both, or neither. - **The label row's type sizes are literals** in the same file. - **The indeterminate sweep's width and travel are literals**, so its rhythm cannot be retuned from a token. -- **Only two of its criteria are guarded by tests**: the invariant-culture fill - width in `tests/DRYL.Components.Tests/GlobalizationTests.cs`, and the class - merge in `tests/DRYL.Components.Tests/ClassMergeTests.cs`. Nothing else above - is covered. +- **Most of its criteria are unguarded.** Tested today: the reported value and + its clamping in `tests/DRYL.Components.Tests/DrylProgressTests.cs`, the + invariant-culture fill width in + `tests/DRYL.Components.Tests/GlobalizationTests.cs`, and the class merge in + `tests/DRYL.Components.Tests/ClassMergeTests.cs`. The label row, the variants, + the sizes and the indeterminate rendering are not. ## Cross-cutting evidence (`SPEC-05`) @@ -216,8 +220,9 @@ both, or neither. in-flow indicator its host mounts; a host that wants it to appear gradually wraps it in `DrylPresence`. - **Keyboard and a11y** — the "Keyboard and accessibility" criteria above, - including the deliberate absence of an ARIA value while indeterminate. The - missing default label is recorded as a gap rather than glossed. + including the deliberate absence of an ARIA value while indeterminate, and + the clamp that keeps the announced value equal to the drawn one. The missing + default label is recorded as a gap rather than glossed. - **AI mode** — yes. A progress bar is the natural place to show a model's long-running work, and the aura is hosted outside the clipping track so it is actually visible there. diff --git a/tests/DRYL.Components.Tests/DrylProgressTests.cs b/tests/DRYL.Components.Tests/DrylProgressTests.cs new file mode 100644 index 0000000..a0d9557 --- /dev/null +++ b/tests/DRYL.Components.Tests/DrylProgressTests.cs @@ -0,0 +1,72 @@ +using Bunit; +using DRYL.Components; + +namespace DRYL.Components.Tests; + +/// +/// Behavioural tests for . The point of them is that the +/// bar a sighted user sees and the value a screen reader is told are derived from the +/// same clamped number — the half nobody can check must not be the wrong one. +/// +public class DrylProgressTests : BunitContext +{ + private IRenderedComponent RenderBar( + double value, double max = 100, bool indeterminate = false) => + Render(ps => ps + .Add(p => p.Value, value) + .Add(p => p.Max, max) + .Add(p => p.Indeterminate, indeterminate)); + + private static string? AriaValueNow(IRenderedComponent cut) => + cut.Find("[role=progressbar]").GetAttribute("aria-valuenow"); + + [Fact] + public void Reports_the_value_in_range() + { + Assert.Equal("42", AriaValueNow(RenderBar(42))); + } + + [Fact] + public void Reports_max_for_a_value_above_it() + { + // The bar draws full; it must not announce "120 of 100". + var cut = RenderBar(120); + Assert.Equal("100", AriaValueNow(cut)); + Assert.Contains("width: 100%", cut.Markup); + } + + [Fact] + public void Reports_zero_for_a_negative_value() + { + var cut = RenderBar(-5); + Assert.Equal("0", AriaValueNow(cut)); + Assert.Contains("width: 0%", cut.Markup); + } + + [Fact] + public void Reports_zero_when_max_is_not_positive() + { + Assert.Equal("0", AriaValueNow(RenderBar(7, max: 0))); + } + + [Fact] + public void Reports_no_value_while_indeterminate() + { + Assert.Null(AriaValueNow(RenderBar(42, indeterminate: true))); + } + + [Fact] + public void Reports_max_as_the_upper_bound() + { + Assert.Equal("5", RenderBar(3, max: 5).Find("[role=progressbar]").GetAttribute("aria-valuemax")); + } + + [Fact] + public void Percentage_label_follows_the_clamped_value() + { + var cut = Render(ps => ps + .Add(p => p.Value, 120d) + .Add(p => p.ShowLabel, true)); + Assert.Contains("100%", cut.Markup); + } +} From 3980429667bed4167f69f1bac03b8f887ad2f18f Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 22:54:51 +0200 Subject: [PATCH 11/15] fix(skeleton): stop the shimmer when the user asked for less motion DrylSkeleton's reduced-motion block calmed its AI states and dropped the stagger between its bars, and that read as the component honouring UX-06. It did not: the sweep lives on the shared .skel primitive in dryl.css, which no reduced-motion rule had ever touched. A user with the preference set got a placeholder that was entirely and permanently moving - and on a loading screen that is most of the page. The strip is no longer painted under prefers-reduced-motion: reduce. Measured in the browser in both modes: strip display none, and the blocks now rest on --glass-3 rather than --glass-1 (light 0.55 -> 0.72 alpha, dark 0.03 -> 0.08), which is the bright midpoint the sweep passed through - a still block is as legible as a moving one was at its clearest instead of resting at its dimmest. In light mode that is the difference between a placeholder and nothing. Streaming keeps its violet-cyan as a static tint on the block, so the signal the motion carried is not lost with the motion. Verified that with motion on everything is exactly as before: strip block, streaming background back on the strip. Fixed in the primitive, so DrylImage's loading state and any consumer of the skel classes are covered by the same change. UX-06's documented hit count moves 22 -> 23. Spec F4 updated in the same commit (SPEC-01); the recorded gap that named this defect is gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- CHANGELOG.md | 1 + .../Feedback/DrylSkeleton.razor.css | 18 ++++++++++----- code/DRYL.Components/wwwroot/dryl.css | 15 +++++++++++++ harness/uiux.md | 2 +- specs/E7 Feedback/F4 DrylSkeleton.md | 22 +++++++++---------- 5 files changed, 41 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aae9ae6..e5cc73e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Version bump guide: ## [2.24.3] — 2026-08-20 ### Fixed +- `DrylSkeleton`, `DrylImage` — **the shimmer now stops when the user has asked for less motion.** The skeleton's own reduced-motion rules calmed its AI states and dropped the stagger between its bars, which read as the component honouring the preference; it did not. The sweep itself lives on the shared `.skel` primitive in `dryl.css` and no reduced-motion rule had ever touched it, so someone who set the preference got a placeholder that was entirely, permanently in motion — on a loading screen, that is most of what is on the page. The sliding strip is no longer painted at all under `prefers-reduced-motion: reduce`, and the blocks rest on `--glass-3`, the bright midpoint the sweep used to pass through, so a still placeholder is as legible as a moving one was at its clearest rather than sitting at the dim end of its own range. `AiState.Streaming` keeps its violet-cyan colour as a static tint on the blocks, so the one thing the moving shimmer was saying — model output is arriving here — survives the loss of the motion that said it. The fix is in the primitive, so `DrylImage`'s loading state is covered by it too, as is any consumer using the `skel` classes through `SkeletonVariant.Custom`. Nothing changes with motion on. - `DrylProgress` — **the value announced to a screen reader is now the value the bar actually draws.** The fill was clamped into the track and `aria-valuenow` was not, so the two halves of the same component disagreed whenever `Value` fell outside `0..Max`: `Value="120" Max="100"` drew a full bar and reported "120 of 100", and a negative value drew an empty bar and reported the negative number. The half a sighted user cannot check was the wrong one. The fill width, the percentage label and the reported value are now all derived from one clamped number, so a bar cannot show one thing and say another. No API change; a bar whose `Value` was always in range renders and reports exactly as before. ## [2.24.2] — 2026-08-20 diff --git a/code/DRYL.Components/Components/Feedback/DrylSkeleton.razor.css b/code/DRYL.Components/Components/Feedback/DrylSkeleton.razor.css index 3145827..5fd0c6e 100644 --- a/code/DRYL.Components/Components/Feedback/DrylSkeleton.razor.css +++ b/code/DRYL.Components/Components/Feedback/DrylSkeleton.razor.css @@ -155,11 +155,19 @@ to { opacity: 0; } } -/* ─── Reduced motion ────────────────────────────────────────── */ +/* ─── Reduced motion ──────────────────────────────────────────── + The sliding strip is switched off for every .skel by the primitive in + dryl.css, so nothing here needs to slow it down — the rules below only + have to make sure the states that spoke *through* the strip still speak. + Streaming is the one that does: its whole signal was the violet-cyan + sweep, and with the sweep gone the block itself carries the colour. */ @media (prefers-reduced-motion: reduce) { - .skel-wrap.ai-aura.ai-thinking .skel::before { animation-duration: 1.4s; } - .skel-wrap.ai-aura.ai-streaming .skel::before { animation-duration: 1.4s; } + .skel-wrap.ai-aura.ai-streaming .skel { + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--ai-a) 10%, transparent) 0%, + color-mix(in srgb, var(--ai-b) 16%, transparent) 100% + ); + } .skel-wrap.ai-aura.ai-generated .skel { animation: none; opacity: 0.25; } - .skel-text-block .skel::before, - .skel-card-header-text .skel::before { animation-delay: 0s; } } diff --git a/code/DRYL.Components/wwwroot/dryl.css b/code/DRYL.Components/wwwroot/dryl.css index c05ca2b..d3f2653 100644 --- a/code/DRYL.Components/wwwroot/dryl.css +++ b/code/DRYL.Components/wwwroot/dryl.css @@ -3550,6 +3550,21 @@ td.tbl-td-editing select { animation: skel 1.4s var(--ease-in-out) infinite; } @keyframes skel { 0% { translate: -80% 0; } 100% { translate: 0 0; } } +/* A placeholder is usually most of the screen while a page loads, so the one + thing on it that never stops moving is the one that matters most here. With + motion reduced the sliding strip is not painted at all and the block rests as + a flat token surface; it still reads as "content is coming", without the + sweep (UX-06). Components that recolor the strip restore their signal as a + static tint instead — see DrylSkeleton's streaming state. */ +@media (prefers-reduced-motion: reduce) { + .skel::before { display: none; } + /* Resting on --glass-3 rather than --glass-1: that is the bright midpoint the + sweep passes through, so a still block is as legible as a moving one was at + its clearest, instead of sitting at the dim end of its own range. It is the + difference between reading as a placeholder and reading as nothing in light + mode, where --glass-1 is near-white on a near-white page. */ + .skel { background: var(--glass-3); } +} /* Spinner */ .spinner { diff --git a/harness/uiux.md b/harness/uiux.md index 770251f..32723cb 100644 --- a/harness/uiux.md +++ b/harness/uiux.md @@ -145,7 +145,7 @@ usable with motion off. The shared motion primitives already do this; any custom component CSS must mirror it. Check: `rg -c 'prefers-reduced-motion' code/DRYL.Components/wwwroot/dryl.css` -— currently **22** (green, count > 0: `dryl.css` itself honours the media +— currently **23** (green, count > 0: `dryl.css` itself honours the media query). This only proves the shared primitive file does its part; it does not scan individual component `.razor.css` files for CSS that introduces new motion outside the shared primitives without its own diff --git a/specs/E7 Feedback/F4 DrylSkeleton.md b/specs/E7 Feedback/F4 DrylSkeleton.md index c149d96..bb57df6 100644 --- a/specs/E7 Feedback/F4 DrylSkeleton.md +++ b/specs/E7 Feedback/F4 DrylSkeleton.md @@ -132,8 +132,13 @@ consumer's, not the library's. while continuous motion is free of it (`DESIGN-10`). - Wherever an easing is applied, it is an easing token rather than a bare keyword. -- Under `prefers-reduced-motion: reduce` the staggered offsets are dropped, so - every bar shimmers in phase and nothing chases anything. +- Under `prefers-reduced-motion: reduce` the shimmer does not run at all: the + sliding strip is not painted, and the blocks rest as flat surfaces. +- Under `prefers-reduced-motion: reduce` the blocks rest on `--glass-3`, the + bright midpoint the sweep would have passed through, so a still placeholder is + as legible as a moving one was at its clearest. +- A placeholder is still recognisable as a placeholder with motion off, so a + user who asked for less motion is not shown an empty page (`UX-06`). ### AI mode @@ -156,11 +161,12 @@ consumer's, not the library's. jump. - Every shimmer mutation targets the sliding strip rather than the block itself, so an AI state change never repaints a large surface. -- Under `prefers-reduced-motion: reduce` the accelerated and the recolored - shimmer both fall back to the base rate, so an AI state no longer speeds - anything up. +- Under `prefers-reduced-motion: reduce` `AiState.Streaming` keeps its + violet-cyan color as a static tint on the blocks themselves, so the signal the + moving shimmer carried survives the loss of the motion that carried it. - Under `prefers-reduced-motion: reduce` `AiState.Generated` settles the blocks at a dimmed opacity instead of animating them out. +- No AI state reintroduces motion under `prefers-reduced-motion: reduce`. - 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 @@ -188,12 +194,6 @@ consumer's, not the library's. - **`SkeletonVariant.Custom` freezes three CSS class names** — `skel`, `skel-circle` and `skel-rect` — into the public contract. They are as bound by the 1.0 freeze as the parameters, and nothing in the build enforces that. -- **The base shimmer keeps running under `prefers-reduced-motion: reduce`.** The - reduced-motion block calms the AI mutations and drops the stagger, but the - `skel` primitive's own sweep is untouched, so a user who asked for less motion - still gets a continuously moving placeholder. The primitive is shared and its - rule lives in `dryl.css`, so the fix is not this component's alone — recorded - here because this is where the shimmer is most of the screen (`UX-06`). - **No tests of its own.** None of the criteria above is guarded by a test. ## Cross-cutting evidence (`SPEC-05`) From 15f6c6ef06ae7dee7b550bcd1bacab9153a04352 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 22:57:25 +0200 Subject: [PATCH 12/15] fix(notifications): stop writing to the caller's list, and say "unread" out loud Two defects the F8 spec turned up, both in the same component. A controlled inbox set Read on the consumer's own DrylNotification and then raised OnMarkRead. Controlled mode's whole promise is the opposite - you own the state, the component raises callbacks - so a caller holding a snapshot found it changed underneath them, and the callback arrived describing something already done. The website's own controlled example had an empty OnMarkRead with a comment explaining that the component had already handled it: the demo documented the bug. Now the component raises the callback and writes nothing, and the example sets Read itself. The unread dot carried aria-label on a bare span. A generic element with no role is not reliably named, so a screen-reader user heard a row's title and time and nothing about it being unread - the one thing a notification bell exists to say. The word is now visually-hidden text inside the row's own button, and the dot is decorative so the state is announced once. Eight tests, the component's first. The controlled-mode one fails against the old behaviour, which is the point of it. Spec F8 updated in the same commit (SPEC-01); both recorded gaps are gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- CHANGELOG.md | 4 + .../Feedback/DrylNotifications.razor | 11 +- specs/E7 Feedback/F8 DrylNotifications.md | 37 ++-- .../DrylNotificationsTests.cs | 175 ++++++++++++++++++ 4 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 tests/DRYL.Components.Tests/DrylNotificationsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e5cc73e..427c275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,11 @@ Version bump guide: ## [2.24.3] — 2026-08-20 +### Changed +- `DrylNotifications` — **a controlled inbox no longer writes to the list you gave it.** Clicking an unread row set `Read` on your own `DrylNotification` and *then* raised `OnMarkRead`, which is the opposite of what controlled mode promises: you own the state, the component raises callbacks. A caller holding a snapshot found it changed underneath them, and the callback arrived describing something that had already happened. The component now raises `OnMarkRead` and writes nothing — **if your handler was empty because the component did the work, the row will no longer turn read; set `Read` in the handler.** Service-driven mode is untouched, because there the service genuinely is the state. + ### Fixed +- `DrylNotifications` — **the unread state is now announced, not only coloured.** The accent dot carried an `aria-label` on a bare `span`, and a generic element with no role is not reliably named by assistive technology, so a screen-reader user heard a row's title and its time and nothing about it being unread — the one thing the bell exists to convey. The word now sits as visually-hidden text inside the row's own button, so it is part of the row's accessible name, and the dot is marked decorative so the state is announced once rather than twice. - `DrylSkeleton`, `DrylImage` — **the shimmer now stops when the user has asked for less motion.** The skeleton's own reduced-motion rules calmed its AI states and dropped the stagger between its bars, which read as the component honouring the preference; it did not. The sweep itself lives on the shared `.skel` primitive in `dryl.css` and no reduced-motion rule had ever touched it, so someone who set the preference got a placeholder that was entirely, permanently in motion — on a loading screen, that is most of what is on the page. The sliding strip is no longer painted at all under `prefers-reduced-motion: reduce`, and the blocks rest on `--glass-3`, the bright midpoint the sweep used to pass through, so a still placeholder is as legible as a moving one was at its clearest rather than sitting at the dim end of its own range. `AiState.Streaming` keeps its violet-cyan colour as a static tint on the blocks, so the one thing the moving shimmer was saying — model output is arriving here — survives the loss of the motion that said it. The fix is in the primitive, so `DrylImage`'s loading state is covered by it too, as is any consumer using the `skel` classes through `SkeletonVariant.Custom`. Nothing changes with motion on. - `DrylProgress` — **the value announced to a screen reader is now the value the bar actually draws.** The fill was clamped into the track and `aria-valuenow` was not, so the two halves of the same component disagreed whenever `Value` fell outside `0..Max`: `Value="120" Max="100"` drew a full bar and reported "120 of 100", and a negative value drew an empty bar and reported the negative number. The half a sighted user cannot check was the wrong one. The fill width, the percentage label and the reported value are now all derived from one clamped number, so a bar cannot show one thing and say another. No API change; a bar whose `Value` was always in range renders and reports exactly as before. diff --git a/code/DRYL.Components/Components/Feedback/DrylNotifications.razor b/code/DRYL.Components/Components/Feedback/DrylNotifications.razor index 059dea6..b08f060 100644 --- a/code/DRYL.Components/Components/Feedback/DrylNotifications.razor +++ b/code/DRYL.Components/Components/Feedback/DrylNotifications.razor @@ -97,7 +97,11 @@ @if (!item.Read) { - + @* The dot is decorative; the state belongs in the row's own + accessible name. aria-label on a role-less span is not + reliably announced, so the word is real text instead. *@ + + Unread } } - + ; - @* Optional dismiss button *@ - @if (Dismissible) - { - - } - - -@code { /// Semantic variant — drives the icon and the accent colour. [Parameter] public AlertKind Kind { get; set; } = AlertKind.Info; @@ -102,6 +125,13 @@ private AiState _prevAi = AiState.None; private int _genTick; private readonly AuraLifecycle _aura = new(); + private bool _selfDismissed; + + /// + /// True when the alert offers a dismiss button that nobody is listening to, and + /// therefore has to answer it itself. + /// + private bool SelfDismisses => Dismissible && !OnDismiss.HasDelegate; protected override void OnParametersSet() { @@ -110,6 +140,10 @@ _genTick++; _prevAi = Ai; _aura.Sync(Ai, () => InvokeAsync(StateHasChanged)); + + // Turning Dismissible off and on again brings a self-dismissed alert back, so a + // host that never had a handler still has a way to show it without remounting. + if (!Dismissible) _selfDismissed = false; } public void Dispose() => _aura.Dispose(); @@ -162,7 +196,13 @@ private async Task HandleDismiss() { if (OnDismiss.HasDelegate) + { await OnDismiss.InvokeAsync(); + return; + } + + // Nobody is listening. The button still has to do the obvious thing. + _selfDismissed = true; } /// Semantic variant of the alert. diff --git a/specs/E7 Feedback/F1 DrylAlert.md b/specs/E7 Feedback/F1 DrylAlert.md index 418dd05..cde2351 100644 --- a/specs/E7 Feedback/F1 DrylAlert.md +++ b/specs/E7 Feedback/F1 DrylAlert.md @@ -46,10 +46,12 @@ The component is also the fallback surface of | `Class` | `string?` | `null` | Extra CSS class(es) merged onto the alert's own classes. | | `AdditionalAttributes` | `IDictionary?` | `null` | Pass-through attributes on the alert root. | -`Dismissible` and `OnDismiss` are separate on purpose: the button appears -because the alert is dismissible, not because someone is listening. An alert -with `Dismissible` and no handler is a valid, if inert, configuration — see the -recorded gap below. +`Dismissible` and `OnDismiss` are separate on purpose, and which of them is set +decides **who owns the alert's lifetime**. With a handler, the host owns it: +dismissing is a request, and the host answers it by unmounting the alert. With +no handler, nobody is listening, so the alert answers the press itself and +animates away. The button is therefore never inert, and an alert that worked +before behaves exactly as it did. ## Acceptance Criteria @@ -86,11 +88,18 @@ recorded gap below. ### Dismissal - The dismiss button is rendered only when `Dismissible` is `true`. -- Activating the dismiss button raises `OnDismiss`. -- Activating the dismiss button when no handler is attached does nothing and - throws nothing. -- The component never removes itself from the DOM: unmounting the alert is the - host's decision, taken in response to `OnDismiss`. +- Activating the dismiss button raises `OnDismiss` when a handler is attached. +- With a handler attached the component does not remove itself: unmounting the + alert is the host's decision, taken in response to `OnDismiss`. +- With no handler attached the component dismisses itself, so the button is + never a control that does nothing. +- A self-dismissing alert animates out rather than disappearing instantly + (`DESIGN-12`). +- Setting `Dismissible` to `false` restores a self-dismissed alert, so a host + without a handler can still bring it back without remounting it. +- The extra wrapper element the self-dismissing configuration needs is present + only in that configuration, so an alert with a handler and a non-dismissible + alert render exactly the markup they did before. - The dismiss button is a `type="button"`, so an alert inside a form cannot submit it. - A dismissible alert reserves the space its button occupies, so adding @@ -136,6 +145,8 @@ recorded gap below. - The alert has no mount animation of its own: it is an in-flow element the host places, and a host that wants it to animate in wraps it in `DrylPresence` (`DESIGN-12`). +- A self-dismissing alert fades out over the shared presence vocabulary rather + than a treatment of its own (`DESIGN-13`). - Under `prefers-reduced-motion: reduce` the alert remains fully legible and fully dismissible. @@ -158,16 +169,19 @@ recorded gap below. ## Recorded gaps -- **`Dismissible` without `OnDismiss` is inert.** The button renders, is - focusable and is announced, and pressing it does nothing visible. The - component cannot fix this alone — it does not own its own mounting — but a - consumer meets a control that lies about being actionable. - **The dismiss button's label is fixed English** (`"Dismiss notification"`), with no parameter to change it. Every other string on the component comes from the consumer. -- **No tests of its own.** `DrylAlert` is covered by one assertion in - `tests/DRYL.Components.Tests/ClassMergeTests.cs` — that a splatted `class` - merges — and by nothing else. None of the criteria above is guarded by a test. +- **Most of its criteria are unguarded.** Tested today, in + `tests/DRYL.Components.Tests/DrylAlertTests.cs`: both halves of the dismissal + contract, the wrapper's absence in the other two configurations, the + role/`aria-live` split by `Kind` and the empty-string icon; plus the class + merge in `tests/DRYL.Components.Tests/ClassMergeTests.cs`. The title and body + slots, the icon resolution per `Kind` and the whole AI section are not. +- **The self-dismissal completes outside bUnit's reach.** The removal finishes + when the presence exit animation ends, which is driven by JS, so the test + suite can only assert that the alert began to leave. That it actually goes was + measured in the browser instead. - **Literal type sizes and paddings.** The alert's font sizes, the icon chip's dimensions and the banner's padding are literals in `code/DRYL.Components/wwwroot/dryl.css`. `DESIGN-01` covers colors, radii, @@ -184,10 +198,12 @@ recorded gap below. `node scripts/validate-light-contrast.mjs`. The glass fill, the frost and the semantic chips 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: an in-flow banner is mounted and unmounted by its host, - which wraps it in `DrylPresence` when it should animate. The component's own - motion is the dismiss button's hover transition and the AI aura. +- **Enter/exit animation** — no enter animation of its own, and that is the + written exception `DESIGN-11` allows: an in-flow banner is mounted by its host, + which wraps it in `DrylPresence` when it should animate in. The one unmount the + component owns — a dismissal nobody is listening to — does animate out, through + `DrylPresence` and the shared presence vocabulary. Its other motion is the + dismiss button's hover transition and the AI aura. - **Keyboard and a11y** — the "Keyboard and accessibility" criteria above. The role/`aria-live` split by `Kind` is the substantive decision: only a failure or a warning interrupts. diff --git a/tests/DRYL.Components.Tests/DrylAlertTests.cs b/tests/DRYL.Components.Tests/DrylAlertTests.cs new file mode 100644 index 0000000..660f4dc --- /dev/null +++ b/tests/DRYL.Components.Tests/DrylAlertTests.cs @@ -0,0 +1,122 @@ +using Bunit; +using DRYL.Components; + +namespace DRYL.Components.Tests; + +/// +/// Behavioural tests for , centred on the question the +/// component answers per configuration: who owns the alert's lifetime when its +/// dismiss button is pressed. +/// +public class DrylAlertTests : BunitContext +{ + // The self-dismissing configuration wraps in DrylPresence, which wires dryl.motion. + public DrylAlertTests() => JSInterop.Mode = JSRuntimeMode.Loose; + + private IRenderedComponent RenderAlert( + bool dismissible = true, Action? onDismiss = null) => + Render(ps => + { + ps.Add(p => p.Dismissible, dismissible).AddChildContent("All changes were applied."); + if (onDismiss is not null) ps.Add(p => p.OnDismiss, onDismiss); + }); + + [Fact] + public void Dismiss_without_a_handler_starts_removing_the_alert() + { + // The removal itself finishes when DrylPresence's exit animation ends, which is + // driven by dryl.motion and therefore out of bUnit's reach — see DrylPresenceTests + // for the same boundary. What is visible here is that the alert answered the press + // by beginning to leave, which is exactly what it did not do before. + var cut = RenderAlert(); + + cut.Find(".alert-dismiss").Click(); + + Assert.Contains("presence-exit", cut.Find(".presence").GetAttribute("class")); + } + + [Fact] + public void Dismiss_with_a_handler_raises_it() + { + var raised = false; + var cut = RenderAlert(onDismiss: () => raised = true); + + cut.Find(".alert-dismiss").Click(); + + Assert.True(raised); + } + + [Fact] + public void Dismiss_with_a_handler_leaves_the_alert_mounted() + { + // The host owns the lifetime here: dismissing is a request, not a removal. + var cut = RenderAlert(onDismiss: () => { }); + + cut.Find(".alert-dismiss").Click(); + + Assert.Single(cut.FindAll(".alert")); + } + + [Fact] + public void An_alert_with_a_handler_gains_no_wrapper_element() + { + // The DrylPresence wrapper exists only in the self-dismissing configuration, + // so an alert that worked before does not change shape. + var cut = RenderAlert(onDismiss: () => { }); + + Assert.DoesNotContain("presence", cut.Markup); + } + + [Fact] + public void A_non_dismissible_alert_gains_no_wrapper_element() + { + var cut = RenderAlert(dismissible: false); + + Assert.DoesNotContain("presence", cut.Markup); + Assert.Empty(cut.FindAll(".alert-dismiss")); + } + + [Fact] + public void Turning_dismissible_off_brings_a_self_dismissed_alert_back() + { + var cut = RenderAlert(); + cut.Find(".alert-dismiss").Click(); + + cut.Render(ps => ps.Add(p => p.Dismissible, false)); + + Assert.Single(cut.FindAll(".alert")); + Assert.DoesNotContain("presence", cut.Markup); + } + + [Fact] + public void Danger_is_announced_assertively() + { + var cut = Render(ps => ps + .Add(p => p.Kind, DrylAlert.AlertKind.Danger) + .AddChildContent("The upload failed.")); + + var root = cut.Find(".alert"); + Assert.Equal("alert", root.GetAttribute("role")); + Assert.Equal("assertive", root.GetAttribute("aria-live")); + } + + [Fact] + public void Info_is_announced_politely() + { + var cut = Render(ps => ps.AddChildContent("Two rows were skipped.")); + + var root = cut.Find(".alert"); + Assert.Equal("status", root.GetAttribute("role")); + Assert.Equal("polite", root.GetAttribute("aria-live")); + } + + [Fact] + public void An_empty_icon_suppresses_the_icon_chip() + { + var cut = Render(ps => ps + .Add(p => p.Icon, string.Empty) + .AddChildContent("No icon here.")); + + Assert.Empty(cut.FindAll(".alert .ico")); + } +} From 5df48af10d8fadd794cc487e24ae8d09491731d5 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 23:06:21 +0200 Subject: [PATCH 14/15] idea(I9): raise the AI tooltip, and split it in two before building it The Product Owner asked for Ai and Aura on DrylTooltip, on the use case "an AI explains this element". The instinct is right - an explanation belongs next to the thing it explains - and the target is wrong. The bubble is one shared page-level element, which is what makes a toolbar of thirty tooltips free; it is aria-hidden, pointer-events:none, textContent-only, and it hides on pointerout. AI content put there is unreadable by a screen reader, unselectable, uncopyable, gone when the pointer moves, and fails WCAG 1.4.13. A bubble measured and clamped once cannot grow token by token either. So the document offers three options: provenance-only Ai on the tooltip; an explanation surface built on DrylPopover, which already portals, traps focus and animates out; or both, with the second carrying the weight. Impact is worked through per option against harness, specs, API and code. Three open points go back to the Product Owner. State stays Draft. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- ideas/I9 An AI tooltip.md | 191 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 ideas/I9 An AI tooltip.md diff --git a/ideas/I9 An AI tooltip.md b/ideas/I9 An AI tooltip.md new file mode 100644 index 0000000..ec1664c --- /dev/null +++ b/ideas/I9 An AI tooltip.md @@ -0,0 +1,191 @@ +# An AI tooltip + +## Meta +- **State:** Draft + +## Problem + +Raised by the Product Owner on 2026-08-20, alongside the five defects the +`E7 Feedback` specs turned up: + +> "Außerdem soll ToolTip Ai und Aura bekommen. Angenommen man möchte mit KI zu +> einem Element was erklären? Dann wäre ein ToolTip ja eigentlich Top dafür +> oder?" + +`DrylTooltip` is the only one of the eight Feedback components that takes +neither `Ai` nor `Aura`. The `F2` spec records that as a deliberate decision +rather than an omission, and the Product Owner is questioning the decision — on +a concrete use case: **an AI explaining an element of the UI to the user.** + +The use case is real and the library has nowhere good to put it today. A short +explanation attached to a specific control, produced by a model, is exactly the +kind of thing DRYL claims to be for. + +## The Tech Lead's reading + +The instinct is right and the target is wrong, and the two halves need +separating before anything is built. + +**Where the instinct is right:** attaching an explanation to the element it is +about, rather than to a panel somewhere else on the page, is the correct shape. +And there *is* a cheap, honest piece of this: marking a tooltip as +AI-provenance, so the user knows that the words they are about to read were +written by a model and not by the application's authors. That is what the aura +vocabulary exists for. + +**Where the target is wrong:** the thing being aimed at is `DrylTooltip`'s +bubble, and the bubble is the wrong surface for AI content — for three reasons +that are properties of what a tooltip *is*, not bugs to be fixed. + +1. **The bubble is shared and singular.** There is exactly one `.tt-portal` + element per page, created lazily by `dryl.tooltip` and reused by every + tooltip on it (see [`../specs/E7 Feedback/_Interop.md`](../specs/E7%20Feedback/_Interop.md)). + That is the design that makes a toolbar of thirty triggers cost nothing. But + the AI aura is per-surface DOM — `.ai-aura-ring`, `.ai-aura-comet`, + `.ai-aura-glow` as children of the surface — and a single shared bubble + cannot carry a per-trigger aura without JS building that markup itself. The + aura vocabulary would then exist in a second place, written in JavaScript, + which is exactly what `AI-02` and `DESIGN-13` are there to prevent. + +2. **The bubble is decorative and transient — by contract.** It is + `aria-hidden`, it is `pointer-events: none`, its content is set as + `textContent`, and it hides on `pointerout`, on `pointerdown` and on scroll. + An AI explanation put there is: never announced to a screen reader, never + selectable, never copyable, and gone the moment the pointer moves. It also + fails WCAG 1.4.13 (*Content on Hover or Focus*), which requires such content + to be hoverable, persistent and dismissible — today's bubble is none of the + three. The `F2` spec already records that a tooltip must never be the only + place a piece of information exists; AI-generated explanation is by + definition information that exists nowhere else. + +3. **Streaming into it cannot work.** The placement routine measures the bubble + once, flips it once and clamps it once. A bubble that grows token by token + would resize and reposition under a pointer that has to stay still to keep it + open. This is not a matter of effort — a growing tooltip is a bad + interaction even when implemented perfectly. + +There is also a fourth point that is about product rather than mechanics: a +tooltip is a *label*. It says what a control is. An explanation is a different +speech act, it is longer, and users expect to be able to keep it open while they +read it. + +## Solution Idea + +Split the idea in two, and decide them separately. + +### Option A — `Ai` and `Aura` on `DrylTooltip`, provenance only + +The trigger wrapper takes the aura, the bubble takes a static provenance mark +(the sparkle glyph and an accent-tinted border) driven by a data attribute the +JS copies onto the shared bubble while that trigger owns it. + +- **Pro:** cheap; no new vocabulary; makes the eighth Feedback component + consistent with the other seven; answers "was this written by AI?" honestly. +- **Pro:** the aura on the wrapper is real per-instance DOM, so it uses the + shared vocabulary unchanged. +- **Con:** the bubble's own treatment is a static mark rather than the living + aura, so it is *less* than what `Ai` means everywhere else — a parameter that + under-delivers relative to its name. +- **Con:** does not address the actual use case at all. + +### Option B — an explanation surface, built on `DrylPopover` + +The use case gets the component it actually needs: a small affordance next to an +element that opens a **popover** with the model's explanation. `DrylPopover` +already portals, positions, flips, traps focus, closes on `Escape` and animates +out; the content can be `DrylMarkdown`, can stream, can be selected and copied, +and can carry the full aura because it is a real per-instance surface. + +- **Pro:** every objection above disappears, because the surface is built for + content rather than for labels. +- **Pro:** reuses two existing components rather than inventing a mechanism. +- **Con:** a new component — a spec, a demo page, a catalog entry, a name. +- **Con:** more than the Product Owner asked for. + +### Option C — both, in that order + +Option A closes the consistency gap on `DrylTooltip` and is small. Option B +serves the use case that prompted the question. They do not conflict: a control +can have an ordinary label on hover *and* an explanation on demand. + +**The Tech Lead recommends C, with B carrying the weight** — and explicitly +recommends **against** putting the explanation itself in the tooltip bubble +under any option. + +## Scope + +- **In scope:** to be settled once the option is chosen. +- **Out of scope (proposed):** streamed AI content inside the tooltip bubble; + making the bubble hoverable or persistent; a second implementation of the aura + vocabulary in JavaScript. + +## Impact + +*(Tech Lead, `IDEA-05`. Written for Options A and B separately, because they +have almost nothing in common.)* + +### Harness + +- **Option A:** no new token, no new animation, no new `AiState`, no new + dependency. The static provenance mark would reuse `--accent-line`, + `--accent-soft` and the existing sparkle icon. **No blocker.** One judgment + call remains for the maintainer: whether a surface carrying a *reduced* AI + treatment is acceptable under `AI-02`'s "one shared visual vocabulary", or + whether that makes the vocabulary two things. That is an `AI-04`-shaped + question even though no new visual is invented. +- **Option B:** no new token or animation expected; it composes `DrylPopover`, + `DrylMarkdown` and the existing aura. **No blocker expected**, to be + re-checked once its behaviour is concrete. + +### Specs + +- **Option A:** rewrites the AI-mode decision in + [`../specs/E7 Feedback/F2 DrylTooltip.md`](../specs/E7%20Feedback/F2%20DrylTooltip.md), + which currently records the *opposite* decision with its reasoning, and adds + criteria to its "Appearance" and a new "AI mode" section. Touches + [`../specs/E7 Feedback/_Api.md`](../specs/E7%20Feedback/_Api.md) (the "AI + parameters" section counts the six components that carry both) and + [`../specs/E7 Feedback/_Interop.md`](../specs/E7%20Feedback/_Interop.md) (the + shared bubble would gain a per-trigger attribute). +- **Option B:** a new `F{n}` in a category to be decided. `E3 AI` is the + likely home if it is an AI-native component in its own right; `E11 Surfaces` + if it is a popover variant. That choice follows the source folder + (`SPEC-02`), so it is a code-layout decision as much as a spec one. + +### Public API + +- **Option A:** two new parameters on `DrylTooltip` — `Ai` and `Aura`, with the + types and defaults every other component uses. Additive, so MINOR + (`REL-01`). No existing member changes. +- **Option B:** a new component with its own surface. Additive, MINOR. + +### Code + +- **Option A:** `DrylTooltip.razor` (two parameters, the aura lifecycle and its + `IDisposable`, a data attribute), `dryl.js` (`dryl.tooltip` copies the + provenance attribute onto the shared bubble on show and clears it on hide), + `dryl.css` (the bubble's provenance treatment). The risk sits in the JS: the + bubble is shared, so a stale attribute left behind on hide would mark an + unrelated tooltip as AI-written. That is a correctness bug that would be + invisible in a screenshot and needs a test. +- **Option B:** a new component under `code/DRYL.Components/`, composing + existing ones. The known risks are `DrylPopover`'s recorded debt, which it + would inherit — see + [`../specs/E11 Surfaces/F1 DrylPopover.md`](../specs/E11%20Surfaces/F1%20DrylPopover.md). + +## Decisions + +- 2026-08-20 (Tech Lead, pending Product Owner): the idea is **not** nodded + through as "add `Ai` to `DrylTooltip`". The use case that motivates it and the + consistency gap it names are two different things and are separated above. + +## Open Points + +1. Which of the two halves is actually wanted — the provenance mark on the + tooltip, the explanation surface, or both? +2. If `DrylTooltip` gets `Ai`: is a **static** provenance mark on the bubble + acceptable, given that `Ai` means a living aura on every other component? + The alternative is a JS-side reimplementation of the aura for the shared + bubble, which the Tech Lead recommends against. +3. If an explanation surface is wanted: is it a new component, or an + `Ai`-carrying mode of `DrylPopover`? From a247bd98a4f10112c2ae938b0b9830914c23c606 Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 20 Aug 2026 23:26:25 +0200 Subject: [PATCH 15/15] idea(I9): narrow to Option A on the Product Owner's decisions Three answers, three open points closed. Ai and Aura go onto DrylTooltip; the explanation surface is not built now; the bubble carries a static provenance mark rather than the living aura, so the aura vocabulary stays in exactly one place and the JS-side reimplementation is refused. The AI-04-shaped question the mark raised - whether a reduced AI treatment is still one shared vocabulary - is signed off in the document rather than left as an assumption. Option B's shape is recorded even though it is deferred: its own component, not a mode of DrylPopover, so the decision is not re-litigated when it is raised. Open Points is empty. State stays Draft for one more beat: IDEA-06's last box is the Product Owner's explicit confirmation of the final version. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoCEzbMKjESrSNDEZjcrnR --- ideas/I9 An AI tooltip.md | 71 ++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/ideas/I9 An AI tooltip.md b/ideas/I9 An AI tooltip.md index ec1664c..224ec84 100644 --- a/ideas/I9 An AI tooltip.md +++ b/ideas/I9 An AI tooltip.md @@ -114,25 +114,46 @@ under any option. ## Scope -- **In scope:** to be settled once the option is chosen. -- **Out of scope (proposed):** streamed AI content inside the tooltip bubble; - making the bubble hoverable or persistent; a second implementation of the aura - vocabulary in JavaScript. +Settled on 2026-08-20: **Option A only.** The idea is now exactly "`DrylTooltip` +gains `Ai` and `Aura`, and says who wrote the words". + +- **In scope:** + - `Ai` and `Aura` parameters on `DrylTooltip`, with the same types and + defaults every other AI-capable component uses. + - The living aura on the **trigger wrapper**, which is real per-instance DOM + and therefore uses the shared vocabulary unchanged. + - A **static provenance mark** on the bubble — the sparkle glyph and an + accent-tinted border — driven by a data attribute the shared bubble picks up + from the trigger that currently owns it. + - Clearing that attribute on hide, so no tooltip inherits the previous one's + provenance. +- **Out of scope:** + - AI-generated explanation *content* in the tooltip bubble, streamed or + otherwise. The bubble stays a label. + - Making the bubble hoverable, persistent, selectable or announced. + - A second implementation of the aura vocabulary in JavaScript. + - The explanation surface (Option B). Not rejected — deferred, and its shape + is already decided for whenever it is raised: **its own component**, not a + mode of `DrylPopover`, so a primitive is not loaded up with subject matter + it deliberately has none of. That will be its own idea document. ## Impact -*(Tech Lead, `IDEA-05`. Written for Options A and B separately, because they -have almost nothing in common.)* +*(Tech Lead, `IDEA-05`. Option B's rows are kept for whenever it is raised, but +only Option A is in scope.)* ### Harness - **Option A:** no new token, no new animation, no new `AiState`, no new - dependency. The static provenance mark would reuse `--accent-line`, - `--accent-soft` and the existing sparkle icon. **No blocker.** One judgment - call remains for the maintainer: whether a surface carrying a *reduced* AI - treatment is acceptable under `AI-02`'s "one shared visual vocabulary", or - whether that makes the vocabulary two things. That is an `AI-04`-shaped - question even though no new visual is invented. + dependency. The static provenance mark reuses `--accent-line`, `--accent-soft` + and the existing sparkle icon. **No blocker.** The one judgment call it did + raise — whether a surface carrying a *reduced* AI treatment is acceptable + under `AI-02`'s "one shared visual vocabulary", or whether that makes the + vocabulary two things — was put to the maintainer as an `AI-04`-shaped + question even though no new visual is invented, and **signed off on + 2026-08-20**: the mark is acceptable precisely because it is not a second + vocabulary but a smaller statement in the same one, and because the + alternative would have put the real vocabulary in a second implementation. - **Option B:** no new token or animation expected; it composes `DrylPopover`, `DrylMarkdown` and the existing aura. **No blocker expected**, to be re-checked once its behaviour is concrete. @@ -175,17 +196,21 @@ have almost nothing in common.)* ## Decisions -- 2026-08-20 (Tech Lead, pending Product Owner): the idea is **not** nodded - through as "add `Ai` to `DrylTooltip`". The use case that motivates it and the - consistency gap it names are two different things and are separated above. +- 2026-08-20 (Tech Lead): the idea is **not** nodded through as "add `Ai` to + `DrylTooltip`". The use case that motivates it and the consistency gap it + names are two different things and are separated above. +- 2026-08-20 (Product Owner): **Option A only.** `DrylTooltip` gets `Ai` and + `Aura`; the explanation surface is not built now. The consistency gap is worth + closing on its own, and the use case can wait for a surface built for it. +- 2026-08-20 (Product Owner): **the static provenance mark is enough.** The + bubble marks who wrote the words; it does not carry the living aura. The + JS-side reimplementation the alternative would need is refused, so the aura + vocabulary stays in exactly one place (`AI-02`, `DESIGN-13`). +- 2026-08-20 (Product Owner): **if the explanation surface is ever built, it is + its own component**, not a mode of `DrylPopover`. Recorded here so the + decision is not re-litigated when it is raised. ## Open Points -1. Which of the two halves is actually wanted — the provenance mark on the - tooltip, the explanation surface, or both? -2. If `DrylTooltip` gets `Ai`: is a **static** provenance mark on the bubble - acceptable, given that `Ai` means a living aura on every other component? - The alternative is a JS-side reimplementation of the aura for the shared - bubble, which the Tech Lead recommends against. -3. If an explanation surface is wanted: is it a new component, or an - `Ai`-carrying mode of `DrylPopover`? +*(none — awaiting the Product Owner's explicit confirmation of this final +version, the last box of `IDEA-06`, before the state moves to `Ready`.)*