@aceshooting/lyra-ui 0.1.1 → 0.1.2

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 (117) hide show
  1. package/README.md +77 -6
  2. package/custom-elements.json +12015 -0
  3. package/dist/components/chart/bar-chart.d.ts +14 -0
  4. package/dist/components/chart/bar-chart.js +14 -0
  5. package/dist/components/chart/box-plot.d.ts +46 -0
  6. package/dist/components/chart/box-plot.js +145 -0
  7. package/dist/components/chart/box-plot.styles.d.ts +1 -0
  8. package/dist/components/chart/box-plot.styles.js +22 -0
  9. package/dist/components/chart/bubble-chart.d.ts +19 -0
  10. package/dist/components/chart/bubble-chart.js +18 -0
  11. package/dist/components/chart/chart-loader.d.ts +8 -0
  12. package/dist/components/chart/chart-loader.js +26 -0
  13. package/dist/components/chart/chart.d.ts +91 -0
  14. package/dist/components/chart/chart.js +275 -0
  15. package/dist/components/chart/chart.styles.d.ts +1 -0
  16. package/dist/components/chart/chart.styles.js +33 -0
  17. package/dist/components/chart/doughnut-chart.d.ts +15 -0
  18. package/dist/components/chart/doughnut-chart.js +14 -0
  19. package/dist/components/chart/histogram-bin.d.ts +6 -0
  20. package/dist/components/chart/histogram-bin.js +19 -0
  21. package/dist/components/chart/histogram.d.ts +20 -0
  22. package/dist/components/chart/histogram.js +70 -0
  23. package/dist/components/chart/histogram.styles.d.ts +1 -0
  24. package/dist/components/chart/histogram.styles.js +1 -0
  25. package/dist/components/chart/line-chart.d.ts +14 -0
  26. package/dist/components/chart/line-chart.js +14 -0
  27. package/dist/components/chart/pie-chart.d.ts +16 -0
  28. package/dist/components/chart/pie-chart.js +15 -0
  29. package/dist/components/chart/polar-area-chart.d.ts +15 -0
  30. package/dist/components/chart/polar-area-chart.js +14 -0
  31. package/dist/components/chart/radar-chart.d.ts +15 -0
  32. package/dist/components/chart/radar-chart.js +14 -0
  33. package/dist/components/chart/scatter-chart.d.ts +15 -0
  34. package/dist/components/chart/scatter-chart.js +15 -0
  35. package/dist/components/combobox/combobox.d.ts +16 -0
  36. package/dist/components/combobox/combobox.js +113 -6
  37. package/dist/components/combobox/combobox.styles.js +68 -7
  38. package/dist/components/combobox/option.d.ts +2 -2
  39. package/dist/components/combobox/option.js +11 -7
  40. package/dist/components/date-picker/date-input.d.ts +12 -1
  41. package/dist/components/date-picker/date-input.js +68 -6
  42. package/dist/components/date-picker/date-input.styles.js +58 -6
  43. package/dist/components/date-picker/date-picker.js +5 -2
  44. package/dist/components/date-picker/date-picker.styles.js +9 -2
  45. package/dist/components/empty/empty.d.ts +1 -0
  46. package/dist/components/empty/empty.js +14 -2
  47. package/dist/components/export-button/csv.d.ts +10 -0
  48. package/dist/components/export-button/csv.js +28 -0
  49. package/dist/components/export-button/export-button.d.ts +41 -0
  50. package/dist/components/export-button/export-button.js +151 -0
  51. package/dist/components/export-button/export-button.styles.d.ts +1 -0
  52. package/dist/components/export-button/export-button.styles.js +79 -0
  53. package/dist/components/file-input/accept.d.ts +14 -0
  54. package/dist/components/file-input/accept.js +30 -0
  55. package/dist/components/file-input/file-input.d.ts +47 -0
  56. package/dist/components/file-input/file-input.js +182 -0
  57. package/dist/components/file-input/file-input.styles.d.ts +1 -0
  58. package/dist/components/file-input/file-input.styles.js +44 -0
  59. package/dist/components/flag/flag.d.ts +4 -0
  60. package/dist/components/flag/flag.js +24 -1
  61. package/dist/components/flag/flag.styles.js +1 -1
  62. package/dist/components/gauge/gauge.d.ts +29 -0
  63. package/dist/components/gauge/gauge.js +133 -0
  64. package/dist/components/gauge/gauge.styles.d.ts +1 -0
  65. package/dist/components/gauge/gauge.styles.js +60 -0
  66. package/dist/components/graph/graph.d.ts +68 -0
  67. package/dist/components/graph/graph.js +276 -0
  68. package/dist/components/graph/graph.styles.d.ts +1 -0
  69. package/dist/components/graph/graph.styles.js +34 -0
  70. package/dist/components/heatmap/heatmap-scale.d.ts +8 -0
  71. package/dist/components/heatmap/heatmap-scale.js +17 -0
  72. package/dist/components/heatmap/heatmap.d.ts +66 -0
  73. package/dist/components/heatmap/heatmap.js +252 -0
  74. package/dist/components/heatmap/heatmap.styles.d.ts +1 -0
  75. package/dist/components/heatmap/heatmap.styles.js +39 -0
  76. package/dist/components/map/map-loader.d.ts +8 -0
  77. package/dist/components/map/map-loader.js +17 -0
  78. package/dist/components/map/map.d.ts +49 -0
  79. package/dist/components/map/map.js +189 -0
  80. package/dist/components/map/map.styles.d.ts +1 -0
  81. package/dist/components/map/map.styles.js +48 -0
  82. package/dist/components/playback/playback.d.ts +51 -0
  83. package/dist/components/playback/playback.js +146 -0
  84. package/dist/components/playback/playback.styles.d.ts +1 -0
  85. package/dist/components/playback/playback.styles.js +40 -0
  86. package/dist/components/split/split.d.ts +41 -0
  87. package/dist/components/split/split.js +171 -0
  88. package/dist/components/split/split.styles.d.ts +1 -0
  89. package/dist/components/split/split.styles.js +51 -0
  90. package/dist/components/stat/stat.d.ts +9 -0
  91. package/dist/components/stat/stat.js +52 -7
  92. package/dist/components/stat/stat.styles.js +15 -5
  93. package/dist/components/table/table.d.ts +18 -6
  94. package/dist/components/table/table.js +76 -24
  95. package/dist/components/table/table.styles.js +32 -0
  96. package/dist/components/time-range/time-range.d.ts +40 -0
  97. package/dist/components/time-range/time-range.js +219 -0
  98. package/dist/components/time-range/time-range.styles.d.ts +1 -0
  99. package/dist/components/time-range/time-range.styles.js +78 -0
  100. package/dist/components/toast/toast-item.js +10 -2
  101. package/dist/components/toast/toast-item.styles.js +13 -4
  102. package/dist/components/toast/toast.styles.js +0 -1
  103. package/dist/components/tree/tree-node.d.ts +50 -0
  104. package/dist/components/tree/tree-node.js +196 -0
  105. package/dist/components/tree/tree.d.ts +65 -0
  106. package/dist/components/tree/tree.js +227 -0
  107. package/dist/components/tree/tree.styles.d.ts +1 -0
  108. package/dist/components/tree/tree.styles.js +13 -0
  109. package/dist/internal/form-associated.js +49 -1
  110. package/dist/internal/icons.d.ts +11 -0
  111. package/dist/internal/icons.js +64 -0
  112. package/dist/internal/tokens.styles.js +41 -1
  113. package/dist/lyra.d.ts +67 -0
  114. package/dist/lyra.js +55 -0
  115. package/llms-full.txt +1425 -0
  116. package/llms.txt +47 -0
  117. package/package.json +53 -4
package/llms-full.txt ADDED
@@ -0,0 +1,1425 @@
1
+ # @aceshooting/lyra-ui — LLM API Reference
2
+
3
+ `@aceshooting/lyra-ui` is a library of free, clean-room [Lit](https://lit.dev) web components — a
4
+ companion to [Web Awesome](https://webawesome.com) that reimplements several Web Awesome **Pro**
5
+ components as open-source equivalents (same tag shape, attributes, slots, events, and CSS parts,
6
+ under a `lyra-` prefix instead of `wa-`), plus a number of extras that have no Web Awesome
7
+ counterpart at all (dashboard atoms, temporal/graph widgets, chart wrappers, a map component, a
8
+ file dropzone). It is **token-compatible with Web Awesome**: every component's stylesheet resolves
9
+ through Web Awesome's `--wa-*` design tokens first, falling back to its own `--lyra-*` tokens, so
10
+ components read as native inside a Web Awesome-themed app and are equally usable standalone with no
11
+ theme at all. This document is the complete, component-by-component API reference intended for
12
+ coding assistants integrating the library; for install instructions, the full tag/tier table, and
13
+ the `<wa-*>` → `<lyra-*>` migration notes, see the package [README](./README.md) — or the shorter
14
+ curated index at [`llms.txt`](./llms.txt) if you just need the shape of the library before diving in
15
+ here.
16
+
17
+ ## Contents
18
+
19
+ - **Shared foundation** — `internal/` (base class, form-association mixin, positioning helper, design tokens)
20
+ - **v1 core** — `lyra-combobox`/`lyra-option`, `lyra-date-picker`/`lyra-date-input`, `lyra-toast` family, `lyra-sparkline`, `lyra-flag`
21
+ - **Dashboard atoms** — `lyra-empty`, `lyra-skeleton`, `lyra-stat`, `lyra-table`, `lyra-gauge`, `lyra-export-button`, `lyra-split`
22
+ - **Temporal & graph** — `lyra-time-range`, `lyra-playback`, `lyra-heatmap`, `lyra-graph`, `lyra-tree`/`lyra-tree-node`
23
+ - **Chart family** — `lyra-chart` (core), typed subclasses, `lyra-histogram`, `lyra-box-plot`
24
+ - **Map & file-input** — `lyra-map`, `lyra-file-input`
25
+ - **Cross-cutting notes** — packaging and adoption caveats that apply across the whole library
26
+
27
+ ---
28
+
29
+ ## Shared foundation: `internal/`
30
+
31
+ Not a custom element itself — infrastructure every `lyra-*` component in the library composes.
32
+
33
+ - **`LyraElement`** (`internal/lyra-element.ts`) — base class every `lyra-*` component `extends`.
34
+ `static styles = [tokens]` (the design-token layer below); subclasses prepend `LyraElement.styles`
35
+ to their own `static styles` array to inherit it. Supplies
36
+ `protected emit<T>(name: string, detail?: T): CustomEvent<T>` — dispatches a
37
+ `{ bubbles: true, composed: true, cancelable: true }` `CustomEvent` and returns it, so callers can
38
+ do `const ev = this.emit('lyra-export', {...}); if (ev.defaultPrevented) return;`.
39
+ - **`FormAssociated(Base)`** mixin (`internal/form-associated.ts`) — makes a `LitElement`
40
+ form-associated: `static formAssociated = true`, `attachInternals()` in the constructor. Adds
41
+ properties `name: string`, `value: string` (hand-written accessor via Lit's `noAccessor` so
42
+ `internals.setFormValue()` fires synchronously on assignment, not on the async update cycle),
43
+ `disabled: boolean` (reflected), `required: boolean` (reflected). Provides
44
+ `checkValidity()`/`reportValidity()` (delegate to `internals`), `formResetCallback()` (sets
45
+ `value = ''`), `formDisabledCallback(disabled)`. Only `lyra-date-input` uses this mixin;
46
+ `lyra-combobox` hand-rolls an equivalent independently (see its gotchas).
47
+ - **`positioner.ts` → `place(anchor, popup, opts?): () => void`** — thin wrapper over
48
+ `@floating-ui/dom`'s `computePosition` + `autoUpdate`. Forces `strategy: 'fixed'` (to match the
49
+ popup's own `position:fixed` CSS — otherwise it lands offset by the page scroll position),
50
+ middleware `offset(opts.offset ?? 4)`, `flip()`, `shift({ padding: 8 })`, default
51
+ `placement: 'bottom-start'`. Returns a cleanup function that stops the `autoUpdate` loop — call it
52
+ in `disconnectedCallback()`. Used by `lyra-combobox`, `lyra-date-input`, `lyra-export-button`.
53
+ - **`tokens.styles.ts` → `tokens`** (a `css` template prepended to every component's stylesheet) —
54
+ the whole design-token layer. Every value chains through a `--wa-*` Web Awesome token first, with
55
+ a hard-coded fallback, e.g. `--lyra-color-brand: var(--wa-color-brand-fill-loud, #0969da)`. Full
56
+ set: `--lyra-color-surface / -text / -text-quiet / -border / -brand / -brand-quiet / -success /
57
+ -warning / -danger`, `--lyra-space-xs / -s / -m / -l`, `--lyra-radius`, `--lyra-shadow`,
58
+ `--lyra-font`. Also sets `:host([hidden]) { display: none !important; }` and an inherited
59
+ `box-sizing: border-box` reset.
60
+ - **`prefix.ts`** — `LYRA_PREFIX = 'lyra'`; `tag(name)` → `` `lyra-${name}` ``; `defineElement(name,
61
+ ctor)` — idempotent `customElements.define`, safe if a module is evaluated twice.
62
+ - **`a11y.ts`** — `nextId(scope)`: monotonic id generator (e.g. `nextId('combobox-list')` →
63
+ `"lyra-combobox-list-3"`); `srOnly`: a visually-hidden-but-AT-visible CSS class (used by
64
+ `lyra-skeleton`'s "Loading…" text).
65
+
66
+ **Known gotchas:**
67
+ - **High** — `required` is currently inert for real validation. `FormAssociated` never calls
68
+ `internals.setValidity(...)`; `ElementInternals` defaults to "valid" until that's called.
69
+ `checkValidity()`/`reportValidity()` always return `true`. Concretely:
70
+ `<form><lyra-date-input name="d" required></lyra-date-input></form>`; `form.reportValidity()`
71
+ returns `true` and the form submits with the field empty — no native bubble, no
72
+ `:invalid`/`:user-invalid` match, no `aria-invalid`. This affects both `lyra-date-input` (via the
73
+ mixin) and `lyra-combobox` (via its own hand-rolled copy). **Do not rely on `required` for
74
+ constraint validation yet** — validate in application code until this is fixed.
75
+ - **Medium** — `formResetCallback()` always resets to blank (`''` / `[]`), not the field's
76
+ originally-constructed value. `form.reset()` on `<lyra-date-input value="2026-07-15">` clears it
77
+ to `''` instead of restoring `"2026-07-15"` the way a native input's `defaultValue` would.
78
+ - **Medium** — only `lyra-date-input` actually uses the shared mixin; `lyra-combobox` independently
79
+ hand-rolls the same internals/validity/reset logic (needed because its value can be an array in
80
+ `multiple` mode) — the two copies can silently diverge, and the `setValidity` gap above has to be
81
+ fixed in both places.
82
+ - **Low** — no shared label/input association helper: `<label part="form-control-label">` in both
83
+ `lyra-combobox` and `lyra-date-input` is a DOM sibling of the input with no `id`/`for` pairing, so
84
+ clicking the visible label text doesn't focus the field the way a native `<label for>` would.
85
+
86
+ ---
87
+
88
+ # v1 core: forms, toasts, sparkline, flag
89
+
90
+ ## `lyra-combobox` / `lyra-option`
91
+
92
+ Filterable single/multi-select combining a text input with a listbox. Mirrors the core
93
+ `<wa-combobox>` API under the `lyra-` prefix. **Form-associated** (hand-rolled internals, not the
94
+ shared `FormAssociated` mixin — see gotchas).
95
+
96
+ ### `lyra-combobox`
97
+
98
+ **Properties:**
99
+ - `multiple: boolean = false` (reflected)
100
+ - `placeholder: string = ''`
101
+ - `disabled: boolean = false` (reflected)
102
+ - `required: boolean = false` (reflected — **inert**, see gotchas)
103
+ - `name: string = ''`
104
+ - `label: string = ''`
105
+ - `hint: string = ''`
106
+ - `open: boolean = false` (reflected)
107
+ - `withClear: boolean = false` (attribute **`with-clear`** — not `clearable`, see gotchas)
108
+ - `maxOptionsVisible: number = 3` (attribute `max-options-visible` — caps how many selected tags
109
+ show before collapsing to `+N`)
110
+ - `emptyText: string = 'No results'` (attribute `empty-text`)
111
+ - `filter: OptionFilter | null = null` (attribute: false — `(option, query) => boolean`; default
112
+ matches `label`/`searchText` case-insensitively)
113
+ - `value: string | string[]` — a getter/setter: plain `string` in single mode, `string[]` in
114
+ `multiple` mode
115
+
116
+ **Events:** `change` (native-style — selection changed), `input` (native-style — typed or selection
117
+ changed), `lyra-show`, `lyra-hide`, `lyra-clear`
118
+
119
+ **Slots:** default (`<lyra-option>` children), `label`, `hint`
120
+
121
+ **CSS parts:** `form-control`, `form-control-label`, `combobox`, `tags`, `tag`,
122
+ `tag__remove-button`, `combobox-input`, `clear-button`, `expand-icon`, `listbox`, `option`, `hint`
123
+
124
+ **Themeable custom properties:** shared tokens only.
125
+
126
+ **Optional peer deps:** none.
127
+
128
+ ### `lyra-option`
129
+
130
+ **Properties:**
131
+ - `value: string = ''`
132
+ - `disabled: boolean = false`
133
+ - `selected: boolean = false` (reflected — set by the parent combobox)
134
+ - `group: string = ''` (section header)
135
+ - `searchText: string = ''` (attribute `search-text` — extra text the filter matches beyond the
136
+ visible label)
137
+ - `label` is a **read-only getter**: explicit `label` attribute wins, else trimmed `textContent`.
138
+
139
+ ```html
140
+ <lyra-combobox id="cb" label="Country" placeholder="Search…" with-clear>
141
+ <lyra-option value="fr">France</lyra-option>
142
+ <lyra-option value="de" search-text="deutschland">Germany</lyra-option>
143
+ </lyra-combobox>
144
+ <script type="module">
145
+ document.getElementById('cb').addEventListener('change', (e) => console.log(e.target.value));
146
+ </script>
147
+ ```
148
+
149
+ Multi-select submits as **repeated `FormData` entries** (not a joined string) —
150
+ `new FormData(form).getAll(name)` behaves like a native multi-value control.
151
+
152
+ **Known gotchas:**
153
+ - **High** — `required` is declared but completely inert: never read for validity (see the shared
154
+ `internal/` gotchas above) **and** never reflected as `aria-required` onto the actual
155
+ `<input part="combobox-input">` — a screen-reader user gets zero indication the field is
156
+ mandatory even before the validity fix lands.
157
+ - **Medium** — declaratively-selected options are **ignored on initial render**:
158
+ `<lyra-option value="b" selected>` in markup never seeds the combobox's selection — only the
159
+ combobox's own state pushes down to options, never the reverse. Set `.value` programmatically
160
+ after mount instead of relying on the `selected` attribute in markup.
161
+ - **Low** — naming drift from Web Awesome parity: the shipped attribute is **`with-clear`**, not
162
+ `clearable` (which is what `<wa-combobox>` uses) — don't copy-paste WA docs assuming a 1:1
163
+ attribute name here.
164
+ - **Low** — no `size` variant (small/medium/large) unlike sibling `lyra-toast-item`.
165
+ - Full ARIA 1.2 combobox pattern (`role=combobox`, roving `aria-activedescendant`, real DOM focus
166
+ kept on the input) is implemented correctly — a genuine strength, safe to build on.
167
+
168
+ ---
169
+
170
+ ## `lyra-date-picker` / `lyra-date-input` (+ `calendar-core.ts`)
171
+
172
+ Mirrors the core `<wa-date-picker>`/`<wa-date-input>` API under `lyra-`. **Value is always ISO
173
+ 8601**: `YYYY-MM-DD` (single) or `YYYY-MM-DD/YYYY-MM-DD` (range).
174
+
175
+ ### `lyra-date-picker`
176
+
177
+ Inline month-grid calendar, not form-associated (used standalone or embedded inside
178
+ `lyra-date-input`'s popover).
179
+
180
+ **Properties:**
181
+ - `value: string = ''`
182
+ - `mode: 'single'|'range' = 'single'`
183
+ - `min: string = ''`
184
+ - `max: string = ''`
185
+ - `disabled: boolean = false` (reflected)
186
+ - `readonly: boolean = false` (reflected)
187
+ - `months: 1|2 = 1`
188
+ - `locale: string = ''`
189
+ - `firstDayOfWeek: string = 'auto'` (attribute `first-day-of-week` — see gotchas)
190
+ - `weekdayFormat: 'narrow'|'short'|'long' = 'short'` (attribute `weekday-format`)
191
+ - `disablePast: boolean = false` (attribute `disable-past`)
192
+ - `disableFuture: boolean = false` (attribute `disable-future`)
193
+ - `withOutsideDays: boolean = false` (attribute `with-outside-days`)
194
+
195
+ **Getters:** `selection: { from: Date|null; to: Date|null }`, `valueAsDate: Date | null` (single
196
+ mode only)
197
+
198
+ **Methods:** `clear()`, `goToToday()`, `goToDate(date: string | Date)`
199
+
200
+ **Events:** `input` (during interaction — for range mode, fires after the first click of a pair),
201
+ `change` (committed value)
202
+
203
+ **Slots:** none.
204
+
205
+ **CSS parts:** `base`, `month`, `header`, `title`, `previous`, `next`, `weekdays`, `weekday`,
206
+ `grid`, `week`, `day`, `day-outside`, `day-today`, `day-selected`, `day-range-start`,
207
+ `day-range-end`, `day-range-inner`, `day-placeholder`
208
+
209
+ **Themeable custom properties:** `--lyra-cell-size` (default `2.25rem`, controls day-cell/grid-column
210
+ size).
211
+
212
+ **Optional peer deps:** none.
213
+
214
+ ### `lyra-date-input`
215
+
216
+ Text field + calendar popover, **form-associated** via the shared `FormAssociated` mixin (`name`,
217
+ `value`, `disabled`, `required` all inherited).
218
+
219
+ **Properties (own):**
220
+ - `mode: 'single'|'range' = 'single'`
221
+ - `min: string = ''`
222
+ - `max: string = ''`
223
+ - `readonly: boolean = false` (reflected)
224
+ - `open: boolean = false` (reflected)
225
+ - `withClear: boolean = false` (attribute `with-clear`)
226
+ - `label: string = ''`
227
+ - `hint: string = ''`
228
+ - `placeholder: string = ''`
229
+ - `locale: string = ''`
230
+ - `months: 1|2 = 1`
231
+ - `firstDayOfWeek: string = 'auto'`
232
+ - `weekdayFormat: 'narrow'|'short'|'long' = 'short'`
233
+
234
+ **Methods:** `show()`, `hide()`, `clear()`
235
+
236
+ **Events:** `input`, `change`, `lyra-show`, `lyra-hide`, `lyra-clear`
237
+
238
+ **Slots:** `label`, `hint`
239
+
240
+ **CSS parts:** `form-control`, `form-control-label`, `input-wrapper`, `input`, `clear-button`,
241
+ `expand-button`, `expand-icon`, `popup`, `date-picker`, `hint`
242
+
243
+ **Themeable custom properties:** shared tokens only.
244
+
245
+ **Optional peer deps:** none.
246
+
247
+ ```html
248
+ <lyra-date-input id="di" label="Start date" with-clear name="start"></lyra-date-input>
249
+ <script type="module">
250
+ const di = document.getElementById('di');
251
+ di.value = '2026-07-10';
252
+ di.addEventListener('change', () => console.log(di.value)); // ISO string
253
+ </script>
254
+ ```
255
+
256
+ `calendar-core.ts` exports the pure date math both components share (**internal — not re-exported
257
+ from the package root**, but worth knowing when reasoning about behavior): `parseISO`, `formatISO`,
258
+ `isSameDay`, `addMonths`, `clampDate`, `monthMatrix(year, month, firstDayOfWeek)`,
259
+ `weekdayLabels(firstDayOfWeek, format, locale)`, `monthTitle(year, month, locale)`,
260
+ `resolveFirstDayOfWeek(value)`.
261
+
262
+ **Known gotchas:**
263
+ - **Medium** — `first-day-of-week="auto"` does **not** derive from `locale` despite the name:
264
+ `resolveFirstDayOfWeek()` hardcodes `0` (Sunday) whenever the value is `'auto'`, even though
265
+ `locale` is already used elsewhere (weekday labels, month title). A French/German-locale user gets
266
+ a Sunday-first grid instead of the Monday-first layout their OS would show. **Workaround:** pass
267
+ an explicit `first-day-of-week="mon"` (accepted values: `sun`/`mon`/`tue`/`wed`/`thu`/`fri`/`sat`)
268
+ if you need locale-correct week starts.
269
+ - **Medium** (date-input only) — the calendar popover has **no Escape-to-close**: only
270
+ `Alt+ArrowDown` opens it; there's no keydown handling to `hide()` on Escape, unlike `lyra-combobox`
271
+ in the same package. A keyboard user must Tab all the way through the grid/nav/clear buttons or
272
+ click elsewhere to dismiss it.
273
+ - **Low** (date-input only) — opening the calendar doesn't move focus into it (`show()` only flips
274
+ `open`) — a keyboard user opening via `Alt+ArrowDown` lands on a visible popover with focus still
275
+ on the text input.
276
+ - **Low** — one hardcoded, non-token color: selected/range-start/range-end day text is
277
+ `color: #fff` in `date-picker.styles.ts` — if you override
278
+ `--lyra-color-brand`/`--wa-color-brand-fill-loud` to something light, contrast on selected-day
279
+ text isn't guaranteed.
280
+ - **Low** (date-input) — shares the mixin's reset-to-blank defect: `form.reset()` always clears to
281
+ `''` rather than restoring the originally-constructed `value`.
282
+ - The grid keyboard pattern (Arrow/PageUp/PageDown/Home/End navigation with correct focus
283
+ sequencing) is implemented correctly and safe to rely on.
284
+
285
+ ---
286
+
287
+ ## `lyra-toast` / `lyra-toast-item` / `toast()`
288
+
289
+ Stacking toast/notification region. Mirrors `<wa-toast>`/`<wa-toast-item>` under `lyra-`.
290
+
291
+ ### `lyra-toast`
292
+
293
+ One per page recommended — the region.
294
+
295
+ **Properties:**
296
+ - `placement: ToastPlacement = 'top-end'` (reflected) — one of `'top-start'|'top-center'|'top-end'|
297
+ 'bottom-start'|'bottom-center'|'bottom-end'`
298
+
299
+ **Methods:** `async create(message: string, options?: ToastCreateOptions): Promise<LyraToastItem>` —
300
+ `ToastCreateOptions = { variant?, duration?, size?, withIcon? }`
301
+
302
+ **Events:** none.
303
+
304
+ **Slots:** default (`<lyra-toast-item>` children)
305
+
306
+ **CSS parts:** `stack`
307
+
308
+ **Themeable custom properties:** `--gap` (default `var(--lyra-space-s)`), `--width` (default
309
+ `28rem`) — set directly on the `<lyra-toast>` element (not `--lyra-`-prefixed, but overridable
310
+ per-instance).
311
+
312
+ **Optional peer deps:** none.
313
+
314
+ ### `lyra-toast-item`
315
+
316
+ A single notification.
317
+
318
+ **Properties:**
319
+ - `duration: number = 5000` (ms; `Infinity` or `<= 0` disables auto-dismiss)
320
+ - `size: 'xs'|'s'|'m'|'l'|'xl' = 'm'` (reflected)
321
+ - `variant: 'brand'|'success'|'warning'|'danger'|'neutral' = 'neutral'` (reflected)
322
+ - `withIcon: boolean = false` (attribute `with-icon`)
323
+
324
+ **Methods:** `async hide(): Promise<void>` — plays the hide animation, then removes itself from the
325
+ DOM.
326
+
327
+ **Events:** `lyra-show`, `lyra-after-show`, `lyra-hide`, `lyra-after-hide`
328
+
329
+ **Slots:** default (message), `icon`
330
+
331
+ **CSS parts:** `toast-item`, `accent`, `icon`, `content`, `close-button`
332
+
333
+ **Themeable custom properties:** `--accent-width` (4px), `--show-duration`/`--hide-duration` (250ms
334
+ each), `--padding` (`var(--lyra-space-m)`), `--accent-color` (defaults to `--lyra-color-border`,
335
+ auto-swapped per `variant` to `--lyra-color-brand/-success/-warning/-danger`).
336
+
337
+ **Optional peer deps:** none.
338
+
339
+ `role` is chosen automatically per `variant`: `"alert"` for `danger`/`warning`, `"status"`
340
+ otherwise. Auto-dismiss timer **pauses** on `pointerenter`/`focusin`, **resumes** on
341
+ `pointerleave`/`focusout`, with real elapsed-time bookkeeping (WCAG 2.2.1 timing-adjustable).
342
+
343
+ ### `toast()`
344
+
345
+ From the `toaster` controller — the ergonomic entry point, no manual `<lyra-toast>` mounting
346
+ needed:
347
+
348
+ ```ts
349
+ import { toast } from '@aceshooting/lyra-ui';
350
+
351
+ toast('Saved');
352
+ toast({ message: 'Deleted', variant: 'danger', action: { label: 'Undo', onClick: (item) => {/*...*/} } });
353
+ ```
354
+
355
+ `toast(input: ToastOptions | string): ToastHandle` where
356
+ `ToastOptions = ToastCreateOptions & { message: string; placement?: ToastPlacement; action?: { label: string; onClick: (item: LyraToastItem) => void } }`,
357
+ and `ToastHandle = { item: Promise<LyraToastItem>; dismiss: () => void }`. Lazily mounts (and
358
+ re-mounts if removed) a single `<lyra-toast>` region on `document.body`.
359
+
360
+ ```html
361
+ <script type="module">
362
+ import { toast } from '@aceshooting/lyra-ui';
363
+ document.getElementById('save-btn').addEventListener('click', () => toast('Saved!'));
364
+ </script>
365
+ ```
366
+
367
+ **Known gotchas:**
368
+ - **Low** — possible nested live-region double-announcement: the stack region is
369
+ `role="status" aria-live="polite"` **and** each `lyra-toast-item` independently self-assigns its
370
+ own `role` (`status`/`alert`) — nesting live regions can cause some screen readers to announce a
371
+ new toast twice. Plausible from the code, unverified against real AT.
372
+ - **Low** — pause/resume-on-hover/focus (the component's main accessibility differentiator) has no
373
+ regression test coverage — don't assume a future refactor won't silently break it; test it
374
+ yourself if it's load-bearing for your app.
375
+ - Prefer the `toast()` helper over manually creating `<lyra-toast>`/`<lyra-toast-item>` — it already
376
+ handles the singleton-region and remount-if-removed logic.
377
+
378
+ ---
379
+
380
+ ## `lyra-sparkline`
381
+
382
+ Zero-dependency inline SVG trend chart (mirrors `<wa-sparkline>`).
383
+
384
+ **Properties:**
385
+ - `values: number[] = []`
386
+ - `type: 'line' | 'bar' | 'area' = 'line'`
387
+ - `min?: number` (defaults to data minimum)
388
+ - `max?: number` (defaults to data maximum)
389
+
390
+ **Events:** none.
391
+
392
+ **Slots:** none.
393
+
394
+ **CSS parts:** `line`, `area`, `bar`
395
+
396
+ **Themeable custom properties:** `--lyra-color-brand` only — the single cleanest theming story in
397
+ the reviewed family (pure CSS cascade, no JS/`getComputedStyle` bridging needed).
398
+
399
+ **Optional peer deps:** none.
400
+
401
+ ```html
402
+ <lyra-sparkline type="area"></lyra-sparkline>
403
+ <script>
404
+ document.querySelector('lyra-sparkline').values = [3, 5, 4, 8, 6, 9, 7];
405
+ </script>
406
+ ```
407
+
408
+ **Known gotchas:**
409
+ - Auto-sets `role="img"` + a descriptive `aria-label` ("Trend of N values, last X" / "No data" when
410
+ empty) — good default, but still just a single summary sentence with no tabular fallback; fine for
411
+ a glance-value sparkline, worth reconsidering if it's ever the *sole* representation of a metric.
412
+ - `min`/`max` overrides and the `bar`/`area` render types are lightly tested — no correctness
413
+ concerns found, just be aware coverage is thin there.
414
+
415
+ ---
416
+
417
+ ## `lyra-flag`
418
+
419
+ Country/language flag image. Flag artwork ships in a **separate, optional peer package**
420
+ (`@aceshooting/lyra-flags`) — importing `lyra-ui` core never pulls in flag image weight.
421
+
422
+ **Properties:**
423
+ - `country?: string` (ISO 3166-1 alpha-2, e.g. `"fr"` — takes precedence over `language`)
424
+ - `language?: string` (BCP-47-ish tag, e.g. `"en"`/`"en-US"`, resolved to a representative country
425
+ via `languageToCountry()`)
426
+ - `label?: string` (accessible name / `alt` text — **defaults to the bare uppercase code if
427
+ omitted**, see gotchas)
428
+ - `round: boolean = false` (reflected — circular crop)
429
+
430
+ **Events:** none.
431
+
432
+ **Slots:** none.
433
+
434
+ **CSS parts:** `image`
435
+
436
+ **Themeable custom properties:** `--lyra-flag-radius` (default `2px`, non-`round` corner radius).
437
+
438
+ **Optional peer deps:** `@aceshooting/lyra-flags` — required for the component to actually render an
439
+ image; without it, renders an empty template (see gotchas).
440
+
441
+ Also exported from the package root:
442
+ `languageToCountry(language: string): string | undefined` and the `LANGUAGE_TO_COUNTRY` lookup
443
+ table (region subtag wins, e.g. `en-US` → `us`; plain `en` → `gb`; override the table per-app if you
444
+ need different defaults).
445
+
446
+ ```html
447
+ <lyra-flag country="fr" label="France"></lyra-flag>
448
+ <lyra-flag language="en-US" round></lyra-flag>
449
+ ```
450
+
451
+ ```bash
452
+ pnpm add @aceshooting/lyra-flags # required peer — without it, <lyra-flag> renders nothing
453
+ ```
454
+
455
+ **Known gotchas:**
456
+ - Requires the optional peer `@aceshooting/lyra-flags` to actually render an image; without it the
457
+ component renders an **empty template** plus a one-time `console.warn` (lazy `import()`, cached
458
+ module-wide so the warning fires only once per page even with many `<lyra-flag>` instances).
459
+ - Rendering is async even when the peer *is* installed: `src` resolves after an `import()` +
460
+ resolver call, so there's a brief empty-render window on first paint/attribute change — don't
461
+ assume the `<img>` exists synchronously right after setting `country`/`language`.
462
+ - **Low** — if `label` is omitted, the accessible name (`alt`) falls back to the **bare uppercase
463
+ ISO code** (e.g. `"FR"`, `"DE"`) — a screen reader will typically spell this out letter-by-letter
464
+ rather than announce a country name. Always pass an explicit `label` (e.g. `"France"`) if the flag
465
+ conveys real information rather than being purely decorative.
466
+
467
+ ---
468
+
469
+ # Dashboard atoms
470
+
471
+ ## `lyra-empty`
472
+
473
+ First-party "no data" state (no Web Awesome equivalent).
474
+
475
+ **Properties:**
476
+ - `heading: string = ''`
477
+ - `description: string = ''`
478
+
479
+ **Events:** none.
480
+
481
+ **Slots:** default (icon/illustration), `actions` (buttons/links below the description)
482
+
483
+ **CSS parts:** `base`, `icon`, `heading`, `description`, `actions`
484
+
485
+ **Themeable custom properties:** shared tokens only (`--lyra-space-s/-l`,
486
+ `--lyra-color-text-quiet/-border/-text`).
487
+
488
+ **Optional peer deps:** none.
489
+
490
+ ```html
491
+ <lyra-empty heading="No results" description="Try a different search.">
492
+ <svg slot="" ...></svg> <!-- default slot: any icon/illustration -->
493
+ <div slot="actions"><button>Clear filters</button></div>
494
+ </lyra-empty>
495
+ ```
496
+
497
+ **Known gotchas:**
498
+ - **Medium** — no live-region semantics: a plain role-less `<div>`, so a list/table transitioning to
499
+ empty announces nothing to screen readers (unlike sibling `lyra-skeleton`'s `role="status"`).
500
+ - **Low** — no compact/density variant; fixed `padding: var(--lyra-space-l)` and 2rem icon sizing
501
+ are tuned for full-page use, awkward embedded in a card or narrow container.
502
+ - Note: correctly works around the classic `:empty`-pseudo-class trap (a wrapper with a `<slot>`
503
+ inside can never match `:empty`) by tracking real slot assignment in JS (`hasIcon`/`hasActions`) —
504
+ `lyra-table` reuses this component for its own empty-rows state, but `lyra-stat` (below)
505
+ reintroduces the very bug this component avoids.
506
+
507
+ ---
508
+
509
+ ## `lyra-skeleton`
510
+
511
+ Loading placeholder (`text`/`circle`/`rect` shapes, `pulse`/`sheen` effects).
512
+
513
+ **Properties:**
514
+ - `variant: 'text'|'circle'|'rect' = 'text'` (reflected)
515
+ - `effect: 'pulse'|'sheen' = 'pulse'` (reflected)
516
+ - `width?: string`
517
+ - `height?: string`
518
+
519
+ **Events:** none.
520
+
521
+ **Slots:** none.
522
+
523
+ **CSS parts:** `base`
524
+
525
+ **Themeable custom properties:** `--lyra-skeleton-w`, `--lyra-skeleton-h` (set/cleared by the
526
+ `width`/`height` properties; default `100%` / `1em`).
527
+
528
+ **Optional peer deps:** none.
529
+
530
+ ```html
531
+ <lyra-skeleton variant="circle" width="3rem" height="3rem"></lyra-skeleton>
532
+ <lyra-skeleton variant="text"></lyra-skeleton>
533
+ ```
534
+
535
+ **Known gotchas:**
536
+ - **Medium** — every instance is an independent, simultaneously-firing `role="status"` live region
537
+ (via `willUpdate` setting `role="status"` plus visually-hidden "Loading…" text). A realistic
538
+ loading state (e.g. a 10×5 skeleton table = 50 instances) mounts 50 separate live regions at
539
+ nearly the same tick — expect screen readers to announce "Loading…" dozens of times, not once.
540
+ There's no grouping primitive to dedupe this; if it matters, wrap your own single `role="status"`
541
+ region around a set of skeletons and mark the individual ones `aria-hidden`.
542
+ - **Low** — no `lines`/`count` shorthand for "N lines of skeleton text" — stamp out N elements
543
+ yourself.
544
+ - Respects `prefers-reduced-motion` (both effects) — safe to leave as-is for that concern.
545
+
546
+ ---
547
+
548
+ ## `lyra-stat`
549
+
550
+ KPI/stat card — value + unit + label + optional icon/trend/caption.
551
+
552
+ **Properties:**
553
+ - `label: string = ''`
554
+ - `value: string = ''`
555
+ - `unit: string = ''`
556
+ - `variant: 'neutral'|'success'|'warning'|'danger' = 'neutral'` (reflected)
557
+ - `trend: number = NaN` (a `NaN` sentinel hides the trend pill entirely — set an actual number to
558
+ show it)
559
+ - `caption: string = ''`
560
+
561
+ **Events:** none.
562
+
563
+ **Slots:** default (leading icon), `caption` (rich caption content — **see gotchas, it does not
564
+ override the attribute**)
565
+
566
+ **CSS parts:** `base`, `icon`, `label`, `value-row`, `value`, `unit`, `trend`, `caption`
567
+
568
+ **Themeable custom properties:** shared tokens only (`--lyra-color-success/-warning/-danger` drive
569
+ the `variant`-colored value text and up/down trend pill).
570
+
571
+ **Optional peer deps:** none.
572
+
573
+ ```html
574
+ <lyra-stat label="Active users" value="1,204" trend="4.2" variant="success">
575
+ <svg slot="">...</svg>
576
+ </lyra-stat>
577
+ ```
578
+
579
+ **Known gotchas:**
580
+ - **High** — icon/caption wrappers can never CSS-collapse: the stylesheet still relies on
581
+ `[part='icon']:empty` / `[part='caption']:empty { display: none }`, but both wrappers always
582
+ contain a literal `<slot>` child, so `:empty` never matches regardless of whether content is
583
+ provided. `<lyra-stat label="x" value="1">` with **no** icon/caption still lays out both empty
584
+ wrappers (visible gap/padding). This is the exact bug `lyra-empty` (above) correctly works around
585
+ via JS-tracked slot state — that fix has not been ported here.
586
+ - **Medium** — the `caption` **slot does not override** the `caption` **attribute** despite the
587
+ intent implied by the docs: `render()` emits `${this.caption}<slot name="caption"></slot>`
588
+ unconditionally, so setting both concatenates rather than the slot replacing the attribute text.
589
+ Pick one or the other, don't set both.
590
+ - **Medium** — `trend` polarity is hardcoded "higher is better": `trend > 0` always renders an
591
+ up-arrow in the success color, `trend < 0` always down/danger, with no invert flag. For metrics
592
+ where a *decrease* is good (cost, latency, error rate, churn), pre-negate the number yourself —
593
+ but that also flips the arrow direction misleadingly, so there's no fully correct workaround
594
+ today.
595
+ - **Low** — the trend glyph (▲/▼/–) has no `aria-hidden`/accessible label, and there's no
596
+ `aria-live` for in-place `value`/`trend` updates.
597
+
598
+ ---
599
+
600
+ ## `lyra-table`
601
+
602
+ Presentational, sort/select-aware data table. **The host owns actual sorting/filtering/pagination**
603
+ of `rows` — this component only renders and emits intents.
604
+
605
+ **Properties:**
606
+ - `columns: TableColumn<T>[] = []` (attribute: false) — `{ key, label, sortable?, align?:
607
+ 'start'|'end', cell: (row) => unknown }`
608
+ - `rows: T[] = []` (attribute: false)
609
+ - `sortKey: string = ''` (attribute `sort-key`)
610
+ - `sortDir: 'asc'|'desc' = 'asc'` (attribute `sort-dir`)
611
+ - `rowKey?: (row: T) => string | number` (attribute: false)
612
+ - `selectedKey: string | number | null = null` (attribute: false) — **single**-selection only
613
+ - `hasMore: boolean = false` (attribute `has-more`, reflected)
614
+ - `moreLabel: string = 'Load more'` (attribute `more-label`)
615
+ - `emptyHeading: string = 'No data'` (attribute `empty-heading`)
616
+ - `emptyDescription: string = ''` (attribute `empty-description`)
617
+
618
+ **Events:** `lyra-sort` (`detail: { key }`, fired on sortable-header activation), `lyra-row-click`
619
+ (`detail: { row }`), `lyra-load-more` (fired on the "load more" button)
620
+
621
+ **Slots:** none — content comes entirely from `columns`/`rows`.
622
+
623
+ **CSS parts:** `base`, `table`, `head`, `header-cell`, `row`, `cell`, `more-button`
624
+
625
+ **Themeable custom properties:** `--lyra-table-max-height` (default `none`; controls the scrollable
626
+ body's `max-block-size`).
627
+
628
+ **Optional peer deps:** none.
629
+
630
+ ```html
631
+ <lyra-table id="t" sort-key="name" sort-dir="asc"></lyra-table>
632
+ <script type="module">
633
+ const t = document.getElementById('t');
634
+ t.columns = [
635
+ { key: 'name', label: 'Name', sortable: true, cell: (r) => r.name },
636
+ { key: 'value', label: 'Value', align: 'end', cell: (r) => r.value },
637
+ ];
638
+ t.rows = [{ name: 'Alpha', value: 1 }, { name: 'Beta', value: 2 }];
639
+ t.rowKey = (r) => r.name;
640
+ t.addEventListener('lyra-sort', (e) => console.log('sort by', e.detail.key));
641
+ t.addEventListener('lyra-row-click', (e) => console.log('clicked', e.detail.row));
642
+ </script>
643
+ ```
644
+
645
+ **Known gotchas:**
646
+ - **Medium** — with `columns.length === 0` and non-empty `rows`, every `<tr role="row">` renders
647
+ with **zero** `<th>`/`<td>` children — an ARIA-invalid row that's still `tabindex="0"` and
648
+ clickable despite no visible/accessible content. Only the zero-**rows** case is handled (falls
649
+ back to `<lyra-empty>`); always pass at least one column.
650
+ - **Medium** — a fresh `@click`/`@keydown` closure is allocated for every sortable header and every
651
+ row on **every render** — real, avoidable GC churn on wide/tall tables that re-render on each
652
+ sort.
653
+ - **Medium** — only single-row selection is modeled (`selectedKey: string | number | null`);
654
+ there's no bulk-select/checkbox-column API — you must hand-roll a checkbox column entirely inside
655
+ a `cell()` callback if you need multi-select.
656
+ - **Low** — no `caption`/`aria-label` property, so the rendered `role="grid"` has no accessible name
657
+ distinguishing it from any other grid on the page.
658
+ - Keyboard equivalents (Enter/Space) for both header-sort and row-activate are implemented and
659
+ solid — a genuine strength versus most siblings in this family.
660
+
661
+ ---
662
+
663
+ ## `lyra-gauge`
664
+
665
+ Dependency-free SVG radial or linear meter (270°-sweep arc math, no charting library).
666
+
667
+ **Properties:**
668
+ - `value: number = 0`
669
+ - `min: number = 0`
670
+ - `max: number = 100`
671
+ - `type: 'radial'|'linear' = 'radial'` (reflected)
672
+ - `label: string = ''`
673
+ - `valueLabel?: string` (attribute: false — overrides only the *visible* text, see gotchas)
674
+
675
+ **Events:** none.
676
+
677
+ **Slots:** none.
678
+
679
+ **CSS parts:** `base` (the `<svg>`), `track`, `fill`, `value`, `label`
680
+
681
+ **Themeable custom properties:** `--lyra-color-brand` (fill color, shared token — no per-instance
682
+ gauge-specific custom property exists).
683
+
684
+ **Optional peer deps:** none.
685
+
686
+ ```html
687
+ <lyra-gauge value="72" min="0" max="100" label="CPU"></lyra-gauge>
688
+ <lyra-gauge type="linear" value="0.4" max="1"></lyra-gauge>
689
+ <script type="module">
690
+ document.querySelector('lyra-gauge').valueLabel = '72°F'; // changes displayed text only, see gotchas
691
+ </script>
692
+ ```
693
+
694
+ **Known gotchas:**
695
+ - **Medium** — no `aria-valuetext`: setting `valueLabel` (e.g. `"72°F"`) changes the visible SVG
696
+ text but never reaches the accessibility tree — `aria-valuenow` always carries the bare numeric
697
+ `value`. A screen reader announces the raw number, not your formatted string.
698
+ - **Medium** — the SVG `<text>` elements aren't `aria-hidden`, so they're exposed inside the same
699
+ `role="meter"` host that already carries `aria-valuenow`/`aria-label` — risks double/mismatched
700
+ announcements when `valueLabel` differs from the raw `value`.
701
+ - **Low** — no color-threshold/variant support: a single fixed `--lyra-color-brand` fill regardless
702
+ of value (no amber/red-near-capacity idiom built in).
703
+ - **Low** — no documented component-specific sizing custom property; host size is fixed em values
704
+ (`8em`/`12em` radial, `12em`/`1.5em` linear) — resize via plain CSS `width`/`height` on the
705
+ element instead.
706
+ - Divide-by-zero guarded (`max - min || 1`), and radial/linear share one component via the `type`
707
+ attribute.
708
+
709
+ ---
710
+
711
+ ## `lyra-export-button`
712
+
713
+ CSV/JSON download button — either single-format (click exports immediately) or multi-format (click
714
+ opens a small menu).
715
+
716
+ **Properties:**
717
+ - `rows: Record<string, unknown>[] = []` (attribute: false)
718
+ - `columns: CsvColumn[] = []` (attribute: false) — `{ key, label }`
719
+ - `filename: string = 'export'`
720
+ - `formats: ExportFormat[] = ['csv']` (attribute: false; `ExportFormat = 'csv' | 'json'` **only** —
721
+ no XLSX despite the family sometimes being described as covering it)
722
+ - `disabled: boolean = false` (reflected)
723
+ - `label: string = 'Export'`
724
+ - `open: boolean = false` (reflected)
725
+
726
+ **Events:** `lyra-export` (`detail: { format }`, **cancelable** — call `preventDefault()` to
727
+ substitute your own server-generated download instead of the built-in client-side one),
728
+ `lyra-export-complete` (`detail: { format }`, fires after a non-cancelled download completes)
729
+
730
+ **Slots:** none.
731
+
732
+ **CSS parts:** `trigger`, `menu`, `menu-item`
733
+
734
+ **Themeable custom properties:** shared tokens only.
735
+
736
+ **Optional peer deps:** none.
737
+
738
+ ```html
739
+ <lyra-export-button id="exp" filename="report" label="Export"></lyra-export-button>
740
+ <script type="module">
741
+ const exp = document.getElementById('exp');
742
+ exp.rows = [{ name: 'Alpha', value: 1 }];
743
+ exp.columns = [{ key: 'name', label: 'Name' }, { key: 'value', label: 'Value' }];
744
+ exp.formats = ['csv', 'json']; // shows a format-choice menu instead of exporting immediately
745
+ exp.addEventListener('lyra-export', (e) => console.log('exporting', e.detail.format));
746
+ </script>
747
+ ```
748
+
749
+ Package-level CSV utilities (used internally, also exported for standalone use — `import {
750
+ escapeCsvField, buildCsv, downloadBlob } from '@aceshooting/lyra-ui'`):
751
+ ```ts
752
+ escapeCsvField(value: unknown): string // quotes/escapes; neutralizes formula-injection (=,+,-,@,tab,CR) with a leading apostrophe
753
+ buildCsv(rows: Record<string, unknown>[], columns: CsvColumn[]): string // CRLF-joined, header row included
754
+ downloadBlob(content: string, filename: string, mime: string): void // triggers a browser download
755
+ ```
756
+
757
+ **Known gotchas:**
758
+ - **High** — the format-choice menu breaks this library's own disclosure-menu conventions: unlike
759
+ `lyra-combobox`/`lyra-date-input` in the same package, it has **no outside-pointerdown dismissal,
760
+ no Escape-to-close**, and `[part="trigger"]` never gets `aria-haspopup`/`aria-expanded`. Clicking
761
+ elsewhere on the page does not close an open format menu — handle this yourself (e.g. close on
762
+ your own outside-click listener) until fixed upstream.
763
+ - **Low** — XLSX isn't implemented (`ExportFormat` really is `'csv' | 'json'`) — don't pass
764
+ `'xlsx'` expecting it to work.
765
+ - CSV formula-injection guarding and the deferred (5s) `URL.revokeObjectURL` (works around Safari
766
+ cancelling in-flight downloads on immediate revoke) are genuine, safe-to-rely-on strengths.
767
+
768
+ ---
769
+
770
+ ## `lyra-split`
771
+
772
+ Resizable panels for dashboard layouts. Direct **light-DOM children are the panels**; a divider is
773
+ auto-inserted between each adjacent pair.
774
+
775
+ **Properties:**
776
+ - `sizes: number[] = []` (attribute: false — percentages per panel, auto-computed equally if
777
+ omitted/mismatched)
778
+ - `min: number = 10` (min % per panel)
779
+ - `orientation: 'horizontal'|'vertical' = 'horizontal'` (reflected)
780
+ - `storageKey?: string` (attribute `storage-key` — persists sizes to `localStorage` under
781
+ `` `lyra-split:${key}:${panelCount}` ``, scoped by panel count so a stale layout for a different
782
+ panel count is ignored)
783
+
784
+ **Events:** `lyra-resize` (`detail: { sizes }`, fired on every drag step/release **and** every
785
+ keyboard step)
786
+
787
+ **Slots:** default (each direct child element is one panel).
788
+
789
+ **CSS parts:** `base`, `divider`
790
+
791
+ **Themeable custom properties:** shared tokens only.
792
+
793
+ **Optional peer deps:** none.
794
+
795
+ ```html
796
+ <lyra-split storage-key="dashboard-main" min="15">
797
+ <div>Panel A</div>
798
+ <div>Panel B</div>
799
+ <div>Panel C</div>
800
+ </lyra-split>
801
+ ```
802
+
803
+ Keyboard: focus a divider (`Tab`), then `ArrowRight`/`ArrowLeft` (horizontal) or
804
+ `ArrowDown`/`ArrowUp` (vertical) to resize by a fixed 2% step.
805
+
806
+ **Known gotchas:**
807
+ - **Medium** — `panelCount` is computed **once**, in `connectedCallback()`, from
808
+ `this.children.length`. Adding/removing a panel after mount (e.g. conditionally showing a side
809
+ panel) leaves `panelCount`/`sizes`/divider count silently stale — there's no
810
+ `slotchange`/`MutationObserver` to react to it. If your panel set can change, re-create the
811
+ `<lyra-split>` rather than mutating its children live.
812
+ - **Medium** — divider `aria-valuemax` (reported as `100 - min`) is only correct for exactly **two**
813
+ panels. With 3+ panels the real achievable max for divider `i` is `sizes[i] + sizes[i+1] - min`
814
+ (resizing only trades size between the adjacent pair) — e.g. three equal 33.3% panels with
815
+ `min=10` all report `aria-valuemax="90"` though the true achievable value is ~56.6. Don't trust
816
+ the reported max for 3+-panel layouts.
817
+ - **Low** — no feasibility check on `min` vs. panel count (e.g. 3 panels with `min=40` is
818
+ unsatisfiable) — result is a silently frozen splitter with no warning.
819
+ - **Low** — `localStorage.getItem`/`setItem` calls themselves are unguarded (only the `JSON.parse`
820
+ result is try/caught) — a blocked or quota-exceeded store can throw synchronously from inside a
821
+ `pointerup`/`keydown` handler.
822
+ - Pointer-drag lifecycle (pointer capture, window-listener cleanup on both drag-end and
823
+ `disconnectedCallback`) is solid and safe to rely on.
824
+
825
+ ---
826
+
827
+ # Temporal & graph
828
+
829
+ ## `lyra-time-range`
830
+
831
+ A two-handle brush/scrubber over a numeric domain (no date logic — callers map their own time axis
832
+ onto `[min, max]`).
833
+
834
+ **Properties:**
835
+ - `min: number = 0`
836
+ - `max: number = 100`
837
+ - `start: number = 0`
838
+ - `end: number = 100`
839
+ - `step: number = 1`
840
+ - `disabled: boolean = false` (reflected)
841
+
842
+ **Events:** `lyra-input` (fired continuously while dragging or on arrow-key press, `detail: {
843
+ start, end }`), `lyra-change` (fired on pointer release / arrow-key-up — commit, `detail: { start,
844
+ end }`)
845
+
846
+ **Slots:** none.
847
+
848
+ **CSS parts:** `base`, `track`, `range`, `handle-start`, `handle-end`
849
+
850
+ **Themeable custom properties:** shared tokens only — `--lyra-color-border`, `--lyra-color-brand`,
851
+ `--lyra-color-surface`, `--lyra-shadow`. No component-local tokens.
852
+
853
+ **Optional peer deps:** none.
854
+
855
+ ```html
856
+ <lyra-time-range min="0" max="1440" start="480" end="1020" step="15"></lyra-time-range>
857
+ <script>
858
+ document.querySelector('lyra-time-range')
859
+ .addEventListener('lyra-change', (e) => console.log(e.detail.start, e.detail.end));
860
+ </script>
861
+ ```
862
+
863
+ **Known gotchas:**
864
+ - Keyboard support is ArrowUp/Right and ArrowDown/Left only (moves by `step`) — no
865
+ Home/End/PageUp/PageDown, unlike native `<input type=range>` or the WAI-ARIA slider pattern it
866
+ otherwise follows.
867
+ - A disabled handle loses `tabindex` but never gets `aria-disabled` — a screen-reader user
868
+ exploring by virtual cursor still hears it announced as a live, adjustable slider.
869
+ - No `aria-valuetext`: only raw numeric `aria-valuenow` is exposed, no hook for a human-readable
870
+ (e.g. formatted date/time) equivalent of the mapped domain.
871
+ - Handles a `min > max` domain and disabled-mid-drag/disconnect-mid-drag correctly (tested) — safe
872
+ to rely on those edge cases.
873
+
874
+ ---
875
+
876
+ ## `lyra-playback`
877
+
878
+ Steps an index through `[0, length)` on a fixed interval — play/pause for time-series scrubbing.
879
+
880
+ **Properties:**
881
+ - `length: number = 0`
882
+ - `index: number = 0`
883
+ - `intervalMs: number = 900` (attribute `interval-ms`)
884
+ - `playing: boolean = false` (reflected)
885
+ - `loop: boolean = true`
886
+ - `hidden: boolean = false` (reflected; re-declared over the native IDL property so Lit's
887
+ change-tracking sees it and auto-pauses on `hidden = true`)
888
+
889
+ **Methods:** `play()`, `pause()`, `toggle()`, `next()`, `previous()`, `goTo(index: number)` — all
890
+ idempotent/clamped; `length <= 1` is a no-op degenerate case.
891
+
892
+ **Events:** `lyra-play`, `lyra-pause` (no detail), `lyra-step` (`detail: { index }`, fired on every
893
+ tick and manual step)
894
+
895
+ **Slots:** none.
896
+
897
+ **CSS parts:** `base`, `play-button`, `slider`
898
+
899
+ **Themeable custom properties:** shared tokens only — `--lyra-space-s`, `--lyra-color-border`,
900
+ `--lyra-color-surface`, `--lyra-color-text`, `--lyra-color-brand`.
901
+
902
+ **Optional peer deps:** none.
903
+
904
+ ```html
905
+ <lyra-playback length="24" interval-ms="500"></lyra-playback>
906
+ <script>
907
+ const pb = document.querySelector('lyra-playback');
908
+ pb.addEventListener('lyra-step', (e) => renderFrame(e.detail.index));
909
+ </script>
910
+ ```
911
+
912
+ **Known gotchas:**
913
+ - `index` is never re-clamped when `length` shrinks below it — e.g. setting `el.length = 2` while
914
+ `el.index = 7` leaves `index` out of range until the next `tick()`/`goTo()`/`next()`/`previous()`
915
+ call happens to correct it.
916
+ - No `aria-valuetext`/visible "N of M" position label on the range input.
917
+ - Calling `play()`/`pause()` programmatically (not via the button) gives no `aria-live`
918
+ announcement of the Play/Pause state change.
919
+
920
+ ---
921
+
922
+ ## `lyra-heatmap`
923
+
924
+ A Canvas-rendered **matrix** heatmap (rows × cols grid) with a DPR-aware, resize-aware redraw loop.
925
+ **Matrix layout only — despite the family sometimes being described as covering "matrix + calendar
926
+ heatmap," no calendar/day-grid layout exists in the shipped source.** Do not assume a
927
+ calendar-heatmap mode is available; it would need to be built as a new variant.
928
+
929
+ **Properties:**
930
+ - `rowLabels: string[] = []` (attribute: false)
931
+ - `colLabels: string[] = []` (attribute: false)
932
+ - `values: number[][] = []` (attribute: false) — `-1` is the "no data" sentinel; ragged/sparse rows
933
+ are safe (`?? -1`)
934
+ - `cellSize: number = 22` (attribute `cell-size`)
935
+ - `valueLabel: string = 'value'` (attribute `value-label`)
936
+ - `scale: 'linear' | 'sqrt' = 'linear'`
937
+
938
+ **Events:** none dispatched.
939
+
940
+ **Slots:** none.
941
+
942
+ **CSS parts:** `base`, `canvas`, `legend`
943
+
944
+ **Themeable custom properties:** `--lyra-heatmap-scale-lo` (default `#cde2fb`),
945
+ `--lyra-heatmap-scale-hi` (default `#0969da`) — the sequential color-ramp endpoints, resolved via
946
+ `getComputedStyle` each draw (any valid CSS color syntax — hex/rgb/hsl/oklch/named — works,
947
+ resolved through a scratch canvas). Also consumes `--lyra-color-text-quiet` (axis label color) and
948
+ `--lyra-space-xs`.
949
+
950
+ **Optional peer deps:** none.
951
+
952
+ ```html
953
+ <lyra-heatmap value-label="requests"></lyra-heatmap>
954
+ <script>
955
+ const hm = document.querySelector('lyra-heatmap');
956
+ hm.rowLabels = ['Mon', 'Tue', 'Wed'];
957
+ hm.colLabels = ['00h', '06h', '12h', '18h'];
958
+ hm.values = [[3, 8, 12, 4], [1, 2, 9, 5], [0, 4, 6, 2]];
959
+ </script>
960
+ ```
961
+
962
+ **Known gotchas:**
963
+ - **High** — the `ResizeObserver` is wired but effectively inert: `draw()` always sizes the canvas
964
+ as `PAD_LEFT + cols * cellSize`, never from the host's measured width, so a container resize
965
+ redraws identical dimensions. The stylesheet's `canvas { inline-size: 100% }` is also dead code —
966
+ `draw()` unconditionally sets an inline `canvas.style.width/height` on every redraw, and inline
967
+ style always wins over that external rule.
968
+ - **Medium** — `NaN`/non-finite cell values are **not** treated as no-data (only `-1` is): `NaN < 0`
969
+ is `false`, so it falls into the normal color-ramp branch, produces an invalid `fillStyle`, and
970
+ that cell silently keeps whatever color was left over from the previous loop iteration.
971
+ - **Medium** — moving the browser window repeatedly across displays with different pixel ratios
972
+ leaks one stale `MediaQueryList` listener per DPR crossing (each `onDprChange` creates a new
973
+ listener without removing the previous one).
974
+ - Per-cell values are not individually inspectable by assistive tech — the whole grid is one
975
+ `role="img"` with only a dimensions+range summary `aria-label`.
976
+
977
+ ---
978
+
979
+ ## `lyra-graph`
980
+
981
+ A force-directed node-link diagram with pan/zoom/drag, built on `d3-force`.
982
+
983
+ **Properties:**
984
+ - `nodes: GraphNode[] = []` (attribute: false) — `GraphNode { id: string; label?: string; radius?:
985
+ number; color?: string }`
986
+ - `links: GraphLink[] = []` (attribute: false) — `GraphLink { source: string; target: string;
987
+ width?: number }` (source/target are node ids; links whose endpoints don't resolve are silently
988
+ dropped)
989
+ - `width: number = 800`
990
+ - `height: number = 600`
991
+ - `chargeStrength: number = -300` (attribute `charge-strength`)
992
+ - `linkDistance: number = 100` (attribute `link-distance`)
993
+
994
+ **Events:** `lyra-node-click` (`detail: { id }`), `lyra-link-click` (`detail: { source, target }`)
995
+
996
+ **Slots:** none.
997
+
998
+ **CSS parts:** `base`, `svg`, `node`, `link`, `label`
999
+
1000
+ **Themeable custom properties:** `--lyra-node-fill` (set inline per-node from `GraphNode.color`,
1001
+ falls back to `--lyra-color-brand`); also uses `--lyra-color-border`, `--lyra-color-text`.
1002
+
1003
+ **Optional peer deps:** `d3-force`, `d3-drag`, `d3-zoom`, `d3-selection` (all four required
1004
+ together; lazy-`import()`ed once per page, `console.warn` once and renders empty if missing —
1005
+ install with `pnpm add d3-force d3-drag d3-zoom d3-selection`).
1006
+
1007
+ ```html
1008
+ <lyra-graph style="display:block;height:500px"></lyra-graph>
1009
+ <script>
1010
+ const g = document.querySelector('lyra-graph');
1011
+ g.nodes = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }];
1012
+ g.links = [{ source: 'a', target: 'b' }];
1013
+ g.addEventListener('lyra-node-click', (e) => console.log(e.detail.id));
1014
+ </script>
1015
+ ```
1016
+
1017
+ **Known gotchas:**
1018
+ - **High** — per-tick full re-render is expensive: every d3-force tick (up to ~300 by default,
1019
+ continuously while dragging via `alphaTarget(0.3)`) replaces `simNodes`/`simLinks` with fresh
1020
+ array references, forcing Lit to re-diff the entire node/link template and re-run
1021
+ `applyInteractions()`. Noticeable cost for graphs of a few hundred+ nodes.
1022
+ - **Medium** — `chargeStrength`/`linkDistance` are **not** live-reactive: changing them post-mount
1023
+ does nothing until `nodes`/`links` also change (only those two trigger `rebuildSimulation()`).
1024
+ - **Medium** — zoom has no `.scaleExtent(...)` (unbounded in/out); pan/zoom/drag are pointer-only
1025
+ with no keyboard equivalent. Links (`<line part="link">`) are click-only — no
1026
+ `role`/`tabindex`/`aria-label`, unlike nodes which are properly keyboard-operable
1027
+ (`tabindex="0"`, `role="button"`, Enter/Space).
1028
+ - No visible fallback if the d3 peers fail to load — just a permanently empty `<svg>` plus a
1029
+ one-time console warning.
1030
+
1031
+ ---
1032
+
1033
+ ## `lyra-tree` / `lyra-tree-node`
1034
+
1035
+ An expand/collapse hierarchy (document/graph navigation tree). `lyra-tree` owns the data and
1036
+ imperatively creates/reconciles light-DOM `<lyra-tree-node>` children by `id`; `lyra-tree-node`
1037
+ recursively renders itself and its own nested children.
1038
+
1039
+ ### `lyra-tree`
1040
+
1041
+ **Properties:**
1042
+ - `data: TreeItem[] = []` (attribute: false) — `TreeItem { id: string; label: string; children?:
1043
+ TreeItem[]; badge?: string | number }`
1044
+
1045
+ **Methods:** `expandAll()`, `collapseAll()` (both recursive, properly sequenced around Lit's render
1046
+ cycle).
1047
+
1048
+ **Events:** none dispatched directly (see `lyra-tree-node` below — they bubble up).
1049
+
1050
+ **Slots:** default (holds the `<lyra-tree-node>` elements it manages).
1051
+
1052
+ **CSS parts:** `base`
1053
+
1054
+ **Themeable custom properties:** shared tokens `--lyra-space-xs`/`-s`, `--lyra-color-brand-quiet`,
1055
+ `--lyra-color-text-quiet`, `--lyra-color-border`, `--lyra-radius`.
1056
+
1057
+ **Optional peer deps:** none.
1058
+
1059
+ ### `lyra-tree-node`
1060
+
1061
+ Normally set internally by `lyra-tree`, but a public element.
1062
+
1063
+ **Properties:**
1064
+ - `item: TreeItem` (required, attribute: false)
1065
+ - `depth: number = 0`
1066
+ - `expanded: boolean = false` (reflected)
1067
+
1068
+ **Events:** `lyra-node-toggle` (`detail: { id, expanded }`), `lyra-node-select` (`detail: { id }`) —
1069
+ dispatched from `lyra-tree-node`, bubble/compose up through `lyra-tree`'s light DOM.
1070
+
1071
+ **Slots:** none.
1072
+
1073
+ **CSS parts:** `row`, `toggle`, `label`, `badge`
1074
+
1075
+ **Themeable custom properties:** `--lyra-tree-depth` (internal, set inline per row for
1076
+ indentation), plus the shared tokens listed above.
1077
+
1078
+ **Optional peer deps:** none.
1079
+
1080
+ ```html
1081
+ <lyra-tree></lyra-tree>
1082
+ <script>
1083
+ document.querySelector('lyra-tree').data = [
1084
+ { id: '1', label: 'Root', children: [{ id: '1a', label: 'Child A' }, { id: '1b', label: 'Child B', badge: 3 }] },
1085
+ ];
1086
+ </script>
1087
+ ```
1088
+
1089
+ **Known gotchas:**
1090
+ - **High** — the treeitem row has **zero keyboard interaction**: `role="treeitem"` has no
1091
+ `tabindex` and no keydown handler, so it never enters the Tab order. The only keyboard-operable
1092
+ control at all is the expand/collapse `<button>` — and that's removed (`tabindex="-1"`,
1093
+ `aria-hidden`) for leaf nodes. `lyra-node-select` fires only from the label's `@click`, with no
1094
+ keyboard equivalent, and there is no roving-tabindex/arrow-key navigation model.
1095
+ - **Medium** — `role="group"` (the expanded-children wrapper) is a shadow-DOM **sibling**, not a DOM
1096
+ descendant, of its `role="treeitem"` row — violates the WAI-ARIA treeitem pattern's
1097
+ DOM-containment expectation. (The library's own axe test does not catch this — axe's structural
1098
+ rules accept a group as a valid child of `role="tree"` regardless of true ownership.)
1099
+ - **Medium** — by-id reconciliation (which preserves `expanded` state across data reassignment)
1100
+ only applies at depth 0. Nested `children` arrays are rendered via a plain `.map()` with no keyed
1101
+ `repeat()`, so reordering/inserting into a nested array can silently attach the wrong node's
1102
+ `expanded` state.
1103
+ - `role="tree"` has no accessible name (`aria-label`/`aria-labelledby`).
1104
+
1105
+ ---
1106
+
1107
+ # Chart family (`chart/`)
1108
+
1109
+ ## `lyra-chart` (core)
1110
+
1111
+ Chart.js wrapper every other `lyra-*-chart` tag subclasses; supports both a simplified
1112
+ `Series`-based attribute surface and a raw Chart.js `config` passthrough (mirrors Web Awesome's
1113
+ `wa-chart` `config` property).
1114
+
1115
+ **Properties:**
1116
+ - `type: LyraChartType = 'line'` — `LyraChartType = 'line' | 'bar' | 'scatter'` (narrow TS union;
1117
+ other Chart.js type strings — `'pie'`, `'doughnut'`, etc. — work at runtime unchecked, which is
1118
+ exactly how the typed subclasses below achieve their locked type)
1119
+ - `labels: string[] = []` (attribute: false)
1120
+ - `datasets: Series[] = []` (attribute: false) — `Series { label: string; data?: (number|null)[];
1121
+ points?: {x,y,label?}[]; color?: string|string[]; fill?: boolean; width?: number; dash?: boolean;
1122
+ noTooltip?: boolean; axis?: 'y'|'y2'; pointColors?: string[]; pointRadius?: number; type?:
1123
+ 'line'|'bar' }`
1124
+ - `legend: boolean = false`
1125
+ - `area: boolean = false`
1126
+ - `zoom: boolean = false`
1127
+ - `height: string = '280px'`
1128
+ - `xLabel: string = ''` (attribute `x-label`)
1129
+ - `yLabel: string = ''` (attribute `y-label`)
1130
+ - `y2Label: string = ''` (attribute `y2-label`)
1131
+ - `beginAtZero: boolean = true` (attribute `begin-at-zero`)
1132
+ - `config?: Partial<ChartConfiguration>` (attribute: false) — deep-merged over the generated
1133
+ config; any nested key wins without clobbering sibling generated keys
1134
+
1135
+ **Methods:** `resetZoom()`
1136
+
1137
+ **Events:** `lyra-zoom` (`detail: { zoomed: boolean }`, fired on zoom-complete and on
1138
+ `resetZoom()`)
1139
+
1140
+ **Slots:** none.
1141
+
1142
+ **CSS parts:** `base`, `canvas`, `reset-zoom-button`
1143
+
1144
+ **Themeable custom properties:** `--lyra-chart-height` (set programmatically on the host from the
1145
+ `height` property — must be read from the host, not a shadow-tree descendant, since custom
1146
+ properties only cascade downward); shared tokens `--lyra-space-xs`, `--lyra-color-border`,
1147
+ `--lyra-color-surface`, `--lyra-color-text`.
1148
+
1149
+ **Optional peer deps:** `chart.js`, `chartjs-plugin-zoom` (lazy-loaded once per page via
1150
+ `chart-loader.ts`; registers only the tree-shaken controller/element/scale subset used).
1151
+
1152
+ ```html
1153
+ <lyra-chart type="line" x-label="Day" y-label="kWh" legend></lyra-chart>
1154
+ <script>
1155
+ const c = document.querySelector('lyra-chart');
1156
+ c.labels = ['Mon', 'Tue', 'Wed'];
1157
+ c.datasets = [{ label: 'Production', data: [12, 19, 7], color: '#2563eb' }];
1158
+ </script>
1159
+ ```
1160
+
1161
+ **Known gotchas:**
1162
+ - **Medium** — "locked" `type` in the typed subclasses (below) is **not actually enforced**:
1163
+ `<lyra-pie-chart type="bar">` or `el.type = 'bar'` at runtime silently turns it into a bar chart.
1164
+ Conversely, `<lyra-chart type="pie">` already works today with no subclass needed at all —
1165
+ `buildConfig()` passes `type` straight through unchecked.
1166
+ - **Medium** — the `lyra-theme` window-event listener (meant for re-theming on theme switch) is
1167
+ dead code: nothing in the library ever dispatches that event, and even if it were dispatched, no
1168
+ `--lyra-*`/`getComputedStyle` color resolution happens anywhere in this component — Chart.js
1169
+ always renders grid/tick/legend/tooltip text with its own hardcoded palette, which can read as
1170
+ low-contrast in dark mode.
1171
+ - **Medium** — generated `scales` config is cartesian (`x`/`y`/`y2`) only: for `lyra-radar-chart`/
1172
+ `lyra-polar-area-chart` (which need a single radial `r` scale) and for `lyra-pie-chart`/
1173
+ `lyra-doughnut-chart` (no scale at all), the inherited `xLabel`/`yLabel`/`beginAtZero`/`y2Label`
1174
+ properties are silently inert with no warning. Reach the radial scale only via raw `config`.
1175
+ - No `chartjs-plugin-annotation` is registered by default — reachable only by importing it
1176
+ separately and using the raw `config` passthrough (Chart.js's registry is a global singleton).
1177
+
1178
+ ---
1179
+
1180
+ ## Typed subclasses: `lyra-line-chart`, `lyra-bar-chart`, `lyra-pie-chart`, `lyra-doughnut-chart`, `lyra-radar-chart`, `lyra-polar-area-chart`, `lyra-bubble-chart`, `lyra-scatter-chart`
1181
+
1182
+ Each is `LyraChart` with `type` overridden to a fixed value; all other properties/events/parts/tokens
1183
+ are identical to `lyra-chart` above. Same optional peer deps (`chart.js`, `chartjs-plugin-zoom`).
1184
+
1185
+ ```html
1186
+ <lyra-bar-chart legend></lyra-bar-chart>
1187
+ <lyra-pie-chart></lyra-pie-chart>
1188
+ <script>
1189
+ document.querySelector('lyra-bar-chart').labels = ['A', 'B'];
1190
+ document.querySelector('lyra-bar-chart').datasets = [{ label: 'Count', data: [4, 9] }];
1191
+
1192
+ document.querySelector('lyra-pie-chart').labels = ['A', 'B', 'C'];
1193
+ document.querySelector('lyra-pie-chart').datasets = [{ label: 'Share', data: [30, 45, 25], color: ['#2563eb', '#16a34a', '#dc2626'] }];
1194
+ </script>
1195
+ ```
1196
+
1197
+ **Known gotchas (in addition to the core `lyra-chart` list above):**
1198
+ - `lyra-bubble-chart` needs `Series.points` entries with an `x`/`y`/`r` triple, but `Series.points`
1199
+ is typed as `{x, y, label?}[]` with no `r` field — cast through `unknown` (or a local
1200
+ `BubblePoint` type) when constructing bubble data.
1201
+ - Reading `chart.type` back in TypeScript for the 5 non-`line`/`bar`/`scatter` subclasses yields a
1202
+ value that, by `LyraChartType`'s own declaration, is never a legal member of that type (each is
1203
+ set via `as unknown as LyraChartType`).
1204
+
1205
+ ---
1206
+
1207
+ ## `lyra-histogram`
1208
+
1209
+ Bins `values` into `bins` equal-width buckets and renders as a bar chart (extends `LyraChart`,
1210
+ `type` fixed to `'bar'`).
1211
+
1212
+ **Properties:**
1213
+ - `bins: number = 10`
1214
+ - `values: number[] = []` (attribute: false)
1215
+ - `label: string = 'Frequency'`
1216
+ - `labels`/`datasets` are **derived, read-only** (installed as getter/setter pairs on the
1217
+ prototype; direct writes are silently ignored) — computed from `values`/`bins`.
1218
+ - All other `LyraChart` properties (`legend`, `area`, `zoom`, `config`, `height`, etc.) are
1219
+ inherited and usable.
1220
+
1221
+ **Events/parts/tokens/peers:** identical to `lyra-chart`.
1222
+
1223
+ ```html
1224
+ <lyra-histogram bins="12"></lyra-histogram>
1225
+ <script>
1226
+ document.querySelector('lyra-histogram').values = [1, 2, 2, 3, 5, 5, 5, 8, 13, 13];
1227
+ </script>
1228
+ ```
1229
+
1230
+ **Known gotchas:**
1231
+ - **High** — **`bins <= 0` crashes** on non-empty `values`: `bins=0` throws `TypeError` inside
1232
+ `binValues()` (bucket index resolves to `-1`, `buckets[-1].count++` fails); negative `bins` throws
1233
+ `RangeError` even earlier (`Array.from({length: -1})`). No guard exists anywhere in the chain —
1234
+ **always pass `bins >= 1`.**
1235
+
1236
+ ---
1237
+
1238
+ ## `lyra-box-plot`
1239
+
1240
+ Box-and-whisker chart from a precomputed five-number summary (no raw sample data sent to the
1241
+ browser). Does **not** extend `LyraChart` — a deliberately bespoke API.
1242
+
1243
+ **Properties:**
1244
+ - `labels: string[] = []` (attribute: false)
1245
+ - `boxes: BoxPlotSeries[] = []` (attribute: false) — `BoxPlotSeries { label: string; data:
1246
+ BoxPlotPoint[]; color?: string }`, `BoxPlotPoint { min, q1, median, q3, max }`
1247
+ - `legend: boolean = false`
1248
+ - `height: string = '280px'`
1249
+ - `yLabel: string = ''` (attribute `y-label`)
1250
+ - `beginAtZero: boolean = true` (attribute `begin-at-zero`)
1251
+
1252
+ **Events:** none.
1253
+
1254
+ **Slots:** none.
1255
+
1256
+ **CSS parts:** `base`, `canvas`
1257
+
1258
+ **Themeable custom properties:** `--lyra-chart-height` (same mechanism as `lyra-chart`; styles file
1259
+ is a re-export of `chart.styles.ts`).
1260
+
1261
+ **Optional peer deps:** `@sgratzl/chartjs-chart-boxplot` (plus `chart.js` transitively, loaded
1262
+ independently of the base `chart-loader.ts`).
1263
+
1264
+ ```html
1265
+ <lyra-box-plot y-label="Latency (ms)"></lyra-box-plot>
1266
+ <script>
1267
+ const bp = document.querySelector('lyra-box-plot');
1268
+ bp.labels = ['Run A', 'Run B'];
1269
+ bp.boxes = [{ label: 'p50–p99', data: [{ min: 10, q1: 20, median: 30, q3: 45, max: 90 }, { min: 12, q1: 18, median: 25, q3: 35, max: 60 }] }];
1270
+ </script>
1271
+ ```
1272
+
1273
+ **Known gotchas:**
1274
+ - **Medium** — no raw `config` passthrough (unlike every other chart tag) — limited to
1275
+ `labels`/`boxes`/`legend`/`height`/`yLabel`/`beginAtZero`; can't reach the underlying
1276
+ controller's own options (`itemRadius`, `outlierRadius`, `coef`).
1277
+ - The peer-dependency-missing failure path (`.catch()` → warn + resolve `null`) is untested —
1278
+ verify it manually if shipping without the peer installed.
1279
+
1280
+ ---
1281
+
1282
+ # Map & file-input
1283
+
1284
+ ## `lyra-map`
1285
+
1286
+ A `maplibre-gl` wrapper with a declarative legend and a single choropleth GeoJSON fill layer, plus a
1287
+ raw `map` escape hatch for anything unexposed.
1288
+
1289
+ **Properties:**
1290
+ - `center: [number, number] = [0, 0]`
1291
+ - `zoom: number = 2`
1292
+ - `mapStyle: StyleSpecification | string = DEFAULT_STYLE` (attribute: false) — default is a basic
1293
+ OSM raster tile style
1294
+ - `legend: LegendEntry[] = []` (attribute: false) — `LegendEntry { color: string; label: string }`
1295
+ (discrete swatch rows only, no continuous gradient bar)
1296
+ - `choropleth?: ChoroplethLayer` (attribute: false) — `ChoroplethLayer { sourceId: string; geojson:
1297
+ GeoJSON.FeatureCollection; field: string; stops: [number, string][] }` (interpolated
1298
+ fill-color expression from `field`'s value against `stops`)
1299
+
1300
+ **Getters:** `map` → the raw `maplibregl.Map` instance.
1301
+
1302
+ **Events:** `lyra-map-load` (fired once, after the underlying map's own `'load'`), `lyra-map-click`
1303
+ (`detail: { lngLat: [lng, lat], feature? }` — feature only populated if a choropleth fill layer
1304
+ exists and was hit)
1305
+
1306
+ **Slots:** none.
1307
+
1308
+ **CSS parts:** `base`, `container`, `legend`, `legend-swatch`
1309
+
1310
+ **Themeable custom properties:** shared tokens only — `--lyra-space-s`, `--lyra-color-surface`,
1311
+ `--lyra-color-border`, `--lyra-shadow`, `--lyra-radius`.
1312
+
1313
+ **Optional peer deps:** `maplibre-gl` (lazy-loaded; consumer must **separately** `import
1314
+ 'maplibre-gl/dist/maplibre-gl.css'` once — the component does not do this for you).
1315
+
1316
+ ```html
1317
+ <lyra-map center="[2.35, 48.85]" zoom="10"></lyra-map>
1318
+ <script>
1319
+ import 'maplibre-gl/dist/maplibre-gl.css';
1320
+ const m = document.querySelector('lyra-map');
1321
+ m.choropleth = {
1322
+ sourceId: 'regions',
1323
+ geojson: myGeoJson,
1324
+ field: 'value',
1325
+ stops: [[0, '#cde2fb'], [100, '#0969da']],
1326
+ };
1327
+ m.legend = [{ color: '#cde2fb', label: 'Low' }, { color: '#0969da', label: 'High' }];
1328
+ m.addEventListener('lyra-map-click', (e) => console.log(e.detail.feature?.properties));
1329
+ </script>
1330
+ ```
1331
+
1332
+ **Known gotchas:**
1333
+ - **Medium** — clearing or swapping the choropleth **leaks the old layer**: `applyChoropleth()`
1334
+ only ever adds or updates; setting `choropleth = undefined`, or changing `choropleth.sourceId` to
1335
+ a different value, is a silent no-op — the previously added GeoJSON source and fill layer keep
1336
+ rendering forever (there is no `removeLayer`/`removeSource` call anywhere).
1337
+ - **Medium** — `mapStyle` is reactive but changing it after construction is a **silent no-op** —
1338
+ unlike `center`/`zoom`, which do call `setCenter`/`setZoom` on change, there's no `setStyle()`
1339
+ call wired up.
1340
+ - No declarative point-marker API — only the single choropleth polygon-fill layer exists; placing
1341
+ pins/points requires dropping to the `.map` escape hatch and calling `new maplibregl.Marker()`
1342
+ manually.
1343
+ - No built-in popup rendering (despite sometimes being described as covering "legend/popup/
1344
+ choropleth") — only the raw `lyra-map-click` event; hand-roll your own `maplibregl.Popup` on
1345
+ click.
1346
+ - No click-select visual highlight on choropleth features (no `setFeatureState` call) — clicking
1347
+ only fires the event, no built-in visual feedback.
1348
+
1349
+ ---
1350
+
1351
+ ## `lyra-file-input`
1352
+
1353
+ A drag-drop + click-to-browse file dropzone. Emits raw `File[]` only — no client-side CSV/XLSX/etc.
1354
+ parsing (that's left entirely to the host).
1355
+
1356
+ **Properties:**
1357
+ - `multiple: boolean = false` (reflected)
1358
+ - `disabled: boolean = false` (reflected)
1359
+ - `accept: string = ''` — **see gotchas: decorative on the drop path**
1360
+ - `allowedMimeTypes: string[] = []` (attribute: false) — exact MIME-string allowlist
1361
+ - `forbiddenMimeTypes: string[] = []` (attribute: false) — exact MIME-string denylist, checked
1362
+ **before** (and takes precedence over) `allowedMimeTypes`
1363
+ - `label: string = 'Drop files here or click to browse'`
1364
+
1365
+ **Methods:** `openPicker()` — programmatically opens the native file dialog.
1366
+
1367
+ **Events:** `lyra-files` (`detail: { files: File[], rejected: File[] }`, fired on both drop and
1368
+ manual file-picker selection — `rejected` carries no reason code, just the raw rejected `File[]`)
1369
+
1370
+ **Slots:** default slot — custom dropzone content, overrides the `label` attribute text when
1371
+ provided.
1372
+
1373
+ **CSS parts:** `base`, `input`
1374
+
1375
+ **Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-space-l`,
1376
+ `--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`, `--lyra-color-text-quiet`,
1377
+ `--lyra-color-success` (drag-accept state), `--lyra-color-danger` (drag-reject state).
1378
+
1379
+ **Optional peer deps:** none.
1380
+
1381
+ ```html
1382
+ <lyra-file-input multiple accept=".csv,.xlsx" allowed-mime-types='["text/csv"]'></lyra-file-input>
1383
+ <script>
1384
+ document.querySelector('lyra-file-input').addEventListener('lyra-files', (e) => {
1385
+ console.log('accepted:', e.detail.files, 'rejected:', e.detail.rejected);
1386
+ });
1387
+ </script>
1388
+ ```
1389
+
1390
+ Note: `allowedMimeTypes`/`forbiddenMimeTypes` are complex properties (`attribute: false`) — set
1391
+ them via JS (`el.allowedMimeTypes = [...]`), not as a JSON string attribute; the snippet above is
1392
+ illustrative of intent only.
1393
+
1394
+ **Known gotchas:**
1395
+ - **High** — **`accept` only constrains the native file-picker dialog; it is silently ignored on
1396
+ the drag-drop path.** `isAllowed()` checks only `allowedMimeTypes`/`forbiddenMimeTypes` (exact
1397
+ MIME-string match — no extension or `image/*`-wildcard support like native `accept` has);
1398
+ `this.accept` itself is never read anywhere else in the component. Concretely:
1399
+ `<lyra-file-input accept=".csv,.xlsx">` with no `allowedMimeTypes` set will silently **accept** a
1400
+ dropped `.png` (fires `lyra-files` with the png in `files`, not `rejected`) even though the UI
1401
+ visually implies only CSV/XLSX are allowed. **To actually enforce type restriction on drop, you
1402
+ must also set `allowedMimeTypes` (and/or `forbiddenMimeTypes`) — `accept` alone is not
1403
+ sufficient.**
1404
+ - No max-file-size validation (no `maxFileSize` property) — a host wanting a byte-limit must
1405
+ intercept `lyra-files` and re-filter itself.
1406
+ - No paste-from-clipboard support (no `paste`-event handling of `e.clipboardData.files`).
1407
+ - No `aria-live` announcement of the drag accept/reject state (`data-drag-state` attribute drives
1408
+ border/background color only, invisible to screen readers mid-drag) — the click/keyboard-to-
1409
+ browse path is unaffected and correctly operable (`role="button"`, `tabindex` 0/-1 by disabled,
1410
+ `aria-disabled`, Enter/Space handling).
1411
+ - Dragged folders aren't specially detected (no `webkitGetAsEntry()` check) — likely surfaces as a
1412
+ phantom zero-byte `File` with an empty MIME type rather than a clear rejection.
1413
+
1414
+ ---
1415
+
1416
+ ## Cross-cutting notes
1417
+
1418
+ These apply across the whole library rather than to any one component:
1419
+
1420
+ - **Packaging gap.** `custom-elements.json` is currently gitignored and missing from
1421
+ `package.json`'s `files` allowlist, with no `prepack`/publish CI step regenerating it — so a
1422
+ manifest-driven IDE/tooling integration (e.g. custom-elements-manifest-based autocomplete) may
1423
+ not see it in a published tarball until that packaging gap is fixed.
1424
+ - **Adoption status.** Zero real-world adoption of `@aceshooting/lyra-ui` currently exists in any
1425
+ consumer repo — treat any "battle-tested in production" framing with that caveat.