@aceshooting/lyra-ui 17.0.0 → 18.1.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +483 -0
  2. package/README.md +2 -2
  3. package/custom-elements.json +1 -1
  4. package/design-tokens.json +1 -1
  5. package/dist/cli/migration-contract.json +1 -1
  6. package/dist/components/conversation/message-actions/message-actions.class.d.ts +2 -1
  7. package/dist/components/conversation/streaming-text/streaming-text-core.class.d.ts +22 -0
  8. package/dist/components/conversation/streaming-text/streaming-text-core.class.js +1 -0
  9. package/dist/components/conversation/streaming-text/streaming-text.class.d.ts +22 -0
  10. package/dist/components/conversation/streaming-text/streaming-text.class.js +1 -0
  11. package/dist/components/conversation/thread-list/thread-list.class.js +1 -1
  12. package/dist/components/data/table/table.styles.js +1 -1
  13. package/dist/components/forms/combobox/combobox.class.d.ts +12 -2
  14. package/dist/components/forms/combobox/combobox.class.js +2 -2
  15. package/dist/components/forms/combobox/combobox.styles.js +1 -1
  16. package/dist/components/forms/icon-button/icon-button.class.d.ts +18 -9
  17. package/dist/components/forms/select/select.class.d.ts +63 -8
  18. package/dist/components/forms/select/select.class.js +4 -4
  19. package/dist/components/forms/select/select.styles.js +1 -1
  20. package/dist/components/layout/filter-bar/filter-bar.class.d.ts +148 -14
  21. package/dist/components/layout/filter-bar/filter-bar.class.js +12 -4
  22. package/dist/components/layout/filter-bar/filter-bar.styles.js +1 -1
  23. package/dist/components/layout/reorder-list/reorder-list.class.js +1 -1
  24. package/dist/components/layout/virtual-list/virtual-list.class.d.ts +154 -18
  25. package/dist/components/layout/virtual-list/virtual-list.class.js +12 -7
  26. package/dist/components/media/map/map.class.d.ts +269 -14
  27. package/dist/components/media/map/map.class.js +62 -30
  28. package/dist/components/media/map/map.styles.js +1 -1
  29. package/dist/components/utility/copy-button/copy-button.class.d.ts +4 -3
  30. package/dist/custom-elements-jsx.d.ts +1 -1
  31. package/dist/events.d.ts +13 -1
  32. package/dist/internal/package-metadata.d.ts +1 -1
  33. package/dist/internal/package-metadata.js +1 -1
  34. package/dist/internal/tokens.styles.js +1 -1
  35. package/dist/lyra.d.ts +1 -1
  36. package/dist/lyra.js +1 -1
  37. package/dist/styles/tokens-root.css +1 -1
  38. package/dist/svelte.d.ts +1 -1
  39. package/dist/testing/lyra-tag-event-map.js +1 -1
  40. package/dist/translations/ar/retrieval.js +1 -1
  41. package/dist/translations/ar/shared.js +1 -1
  42. package/dist/translations/de/retrieval.js +1 -1
  43. package/dist/translations/de/shared.js +1 -1
  44. package/dist/translations/es/retrieval.js +1 -1
  45. package/dist/translations/es/shared.js +1 -1
  46. package/dist/translations/fa/retrieval.js +1 -1
  47. package/dist/translations/fa/shared.js +1 -1
  48. package/dist/translations/fr/retrieval.js +1 -1
  49. package/dist/translations/fr/shared.js +1 -1
  50. package/dist/translations/he/retrieval.js +1 -1
  51. package/dist/translations/he/shared.js +1 -1
  52. package/dist/translations/it/retrieval.js +1 -1
  53. package/dist/translations/it/shared.js +1 -1
  54. package/dist/translations/ja/retrieval.js +1 -1
  55. package/dist/translations/ja/shared.js +1 -1
  56. package/dist/translations/pt-BR/retrieval.js +1 -1
  57. package/dist/translations/pt-BR/shared.js +1 -1
  58. package/dist/translations/ro/agent-tools.d.ts +1 -0
  59. package/dist/translations/ro/agent-tools.js +1 -0
  60. package/dist/translations/ro/charts.d.ts +1 -0
  61. package/dist/translations/ro/charts.js +1 -0
  62. package/dist/translations/ro/conversation.d.ts +1 -0
  63. package/dist/translations/ro/conversation.js +1 -0
  64. package/dist/translations/ro/data.d.ts +1 -0
  65. package/dist/translations/ro/data.js +1 -0
  66. package/dist/translations/ro/forms.d.ts +1 -0
  67. package/dist/translations/ro/forms.js +1 -0
  68. package/dist/translations/ro/layout.d.ts +1 -0
  69. package/dist/translations/ro/layout.js +1 -0
  70. package/dist/translations/ro/media.d.ts +1 -0
  71. package/dist/translations/ro/media.js +1 -0
  72. package/dist/translations/ro/overlays.d.ts +1 -0
  73. package/dist/translations/ro/overlays.js +1 -0
  74. package/dist/translations/ro/retrieval.d.ts +1 -0
  75. package/dist/translations/ro/retrieval.js +1 -0
  76. package/dist/translations/ro/shared.d.ts +1 -0
  77. package/dist/translations/ro/shared.js +1 -0
  78. package/dist/translations/ro/utility.d.ts +1 -0
  79. package/dist/translations/ro/utility.js +1 -0
  80. package/dist/translations/ro/viewers.d.ts +1 -0
  81. package/dist/translations/ro/viewers.js +5 -0
  82. package/dist/translations/ro.d.ts +1 -0
  83. package/dist/translations/ro.js +1 -0
  84. package/dist/translations/ru/retrieval.js +1 -1
  85. package/dist/translations/ru/shared.js +1 -1
  86. package/dist/translations/zh-CN/retrieval.js +1 -1
  87. package/dist/translations/zh-CN/shared.js +1 -1
  88. package/dist/vue.d.ts +1 -1
  89. package/llms/components/lr-button.md +5 -4
  90. package/llms/components/lr-card.md +2 -1
  91. package/llms/components/lr-combobox.md +11 -0
  92. package/llms/components/lr-filter-bar.md +123 -7
  93. package/llms/components/lr-icon-button.md +17 -8
  94. package/llms/components/lr-map.md +127 -8
  95. package/llms/components/lr-option.md +11 -0
  96. package/llms/components/lr-select.md +45 -3
  97. package/llms/components/lr-streaming-text-core.md +7 -3
  98. package/llms/components/lr-streaming-text.md +7 -2
  99. package/llms/components/lr-virtual-list.md +53 -0
  100. package/llms/shared.md +47 -3
  101. package/llms/tokens.md +7 -4
  102. package/llms-full.txt +465 -36
  103. package/package.json +28 -2
  104. package/registrations.json +1 -1
  105. package/vscode-css-data.json +1 -1
  106. package/vscode-html-data.json +1 -1
  107. package/web-types.json +1 -1
package/llms-full.txt CHANGED
@@ -1218,6 +1218,47 @@ code. See each control's own reference page for its exact pair.
1218
1218
  `lr-select`. It is a _floor_, not a fixed size. Keep the resolved value **at or above 24px**
1219
1219
  (WCAG 2.2 SC 2.5.8 target size); the default leaves headroom. Lowering it below that shrinks
1220
1220
  every affordance in the library at once.
1221
+ - **`--lr-icon-button-size-scope`** resizes icon buttons for **one subtree** instead of the whole
1222
+ application. Set it on any wrapper; it inherits past intervening components and reaches every
1223
+ icon-only control below it. Three names, three scopes — pick by how far you want the change to
1224
+ reach:
1225
+
1226
+ | Property | Scope | Set it on |
1227
+ | --- | --- | --- |
1228
+ | `--lr-theme-icon-button-size` | application-wide | `:root`, or any ancestor |
1229
+ | `--lr-icon-button-size-scope` | one subtree | the wrapper you want affected |
1230
+ | `--lr-icon-button-size` | one element | the icon-only control itself |
1231
+
1232
+ `--lr-theme-icon-button-size` wins wherever both ancestor inputs are set. `--lr-icon-button-size`
1233
+ is **element-scoped on purpose** and is not a wrapper knob: every component re-declares it on its
1234
+ own host so the touch-target floor can apply per element, so a value set on a wrapper is replaced
1235
+ at the first component in between and never reaches anything nested inside one. That behaviour is
1236
+ unchanged — use `--lr-icon-button-size-scope` for the wrapper case.
1237
+
1238
+ ```css
1239
+ /* A dense toolbar, without touching the rest of the app. */
1240
+ .message-toolbar {
1241
+ --lr-icon-button-size-scope: 1.75rem;
1242
+ }
1243
+ ```
1244
+
1245
+ The coarse-pointer floor still applies to both ancestor inputs: on a touch device (`hover: none`
1246
+ or `pointer: coarse`) a resolved value below `2.75rem` is raised back to it, so the new subtree
1247
+ knob is not a way around WCAG 2.2 SC 2.5.8 — a deliberately dense desktop toolbar still becomes
1248
+ tappable on a phone.
1249
+ - **`--lr-color-surface-overlay` follows `--lr-theme-color-surface-default` in both modes.** It is
1250
+ the panel colour behind every floating surface — dropdowns, listboxes, menus, toasts, popovers,
1251
+ dialogs, and the `lr-app-rail` mobile drawer. In light mode it resolves straight to
1252
+ `--lr-color-surface`, so a re-skinned page surface carries them all with it. Dark mode cannot
1253
+ resolve to the page surface — panel and page would be the same near-black, and an open dialog
1254
+ would read as a scrim with text floating on it and no panel at all — so it is **derived** from
1255
+ the page surface instead: `color-mix(in srgb, var(--lr-color-surface) 85%, #8bade2)`, which lifts
1256
+ the panel a fixed amount above whatever the base happens to be. One
1257
+ `--lr-theme-color-surface-default` override therefore re-skins every floating surface in dark
1258
+ mode too, and the elevation delta survives the re-skin. At the built-in dark base the pair still
1259
+ resolves to the same panel colour it always has, so no existing dark theme moves.
1260
+ `--lr-theme-color-surface-overlay` still wins outright when you set it — reach for it only when
1261
+ you want a panel colour unrelated to the page surface.
1221
1262
  - **Aligning your own content next to a checkbox or radio.** `--lr-checkbox-label-indent` /
1222
1263
  `--lr-radio-label-indent` publish the label offset, but custom properties inherit _down_, not
1223
1264
  sideways, so a sibling node in your tree cannot read them off the control. Compute the same
@@ -1821,8 +1862,8 @@ silently defeats a registered catalog — omit it, or pass `undefined`.
1821
1862
 
1822
1863
  ### Ready-made catalogs: `@aceshooting/lyra-ui/translations/<locale>.js`
1823
1864
 
1824
- Eleven full catalogs ship with the package — **`ar`, `de`, `es`, `fa`, `fr`, `he`, `it`, `ja`,
1825
- `pt-BR`, `ru`, `zh-CN`** — each covering every key in `LYRA_DEFAULT_STRINGS`. They are
1865
+ Twelve full catalogs ship with the package — **`ar`, `de`, `es`, `fa`, `fr`, `he`, `it`, `ja`,
1866
+ `pt-BR`, `ro`, `ru`, `zh-CN`** — each covering every key in `LYRA_DEFAULT_STRINGS`. They are
1826
1867
  **side-effect-only modules**: import one bare, read nothing from it, and it calls
1827
1868
  `registerLyraLocale()` for you.
1828
1869
 
@@ -1833,11 +1874,14 @@ import "@aceshooting/lyra-ui/translations/fa.js"; // fa-IR falls back to this ba
1833
1874
  import "@aceshooting/lyra-ui/translations/he.js"; // he-IL falls back to this base catalog
1834
1875
  import "@aceshooting/lyra-ui/translations/it.js"; // Italian
1835
1876
  import "@aceshooting/lyra-ui/translations/pt-BR.js"; // also serves pt and pt-PT
1877
+ import "@aceshooting/lyra-ui/translations/ro.js"; // Romanian
1836
1878
  import "@aceshooting/lyra-ui/translations/zh-CN.js"; // also serves zh, zh-Hans and zh-Hans-CN
1837
1879
  ```
1838
1880
 
1839
1881
  Persian and Hebrew use CLDR plural categories (`fa`: `one`/`other`; `he`:
1840
- `one`/`two`/`other`); Italian uses a non-default set too (`it`: `one`/`many`/`other`). `ar`, `fa`
1882
+ `one`/`two`/`other`); Italian uses a non-default set too (`it`: `one`/`many`/`other`), and so does
1883
+ Romanian (`ro`: `one`/`few`/`other`, where `few` covers `0` and `2`-`19` and `other` is the
1884
+ `de`-requiring form from `20` upward). `ar`, `fa`
1841
1885
  and `he` declare `dir: 'rtl'`, so `getLyraLocaleDirection()`
1842
1886
  answers for them (and for `ar-EG`, `fa-IR`, `he-IL`) — but locale selection still does not _force_
1843
1887
  writing direction: set `dir="rtl"` on the page or an ancestor yourself.
@@ -4928,6 +4972,17 @@ An async `source` row can carry the same two fields (`start`, `end`) alongside i
4928
4972
  default; an explicit instance value always wins. There is deliberately no `hoist` alias here:
4929
4973
  unlike on `lr-select`, where it is Shoelace's established spelling, it would be a boolean
4930
4974
  defaulting to `true`, so its attribute could only ever express the value the control already has
4975
+ - `sync?: PlaceSync` (reflected) — copies the trigger's width, height, or both onto the listbox,
4976
+ spelled the same as on `lr-dropdown`/`lr-popup`/`lr-popover`. Unset (the default), the listbox
4977
+ sizes to its own content, clamped between `--lr-size-12rem` and `--lr-size-28rem`, exactly as
4978
+ before. `sync="width"` drops that content-based clamp so a full-width trigger with short option
4979
+ labels gets a listbox that aligns to its own edges instead of floating narrower in the middle.
4980
+ A synced listbox is capped on `--lr-positioner-available-inline-size` **alone** — the space the
4981
+ positioner actually measured beside the anchor, which still keeps an over-wide trigger from
4982
+ pushing it off-screen. `--lr-popover-viewport-clamp` does **not** apply to a synced listbox — its
4983
+ 92vw default used to shorten the listbox against its own trigger in exactly the full-width case
4984
+ `sync` exists for, and `lr-popup` never applied it either. It still applies with `sync` unset.
4985
+ Like `placement`, a change takes effect the next time the listbox opens
4931
4986
  - `clearable: boolean = false` (reflected) — displays the clear button while there is something to
4932
4987
  clear on **either** axis this control owns: a committed selection, or _visible_ filter text. See
4933
4988
  "the clear button covers two axes" below
@@ -5512,7 +5567,11 @@ exactly like the multi-option case, until the trigger is actually activated.
5512
5567
 
5513
5568
  **Properties:**
5514
5569
 
5515
- - `placeholder: string = ''`
5570
+ - `placeholder: string = ''` — text shown on the trigger while nothing is selected, and the
5571
+ trigger's accessible name when neither a host `aria-label` nor a `label` supplies one. One
5572
+ exception to "an empty selection always shows this": while `loading` is `true` the trigger shows
5573
+ the localized `loading` text instead — see `loading` below. The accessible name is unaffected
5574
+ either way
5516
5575
  - `disabled: boolean = false` (reflected)
5517
5576
  - `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
5518
5577
  - `name: string = ''`
@@ -5551,13 +5610,41 @@ exactly like the multi-option case, until the trigger is actually activated.
5551
5610
  authored, the listbox is actually placed with the cascading `--lr-positioning-strategy` custom
5552
5611
  property honored ahead of that default — see the listbox's own **Themeable custom properties**
5553
5612
  below
5613
+ - `sync?: PlaceSync` (reflected) — copies the trigger's width, height, or both onto the listbox,
5614
+ spelled and typed the same as on `lr-popup`/`lr-popover`/`lr-dropdown`/`lr-combobox`
5615
+ (`'width' | 'height' | 'both'`). Unset (the default), the listbox sizes to its own content,
5616
+ clamped between `--lr-size-12rem` and `min(--lr-popover-viewport-clamp, --lr-size-28rem)`,
5617
+ exactly as before. `sync="width"` drops that content-based clamp so a full-width trigger with
5618
+ short option labels gets a listbox that aligns to its own edges instead of floating narrower in
5619
+ the middle. A synced listbox is capped on `--lr-positioner-available-inline-size` **alone** — the
5620
+ space the positioner actually measured beside the trigger, which still keeps an over-wide trigger
5621
+ from pushing it off-screen; `--lr-popover-viewport-clamp` does **not** apply to a synced listbox,
5622
+ and still does with `sync` unset. Assignment while open repositions in place without closing;
5623
+ unsetting it releases the inline width the positioner wrote
5554
5624
  - `showUnknownOption: boolean = false` (attribute `show-unknown-option`, reflected) — appends every
5555
5625
  committed value that no `<lr-option>` claims to the end of the listbox as a synthetic, badged,
5556
5626
  keyboard-reachable, re-selectable row. Off by default
5557
5627
  - `getUnknownLabel?: (value: string) => string` (attribute: false) — renders the label for a
5558
5628
  committed value that matches no option, everywhere it appears (trigger, `multiple` tag, synthetic
5559
5629
  row). `getTag` cannot serve this case: it is handed a matched option and there is none. A blank
5560
- return falls back to the raw value
5630
+ return falls back to the raw value. Not consulted while `loading` suppresses that same value —
5631
+ see below
5632
+ - `loading: boolean = false` (reflected) — whether a committed value's real label may still be
5633
+ pending because its `<lr-option>` catalog hasn't arrived yet (e.g. an async fetch still in
5634
+ flight). While `true`, a committed value that currently matches no option renders the localized
5635
+ `loading` placeholder in the trigger label or the relevant `multiple` tag instead of the raw
5636
+ value, with no `notInCatalog`/`[part='unknown-value']` badge and no synthetic
5637
+ `showUnknownOption` listbox row — "not yet resolved" is a different state from "known to be
5638
+ missing". It covers an **empty** selection too: with nothing selected — a create form whose
5639
+ catalogue is still being fetched, or an edit form whose saved selection is legitimately empty —
5640
+ the trigger renders that same localized text in place of `placeholder`, from the same `loading`
5641
+ message key, so both halves of a pending state read the same words and one
5642
+ `registerLyraLocale()` translation reaches both. Nothing to re-localize in the consuming app.
5643
+ With `loading` false an empty selection renders `placeholder` exactly as before. The trigger's
5644
+ accessible name never changes for this: a host `aria-label` wins, then `label`, then
5645
+ `placeholder`, then the localized `select` fallback. A value already matching a live option is
5646
+ unaffected. Never mutates `value`/`selectedOptions` itself, and does not itself disable the
5647
+ trigger — pair it with `disabled` when the control should also be non-interactive while pending
5561
5648
  - `filled: boolean = false` (reflected) — Shoelace alias for the filled trigger treatment
5562
5649
  - `autofocus: boolean = false` / `title: string = ''` — forwarded to the internal trigger
5563
5650
  - `multiple: boolean = false` (reflected) — several options selectable at once; see "Multi-select"
@@ -5609,7 +5696,17 @@ from a removed option, or a programmatic assignment with a typo) still commits
5609
5696
  stays fully reachable through `value`/`selectedOptions` — but renders a dashed/italic
5610
5697
  `[part='unknown-value']` badge next to the trigger label, or on the relevant `multiple`-mode tag,
5611
5698
  instead of an unexplained bare label, mirroring `<lr-model-select>`'s synthetic "not in catalog"
5612
- stale-value row — see `--lr-select-unknown-value-border-style`/`-color` below.
5699
+ stale-value row — see `--lr-select-unknown-value-border-style`/`-color` below. Set `loading` while
5700
+ that same value's own `<lr-option>` catalog simply hasn't arrived yet (unlike `<lr-combobox>`,
5701
+ `<lr-select>` has no async `source` of its own, so this is consumer-driven rather than automatic):
5702
+ a still-unmatched value then renders the localized `loading` placeholder instead of the raw value,
5703
+ with no `unknown-value` badge and no synthetic `showUnknownOption` row, since it is not yet known
5704
+ to be missing. Once the matching option mounts, the real label renders on the next render with no
5705
+ `value`/`selectedOptions` re-assignment needed, whether or not `loading` is also flipped back to
5706
+ `false`. The same flag covers the other half of that state: with **nothing** selected the trigger
5707
+ renders the same localized `loading` text in place of `placeholder`, so a consumer never has to
5708
+ hand-write a conditional placeholder bound to the same flag and re-localize, in its own catalogue,
5709
+ the string this control already owns.
5613
5710
 
5614
5711
  **Methods:** `focus(options?)`, `blur()`, and `click()` forward to the internal trigger button.
5615
5712
  `show()` and `hide()` return `Promise<void>` and resolve after `lr-after-show`/`lr-after-hide` once
@@ -6909,10 +7006,11 @@ box no matter what tier or override is in play.
6909
7006
  For toggle buttons, host `aria-pressed="true|false|mixed"` reaches the focused native button —
6910
7007
  BUTTONS only. A link button (`href` set) never receives it, because `role="link"` does not support
6911
7008
  `aria-pressed`; remove `href` and the same host attribute starts reaching the `<button>` that
6912
- replaces the anchor. Navigation can use the global
6913
- `aria-current="page|step|location|date|time|true|false"`, which does reach the anchor. Both states
6914
- update reactively, including native `ariaPressed`/`ariaCurrent` property assignments, attribute
6915
- removal and `href` changes. Empty or unsupported tokens are omitted. The native button/link role
7009
+ replaces the anchor. That link carve-out arrived in **16.0.0** — before it, `aria-pressed` was
7010
+ forwarded onto the anchor as well, asserting a state `role="link"` does not have. Navigation can
7011
+ use the global `aria-current="page|step|location|date|time|true|false"`, which does reach the
7012
+ anchor. Both states update reactively, including native `ariaPressed`/`ariaCurrent` property
7013
+ assignments, attribute removal and `href` changes. Empty or unsupported tokens are omitted. The native button/link role
6916
7014
  remains unchanged for an enabled control: use pressed state for button toggles and current state
6917
7015
  for navigation links. A DISABLED link button drops `href` (so it genuinely cannot navigate) and
6918
7016
  therefore also drops the anchor's implicit role, so it renders an explicit `role="link"` — without
@@ -6992,7 +7090,8 @@ button whose purpose isn't generic.
6992
7090
  Host `aria-haspopup` and `aria-expanded` values are forwarded reactively to the shadow-internal
6993
7091
  native button. `aria-pressed` (`true`, `false`, `mixed`) supports icon-only toggle actions such as
6994
7092
  mute, favorite, or pin, and reaches the `<button>` rendering only — a link icon-button (`href` set)
6995
- never receives it, since `role="link"` has no pressed state; `aria-current` (`page`, `step`,
7093
+ never receives it, since `role="link"` has no pressed state (that carve-out arrived in **16.0.0**;
7094
+ earlier versions forwarded it onto the anchor too); `aria-current` (`page`, `step`,
6996
7095
  `location`, `date`, `time`, `true`, `false`) is global and supports current-item icon buttons such
6997
7096
  as an active nav/pagination target in both renderings. Both follow attribute
6998
7097
  changes, removal, and button/link replacement without changing the native role of an enabled
@@ -7053,15 +7152,23 @@ above 24px — see `llms/shared.md`. **`--lr-icon-button-size` is element-scoped
7053
7152
  token in this section:** the shared token layer re-declares it on every `lr-*` host's own `:host`,
7054
7153
  so a rule that sets `--lr-icon-button-size` on an ancestor wrapper is reset the moment it crosses
7055
7154
  into any intervening `lr-*` component and never reaches a `<lr-icon-button>` composed inside it
7056
- (e.g. one slotted through `<lr-popover>`). The only two levers that actually reach it are: setting
7155
+ (e.g. one slotted through `<lr-popover>`). The three levers that actually reach it are: setting
7057
7156
  `--lr-icon-button-size` directly on the icon button element itself, where no intervening component
7058
- sits between the rule and the property; or setting `--lr-theme-icon-button-size` on an ancestor,
7059
- which the shared layer reads through `var()` at every level and which therefore reaches a
7157
+ sits between the rule and the property; setting `--lr-icon-button-size-scope` on any ancestor to
7158
+ resize one subtree; or setting `--lr-theme-icon-button-size` on an ancestor to resize the whole
7159
+ application. The shared layer reads the latter two through `var()` at every level, so both reach a
7060
7160
  `<lr-icon-button>` composed inside another component, e.g. `<lr-copy-button>`/
7061
- `<lr-message-actions>`. Lowering the floor for a dense action row through either lever is safe even
7062
- below 24px: a coarse-pointer/no-hover media rule floors the RENDERED hit area back at 2.75rem/44px
7063
- regardless of how far the override lowered it, so the control stays comfortably tappable the moment
7064
- the pointer reaching it is a finger rather than a mouse. `--lr-icon-button-radius` (default
7161
+ `<lr-message-actions>` -- see `llms/shared.md` for the full scope table.
7162
+
7163
+ Lowering the floor for a dense action row below 24px is safe **only through the two ancestor
7164
+ levers**. The coarse-pointer/no-hover media rule reads those, and floors the RENDERED hit area back
7165
+ at 2.75rem/44px however far the override lowered it, so the control stays comfortably tappable the
7166
+ moment the pointer reaching it is a finger rather than a mouse. It does **not** rescue
7167
+ `--lr-icon-button-size` set directly on the element: that declaration comes from the outer tree and
7168
+ outranks the shadow tree's own `:host` rule, so the media rule never wins and the rendered hit area
7169
+ is exactly the value set -- `--lr-icon-button-size: 1rem` really does render a 16px target under a
7170
+ coarse pointer, which fails WCAG 2.2 SC 2.5.8. Prefer `--lr-icon-button-size-scope` when the intent
7171
+ is a denser row; reach for the element-scoped name only as a deliberate, localized trade-off. `--lr-icon-button-radius` (default
7065
7172
  `--lr-radius`) is not re-declared anywhere in the shared layer, so — like every other token below —
7066
7173
  it inherits normally from an ancestor even through an intervening component; it is the
7067
7174
  `[part='button']` corner radius, retunable without a `::part(button)` rule — the same
@@ -18109,6 +18216,17 @@ list's `base` scroll container exposes horizontal scrolling for that explicit op
18109
18216
  number `56` and fixes every row to that many pixels. Property callers assign a number, not a
18110
18217
  numeric string. Anything else (non-numeric, zero, negative, non-finite) safely canonicalizes to
18111
18218
  `'auto'` rather than throwing.
18219
+ - `rowProjection: 'shadow' | 'light' = 'shadow'` (attribute `row-projection`) — where
18220
+ `renderItem`'s output is instantiated. `'shadow'` (default) stamps it inside this component's own
18221
+ shadow root, so only inherited custom properties and the public row parts reach it. `'light'`
18222
+ renders the windowed rows into the host's own light DOM instead, assigned into the shadow viewport
18223
+ through internal named slots, so ordinary document CSS styles a virtualized row exactly as it
18224
+ styles the same row unvirtualized. The component keeps owning windowing, measurement, spacer
18225
+ sizing, `scrollToIndex()`, the external-scroller mode and the ARIA contract either way, and
18226
+ positioning stays on the shadow-side `[part="row"]` wrapper that document CSS cannot select — so
18227
+ consumer styles can never break windowing. Any other value canonicalizes to `'shadow'`. Left
18228
+ unset, the rendered output is byte-identical to before and the host's light DOM stays empty.
18229
+ See **Light-DOM row projection** below for the trade-offs it carries.
18112
18230
  - `itemRole: 'listitem' | 'row' = 'listitem'` (attribute `item-role`) — `'listitem'` (default)
18113
18231
  preserves the plain `role="list"`/`role="listitem"` mapping with `aria-setsize`/`aria-posinset`.
18114
18232
  `'row'` additionally maps `[part="base"]` to `role="rowgroup"`, `[part="spacer"]` to
@@ -18363,6 +18481,48 @@ default estimate with sparse `ResizeObserver` measurements for rows that have ac
18363
18481
  decides: groups render before the rows, so an active row wins while (and only while) it needs to,
18364
18482
  which is right — a group header is a non-interactive `pointer-events: none` label.
18365
18483
 
18484
+ ### Light-DOM row projection
18485
+
18486
+ `rowProjection="light"` exists for one shape: an application whose list rows are already styled by
18487
+ its own global stylesheet, and which therefore could not adopt virtualization without rehoming a
18488
+ dozen descendant rules per row into a new custom element or a growing set of custom properties. In
18489
+ projection mode the windowed rows render into the host's own light DOM, so ordinary document CSS
18490
+ reaches row content directly.
18491
+
18492
+ Positioning, measurement and semantics stay where they were. The `[part="row"]` wrapper remains in
18493
+ the shadow root and keeps `position: absolute`, the per-frame `transform`, `role`, `aria-setsize`/
18494
+ `aria-posinset` (or `aria-rowindex`) and the `ResizeObserver` box — document CSS cannot select it,
18495
+ so consumer styles can never break windowing. The whole part vocabulary (`base`, `spacer`, `row`,
18496
+ `group`, `sticky-group`) keeps matching in both modes, and `row-height="auto"` still measures
18497
+ projected content because the light row is an ordinary in-flow child of that wrapper.
18498
+
18499
+ `projectedRows: HTMLElement[]` returns the projected light-DOM row wrappers in item order, and is
18500
+ empty outside projection mode. The exported type is `LyraVirtualListRowProjection`; the reserved
18501
+ attributes marking library-owned light-DOM nodes are exported as `VIRTUAL_LIST_ROW_ATTRIBUTE`
18502
+ (`data-lr-virtual-list-row`) and `VIRTUAL_LIST_STICKY_ATTRIBUTE` (`data-lr-virtual-list-sticky`).
18503
+
18504
+ **Known gotchas, all inherent to handing the cascade back to the consumer:**
18505
+
18506
+ - **One component-owned wrapper sits between the host and your markup.** A slot cannot assign a text
18507
+ node or a multi-root fragment by attribute, so each row's content lives inside a wrapper carrying
18508
+ `data-lr-virtual-list-row`. Descendant selectors (`lr-virtual-list .row-title`) port unchanged;
18509
+ child combinators (`lr-virtual-list > .row`), `:nth-child`, `:first-child` and sibling combinators
18510
+ written against the unvirtualized markup do not. `:nth-child` on the wrappers reflects the current
18511
+ *window*, not the item index.
18512
+ - **`closest('[part="row"]')` stops resolving.** A delegated listener on the host now sees an
18513
+ un-retargeted `event.target` inside the light DOM. Use `closest('[data-lr-virtual-list-row]')`.
18514
+ - **The document cascade now reaches row content**, including resets and element-level rules that
18515
+ previously could not, so a projected row can look different from the same row in shadow mode.
18516
+ - **Per-row light-DOM state does not survive a disconnect/reconnect.** Disconnect removes the
18517
+ projected rows completely (no rows, no markers, no anchor left behind), so a reparenting move
18518
+ rebuilds them. Scroll position, measurements and the window are unaffected — they live in
18519
+ component state, not in the rows.
18520
+ - **Projection activates one task after hydration.** A server render has no DOM to project into, so
18521
+ the first window is shadow-rendered, hydration matches the server markup, and the rows then swap
18522
+ into the light DOM on the next task.
18523
+ - **A row taken out of flow collapses its wrapper.** `position: fixed`/`absolute` or
18524
+ `display: none` on your own row leaves nothing for the wrapper to measure.
18525
+
18366
18526
  ---
18367
18527
 
18368
18528
  ## `lr-app-rail`
@@ -19600,7 +19760,8 @@ to `<wa-card>`'s contract, staying slot-compatible with `lr-result-card` where t
19600
19760
  - `aria-pressed` and `aria-current` (attributes only) — forwarded reactively onto the native
19601
19761
  control the card actually renders, the same mechanism `<lr-button>` and `<lr-icon-button>` use.
19602
19762
  `aria-pressed` accepts `'true' | 'false' | 'mixed'` and reaches the `activation-button` only —
19603
- `link` has no pressed state, so a linked card never receives it. The global `aria-current`
19763
+ `link` has no pressed state, so a linked card never receives it the same **16.0.0** carve-out
19764
+ `<lr-button>` and `<lr-icon-button>` took. The global `aria-current`
19604
19765
  accepts `'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'` and reaches both the
19605
19766
  activation button and the stretched link. Anything outside those sets is dropped rather than
19606
19767
  passed through, so a typo never reaches the accessibility tree. This is what lets a single-select
@@ -20374,7 +20535,10 @@ Dashboard filter row that composes Lyra inputs and removable chips, with reset a
20374
20535
  Choice option entries must expose string `value` and `label` data fields; malformed entries are
20375
20536
  omitted independently, while supplied empty strings remain valid. A custom definition requires its
20376
20537
  adapter and a callable `render`; a rejected definition does not reserve its filter ID. Valid
20377
- siblings remain available. Exceptions thrown by an admitted trusted renderer still propagate.
20538
+ siblings remain available. Exceptions thrown by an admitted trusted renderer still propagate. A
20539
+ chip-only definition requires neither options nor an adapter — a stable filter ID and a label are
20540
+ the whole schema, since a malformed `formatValue`/`isEmpty` still has a correct fallback and so is
20541
+ guarded where it is used rather than rejected outright.
20378
20542
 
20379
20543
  **Lean registration entry.** `components/layout/filter-bar/filter-bar.js` (the default entry)
20380
20544
  eagerly imports every composed control this bar could possibly render — `<lr-select>`,
@@ -20395,6 +20559,7 @@ the filter `type`s actually declared:
20395
20559
  | `'checkbox-menu'` | `components/overlays/overlay/dropdown.js` **and** `components/layout/menu/dropdown-item.js` |
20396
20560
  | `'date'` / `'date-range'` | `components/forms/date-picker/date-input.js` |
20397
20561
  | `'text'` | `components/forms/input/input.js` |
20562
+ | `'chip'` | none — renders no control |
20398
20563
 
20399
20564
  Two more are unconditional regardless of which filter `type`s are declared: `<lr-button>` renders
20400
20565
  the reset action on every bar, and `<lr-chip>`/`<lr-chip-group>` render the active-filter row
@@ -20431,8 +20596,21 @@ until something else registers it, the same trade `icon-button-register.js` docu
20431
20596
  - `disabled: boolean = false` (reflected) — disables every filter control and reset action.
20432
20597
  - `loading: boolean = false` (reflected) — shows the status spinner and disables reset while leaving
20433
20598
  filters editable.
20434
- - `hasActiveFilters: boolean` (read-only) — whether any configured filter currently has a value.
20435
- Drives the reset button's own disabled state; unaffected by `activeFiltersDisplay`.
20599
+ - `hasActiveFilters: boolean` (read-only) — whether any configured filter currently has a value,
20600
+ including one sitting at its own declared `defaultValue`. Drives the reset button's own disabled
20601
+ state in every `activeFiltersDisplay` mode except `'changed'`; the getter itself is unaffected by
20602
+ `activeFiltersDisplay`.
20603
+ - `hasChangedFilters: boolean` (read-only) — whether any filter's value differs from its own
20604
+ declared `defaultValue`, using the same equality `activeFiltersDisplay: 'changed'` filters its
20605
+ chip row on: a `readonly string[]` default compares positionally, everything else compares with
20606
+ `Object.is`. This is the counterpart to `hasActiveFilters`, not a synonym — a bar whose filters
20607
+ were all declared with non-empty defaults and never touched reads `hasActiveFilters === true` and
20608
+ `hasChangedFilters === false`, because a bar whose defaults narrow the view on load has not been
20609
+ narrowed by the user. A filter with no declared `defaultValue` counts as changed the moment it
20610
+ holds any value at all (there is nothing for it to still equal), and clearing a filter that *does*
20611
+ declare one counts as changed too, since `reset()` would restore it — which is the one case where
20612
+ this getter and the `'changed'` chip row differ, the row's entries being non-empty by
20613
+ construction. Always live, never cached.
20436
20614
  - `invalidFilterIds: readonly string[]` (read-only) — immutable ids of required filters whose
20437
20615
  values are unset.
20438
20616
  - `activeFiltersDisplay: 'all' | 'changed' | 'hidden' = 'all'` (reflected, attribute
@@ -20446,6 +20624,10 @@ until something else registers it, the same trade `icon-button-register.js` docu
20446
20624
  at each index), matching this component's only other array-equality precedent (a custom adapter's
20447
20625
  own `clearValue` comparison); a `'date-range'` value is a single composed string, so it compares
20448
20626
  like any other string. Removing a chip always clears that filter, unaffected by this property.
20627
+ `'changed'` additionally gates the reset button on `hasChangedFilters` instead of
20628
+ `hasActiveFilters`, so an untouched defaults-only bar — which renders no chip in this mode — no
20629
+ longer offers an enabled reset that would change nothing. Enablement under `'all'` and `'hidden'`
20630
+ is unchanged, `disabled`/`loading` still win in every mode, and `reset()` itself is untouched.
20449
20631
 
20450
20632
  The composed reset action uses `lr-button`'s default `m` size tier, matching the default rendered
20451
20633
  height of adjacent select, combobox, input, and date fields instead of introducing a shorter action
@@ -20575,11 +20757,13 @@ same-named counterparts (with `combobox`'s `multiple` opting into a multi-value
20575
20757
  `'date'`/`'date-range'` both map to `<lr-date-input>` (single vs. `mode="range"`), and `'text'` maps
20576
20758
  to `<lr-input>` for an open-ended free-text query rather than a closed choice set. A `'text'`
20577
20759
  filter's value is the raw query string, verbatim, and its chip shows exactly that string — the same
20578
- text the user typed, not a truncated or normalized form.
20760
+ text the user typed, not a truncated or normalized form. `'chip'` is the one type that renders no
20761
+ control at all (see **Chip-only filters** below): its value belongs to a widget elsewhere on the
20762
+ page, so the bar renders only its active-filter chip and gives it no toolbar cell.
20579
20763
 
20580
20764
  Every built-in (non-`'custom'`) filter definition additionally accepts optional `size: LyraSize`,
20581
- `icon: unknown` and `labelVisibility: 'visible' | 'hidden'` fields, and every one whose composed
20582
- control ships a clear action also accepts `clearable: boolean`. They are forwarded verbatim to that
20765
+ `icon: unknown` and `labelVisibility: 'visible' | 'hidden' | 'auto'` fields, and every one whose
20766
+ composed control ships a clear action also accepts `clearable: boolean`. They are forwarded verbatim to that
20583
20767
  control's own same-named property — `icon` into its `start` slot exactly like a choice option's own
20584
20768
  `icon`, rendered inert and `aria-hidden`; `clearable` reaching `<lr-date-input>` under its own
20585
20769
  `with-clear` spelling, since that control has no `clearable`. `'text'` also accepts
@@ -20598,6 +20782,20 @@ so a compact toolbar row still names every field for assistive technology; visua
20598
20782
  `::part(filter-control-label)` in CSS, the only previous option, removed the accessible name along
20599
20783
  with the text.
20600
20784
 
20785
+ `labelVisibility: 'auto'` is the width-dependent middle between the two. It renders exactly what
20786
+ `'visible'` renders — the same stacked label element, the same accessible name computed from it, no
20787
+ `aria-label` and no placeholder fallback — and the bar's own stylesheet visually clips that label
20788
+ once the bar's allocation drops below `30rem`. The threshold is a container query on the host, so it
20789
+ reads the bar's own allocated width, not the viewport's: the same definitions render labelled across
20790
+ a dashboard and unlabelled in a 320px side panel, dialog or split pane, with no host-side breakpoint
20791
+ logic. The label element is never removed at any width, so the field's accessible name is identical
20792
+ in both states, and `'auto'` deliberately does not route the name onto the control the way
20793
+ `'hidden'` does — doing so would name a wide-allocation field twice. A `'checkbox-menu'` filter
20794
+ participates through its own trigger label run, the same one `'hidden'` already clips there. The
20795
+ threshold is fixed rather than themeable: a CSS container query's prelude cannot read a custom
20796
+ property (`var()` is not substituted in an at-rule prelude), so a `--lr-*` hook for it would parse
20797
+ and silently never apply.
20798
+
20601
20799
  `'combobox'` also accepts the same `debounce?: number` (ms) `'text'` already had: it coalesces a
20602
20800
  burst of rapid selection changes (picks, a multi-select toggle, an
20603
20801
  `allowCustomValue`/`allowCreate` commit, or the clear action) into one delayed commit. Unlike
@@ -20627,6 +20825,8 @@ makes that text visually hidden — never removed — so the button keeps its ac
20627
20825
  one case where the hidden label would be the *only* thing the button says (hidden routing, no
20628
20826
  declared `placeholder`, nothing selected) the label routes to the visible summary instead of being
20629
20827
  emitted twice, so the trigger's accessible name stays "Teams", never "Teams Teams".
20828
+ `labelVisibility: 'auto'` clips that same trigger label run, and only below the `30rem` threshold —
20829
+ the label run is always emitted under `'auto'`, since nothing is routed to the summary there.
20630
20830
 
20631
20831
  Because its trigger is a button rather than a field, a `required` `'checkbox-menu'` deliberately
20632
20832
  renders **no** required asterisk and sets **no** `aria-invalid`: the shared required marker has no
@@ -20791,12 +20991,89 @@ const filters: LyraFilterBarFilterDefinition[] = [
20791
20991
  The custom renderer returns a Lit `TemplateResult`; the filter bar places it in its
20792
20992
  `filter-control` part and re-renders it whenever the controlled value or validation state changes.
20793
20993
 
20994
+ ### Chip-only filters
20995
+
20996
+ Use `type: 'chip'` when the value is already owned by a widget elsewhere on the page — a calendar
20997
+ heatmap cell, a map selection, a chart brush — and the bar's job is only to *show* that the filter
20998
+ is applied and to let the user take it off. Unlike `type: 'custom'`, which still renders a control
20999
+ inside the toolbar, a chip-only filter renders **no control and no toolbar cell at all**: no `field`
21000
+ wrapper is emitted for it, so `lr-filter-bar::part(field)` and `::part(field-<filterId>)` never
21001
+ match one and a bar whose filters are *all* chip-only shows no empty column — its `controls` row
21002
+ still holds the reset button (the "clear all" action such a bar needs), the `end` slot, and the
21003
+ loading spinner, exactly like a bar with no filters at all.
21004
+
21005
+ Everything else is unchanged from any other filter type. The value lives in `value` under its own
21006
+ filter ID, rides every `lr-input`/`lr-reset` detail, counts toward `hasActiveFilters` (so it enables
21007
+ the reset button) and toward `invalidFilterIds` when the definition is `required`, renders a
21008
+ removable active-filter chip subject to `activeFiltersDisplay`, and is cleared both by removing that
21009
+ chip and by `reset()`. A `required` chip-only filter is honoured in **bookkeeping only**: it joins
21010
+ `invalidFilterIds`, fails `checkValidity()` and moves `lr-validity-change`, but renders no inline
21011
+ error, because the bar renders no element of its own for it — the owning widget is responsible for
21012
+ its own error affordance. The inherited `placeholder` is inert here for the same reason it is for
21013
+ `type: 'custom'`: there is no field to place it in.
21014
+
21015
+ A chip-only definition adds three optional fields of its own:
21016
+
21017
+ ```ts
21018
+ type: 'chip';
21019
+ formatValue?: (value: LyraFilterBarFieldValue, locale: string) => string;
21020
+ clearValue?: LyraFilterBarFieldValue;
21021
+ isEmpty?: (value: LyraFilterBarFieldValue) => boolean;
21022
+ ```
21023
+
21024
+ `formatValue` produces the chip's text, and its `locale` argument is the bar's `effectiveLocale` —
21025
+ the same locale every built-in type's own chip formatting and a custom adapter's `formatValue`
21026
+ already receive, and the reason a chip-only value (normally an already-formatted string such as a
21027
+ localized date) can be localized by the caller. That output is caller data, so — like a filter's
21028
+ own label — the bar never routes it through its own localization. Omitted, the fallback ladder is
21029
+ the one a custom adapter's omitted `formatValue` uses: a string array renders as a localized
21030
+ conjunction list, anything else renders verbatim through `String(value)`, and an unset value renders
21031
+ as the empty string. Verbatim is exact: a chip-only value is never run through the date branch that
21032
+ localizes a `'date'`/`'date-range'` chip, so an ISO day is not silently reformatted and a value
21033
+ containing a slash is not mangled.
21034
+
21035
+ `clearValue` is what a chip removal (and `clearFilter()`) writes, defaulting to the empty string —
21036
+ what every non-multi built-in type writes. Declare an empty array for an array-valued chip-only
21037
+ filter. `isEmpty` overrides the built-in emptiness rule (absent, `false`, the empty string and the
21038
+ empty array are empty; everything else is set). **A domain sentinel must pair the two**: declaring
21039
+ a sentinel clear value without a matching `isEmpty` leaves the bar reading the "cleared" value as
21040
+ still set, so it keeps rendering a chip for it — the identical pairing a custom adapter's own
21041
+ `clearValue`/`isEmpty` documents. With the pair declared, the sentinel is never stored in `value`
21042
+ (cleared keys are omitted) and an absent key reads back as the sentinel for the owning widget.
21043
+
21044
+ ```ts
21045
+ const filters: LyraFilterBarFilterDefinition[] = [
21046
+ { filterId: "query", label: "Query", type: "text" },
21047
+ {
21048
+ filterId: "day",
21049
+ label: "Day",
21050
+ type: "chip",
21051
+ formatValue: (value, locale) =>
21052
+ new Intl.DateTimeFormat(locale, {
21053
+ dateStyle: "medium",
21054
+ timeZone: "UTC",
21055
+ }).format(new Date(`${String(value)}T00:00:00Z`)),
21056
+ },
21057
+ ];
21058
+
21059
+ // The calendar heatmap beside the bar owns the value; the bar only shows and removes it.
21060
+ heatmap.addEventListener("app-select-day", (event) => {
21061
+ const { isoDate } = (event as CustomEvent<{ isoDate: string }>).detail;
21062
+ bar.value = { ...bar.value, day: isoDate };
21063
+ });
21064
+ bar.addEventListener("lr-input", (event) => {
21065
+ const { value } = (event as LyraFilterBarInputEvent).detail;
21066
+ heatmap.selectedDay = (value["day"] as string | undefined) ?? "";
21067
+ });
21068
+ ```
21069
+
20794
21070
  **TypeScript:** `LyraFilterBar<Defs extends readonly LyraFilterBarFilterDefinition[] =
20795
21071
  readonly LyraFilterBarFilterDefinition[]>` — `value` and the `lr-input`/`lr-reset` detail `value`
20796
21072
  narrow to a record keyed per `filterId`, whose value type follows that filter's own definition (a
20797
21073
  `'select'`, a non-`multiple` `'combobox'`, `'text'`, `'date'`, and `'date-range'` narrow to
20798
21074
  `string`; a `'checkbox-menu'` and a `multiple: true` `'combobox'` narrow to `readonly string[]`; a
20799
- `'custom'` filter keeps the full unconstrained field value). Declare the schema with `as const
21075
+ `'custom'` filter keeps the full unconstrained field value, and so does a `'chip'` filter, whose
21076
+ value is owned by a widget this component never renders). Declare the schema with `as const
20800
21077
  satisfies readonly LyraFilterBarFilterDefinition[]` and type the element as
20801
21078
  `LyraFilterBar<typeof FILTERS>` to pick it up. Types only; the runtime is unchanged, and an untyped
20802
21079
  `<lr-filter-bar>` keeps today's `LyraFilterBarValue` (`Readonly<Record<string, string | readonly
@@ -21218,6 +21495,19 @@ These named interfaces and helper signatures are available to typed integrations
21218
21495
  readonly defaultValue?: string | readonly string[] | boolean;
21219
21496
  }`
21220
21497
  Import: `@aceshooting/lyra-ui/components/layout/filter-bar/filter-bar.class.js`.
21498
+ `LyraFilterBarChipDefinition extends LyraFilterBarDefinitionBase {
21499
+ readonly type: 'chip';
21500
+ readonly formatValue?: (value: LyraFilterBarFieldValue, locale: string) => string;
21501
+ readonly clearValue?: LyraFilterBarFieldValue;
21502
+ readonly isEmpty?: (value: LyraFilterBarFieldValue) => boolean;
21503
+ // Inherited from LyraFilterBarDefinitionBase.
21504
+ readonly filterId: string;
21505
+ readonly label: string;
21506
+ readonly placeholder?: string;
21507
+ readonly required?: boolean;
21508
+ readonly defaultValue?: string | readonly string[] | boolean;
21509
+ }`
21510
+ Import: `@aceshooting/lyra-ui/components/layout/filter-bar/filter-bar.class.js`.
21221
21511
  `LyraFilterBarCustomControlAdapter {
21222
21512
  readonly valueFromEvent: (event: Event) => LyraFilterBarFieldValue;
21223
21513
  readonly clearValue: LyraFilterBarFieldValue;
@@ -27027,18 +27317,72 @@ LyraMapLegendPattern }`, where `LyraMapLegendPattern` is `'solid' | 'diagonal' |
27027
27317
  'crosshatch'`. Pattern is required so color is never the sole category cue. At most 100 valid
27028
27318
  rows, 256 characters per label, and 8,192 aggregate label characters are retained; colors are
27029
27319
  bounded before validation. The overlay scrolls within the map allocation.
27320
+ A row may also carry its own `value`: the category key that row stands for — the same string a
27321
+ `point.colors`/`point.icons` entry matches against `point.field`/`point.iconField`. It is
27322
+ trimmed, bounded to 256 characters (sliced, never ellipsized, so a bounded key still matches) and
27323
+ **retained** in the canonical readback; a non-string, empty or whitespace-only key leaves no
27324
+ `value` property on the frozen row at all. It is what makes a row operable under
27325
+ `legendInteractive`, and it does not count toward the label budget.
27030
27326
  A row may also carry `icon`, deliberately the same record a `point.icons` entry uses — hand the
27031
27327
  legend the very icon object its point layer renders and the key shows the symbol drawn on the
27032
- map instead of describing it in colour alone. The point layer's category key (`value`) is
27033
- accepted so a pass-through needs no reshaping, and is left out of the canonical readback;
27328
+ map instead of describing it in colour alone. The **icon record's own** `value` is a separate
27329
+ field from the row-level one above: it is accepted so a pass-through needs no reshaping, and it
27330
+ is still left out of the canonical readback. A row's key is never derived from its glyph's, which
27331
+ would silently make a row interactive that the author never marked;
27034
27332
  `path`, `viewBox`, `mode`, `strokeWidth`, `lineCap` and `lineJoin` keep their point-icon meaning
27035
27333
  and their point-icon defaults, and the same validation applies (path data only, at most 8,192
27036
27334
  characters, positive `viewBox` dimensions). An unusable record is dropped and that row keeps
27037
27335
  rendering its colour swatch, exactly as a row that supplies no `icon` does.
27336
+ A row may finally carry a `group`: the section it belongs to, for a key that describes two
27337
+ layers at once and otherwise could not say which rows belong to which. The rule is pinned
27338
+ rather than inferred: **consecutive** entries sharing an identical `group` render as one section
27339
+ — a visible heading plus a `role="group"` that heading names — an entry with **no** `group`
27340
+ keeps its **declared** position rather than being hoisted above or sunk below a section, and a
27341
+ `group` that reappears after an interruption opens a *second* section rather than reordering
27342
+ rows to merge them. Declaration order is the one thing the legend never rewrites, because the
27343
+ order is itself information about the map. `group` is caller-supplied **data**: it renders
27344
+ verbatim and is never resolved through the locale catalogue. It is trimmed and bounded to 256
27345
+ characters (ellipsized, since it is rendered prose rather than a key matched against
27346
+ `point.field`); a non-string, empty or whitespace-only value leaves no `group` property on the
27347
+ frozen row at all, so an empty string means "ungrouped" instead of an empty heading. Like the
27348
+ row-level `value`, it does not count toward the aggregate label budget — the rendered total is
27349
+ already finite and stated: at most one heading per rendered row, so at most 100 of them. A
27350
+ section is not a row: the 100-row cap and the `legend-limit` summary count rows, never sections.
27038
27351
  - readonly `legendProjection: LyraMapLegendProjection` — frozen `{ inputCount, renderedCount,
27039
27352
  omittedCount, truncatedLabelCount, truncated }` result for the latest assignment. A truncated
27040
27353
  projection renders a localized visible `1–N of M items` summary rather than silently claiming
27041
27354
  the bounded rows are complete.
27355
+ - `legendInteractive: boolean = false` (attribute `legend-interactive`, reflected) — opt-in: turns
27356
+ every legend row that carries a `value` into a keyboard-operable visibility toggle, and leaves a
27357
+ row without one inert. Unset, the legend renders exactly the read-only key it rendered before —
27358
+ no `button`, no extra attribute, and no extra MapLibre paint key. Each toggle is an independently
27359
+ tabbable native `button`, so Enter and Space are the platform's own activation and there is no
27360
+ roving tabindex: a 100-row interactive legend contributes 100 tab stops, exactly as a 100-series
27361
+ `lr-chart` legend does. Each interactive row also grows to the shared `--lr-icon-button-size`
27362
+ hit-area floor (WCAG 2.5.8), which the panel's existing `max-block-size` and scrolling contain.
27363
+ - `legendCollapsible: boolean = false` (attribute `legend-collapsible`, reflected) — opt-in: renders
27364
+ a `legend-disclosure` `button` inside the panel that collapses the key down to its header, so a
27365
+ large legend stops permanently covering part of the map. Unset, the panel renders exactly what it
27366
+ rendered before — no button, no `id` minted on the row list, and no `hidden` attribute anywhere.
27367
+ Collapsing hides the gradient bar, the rows, the `legend-limit` summary and the trailing `legend`
27368
+ slot; the `legend-start` slot and the disclosure itself stay visible, so a slotted header survives
27369
+ the collapse and the control that restores the key is never what the collapse hides.
27370
+ - `legendOpen: boolean = true` (attribute `legend-open`, reflected) — whether a `legendCollapsible`
27371
+ panel is expanded. It defaults **open**, so adding only `legendCollapsible` never hides an existing
27372
+ key, and it does nothing at all while `legendCollapsible` is unset. Because it is a
27373
+ `true`-defaulting boolean it uses a custom attribute converter, so `legend-open="false"` parses —
27374
+ the bare presence-based boolean form cannot express `false` at all — and the reflection follows the
27375
+ same converter: open (the default) reflects as an **absent** attribute and collapsed reflects as
27376
+ `legend-open="false"`. It is controlled public state and survives a disconnect/reconnect. Assigning
27377
+ it programmatically reconciles the rendered panel and emits nothing.
27378
+ - `hiddenCategories: readonly string[] = []` (attribute: false) — the complete controlled set of
27379
+ muted category keys, mirroring `lr-chart`'s `hiddenDatasets`. Clone-owned and frozen; non-string,
27380
+ empty, whitespace-only and duplicate entries are dropped (first occurrence wins, matching
27381
+ `point.colors`), and at most 100 keys are retained. Honoured on the **first** render and the
27382
+ first MapLibre paint, not only after a user toggle, so a host can restore a saved selection. An
27383
+ empty array deliberately means every category is visible. It is controlled public state and
27384
+ survives a disconnect/reconnect. A hidden key that matches no category is harmless. Assigning it
27385
+ programmatically emits nothing.
27042
27386
  - `choropleth?: LyraMapChoroplethLayer` (attribute: false) — `LyraMapChoroplethLayer { sourceId:
27043
27387
  string; geojson: GeoJSON.FeatureCollection; field: string; stops: [number, string][]; interpolation?:
27044
27388
  'linear' | 'logarithmic' | 'step'; stepBaseColor?: string }` (interpolated
@@ -27319,7 +27663,8 @@ payload beside the map.
27319
27663
  `LyraMapHeatmapZoomValue`, `LyraMapLineOptions`, `LyraMapPointOptions`, `LyraMapPointRadiusOptions`,
27320
27664
  `LyraMapPointRadiusInterpolation`, `LyraMapPointIcon`, `LyraMapPointIconMode`,
27321
27665
  `LyraMapPointIconLineCap`, `LyraMapPointIconLineJoin`, `LyraMapMarker`, `LyraMapMarkerActivationDetail`,
27322
- `LyraMapMarkerActivationSource`, `LyraMapStyleSpecification`, and `LyraMapInstance`.
27666
+ `LyraMapMarkerActivationSource`, `LyraMapLegendToggleDetail`, `LyraMapLegendPanelToggleDetail`,
27667
+ `LyraMapStyleSpecification`, and `LyraMapInstance`.
27323
27668
  The former `LegendEntry`, `ChoroplethLayer`, `GeoJsonDataLayer`, and `MapMarker` names are removed
27324
27669
  in v9 rather than retained as aliases.
27325
27670
 
@@ -27335,7 +27680,24 @@ shared `maplibre-gl` import without constructing a map or allocating a WebGL con
27335
27680
  an element.
27336
27681
 
27337
27682
  **Events:** `lr-map-load` (fired once, after the underlying map's own `'load'`),
27338
- `lr-map-marker-activate` (non-cancelable; frozen `LyraMapMarkerActivationDetail { id, lngLat,
27683
+ `lr-map-legend-toggle` (**cancelable**; frozen `LyraMapLegendToggleDetail { value, visible,
27684
+ hiddenCategories }` — the activated row's category key, its proposed visibility, and the complete
27685
+ proposed hidden set in the order it would be committed, with the array detached and frozen so a
27686
+ listener cannot mutate the component's state through it). `preventDefault()` is a genuine veto, not
27687
+ a notification: `hiddenCategories` is not written, the row's `aria-pressed` does not change, the
27688
+ MapLibre paint is untouched, and nothing is announced — which is exactly what a host needs to own
27689
+ the set and assign its own value instead. There is deliberately no second, confirmation event: the
27690
+ committed state is `hiddenCategories`, which the host already observes, so a paired before/after
27691
+ vocabulary would be permanent public surface nobody asked for. The event is a DOM-interaction
27692
+ proposal only, so a programmatic `hiddenCategories` assignment reconciles without emitting it.
27693
+ Also `lr-map-legend-panel-toggle` (**cancelable**; frozen `LyraMapLegendPanelToggleDetail { open }` —
27694
+ the proposed `legendOpen` value), fired once when the `legendCollapsible` disclosure is activated by
27695
+ pointer or by Enter/Space. It is the *panel's* disclosure, not a *category's* visibility, so it
27696
+ deliberately does not reuse `lr-map-legend-toggle`. `preventDefault()` is the same genuine veto:
27697
+ `legendOpen` is not written, the rendered rows and the disclosure's `aria-expanded` do not change,
27698
+ so a host can own the open state and assign its own value from `event.detail.open`. A programmatic
27699
+ `legendOpen` assignment reconciles without emitting it, so a controlled host cannot loop.
27700
+ Also `lr-map-marker-activate` (non-cancelable; frozen `LyraMapMarkerActivationDetail { id, lngLat,
27339
27701
  marker, source }`; `id` is the trimmed explicit identity or `undefined`, `marker` is the accepted
27340
27702
  declarative snapshot, and `source` is `'pointer' | 'keyboard'`), and `lr-map-click`
27341
27703
  (frozen `detail: { readonly lngLat: readonly [lng, lat], readonly feature?, readonly origin?,
@@ -27357,9 +27719,15 @@ markup as described above.
27357
27719
 
27358
27720
  **Slots:** `legend` — custom legend content, rendered inside the legend panel's own layout so it
27359
27721
  stays positioned with the map instead of floating beside it. Supplying it opens the panel even
27360
- when `legend` and `legendGradient` are both empty.
27361
-
27362
- **CSS parts:** `base`, `container`, `legend`, `legend-swatch`, `legend-gradient`, `legend-lo`,
27722
+ when `legend` and `legendGradient` are both empty. `legend-start` — the same extension point at the
27723
+ **top** of the panel: it renders ahead of the gradient bar and every projected row, where `legend`
27724
+ renders after them, so a host-authored panel header is no longer forced to be a footer. Content in
27725
+ it alone opens the panel too, and neither slot is ever made interactive by `legendInteractive`,
27726
+ which only reaches rows projected from `legend`.
27727
+
27728
+ **CSS parts:** `base`, `container`, `legend`, `legend-disclosure`, `legend-disclosure-icon`,
27729
+ `legend-group`, `legend-group-heading`, `legend-swatch`, `legend-toggle`,
27730
+ `legend-toggle-hidden`, `legend-gradient`, `legend-lo`,
27363
27731
  `legend-hi`, `legend-limit`, `marker`, `popup`,
27364
27732
  `popup-content`, `popup-close-button`, `attribution`, `attribution-toggle`, `navigation`,
27365
27733
  `zoom-in`, `zoom-out`, `compass`, `scale`, `error`.
@@ -27384,6 +27752,38 @@ visible label carries its meaning. That association is advertised only while it
27384
27752
  the optional `maplibre-gl` peer is still loading, and after any failure, there is no map
27385
27753
  container in the tree, so `legend` withholds `aria-controls` rather than leaving a dangling
27386
27754
  idref.
27755
+ Under `legendInteractive`, a row carrying a `value` wraps its swatch and label in a
27756
+ `legend-toggle` `button` with `aria-pressed` rendered as the literal `"true"`/`"false"` — never
27757
+ omitted, because a missing attribute reports "not a toggle button" rather than "unpressed". The
27758
+ button's accessible name is its own visible label (caller-supplied data, so deliberately not
27759
+ localized), scoped by the legend's own localized group name; no `aria-label` restates the state,
27760
+ which would make assistive tech announce it twice. A hidden row's button additionally carries the
27761
+ `legend-toggle-hidden` token — state lives in the part name, so `::part(legend-toggle-hidden)` is a
27762
+ reachable hook — and dims only its `aria-hidden` swatch while re-colouring the label through the
27763
+ quiet text token, so the label keeps AA contrast rather than fading with the whole button. In
27764
+ forced colors the hidden row falls back to `line-through`, which survives a system-color collapse.
27765
+ Each activation is announced through the shared light-DOM polite live region, using the same
27766
+ `legendTypeShown`/`legendTypeHidden` strings `lr-graph-legend` announces with.
27767
+ Under `legendCollapsible`, `legend-disclosure` is a native `button` whose **visible localized text
27768
+ is its accessible name** and whose `aria-expanded` renders the literal `"true"`/`"false"` — never
27769
+ omitted — with `aria-controls` naming the row list in the same shadow root (idrefs do not cross
27770
+ shadow boundaries, so this is deliberately not an idref into the light DOM). It carries the shared
27771
+ `--lr-icon-button-size` hit-area floor, and `legend-disclosure-icon` is the decorative chevron it
27772
+ rotates: the shared icon set ships one right-pointing glyph and asks callers to rotate the wrapping
27773
+ part, so collapsed points along the reading direction and expanded points down in both directions.
27774
+ The rotation runs on `--lr-transition-fast`, which the token layer already flattens under
27775
+ `prefers-reduced-motion`. A collapsed panel hides its gradient, rows, `legend-limit` summary and
27776
+ trailing `legend` slot with the plain `hidden` attribute, so they leave layout, the accessibility
27777
+ tree and the tab order together.
27778
+ When at least one entry carries a `group`, each consecutive run renders inside `legend-group`
27779
+ (`role="group"`, named by its own `legend-group-heading` through `aria-labelledby`) and **each run
27780
+ gets its own `role="list"`**, because a `list` may only own `listitem`s — a `group` sitting directly
27781
+ inside the outer list is an `aria-required-children`/`aria-required-parent` violation. The outer
27782
+ container therefore drops its own `role` in that case; a legend with no groups keeps the single
27783
+ `role="list"` it has always had. Row `aria-posinset`/`aria-setsize` stay whole-key values inside a
27784
+ section: `aria-setsize` already reports the *input* count so a bounded key stays honest, and a
27785
+ dropped row carries no attributable group, so a section adds a labelled sub-region without
27786
+ renumbering the key.
27387
27787
  `legend-limit` is the localized bounded-projection summary. The five peer-chrome parts project
27388
27788
  stable Lyra names onto MapLibre-generated DOM without erasing peer-supplied part tokens;
27389
27789
  `marker` retains a 24px minimum target in both axes even when a peer/custom marker has no intrinsic
@@ -27415,6 +27815,15 @@ integration; there is no declarative controls property.
27415
27815
  - `--lr-map-choropleth-fill-opacity` (default `0.75`) — fill opacity for the declarative
27416
27816
  `choropleth` layer and polygon fills in every `dataLayers` entry. It intentionally inherits from
27417
27817
  an ancestor, so one scoped declaration rethemes every nested map without setting each host.
27818
+ - `--lr-map-hidden-category-opacity` (default `0.15`) — opacity a category listed in
27819
+ `hiddenCategories` is muted to in the rendered MapLibre paint: `circle-opacity` and
27820
+ `circle-stroke-opacity` on the points layer and `icon-opacity` on the point-icon symbol layer.
27821
+ Like the fill-opacity token it is read from the resolved cascade on every paint, because MapLibre
27822
+ draws to a WebGL canvas the CSS cascade never reaches, and it inherits from an ancestor. A
27823
+ `kind: 'heatmap'` entry is out of scope: a density surface has no per-category field to mute.
27824
+ - `--lr-map-legend-hidden-swatch-opacity` (default `0.5`) — opacity of a hidden interactive legend
27825
+ row's decorative swatch. Only the `aria-hidden` swatch dims; the label re-colours through
27826
+ `--lr-color-text-quiet` instead, so it never drops below 4.5:1.
27418
27827
  - `--lr-map-popup-close-button-hover-bg` (default `var(--lr-color-brand-quiet)`) and
27419
27828
  `--lr-map-popup-close-button-hover-color` (default `var(--lr-color-brand)`) — hover background
27420
27829
  and foreground of `popup-close-button`.
@@ -30116,6 +30525,7 @@ These named interfaces and helper signatures are available to typed integrations
30116
30525
  readonly label: string;
30117
30526
  readonly pattern: LyraMapLegendPattern;
30118
30527
  readonly icon?: LyraMapPointIcon | Omit<LyraMapPointIcon, 'value'>;
30528
+ readonly group?: string;
30119
30529
  }`
30120
30530
  Import: `@aceshooting/lyra-ui/components/media/map/map.class.js`.
30121
30531
  `LyraMapLegendProjection {
@@ -30141,6 +30551,16 @@ These named interfaces and helper signatures are available to typed integrations
30141
30551
  readonly source: LyraMapMarkerActivationSource;
30142
30552
  }`
30143
30553
  Import: `@aceshooting/lyra-ui/components/media/map/map.class.js`.
30554
+ `LyraMapLegendToggleDetail {
30555
+ readonly value: string;
30556
+ readonly visible: boolean;
30557
+ readonly hiddenCategories: readonly string[];
30558
+ }`
30559
+ Import: `@aceshooting/lyra-ui/components/media/map/map.class.js`.
30560
+ `LyraMapLegendPanelToggleDetail {
30561
+ readonly open: boolean;
30562
+ }`
30563
+ Import: `@aceshooting/lyra-ui/components/media/map/map.class.js`.
30144
30564
  `LyraMapStyleSpecification {
30145
30565
  readonly version: 8;
30146
30566
  readonly sources: Readonly<Record<string, unknown>>;
@@ -36100,7 +36520,12 @@ never see that update happen. See `<lr-thinking-panel>`'s own reference at `llms
36100
36520
 
36101
36521
  **Slots:** none — content renders from `content`, not a slot.
36102
36522
 
36103
- **CSS parts:** `base`, `cursor` (only rendered while `streaming` is `true`)
36523
+ **CSS parts:** `base`, `cursor` (only rendered while `streaming` is `true`), plus every part
36524
+ `<lr-markdown>` documents forwarded verbatim (no aliasing — none collides with `base`/`cursor`)
36525
+ from the composed `<lr-markdown>` in Markdown mode via `exportparts`: `content`, `heading`,
36526
+ `paragraph`, `list`, `code-block`, `inline-code`, `link`, `table`, `blockquote`, `img`, `math`. A
36527
+ host-level `lr-streaming-text::part(link)`/`::part(img)` rule reaches the rendered `<a>`/`<img>`
36528
+ exactly as the same rule does applied directly to `<lr-markdown>`.
36104
36529
 
36105
36530
  **Themeable custom properties:** `--lr-inline-cursor-width` (default
36106
36531
  `var(--lr-size-0-125rem)`, the shared inline cursor width), `--lr-inline-cursor-height` (default
@@ -36214,8 +36639,12 @@ share one implementation.
36214
36639
 
36215
36640
  **Slots:** none — content renders from `content`, not a slot.
36216
36641
 
36217
- **CSS parts:** `base`, `cursor` (only rendered while `streaming` is `true`) identical to
36218
- `<lr-streaming-text>`'s own.
36642
+ **CSS parts:** `base`, `cursor` (only rendered while `streaming` is `true`), plus every part
36643
+ `<lr-markdown-core>` documents forwarded verbatim from the composed `<lr-markdown-core>` in
36644
+ Markdown mode via `exportparts`: `content`, `heading`, `paragraph`, `list`, `code-block`,
36645
+ `inline-code`, `link`, `table`, `blockquote`, `img`, `math` — the identical forwarded list
36646
+ `<lr-streaming-text>` documents, since `<lr-markdown>` and `<lr-markdown-core>` share the same
36647
+ documented part vocabulary.
36219
36648
 
36220
36649
  **Themeable custom properties:** `--lr-inline-cursor-width` (default `var(--lr-size-0-125rem)`) and
36221
36650
  `--lr-inline-cursor-height` (default `var(--lr-size-1em)`) — the same shared inline-cursor tokens