@hypermedia-components/core 0.0.1-alpha.0 → 0.1.1

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 (50) hide show
  1. package/dist/field-error-core.d.ts +26 -0
  2. package/dist/field-error-core.js +71 -0
  3. package/dist/field-errors.d.ts +15 -0
  4. package/dist/field-errors.js +293 -0
  5. package/dist/hc-alert.css +29 -1
  6. package/dist/hc-chip.css +39 -0
  7. package/dist/hc-combobox.css +1 -1
  8. package/dist/hc-command.css +1 -1
  9. package/dist/hc-datagrid.css +3 -3
  10. package/dist/hc-dialog.css +1 -1
  11. package/dist/hc-drawer.css +1 -1
  12. package/dist/hc-hovercard.css +1 -1
  13. package/dist/hc-item.css +4 -2
  14. package/dist/hc-menu.css +1 -1
  15. package/dist/hc-multicombobox.css +1 -1
  16. package/dist/hc-navmenu.css +1 -1
  17. package/dist/hc-popover.css +1 -1
  18. package/dist/hc-switch.css +1 -1
  19. package/dist/hc-table.css +21 -0
  20. package/dist/hc-tabs.css +2 -2
  21. package/dist/hc-toast.css +1 -1
  22. package/dist/hc.behaviors.d.ts +4 -2
  23. package/dist/hc.behaviors.js +7 -1
  24. package/dist/hc.behaviors.min.js +2 -2
  25. package/dist/hc.core.css +43 -0
  26. package/dist/hc.core.min.css +1 -1
  27. package/dist/hc.css +230 -19
  28. package/dist/hc.min.css +1 -1
  29. package/dist/hc.min.js +2 -2
  30. package/dist/hc.tokens.core.css +43 -0
  31. package/dist/hc.tokens.css +71 -0
  32. package/dist/hc.tokens.neutral-neutral.css +7 -0
  33. package/dist/hc.tokens.neutral-slate.css +7 -0
  34. package/dist/hc.tokens.neutral-stone.css +7 -0
  35. package/dist/hc.tokens.neutral-zinc.css +7 -0
  36. package/dist/hc.utilities.css +49 -0
  37. package/dist/i18n.d.ts +12 -0
  38. package/dist/i18n.js +29 -7
  39. package/dist/index.d.ts +4 -2
  40. package/dist/index.js +4 -2
  41. package/dist/locales/ja.d.ts +3 -0
  42. package/dist/locales/ja.js +44 -0
  43. package/dist/theme-toggle.d.ts +8 -0
  44. package/dist/theme-toggle.js +130 -0
  45. package/dist/validation.js +12 -30
  46. package/package.json +11 -6
  47. package/src/tokens/README.md +9 -1
  48. package/src/tokens/component.tokens.json +13 -1
  49. package/src/tokens/semantic.tokens.json +8 -0
  50. package/src/tokens/theme.dark.tokens.json +9 -0
@@ -0,0 +1,26 @@
1
+ /** @param {Element} control */
2
+ export function fieldOf(control: Element): Element;
3
+ /**
4
+ * Append `id` to the control's `aria-describedby` token list (idempotent).
5
+ *
6
+ * @param {Element} control
7
+ * @param {string} id
8
+ */
9
+ export function ensureDescribedBy(control: Element, id: string): void;
10
+ /**
11
+ * Remove `id` from the control's `aria-describedby` token list, dropping
12
+ * the attribute entirely when no tokens remain.
13
+ *
14
+ * @param {Element} control
15
+ * @param {string} id
16
+ */
17
+ export function pruneDescribedBy(control: Element, id: string): void;
18
+ /**
19
+ * Find or create the field's `.hc-field__error` element, give it a stable
20
+ * id, and point the control's `aria-describedby` at it.
21
+ *
22
+ * @param {Element} field
23
+ * @param {Element} control
24
+ * @returns {Element}
25
+ */
26
+ export function getOrCreateError(field: Element, control: Element): Element;
@@ -0,0 +1,71 @@
1
+ // Shared field-error plumbing for `installValidation()` (native constraint
2
+ // validation) and `installFieldErrors()` (server-sent validation errors).
3
+ //
4
+ // Both behaviors surface a message inside a field's `.hc-field__error`
5
+ // element with identical ARIA wiring (`aria-describedby` on the control,
6
+ // `aria-live` on the message). Keeping the mechanics in one module
7
+ // guarantees the two error sources stay byte-identical instead of
8
+ // drifting copies. Internal module — not part of the public entry.
9
+
10
+ let errorIdSeq = 0;
11
+
12
+ /** @param {Element} control */
13
+ export function fieldOf(control) {
14
+ return control?.closest?.('.hc-field') ?? null;
15
+ }
16
+
17
+ /**
18
+ * Append `id` to the control's `aria-describedby` token list (idempotent).
19
+ *
20
+ * @param {Element} control
21
+ * @param {string} id
22
+ */
23
+ export function ensureDescribedBy(control, id) {
24
+ const existing = (control.getAttribute('aria-describedby') ?? '')
25
+ .split(/\s+/)
26
+ .filter(Boolean);
27
+ if (!existing.includes(id)) {
28
+ existing.push(id);
29
+ control.setAttribute('aria-describedby', existing.join(' '));
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Remove `id` from the control's `aria-describedby` token list, dropping
35
+ * the attribute entirely when no tokens remain.
36
+ *
37
+ * @param {Element} control
38
+ * @param {string} id
39
+ */
40
+ export function pruneDescribedBy(control, id) {
41
+ const existing = (control.getAttribute('aria-describedby') ?? '')
42
+ .split(/\s+/)
43
+ .filter(Boolean)
44
+ .filter((token) => token !== id);
45
+ if (existing.length) {
46
+ control.setAttribute('aria-describedby', existing.join(' '));
47
+ } else {
48
+ control.removeAttribute('aria-describedby');
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Find or create the field's `.hc-field__error` element, give it a stable
54
+ * id, and point the control's `aria-describedby` at it.
55
+ *
56
+ * @param {Element} field
57
+ * @param {Element} control
58
+ * @returns {Element}
59
+ */
60
+ export function getOrCreateError(field, control) {
61
+ let error = field.querySelector('.hc-field__error');
62
+ if (!error) {
63
+ error = field.ownerDocument.createElement('p');
64
+ error.className = 'hc-field__error';
65
+ error.setAttribute('aria-live', 'polite');
66
+ field.appendChild(error);
67
+ }
68
+ if (!error.id) error.id = `hc-field-error-${(errorIdSeq += 1)}`;
69
+ ensureDescribedBy(control, error.id);
70
+ return error;
71
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Install the field-errors behavior: distribute server-sent
3
+ * `[data-hc-field-errors]` fragments (see the `field-errors` recipe) to
4
+ * the form fields they name, with the same ARIA wiring
5
+ * `installValidation()` uses.
6
+ *
7
+ * Fragments are picked up when swapped in by htmx (`htmx:afterSwap` /
8
+ * `htmx:oobAfterSwap`), when inserted by any other means
9
+ * (`MutationObserver`), and once at install time for a full-page render.
10
+ *
11
+ * @param {Document|Element} [root]
12
+ * The root to watch. Defaults to the global document when available.
13
+ * @returns {() => void} an idempotent uninstaller.
14
+ */
15
+ export function installFieldErrors(root?: Document | Element): () => void;
@@ -0,0 +1,293 @@
1
+ // installFieldErrors — distribute a server-sent validation-error fragment
2
+ // to the form fields it names (recipe `field-errors`).
3
+ //
4
+ // The canonical fragment (the server returns this on e.g. a 422; htmx
5
+ // swaps it into a container inside — or pointed at — the form):
6
+ //
7
+ // <div class="hc-alert" data-variant="error" role="alert" data-hc-field-errors>
8
+ // <p class="hc-alert__title">Unprocessable Entity</p>
9
+ // <ul class="hc-alert__errors">
10
+ // <li class="hc-alert__error" data-field="email" data-code="duplicate"
11
+ // data-message-key="members.email.duplicate">email: duplicate</li>
12
+ // </ul>
13
+ // <p class="hc-alert__body">optional hint line</p>
14
+ // </div>
15
+ //
16
+ // `data-hc-field-errors` is the opt-in: empty means "distribute into the
17
+ // closest form"; a non-empty value is a CSS selector for the form (for
18
+ // out-of-band swaps or an alert rendered outside the form).
19
+ //
20
+ // For each `.hc-alert__error[data-field]` whose name matches a control in
21
+ // the form, the behavior writes the message into the field's
22
+ // `.hc-field__error` (creating one after a bare control), sets
23
+ // `aria-invalid` + `aria-describedby` on the control and `data-invalid`
24
+ // on the field — the same wiring installValidation() uses — and marks the
25
+ // item `data-distributed` so the summary doesn't read it twice. Items
26
+ // naming no known control stay visible in the summary. The first invalid
27
+ // control is focused (opt out with `data-focus="none"` on the alert).
28
+ //
29
+ // Message resolution per item: `data-message-key` found in the i18n
30
+ // catalog → `t(key, { field, code, ...data-message-params })`; otherwise
31
+ // the item's own text; otherwise `t('fieldErrors.unknown')`. Localize once
32
+ // via `setMessages()`. `data-message-params` is an optional JSON object of
33
+ // server-provided interpolation values (constraint declarations, validation
34
+ // row columns) for translations with placeholders beyond {field}/{code}.
35
+ //
36
+ // Server errors are stale the moment the user edits the field or
37
+ // resubmits: cleared on first `input`/`change` per field, on `submit` /
38
+ // `reset` of the form, and before re-distributing a newly swapped-in
39
+ // fragment. Native constraint validation outranks a server error on the
40
+ // same control (the native message reflects the current value).
41
+ //
42
+ // The behavior never makes a request — htmx owns the network. State lives
43
+ // in HTML attributes; install is idempotent and returns an uninstaller.
44
+
45
+ import {
46
+ ensureDescribedBy,
47
+ fieldOf,
48
+ getOrCreateError,
49
+ pruneDescribedBy,
50
+ } from './field-error-core.js';
51
+ import { hasMessage, t } from './i18n.js';
52
+
53
+ const INSTALL_KEY = '__hcFieldErrorsUninstall';
54
+
55
+ function escapeName(name) {
56
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(name);
57
+ return name.replace(/["\\]/g, '\\$&');
58
+ }
59
+
60
+ // Resolve the scope (normally the <form>) an alert distributes into.
61
+ function scopeOf(alert, root) {
62
+ const selector = alert.getAttribute('data-hc-field-errors');
63
+ if (selector) {
64
+ const doc = root.nodeType === 9 ? root : root.ownerDocument;
65
+ return doc.querySelector(selector);
66
+ }
67
+ return alert.closest('form');
68
+ }
69
+
70
+ // First control in the scope whose `name` matches. `form.elements`
71
+ // handles radio/checkbox groups natively (RadioNodeList → first member).
72
+ function controlFor(scope, name) {
73
+ let found;
74
+ if (scope.elements && typeof scope.elements.namedItem === 'function') {
75
+ found = scope.elements.namedItem(name);
76
+ } else {
77
+ found = scope.querySelector(`[name="${escapeName(name)}"]`);
78
+ }
79
+ if (found && found.tagName == null && typeof found.length === 'number') {
80
+ found = found[0] ?? null; // RadioNodeList
81
+ }
82
+ return found ?? null;
83
+ }
84
+
85
+ function resolveMessage(item) {
86
+ const key = item.getAttribute('data-message-key');
87
+ const params = {
88
+ field: item.getAttribute('data-field') ?? '',
89
+ code: item.getAttribute('data-code') ?? '',
90
+ };
91
+ // Optional server-provided interpolation params (a JSON object), so a
92
+ // catalog translation may use placeholders beyond {field}/{code} — e.g.
93
+ // data-message-params='{"stock": 5}' for "在庫 {stock} を超えています。".
94
+ // Item params win over the implicit field/code; malformed or non-object
95
+ // JSON degrades to the attribute being ignored.
96
+ const raw = item.getAttribute('data-message-params');
97
+ if (raw) {
98
+ try {
99
+ const extra = JSON.parse(raw);
100
+ if (extra && typeof extra === 'object' && !Array.isArray(extra)) {
101
+ Object.assign(params, extra);
102
+ }
103
+ } catch {
104
+ /* malformed JSON — keep the default params */
105
+ }
106
+ }
107
+ if (key && hasMessage(key)) return t(key, params);
108
+ const text = item.textContent.trim();
109
+ if (text) return text;
110
+ return t('fieldErrors.unknown', params);
111
+ }
112
+
113
+ let ownedIdSeq = 0;
114
+
115
+ // Write one or more messages (one per line) into the field's error slot —
116
+ // or into a created slot right after a control that has no `.hc-field`.
117
+ function applyErrors(control, messages) {
118
+ const doc = control.ownerDocument;
119
+ const field = fieldOf(control);
120
+ let error;
121
+ if (field) {
122
+ error = getOrCreateError(field, control);
123
+ field.setAttribute('data-invalid', 'true');
124
+ } else {
125
+ error = doc.createElement('p');
126
+ error.className = 'hc-field__error';
127
+ error.setAttribute('aria-live', 'polite');
128
+ // "owned" = created by this behavior next to a bare control; removed
129
+ // entirely on clear (a field's shared slot is only emptied).
130
+ error.setAttribute('data-hc-server-error-owned', '');
131
+ error.id = `hc-server-error-${(ownedIdSeq += 1)}`;
132
+ control.insertAdjacentElement('afterend', error);
133
+ ensureDescribedBy(control, error.id);
134
+ }
135
+ error.textContent = '';
136
+ messages.forEach((message, index) => {
137
+ if (index > 0) error.appendChild(doc.createElement('br'));
138
+ error.appendChild(doc.createTextNode(message));
139
+ });
140
+ error.setAttribute('data-hc-server-error', '');
141
+ control.setAttribute('aria-invalid', 'true');
142
+ control.dataset.hcServerInvalid = '';
143
+ }
144
+
145
+ // Clear every server error inside the scope (errors created by us are
146
+ // removed; errors living in a field's shared slot are emptied).
147
+ function clearServerErrors(scope) {
148
+ for (const control of scope.querySelectorAll('[data-hc-server-invalid]')) {
149
+ clearServerErrorFor(control);
150
+ }
151
+ }
152
+
153
+ function clearServerErrorFor(control) {
154
+ delete control.dataset.hcServerInvalid;
155
+ control.removeAttribute('aria-invalid');
156
+ const field = fieldOf(control);
157
+ if (field) field.removeAttribute('data-invalid');
158
+ // The error element(s) this control points at via aria-describedby.
159
+ const doc = control.ownerDocument;
160
+ const ids = (control.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
161
+ for (const id of ids) {
162
+ const error = doc.getElementById(id);
163
+ if (!error || !error.hasAttribute('data-hc-server-error')) continue;
164
+ if (error.hasAttribute('data-hc-server-error-owned')) {
165
+ pruneDescribedBy(control, id);
166
+ error.remove();
167
+ } else {
168
+ error.textContent = '';
169
+ error.removeAttribute('data-hc-server-error');
170
+ }
171
+ }
172
+ }
173
+
174
+ function distribute(alert, root) {
175
+ if (alert.dataset.distributed != null) return;
176
+
177
+ const scope = scopeOf(alert, root);
178
+ if (!scope) {
179
+ // No form to distribute into — everything stays in the summary.
180
+ alert.dataset.distributed = 'none';
181
+ return;
182
+ }
183
+
184
+ clearServerErrors(scope);
185
+
186
+ const items = alert.querySelectorAll('.hc-alert__error[data-field]');
187
+ // Group by control so several errors for one field render one per line.
188
+ const perControl = new Map();
189
+ let distributed = 0;
190
+ let total = 0;
191
+ for (const item of items) {
192
+ total += 1;
193
+ const control = controlFor(scope, item.getAttribute('data-field'));
194
+ if (!control) continue; // unknown field — stays visible in the summary
195
+ if (!perControl.has(control)) perControl.set(control, []);
196
+ perControl.get(control).push(resolveMessage(item));
197
+ item.setAttribute('data-distributed', 'true');
198
+ distributed += 1;
199
+ }
200
+
201
+ for (const [control, messages] of perControl) {
202
+ applyErrors(control, messages);
203
+ }
204
+
205
+ alert.dataset.distributed =
206
+ distributed === 0 ? 'none' : distributed === total ? 'all' : 'partial';
207
+
208
+ if (alert.getAttribute('data-focus') !== 'none') {
209
+ const first = perControl.keys().next().value;
210
+ try {
211
+ first?.focus?.();
212
+ } catch {
213
+ /* unfocusable control — the wiring still stands */
214
+ }
215
+ }
216
+ }
217
+
218
+ function scan(node, root) {
219
+ if (!node || typeof node.querySelectorAll !== 'function') return;
220
+ if (typeof node.matches === 'function' && node.matches('[data-hc-field-errors]')) {
221
+ distribute(node, root);
222
+ }
223
+ for (const alert of node.querySelectorAll('[data-hc-field-errors]')) {
224
+ distribute(alert, root);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Install the field-errors behavior: distribute server-sent
230
+ * `[data-hc-field-errors]` fragments (see the `field-errors` recipe) to
231
+ * the form fields they name, with the same ARIA wiring
232
+ * `installValidation()` uses.
233
+ *
234
+ * Fragments are picked up when swapped in by htmx (`htmx:afterSwap` /
235
+ * `htmx:oobAfterSwap`), when inserted by any other means
236
+ * (`MutationObserver`), and once at install time for a full-page render.
237
+ *
238
+ * @param {Document|Element} [root]
239
+ * The root to watch. Defaults to the global document when available.
240
+ * @returns {() => void} an idempotent uninstaller.
241
+ */
242
+ export function installFieldErrors(root = (typeof document !== 'undefined' ? document : null)) {
243
+ if (!root) return () => {};
244
+ if (root[INSTALL_KEY]) return root[INSTALL_KEY];
245
+
246
+ function onSwap(event) {
247
+ scan(event.target, root);
248
+ }
249
+
250
+ function onInput(event) {
251
+ const el = event.target;
252
+ if (el?.dataset?.hcServerInvalid != null) clearServerErrorFor(el);
253
+ }
254
+
255
+ function onSubmitOrReset(event) {
256
+ const form = event.target;
257
+ if (form && typeof form.querySelectorAll === 'function') clearServerErrors(form);
258
+ }
259
+
260
+ root.addEventListener('htmx:afterSwap', onSwap);
261
+ root.addEventListener('htmx:oobAfterSwap', onSwap);
262
+ root.addEventListener('input', onInput);
263
+ root.addEventListener('change', onInput);
264
+ root.addEventListener('submit', onSubmitOrReset, true);
265
+ root.addEventListener('reset', onSubmitOrReset, true);
266
+
267
+ const observer = new MutationObserver((mutations) => {
268
+ for (const mutation of mutations) {
269
+ for (const node of mutation.addedNodes) {
270
+ if (node.nodeType === 1) scan(node, root);
271
+ }
272
+ }
273
+ });
274
+ const observeTarget = root.nodeType === 9 ? root.documentElement : root;
275
+ observer.observe(observeTarget, { childList: true, subtree: true });
276
+
277
+ // A full-page error render (no swap) is distributed immediately.
278
+ scan(observeTarget, root);
279
+
280
+ const uninstall = () => {
281
+ if (root[INSTALL_KEY] !== uninstall) return;
282
+ root.removeEventListener('htmx:afterSwap', onSwap);
283
+ root.removeEventListener('htmx:oobAfterSwap', onSwap);
284
+ root.removeEventListener('input', onInput);
285
+ root.removeEventListener('change', onInput);
286
+ root.removeEventListener('submit', onSubmitOrReset, true);
287
+ root.removeEventListener('reset', onSubmitOrReset, true);
288
+ observer.disconnect();
289
+ delete root[INSTALL_KEY];
290
+ };
291
+ root[INSTALL_KEY] = uninstall;
292
+ return uninstall;
293
+ }
package/dist/hc-alert.css CHANGED
@@ -1,7 +1,10 @@
1
1
  /* hc-alert — block-level notice / status message.
2
2
  *
3
3
  * Variants via data-variant: info (default) | success | warning | error
4
- * Optional parts: .hc-alert__title, .hc-alert__body
4
+ * Optional parts: .hc-alert__title, .hc-alert__body,
5
+ * .hc-alert__errors > .hc-alert__error (field-error list — see the
6
+ * `field-errors` recipe; `installFieldErrors()` distributes the items
7
+ * to their fields and marks them `data-distributed`).
5
8
  *
6
9
  * Accessibility: set role="status" (or aria-live="polite") on the
7
10
  * element for transient updates; role="alert" for urgent ones. The
@@ -34,6 +37,31 @@
34
37
  margin: 0;
35
38
  }
36
39
 
40
+ /* Field-error list (server validation fragment — `field-errors` recipe). */
41
+ .hc-alert__errors {
42
+ margin: 0;
43
+ padding-inline-start: 1.25em;
44
+ }
45
+
46
+ /* An item that installFieldErrors() has distributed to its field is
47
+ * hidden in the summary so the message isn't read twice. Items naming
48
+ * no known control keep rendering here. */
49
+ .hc-alert[data-hc-field-errors] .hc-alert__error[data-distributed="true"] {
50
+ display: none;
51
+ }
52
+
53
+ /* …and when every item was distributed, the now-empty list collapses. */
54
+ .hc-alert[data-hc-field-errors]
55
+ .hc-alert__errors:not(:has(.hc-alert__error:not([data-distributed="true"]))) {
56
+ display: none;
57
+ }
58
+
59
+ /* Opt-in: a fragment that carries only field errors (no standalone
60
+ * summary worth keeping) hides itself entirely once fully distributed. */
61
+ .hc-alert[data-hc-field-errors][data-summary="auto"][data-distributed="all"] {
62
+ display: none;
63
+ }
64
+
37
65
  .hc-alert[data-variant="success"] {
38
66
  background: var(--hc-alert-success-bg);
39
67
  color: var(--hc-alert-success-fg);
@@ -0,0 +1,39 @@
1
+ /* hc-chip — a quiet, pill-shaped token for facts and attributes
2
+ * (capabilities, tags, applied filters). Neutral surface + border by
3
+ * design: for *status* coloring use hc-badge (variant pills) or the
4
+ * .hc-status utility instead.
5
+ *
6
+ * <ul class="hc-chips">
7
+ * <li class="hc-chip">read:members</li>
8
+ * <li class="hc-chip">write:members</li>
9
+ * </ul>
10
+ *
11
+ * Pure CSS, no behavior. A chip with a trailing remove control is the
12
+ * multicombobox tag's job — chips here are presentational.
13
+ */
14
+ @layer hc.components {
15
+ .hc-chip {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ gap: var(--hc-space-1);
19
+ padding-inline: var(--hc-chip-padding-x);
20
+ padding-block: var(--hc-chip-padding-y);
21
+ border: 1px solid var(--hc-chip-border);
22
+ border-radius: var(--hc-chip-radius);
23
+ background: var(--hc-chip-bg);
24
+ color: var(--hc-chip-fg);
25
+ font-size: var(--hc-chip-font-size);
26
+ line-height: 1.4;
27
+ }
28
+
29
+ /* List wrapper — strips the list chrome and wraps chips on a gap. The
30
+ * <ul>/<li> form keeps the set announceable as a list ("3 items"). */
31
+ .hc-chips {
32
+ display: flex;
33
+ flex-wrap: wrap;
34
+ gap: var(--hc-chip-gap);
35
+ margin: 0;
36
+ padding: 0;
37
+ list-style: none;
38
+ }
39
+ }
@@ -51,7 +51,7 @@
51
51
  min-inline-size: var(--hc-combobox-listbox-min-width);
52
52
  max-block-size: var(--hc-combobox-listbox-max-height);
53
53
  overflow-y: auto;
54
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
54
+ box-shadow: var(--hc-shadow-lg);
55
55
  list-style: none;
56
56
  }
57
57
 
@@ -36,7 +36,7 @@
36
36
  background: var(--hc-command-bg);
37
37
  color: var(--hc-command-fg);
38
38
  overflow: hidden;
39
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
39
+ box-shadow: var(--hc-shadow-lg);
40
40
  }
41
41
 
42
42
  .hc-command__input {
@@ -38,7 +38,7 @@
38
38
  /* Contextual shadow cast by a frozen column's trailing edge — its
39
39
  direction flips per edge (see the frozen-edge rules below), so it
40
40
  stays a CSS-local var, not a theme token. */
41
- --hc-datagrid-freeze-shadow: 2px 0 4px -2px rgb(0, 0, 0, 0.25);
41
+ --hc-datagrid-freeze-shadow: 2px 0 4px -2px var(--hc-shadow-edge);
42
42
 
43
43
  position: relative;
44
44
  border: 1px solid var(--hc-datagrid-border);
@@ -173,7 +173,7 @@
173
173
 
174
174
  /* RTL: the freeze line falls on the other (inline-end) side. */
175
175
  .hc-datagrid:dir(rtl) {
176
- --hc-datagrid-freeze-shadow: -2px 0 4px -2px rgb(0, 0, 0, 0.25);
176
+ --hc-datagrid-freeze-shadow: -2px 0 4px -2px var(--hc-shadow-edge);
177
177
  }
178
178
 
179
179
  /* ---- States ----
@@ -355,7 +355,7 @@
355
355
  line-height: 1.4;
356
356
  white-space: normal;
357
357
  overflow-wrap: anywhere;
358
- box-shadow: 0 4px 12px rgb(0, 0, 0, 0.25);
358
+ box-shadow: var(--hc-shadow-lg);
359
359
  pointer-events: none;
360
360
  }
361
361
 
@@ -14,7 +14,7 @@
14
14
  color: var(--hc-dialog-fg);
15
15
  max-inline-size: var(--hc-dialog-max-width);
16
16
  inline-size: calc(100% - 2 * var(--hc-space-4));
17
- box-shadow: 0 10px 30px rgb(0, 0, 0, 0.15);
17
+ box-shadow: var(--hc-shadow-overlay);
18
18
  }
19
19
 
20
20
  .hc-dialog::backdrop {
@@ -30,7 +30,7 @@
30
30
  background: var(--hc-drawer-bg);
31
31
  color: var(--hc-drawer-fg);
32
32
  overflow: hidden;
33
- box-shadow: 0 0 30px rgb(0, 0, 0, 0.2);
33
+ box-shadow: var(--hc-shadow-overlay);
34
34
 
35
35
  /* Default side = right. Specific sides override below. */
36
36
  inset-block: 0;
@@ -40,7 +40,7 @@
40
40
  color: var(--hc-hovercard-fg);
41
41
  inline-size: max-content;
42
42
  max-inline-size: var(--hc-hovercard-max-width);
43
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.14);
43
+ box-shadow: var(--hc-shadow-lg);
44
44
 
45
45
  /* Shared directional placement (hc-anchored.css). */
46
46
  --hc-anchored-offset: var(--hc-hovercard-offset);
package/dist/hc-item.css CHANGED
@@ -52,9 +52,11 @@
52
52
  outline-offset: -2px;
53
53
  }
54
54
 
55
- /* Selected — option / list selection. */
55
+ /* Selected — option / list selection — and the current page in a nav
56
+ * list (aria-current="page" on the active sidebar link). */
56
57
  .hc-item[aria-selected="true"],
57
- .hc-item[data-selected] {
58
+ .hc-item[data-selected],
59
+ .hc-item[aria-current]:not([aria-current="false"]) {
58
60
  background: var(--hc-item-selected-bg);
59
61
  color: var(--hc-item-selected-fg);
60
62
  }
package/dist/hc-menu.css CHANGED
@@ -34,7 +34,7 @@
34
34
  color: var(--hc-menu-fg);
35
35
  min-inline-size: var(--hc-menu-min-width);
36
36
  max-inline-size: var(--hc-menu-max-width);
37
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
37
+ box-shadow: var(--hc-shadow-lg);
38
38
  }
39
39
 
40
40
  /* Anchor Positioning path. installMenu injects the matching
@@ -117,7 +117,7 @@
117
117
  min-inline-size: var(--hc-multicombobox-listbox-min-width);
118
118
  max-block-size: var(--hc-multicombobox-listbox-max-height);
119
119
  overflow-y: auto;
120
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
120
+ box-shadow: var(--hc-shadow-lg);
121
121
  list-style: none;
122
122
  }
123
123
 
@@ -77,7 +77,7 @@
77
77
  background: var(--hc-navmenu-panel-bg);
78
78
  color: var(--hc-navmenu-panel-fg);
79
79
  min-inline-size: var(--hc-navmenu-panel-min-width);
80
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
80
+ box-shadow: var(--hc-shadow-lg);
81
81
  }
82
82
 
83
83
  /* Links inside a panel stack as a readable list. */
@@ -14,7 +14,7 @@
14
14
  color: var(--hc-popover-fg);
15
15
  min-inline-size: var(--hc-popover-min-width);
16
16
  max-inline-size: var(--hc-popover-max-width);
17
- box-shadow: 0 6px 20px rgb(0, 0, 0, 0.12);
17
+ box-shadow: var(--hc-shadow-lg);
18
18
 
19
19
  /* Shared directional placement (hc-anchored.css). Anchoring is opt-in via
20
20
  * data-side + installPopover; a bare popover stays browser-positioned. */
@@ -46,7 +46,7 @@
46
46
  block-size: var(--hc-switch-thumb-size);
47
47
  background-color: var(--hc-switch-thumb-bg);
48
48
  border-radius: 50%;
49
- box-shadow: 0 1px 2px rgb(0, 0, 0, 0.15);
49
+ box-shadow: var(--hc-shadow-sm);
50
50
  translate: 0 -50%;
51
51
  transition: translate 120ms ease;
52
52
  }
package/dist/hc-table.css CHANGED
@@ -38,6 +38,27 @@
38
38
  padding-block: var(--hc-space-1);
39
39
  }
40
40
 
41
+ /* Key-value variant — a two-column definition table for detail views:
42
+ *
43
+ * <table class="hc-table" data-variant="kv">
44
+ * <tbody>
45
+ * <tr><th scope="row">Realm id</th><td>local</td></tr>
46
+ * <tr><th scope="row">Created</th><td>2026-06-01</td></tr>
47
+ * </tbody>
48
+ * </table>
49
+ *
50
+ * Keys are row headers (<th scope="row">) on a fixed inline size with
51
+ * muted text; values keep the body styling. No <thead> needed. */
52
+ .hc-table[data-variant="kv"] tbody th {
53
+ inline-size: var(--hc-table-kv-key-width);
54
+ color: var(--hc-table-kv-key-fg);
55
+ font-weight: var(--hc-table-header-weight);
56
+ }
57
+
58
+ .hc-table[data-variant="kv"] tbody tr:hover {
59
+ background: none;
60
+ }
61
+
41
62
  /* Horizontal-scroll wrapper for wide tables on narrow viewports. A
42
63
  * data table has no intrinsic way to shrink below its columns' content
43
64
  * width, so wrap it in this element to confine the overflow to a