@wildmason/aegis 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,355 @@ that 404s.
18
18
 
19
19
  ## [Unreleased]
20
20
 
21
+ ## [2.2.0] - 2026-10-02
22
+
23
+ Minor: new components, variants and inputs, and accessibility fixes. Nothing is
24
+ renamed or removed, so a consumer on `^2.0.0` picks this up on its next install
25
+ and nothing it already names moves. Two fixes change the ARIA that renders, so
26
+ a consumer test that reads it can see a difference: the `<wm-popover>` host no
27
+ longer carries the panel's role, and a `dialog` or `alertdialog` panel now has
28
+ `aria-modal="true"`.
29
+
30
+ ### Added
31
+
32
+ - `WmTabs` accepts `activeValue` `null` for "no tab selected". Use it when the
33
+ stage shows something that is not any tab's panel, such as a document opened
34
+ from a second tablist in the same row. Every tab reports
35
+ `aria-selected="false"` and none paints as active, and the tablist keeps one
36
+ tab stop (see Fixed). The input type widens from `string` to
37
+ `string | null`, so every existing template binding still compiles.
38
+ - `WmTabs` `ariaLabel` and `ariaLabelledby` inputs name the inner
39
+ `role="tablist"`; `ariaLabelledby` wins when both are set. The tablist had no
40
+ way to take a name, so two tablists in one row sounded identical to a screen
41
+ reader. An `aria-label` attribute on the `<wm-tabs>` element still names
42
+ nothing, because that element has no role.
43
+ - `WmButtonDirective` (`button[wmButton]`, `a[wmButton]`) puts the Aegis
44
+ button classes on a native element the consumer writes, from the same
45
+ `variant`, `size` and `iconOnly` inputs as `<wm-button>`. `<wm-button>`
46
+ renders its own `<button>` inside a host that has no role and cannot take
47
+ focus, and passes it only `disabled`, `type`, `ariaLabel` and `btnTitle`.
48
+ So `aria-pressed`, `aria-expanded`, `aria-controls`, `cdkFocusInitial`, an
49
+ `id`, a popover `trigger`, `[wmTooltip]`, `data-tooltip` and layout classes
50
+ all stayed on the wrapper. On the directive's element they reach the button
51
+ with no forwarding list. Both forms compute their classes in one function,
52
+ so they cannot drift apart. A `<button>` with no `type` gets
53
+ `type="button"`, the `<wm-button>` default, so moving from the component to
54
+ the directive does not create a submit button; a written or bound `type`
55
+ wins. The consumer's own classes, static or bound, are kept. `WmButton` is
56
+ unchanged.
57
+ - `.wm-btn--dashed`, and `variant="dashed"` on `<wm-button>` and
58
+ `[wmButton]`: the empty slot, for the place a new item goes at the end of
59
+ the list it adds to. It is transparent, with a 1px dashed edge and a
60
+ `--wm-text-secondary` label. On hover it fills with `--wm-bg-sunken`, the
61
+ edge turns solid and the label moves to `--wm-text-primary`. Focus uses the
62
+ pulse, as `ghost` does. `ButtonVariant` gains `'dashed'`. Helm hand-rolled
63
+ this twice (`workflow-chain-add`, `lfs-add-pattern`), and the two copies
64
+ disagreed on edge token, opacity and padding. At full strength, neither
65
+ copy's edge token reached 3:1 on every theme and surface: `--wm-border` is
66
+ 1.04:1 on warm-light, and `--wm-border-subtle` is 1.00:1 on arctic-night.
67
+ Their 80% and 78% mixes toward transparent fall below 3:1 on all 48 theme
68
+ and surface pairs. The shipped edge mixes `--wm-border-input` 70/30 toward
69
+ `--wm-text-primary`, which clears 3:1 everywhere (worst 3.34:1, wildmason
70
+ on `--wm-bg-float`).
71
+ - `scripts/check-dashed-contrast.mjs` (`npm run check:dashed-contrast`, part
72
+ of `build`) reads the variant's rules out of inputs.css and holds them on
73
+ every theme and on `--wm-bg-base`, `-raised`, `-float` and `-sunken`: a
74
+ 4.5:1 label at rest and on hover, APCA Lc 60 on hover, and a 3:1 edge at
75
+ rest and on hover, against the fill as well as the surface.
76
+ - `scripts/check-focus-pulse.mjs` (`npm run check:focus-pulse`, part of
77
+ `build`) fails when a selector animates `wm-focus-pulse` and has no
78
+ reduced-motion rule in the same file that stops the animation and sets a
79
+ static `box-shadow` ring.
80
+ - `WmToggleGroup` and `WmToggleGroupItem` (`<wm-toggle-group>`,
81
+ `<wm-toggle-group-item>`): a single choice among a few visible options,
82
+ drawn as joined `segmented` items in one sunken track or as separate
83
+ `chip`s that wrap, in `size` `sm` (24px) or `md` (28px). Aegis had no
84
+ single-select control of this kind, so each consumer hand-rolled one: Helm
85
+ has six, each a `role="group"` row of `aria-pressed` buttons in which every
86
+ button is its own Tab stop, the arrow keys do nothing, and nothing tells a
87
+ screen reader that the options exclude each other. The component is the
88
+ WAI-ARIA radio group: the group element is `role="radiogroup"`, each item
89
+ element is `role="radio"` with `aria-checked`, the group keeps one Tab stop
90
+ (the focused item while focus is inside, otherwise the checked item,
91
+ otherwise the first item that is not disabled), and the arrow keys move
92
+ focus and check, wrapping and stepping over disabled items, with Left and
93
+ Right mirrored in a right-to-left layout. `Home` and `End` go to the ends,
94
+ `Space` checks, and `Enter` does not. `(valueChange)` fires once per user
95
+ choice and never with `null`, so `[(value)]` type-checks against a signal
96
+ that cannot hold `null`. Values compare by `Object.is` and keep their type.
97
+ It is a ControlValueAccessor and marks the control touched when focus
98
+ leaves the group. The items are the radios, so `aria-label` on an
99
+ icon-only item, `data-testid` and `[wmTooltip]` land on the control. New
100
+ exported types: `WmToggleGroupAppearance`, `WmToggleGroupSize`.
101
+ - `scripts/check-toggle-group-contrast.mjs` (`npm run
102
+ check:toggle-group-contrast`, part of `build`) reads the toggle group's
103
+ rules out of its stylesheet and holds both appearances on every theme and
104
+ on `--wm-bg-base`, `-raised`, `-float` and `-sunken`: 4.5:1 labels
105
+ unchecked, hovered and checked, APCA Lc 60 on hover, and a 3:1 checked edge
106
+ and focus ring. It measured out the first choices: a bare
107
+ `--wm-color-accent` fill is 2.91:1 against `--wm-bg-float` on
108
+ posh-sandalwood, so the checked item carries a
109
+ `--wm-color-accent-readable` edge, and primary text on `--wm-bg-hover` is
110
+ 4.41:1 on vampires-kiss, so segmented hover fills with `--wm-bg-float`.
111
+
112
+ - `.wm-hatch` and `.wm-hatch--warning` in inputs.css: the stand-in texture,
113
+ for an elided node that stands for items the view does not draw and for a
114
+ value that nobody has filled in. The class paints 135deg bands, 4px of tone
115
+ then 4px clear, as `background-image` over the element's own fill. The
116
+ neutral tone is `--wm-text-primary` at 10% and the warning tone is
117
+ `--wm-color-warning` at 14%, both mixed toward `transparent`;
118
+ `--wm-hatch-tone` replaces the band colour. Aegis had no texture, so two
119
+ consumers hand-rolled the same geometry: Helm's Stacks overflow node
120
+ (`+N`) and Mortar's unset path variable. Helm's copy built its band from
121
+ `--wm-bg-hover` mixed into `--wm-bg-raised`, which measures 1.08:1 on
122
+ thistle-mocha, close to invisible, and 2.24:1 on vampires-kiss. Each
123
+ shipped share is the smallest that shows on every theme and surface,
124
+ because every point above it costs the text on top contrast.
125
+ - `scripts/check-hatch-contrast.mjs` (`npm run check:hatch-contrast`, part of
126
+ `build`) reads the hatch rules out of inputs.css and holds both tones on
127
+ every theme and on `--wm-bg-base`, `-raised`, `-float` and `-sunken`: a band
128
+ of at least 1.15:1 against the surface, `--wm-text-primary` on the band at
129
+ 4.5:1 and APCA Lc 60, every token the tone reads defined by the theme, the
130
+ 135deg 4px/8px geometry, and a translucent tone. One pair has no APCA bar:
131
+ on solarized-precision's `--wm-bg-float` the bare text is Lc 65.4, and no
132
+ share of either tone gives a visible band with Lc 60 text (the neutral band
133
+ leaves Lc 57.5 and 5.87:1). The guard proves that on every run and fails if
134
+ a theme change makes the pair reachable.
135
+
136
+ ### Fixed
137
+
138
+ - `.wm-btn--flat` and the `.wm-checkbox` class kept the animated focus pulse
139
+ under `prefers-reduced-motion: reduce`. The reduced-motion rule in
140
+ inputs.css lists each pulsing selector by hand, and these two were missing,
141
+ although the Accessibility page said every pulse becomes a static ring. Both
142
+ now get the static 3px ring. `check:focus-pulse` found them.
143
+
144
+ - `WmTabs` no longer drops out of the page's Tab order when no tab is
145
+ selected. It gave the tab stop to the selected tab only, so an `activeValue`
146
+ that matched no tab left every tab at `tabindex="-1"`, and a consumer could
147
+ not repair it because the binding re-applied `-1` on each change detection.
148
+ The stop is now the focused tab while focus is inside, otherwise the
149
+ selected tab, otherwise the first tab that is not `unavailable` (the first
150
+ tab when all are). Helm's Navigator hid the selected pill in CSS to avoid
151
+ this, which left `aria-selected="true"` on a tool that did not look
152
+ selected.
153
+ - `WmTabs` tracks the focused tab by value instead of by index. A tab added or
154
+ removed before the focused one shifted the index, so the stop moved to a
155
+ different tab or past the end of the list, where no tab had
156
+ `tabindex="0"`.
157
+ - `WmTabs` returns its tab stop to the selected tab when focus moves to a
158
+ second `<wm-tabs>`. Its blur check asked whether focus was inside any
159
+ `.wm-tabs` element, so focus in the other tablist counted as inside this
160
+ one and left the stop on the tab last focused.
161
+
162
+ - `WmPopover` sets `aria-modal="true"` on a `role="dialog"` or
163
+ `role="alertdialog"` panel. The panel always opens over a click-catching
164
+ backdrop and traps focus, so it is modal, and without the attribute a screen
165
+ reader kept reading the page behind it. Other roles get no `aria-modal`,
166
+ since the attribute is defined for those two only. A panel with
167
+ `[trapFocusAutoCapture]="false"` gets none either: focus stays on the trigger
168
+ outside the panel, and `aria-modal` would hide that focused trigger. Mortar's
169
+ combobox is that case (default `dialog` role, auto-capture off), and keeps
170
+ its current semantics. A modal panel whose content has nothing tabbable
171
+ (text only, or a lone disabled button) takes focus itself, through
172
+ `tabindex="-1"`, and Tab and Shift+Tab keep focus on it. The focus trap
173
+ finds nothing to focus there, so focus used to stay on the trigger, which
174
+ `aria-modal` hides, and a second Tab walked out to the page behind the
175
+ backdrop. A non-modal panel gets no `tabindex`, so a click on it cannot pull
176
+ focus off a typeahead input.
177
+ - The `<wm-popover>` host no longer carries the panel's role. A consumer
178
+ writes `role="dialog"` as a plain attribute, and Angular copies a static
179
+ attribute onto the host as well as into the input, so each popover written
180
+ that way wrapped its trigger in a second, unnamed `dialog` — or in a `menu`
181
+ that owned no `menuitem`. The role now lives on the panel alone.
182
+ - `WmModal` lays out its own backdrop. It used Tailwind utilities for this
183
+ (`fixed inset-0 flex items-center justify-center z-50`, and `items-start
184
+ pt-20` for `align="top"`), which Aegis does not ship. A consumer's Tailwind
185
+ build emits only the classes its own sources name, so the backdrop was fixed
186
+ and centred only where a consumer happened to use the same utilities; in an
187
+ app without Tailwind, the docs app included, it rendered in the page flow.
188
+ Helm's sources name every centred-layout utility but not `pt-20`, so
189
+ `align="top"` never got its offset there; Helm does not use `align="top"`.
190
+ The rendered values are unchanged: fixed, full viewport, z-index 50, and
191
+ `5rem` from the top for `align="top"`. The inline
192
+ `background: var(--wm-modal-backdrop)` stays, because consumers detect an
193
+ open modal by `[style*="--wm-modal-backdrop"]`.
194
+
195
+ ### Documentation
196
+
197
+ - The `popover` guide page, which the MCP serves, documented the docs app's
198
+ private copy of the popover rather than the shipped `WmPopover`: a required
199
+ `data-popover-trigger`, two-way `[(isOpen)]`, `align="center"` and CSS-string
200
+ widths, none of which the shipped component has. The page now runs its demos
201
+ on `WmPopover`, documents `(closed)` and `trapFocusAutoCapture`, and adds an
202
+ Accessibility section. The private copy is deleted.
203
+ - The `popover` page, DESIGN_LANGUAGE.md §7.13 and `WmPopover`'s doc comment
204
+ now say what a consumer following the old page got wrong. The `trigger`
205
+ attribute is the only thing the component reads; `data-popover-trigger` is
206
+ not. The component writes `aria-haspopup`, `aria-expanded` and
207
+ `aria-controls` to the trigger on every change to `isOpen` or `hasPopup`, so
208
+ the template does not bind them, and a static `aria-haspopup` is replaced by
209
+ `hasPopup`. A `<wm-button>` cannot be the trigger: the attribute stays on
210
+ its host, which then wraps a second button role around the real `<button>`,
211
+ so use a native `<button trigger>` with the `wm-btn` classes. The
212
+ Accessibility page's trigger row now lists `aria-controls` too. Helm carries
213
+ nine dead `data-popover-trigger` attributes, eight hand-written
214
+ `aria-expanded` bindings and two `<wm-button trigger>` sites from the old
215
+ page; Mortar carries five dead attributes and one `<mortar-button trigger>`.
216
+ - The `modal` guide page (the Dialog topic) never mentioned
217
+ `--wm-dialog-width`, so it read as though `size="lg"` (640px) were the widest
218
+ `wm-dialog` can go. The property overrides every preset, and Helm sets it on
219
+ ten dialogs, up to 960px. The page now documents it, with a demo, and ran its
220
+ demos on a private copy of the dialog that ignored it; the page now runs on
221
+ the shipped `WmDialog` and `WmButton`, and the dialog copy is deleted. The
222
+ page also documents `wm-modal` for the first time: when to choose it over
223
+ `wm-dialog`, its inputs, what it supplies (backdrop, dismissal, focus trap)
224
+ and what the consumer supplies (`role`, `aria-modal`, the accessible name, a
225
+ close control), with a working demo. `WmDialog`'s own doc comment wrongly
226
+ credited the browser with returning focus on close; `cdkTrapFocusAutoCapture`
227
+ does it.
228
+ - DESIGN_LANGUAGE.md lists `<wm-modal>` among the shipped components (§20 says
229
+ anything unlisted is not shipped), §8.6 names both components and
230
+ `--wm-dialog-width`, and §15.2 says the shipped overlays trap focus
231
+ themselves instead of telling consumers to implement a trap.
232
+ - The `tabs` guide page, which the MCP serves, ran its demos on a private
233
+ copy of the tabs in the docs app; it now runs on the shipped `WmTabs`, and
234
+ the copy is deleted. The page adds a "No Tab Selected" section with a
235
+ working demo of two named tablists that hand the stage to each other, names
236
+ every demo tablist, and documents `ariaLabel`, `ariaLabelledby`, the tab
237
+ stop rule, the `wm-tab-{value}`/`wm-panel-{value}` ids and `WmTab`'s
238
+ `unavailable`, `unavailableReason` and `separatorBefore` inputs, which its
239
+ API table left out. Its request and response demos both used the values
240
+ `body` and `headers`, so the page rendered `wm-tab-body` and
241
+ `wm-tab-headers` twice; the response demo now uses its own values.
242
+ - DESIGN_LANGUAGE.md §10 documents `<wm-tabs>` for the first time (§10.0).
243
+ The section taught only hand-rolled switchers, with no tablist semantics.
244
+ - The Quick Reference component table listed an `[options]` input on
245
+ `<wm-tabs>`, which does not exist, and gave `<wm-popover>` the export name
246
+ `Popover` and a two-way `[(isOpen)]`. The rows now name `WmTabs`'s real
247
+ inputs, and `WmPopover` with `[isOpen]` and `(closed)`.
248
+ - The `button` guide page, which the MCP serves, said `<wm-button>` and the
249
+ `.wm-btn` classes "are interchangeable" and "produce identical output".
250
+ They are not: attributes written on `<wm-button>` stay on its host. Helm
251
+ met this twice on 2026-08-09 (an `aria-pressed` that could not reach the
252
+ button, and a `cdkFocusInitial` that CDK reported as "not focusable") and
253
+ fell back to writing the classes by hand. The page now opens with a
254
+ "Component, Directive or Class"
255
+ section: use `<wm-button>` by default, `[wmButton]` whenever something has
256
+ to land on the focusable element, and the bare classes only outside
257
+ Angular, with a table of what does not reach the inner button and a working
258
+ `aria-pressed` toggle. `data-tooltip` is in that table: it opens on
259
+ `:focus-visible`, which the wrapper never matches, so an icon-only
260
+ `<wm-button>` never showed its tooltip to a keyboard user. DESIGN_LANGUAGE.md
261
+ gains the same rule as §6.5, and `WmButton` gains a doc comment that says
262
+ what it forwards.
263
+ - The `button` page ran on a private copy of `WmButton` in the docs app that
264
+ had drifted from the library (no `xs`, `iconOnly`, `ariaLabel` or
265
+ `btnTitle`). The page and the Composition, Form Patterns and Toast pages now
266
+ use the library component, and the copy is deleted. The page documents the
267
+ `xs` size, `iconOnly`, `ariaLabel` and `btnTitle`, which its API table left
268
+ out. Its own stylesheet set `.btn-icon-only` to 36 × 36px, so every
269
+ icon-only demo rendered at the default size whatever its size class, against
270
+ the 28px and 40px its own table promised; that rule is removed.
271
+ - DESIGN_LANGUAGE.md §8.4 (toolbar button) and §12.3 (notification dot) wrote
272
+ `class="btn btn-sm btn-ghost"`, which Aegis does not ship; the docs check
273
+ cannot see it because the names have no `wm-` prefix. Both now use
274
+ `[wmButton]`, and §8.4 tells a toolbar toggle to expose its state with
275
+ `aria-pressed` or `aria-expanded` rather than colour alone. §20 said
276
+ anything it did not list is not shipped, and it did not list `<wm-button>`,
277
+ `<wm-checkbox>`, `<wm-toggle>`, `<wm-ghost-field>` or `<wm-rich-tooltip>`;
278
+ it lists them now, with `[wmButton]`.
279
+ - The `popover` page, DESIGN_LANGUAGE.md §7.13 and `WmPopover`'s doc comment
280
+ now point an Aegis-styled trigger at `<button wmButton trigger>`, and the
281
+ page's demo triggers use it. The Quick Reference and Accessibility pages
282
+ list `[wmButton]` beside `<wm-button>`.
283
+ - The `button` page gains an Empty Slot section: when to use `dashed`, a demo
284
+ of both Helm shapes (a full-width slot and one at its own width), what each
285
+ state paints, and why the edge is mixed. It says that width is the
286
+ consumer's layout, so a full-width slot is `[wmButton]` plus a width class.
287
+ The Variants demo, table and both API tables list `dashed`. The Quick
288
+ Reference page lists `.wm-btn--flat`, `.wm-btn--dashed` and `.wm-btn--xs`;
289
+ it listed none of the three before. The Accessibility page's pulse card
290
+ lists every control that pulses. DESIGN_LANGUAGE.md §6.3 and §15.1 and the
291
+ inputs.css button header are updated.
292
+ - A `toggle-group` guide page (served by the MCP) documents
293
+ `<wm-toggle-group>` with working demos of both appearances, `size="sm"`,
294
+ icon-only items, a reactive form with numeric values, disabled items and
295
+ groups, the keyboard and ARIA contract, and how to replace a hand-rolled
296
+ `aria-pressed` row. DESIGN_LANGUAGE.md gains §10.4. §10's opening splits
297
+ tabs from toggle groups by what the control does (choose a panel, or set a
298
+ value), and §7.5, §10.2, §10.3 and §12.5 point at the component. §15.1
299
+ adds the outline focus treatment its items use, and §20 lists it.
300
+ - The Checkbox, Combobox, Select and Toggle pages sent exclusive choices to
301
+ "radio buttons", which Aegis does not ship, and the Slider page and
302
+ `WmSlider`'s doc comment sent them to "a pill tab group", which is for
303
+ switching panels. All now name `<wm-toggle-group>`. The Tabs page adds a
304
+ Don't: pill tabs do not set a value. The Quick Reference and Accessibility
305
+ pages list the component, its keys, its roles and its reduced-motion rule.
306
+ - DESIGN_LANGUAGE.md gains §2.8, the hatch: what it marks and what it must
307
+ not, the geometry, why the fill must be `background-color` (the
308
+ `background` shorthand resets `background-image`, and a component
309
+ stylesheet outranks `.wm-hatch`), and the rules. Text on a hatch is
310
+ `--wm-text-primary`: under the neutral band `--wm-text-secondary` falls to
311
+ 3.70:1 on vampires-kiss. The hatch never carries the meaning alone, because
312
+ forced-colors mode computes a gradient `background-image` and `box-shadow`
313
+ to `none`; a border survives. §6.3 and §18.3 point at it. The `color` guide
314
+ page gains a Hatch section with demos on all four surfaces, and Quick
315
+ Reference gains rows for both classes.
316
+ - DESIGN_LANGUAGE.md §2.7 said Aegis ships 10 themes and left `sage-linen`
317
+ and `wildmason` out of its table; it ships 12. §20 now lists the
318
+ `.wm-shadow` utilities, which it left out, and the hatch classes.
319
+
320
+ ## [2.1.0] - 2026-09-20
321
+
322
+ Minor: one new component and its inputs. Nothing existing changes, so a consumer
323
+ on `^2.0.0` picks this up on its next install and nothing it already names moves.
324
+
325
+ ### Added
326
+
327
+ - `<wm-slider>` (`WmSlider`) — bounded continuous value selection, exported from
328
+ `@wildmason/aegis/components` with its `WmSliderTick` type. Built on a native
329
+ `<input type="range">`, so pointer drag, touch, arrow keys, `Page Up`/`Page
330
+ Down`, `Home`/`End`, RTL mirroring and the `role="slider"` semantics come from
331
+ the browser. Every value — two-way `[(value)]` binding, form write, or drag —
332
+ is clamped into `min`…`max` and snapped onto the step grid measured from
333
+ `min`, then written back, so the binding, the form control and the thumb never
334
+ disagree. Implements `ControlValueAccessor`. `ticks` draws labelled stops under
335
+ the rail; `showValue` renders a readout, and `formatValue` formats it and sets
336
+ `aria-valuetext`. Host knobs: `--wm-slider-thumb`, `--wm-slider-track`.
337
+ Documented at the `slider` guide page, served by the MCP, and specified in
338
+ `DESIGN_LANGUAGE.md` §7.6a.
339
+ - `WmSlider` `track` input — `'rail'` (default) or `'segmented'`. A segmented
340
+ track breaks the rail into blocks bounded by the resolved tick grid plus the
341
+ ends of the range, sized by their own spans, with the block holding the value
342
+ filling partially. Knob: `--wm-slider-gap` (3px). There is no `'auto'`
343
+ degradation, deliberately.
344
+ - `WmSlider` `ticks` accepts a **number** (a stop every N from `min`), a
345
+ `WmSliderTick[]`, or a `WmSliderTickFn` of `{ min, max, step }`, all exported.
346
+ `WmSliderTick` gains `level?: 'major' | 'minor'` for a second grid density.
347
+ The resolved grid is clamped, sorted and de-duplicated, and it both draws the
348
+ marks and bounds the segments — one source, two renderings, so a block and the
349
+ mark on its edge cannot disagree. A numeric interval that would draw over 1000
350
+ marks draws none rather than truncating or hanging. The grid stays independent
351
+ of `step`, so a 0–100 slider stepping by 1 can carry an 11-mark grid.
352
+ - `WmSlider` `showValue` reserves its column from the widest string the readout
353
+ can hold — the formatted endpoints plus every labelled stop — instead of from
354
+ the current value. The shell is `1fr auto`, so a column that grew with its text
355
+ took width from the rail and gave it back on the next value; with word labels
356
+ the bar moved while the user dragged it. Unlabelled stops are ignored, so a
357
+ 101-mark numeric grid reserves two strings rather than 101.
358
+
359
+ ### Fixed
360
+
361
+ - The MCP server served every guide page with its code samples missing. The
362
+ extractor decoded HTML entities in its `<pre>` pass, before the document-wide
363
+ tag strip, so an escaped sample — every Angular template in the docs — became
364
+ real tags and was deleted as markup. Entities now stay encoded until after the
365
+ strip and are decoded once at the end, with `&amp;` last so a double-escaped
366
+ sequence cannot be decoded twice. `&#123;` and `&#125;` are decoded for the
367
+ first time, so Angular interpolation braces survive. `mcp/` gains its first
368
+ tests: 8 cases under `node --test`, run by `npm test` in that package.
369
+
21
370
  ## [2.0.0] - 2026-09-15
22
371
 
23
372
  Major version because two exports go away: the license UI leaves
@@ -411,7 +760,10 @@ a `^1.x` range does not pick this release up by accident.
411
760
  Releases before 1.8.0 are in `git log`. See also `wiki/products/Aegis - Health.md`
412
761
  for the decisions behind these changes, including the ones decided against.
413
762
 
414
- [Unreleased]: https://github.com/wildmason/aegis/compare/v1.18.0...HEAD
763
+ [Unreleased]: https://github.com/wildmason/aegis/compare/v2.2.0...HEAD
764
+ [2.2.0]: https://github.com/wildmason/aegis/releases/tag/v2.2.0
765
+ [2.1.0]: https://github.com/wildmason/aegis/releases/tag/v2.1.0
766
+ [2.0.0]: https://github.com/wildmason/aegis/releases/tag/v2.0.0
415
767
  [1.18.0]: https://github.com/wildmason/aegis/releases/tag/v1.18.0
416
768
  [1.17.0]: https://github.com/wildmason/aegis/releases/tag/v1.17.0
417
769
  [1.16.0]: https://github.com/wildmason/aegis/releases/tag/v1.16.0