@urbicon-ui/blocks 6.24.0 → 6.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
- package/dist/components/Calendar/calendar.variants.js +5 -3
- package/dist/components/CommandPalette/CommandPalette.svelte +32 -16
- package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +1 -1
- package/dist/components/CommandPalette/commandPalette.variants.d.ts +10 -0
- package/dist/components/CommandPalette/commandPalette.variants.js +9 -7
- package/dist/components/CommandPalette/index.d.ts +19 -0
- package/dist/components/CompositionBar/CompositionBar.svelte +1 -1
- package/dist/components/CompositionBar/composition-bar.variants.js +1 -1
- package/dist/components/Planner/planner.variants.js +1 -1
- package/dist/components/Sankey/sankey.variants.js +1 -1
- package/dist/internal/charts/variants.js +2 -2
- package/dist/internal/date-grid/DateGridScaffold.svelte +9 -2
- package/dist/internal/date-grid/date-grid.svelte.d.ts +4 -2
- package/dist/internal/date-grid/date-grid.svelte.js +63 -7
- package/dist/primitives/Avatar/Avatar.svelte +38 -24
- package/dist/primitives/Avatar/index.d.ts +1 -1
- package/dist/primitives/Button/Button.svelte +12 -25
- package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
- package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
- package/dist/primitives/Collapsible/index.d.ts +13 -2
- package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
- package/dist/primitives/ConfirmDialog/index.d.ts +13 -2
- package/dist/primitives/Dialog/Dialog.svelte +31 -8
- package/dist/primitives/Drawer/Drawer.svelte +29 -7
- package/dist/primitives/Input/Input.svelte +7 -1
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
- package/dist/primitives/Menu/Menu.svelte +8 -0
- package/dist/primitives/Menu/index.d.ts +11 -13
- package/dist/primitives/Popover/Popover.svelte +107 -4
- package/dist/primitives/Popover/index.d.ts +15 -2
- package/dist/primitives/Popover/index.js +1 -1
- package/dist/primitives/Popover/popover.variants.d.ts +25 -0
- package/dist/primitives/Popover/popover.variants.js +35 -0
- package/dist/primitives/Progress/progress.variants.js +1 -1
- package/dist/primitives/RadioGroup/radioGroup.variants.js +1 -1
- package/dist/primitives/Select/index.d.ts +8 -1
- package/dist/primitives/Slider/Slider.svelte +32 -3
- package/dist/primitives/Stepper/stepper.variants.js +1 -1
- package/dist/primitives/Textarea/Textarea.svelte +5 -1
- package/dist/primitives/Tooltip/tooltip.variants.js +13 -2
- package/dist/style/foundation.css +30 -3
- package/dist/style/interaction.css +19 -2
- package/dist/style/semantic.css +72 -1
- package/dist/style/themes/neutral.css +10 -1
- package/dist/style/themes/ocean.css +5 -1
- package/dist/utils/compose-handlers.d.ts +72 -0
- package/dist/utils/compose-handlers.js +65 -0
- package/dist/utils/figma-token-export.js +1 -1
- package/dist/utils/guide.svelte.js +92 -29
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/overlay-tokens.d.ts +17 -0
- package/dist/utils/overlay-tokens.js +30 -0
- package/dist/utils/use-floating-panel.svelte.js +9 -3
- package/dist/utils/variants.js +5 -1
- package/package.json +3 -3
|
@@ -1,9 +1,44 @@
|
|
|
1
1
|
import { tv } from '../../utils/variants.js';
|
|
2
|
+
/**
|
|
3
|
+
* CSS-native enter/exit motion for the floating panel (ACC-3 rest), keyed on
|
|
4
|
+
* the `data-state` attribute Popover always stamps. Kept as its own fragment
|
|
5
|
+
* because two call sites need exactly these classes: `popoverVariants.base`
|
|
6
|
+
* below, and Menu — which renders its inner Popover `unstyled` (to avoid a
|
|
7
|
+
* double surface) and re-applies the fragment via Popover's `class` prop.
|
|
8
|
+
*
|
|
9
|
+
* How the two halves work:
|
|
10
|
+
* - **Enter** — the panel un-hides via `showPopover()` (top layer) or a
|
|
11
|
+
* `display` flip (in-place mode), so a plain transition has no before-state;
|
|
12
|
+
* `starting:` (`@starting-style`) supplies it.
|
|
13
|
+
* - **Exit** — `hidePopover()` / a native light dismiss yank the panel to
|
|
14
|
+
* `display: none` in the same style recalc that flips `data-state`;
|
|
15
|
+
* `transition-discrete` (`transition-behavior: allow-discrete`) on
|
|
16
|
+
* `display`/`overlay` keeps it painted (and in the top layer) until the
|
|
17
|
+
* fade lands. Popover lags the children-teardown to match — see the
|
|
18
|
+
* exit-motion block in Popover.svelte.
|
|
19
|
+
*
|
|
20
|
+
* Browsers without `@starting-style`/`allow-discrete` simply skip the motion
|
|
21
|
+
* and keep today's instant toggle. Duration/easing resolve through the
|
|
22
|
+
* `--blocks-popover-*` tokens (interaction.css), which reduced motion
|
|
23
|
+
* collapses to 1ms; `motion-reduce:duration-[1ms]` guards the inline
|
|
24
|
+
* per-instance override path, which can't see the media query.
|
|
25
|
+
*/
|
|
26
|
+
export const popoverMotion = [
|
|
27
|
+
'transition-[opacity,scale,display,overlay] transition-discrete',
|
|
28
|
+
'duration-[var(--blocks-popover-duration)] ease-[var(--blocks-popover-easing)]',
|
|
29
|
+
'motion-reduce:duration-[1ms]',
|
|
30
|
+
// pointer-events-none while closed: during the exit fade the children are
|
|
31
|
+
// still mounted and painted — without this, a quick click on the fading
|
|
32
|
+
// panel would fire consumer handlers from a visually dismissed surface.
|
|
33
|
+
'data-[state=closed]:opacity-0 data-[state=closed]:scale-[0.98] data-[state=closed]:pointer-events-none',
|
|
34
|
+
'starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-[0.98]'
|
|
35
|
+
].join(' ');
|
|
2
36
|
export const popoverVariants = tv({
|
|
3
37
|
// tier: contain — floating panel surface.
|
|
4
38
|
base: [
|
|
5
39
|
'bg-surface-elevated border border-border-hairline rounded-contain',
|
|
6
40
|
'shadow-[var(--blocks-shadow-md)] backdrop-blur-sm',
|
|
41
|
+
popoverMotion,
|
|
7
42
|
// `calc(100dvh-4rem)` is the static design cap; Floating UI's `size`
|
|
8
43
|
// middleware (via useFloatingPanel) narrows it to the room actually left
|
|
9
44
|
// between the anchor and the visual viewport edge through
|
|
@@ -256,7 +256,14 @@ interface SelectBaseProps<T extends SelectValue = string> extends Omit<SelectVar
|
|
|
256
256
|
* and make the custom look reusable across the project.
|
|
257
257
|
*/
|
|
258
258
|
preset?: string;
|
|
259
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* Explicit `id` for the component. Auto-generated if omitted.
|
|
261
|
+
*
|
|
262
|
+
* The id lands on the component's wrapper, which is not a labelable element.
|
|
263
|
+
* The focusable trigger is derived from it as `` `${id}-trigger` `` — that is
|
|
264
|
+
* the id an external `<label for>` has to address, since a `for` pointing at
|
|
265
|
+
* the wrapper focuses nothing.
|
|
266
|
+
*/
|
|
260
267
|
id?: string;
|
|
261
268
|
}
|
|
262
269
|
/**
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
slotClasses: slotClassesProp = {},
|
|
36
36
|
preset,
|
|
37
37
|
'aria-describedby': ariaDescribedby,
|
|
38
|
+
'aria-labelledby': ariaLabelledby,
|
|
38
39
|
...restProps
|
|
39
40
|
}: SliderProps = $props();
|
|
40
41
|
|
|
@@ -53,6 +54,8 @@
|
|
|
53
54
|
disabled
|
|
54
55
|
}));
|
|
55
56
|
const statusId = `${uid}-status`;
|
|
57
|
+
const minLabelId = `${uid}-min-label`;
|
|
58
|
+
const maxLabelId = `${uid}-max-label`;
|
|
56
59
|
|
|
57
60
|
let trackRef = $state<HTMLDivElement>();
|
|
58
61
|
let dragging = $state<'single' | 'start' | 'end' | null>(null);
|
|
@@ -251,6 +254,17 @@
|
|
|
251
254
|
.join(' ') || undefined
|
|
252
255
|
);
|
|
253
256
|
|
|
257
|
+
// `aria-labelledby` has the same reach problem as `aria-describedby` above:
|
|
258
|
+
// restProps land on the roleless wrapper div, so an external visible label
|
|
259
|
+
// would never name the focusable thumbs. On the range arm one external label
|
|
260
|
+
// would name both thumbs identically ("Padding" / "Padding"), so it is
|
|
261
|
+
// composed with a visually-hidden minimum/maximum qualifier —
|
|
262
|
+
// aria-labelledby concatenates its references in order, yielding
|
|
263
|
+
// "Padding Minimum" / "Padding Maximum" and preserving the distinction the
|
|
264
|
+
// `label`-prop arm gets for free.
|
|
265
|
+
const startLabelledBy = $derived(ariaLabelledby ? `${ariaLabelledby} ${minLabelId}` : undefined);
|
|
266
|
+
const endLabelledBy = $derived(ariaLabelledby ? `${ariaLabelledby} ${maxLabelId}` : undefined);
|
|
267
|
+
|
|
254
268
|
$effect(() => {
|
|
255
269
|
if (trackRef && mint && mint !== 'none' && !disabled) {
|
|
256
270
|
return mintRegistry.apply(trackRef, mint);
|
|
@@ -429,13 +443,22 @@
|
|
|
429
443
|
{/if}
|
|
430
444
|
|
|
431
445
|
{#if range}
|
|
446
|
+
{#if ariaLabelledby}
|
|
447
|
+
<span id={minLabelId} class="sr-only">{bt('accessibility.minimum') || 'Minimum'}</span>
|
|
448
|
+
<span id={maxLabelId} class="sr-only">{bt('accessibility.maximum') || 'Maximum'}</span>
|
|
449
|
+
{/if}
|
|
432
450
|
<div
|
|
433
451
|
role="slider"
|
|
434
452
|
tabindex={disabled ? -1 : 0}
|
|
435
453
|
aria-valuemin={min}
|
|
436
454
|
aria-valuemax={rangeValue[1]}
|
|
437
455
|
aria-valuenow={rangeValue[0]}
|
|
438
|
-
aria-
|
|
456
|
+
aria-labelledby={startLabelledBy}
|
|
457
|
+
aria-label={startLabelledBy
|
|
458
|
+
? undefined
|
|
459
|
+
: label
|
|
460
|
+
? `${label} minimum`
|
|
461
|
+
: bt('accessibility.minimum') || 'Minimum'}
|
|
439
462
|
aria-describedby={describedBy}
|
|
440
463
|
aria-disabled={disabled || undefined}
|
|
441
464
|
class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
|
|
@@ -449,7 +472,12 @@
|
|
|
449
472
|
aria-valuemin={rangeValue[0]}
|
|
450
473
|
aria-valuemax={max}
|
|
451
474
|
aria-valuenow={rangeValue[1]}
|
|
452
|
-
aria-
|
|
475
|
+
aria-labelledby={endLabelledBy}
|
|
476
|
+
aria-label={endLabelledBy
|
|
477
|
+
? undefined
|
|
478
|
+
: label
|
|
479
|
+
? `${label} maximum`
|
|
480
|
+
: bt('accessibility.maximum') || 'Maximum'}
|
|
453
481
|
aria-describedby={describedBy}
|
|
454
482
|
aria-disabled={disabled || undefined}
|
|
455
483
|
class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
|
|
@@ -464,7 +492,8 @@
|
|
|
464
492
|
aria-valuemin={min}
|
|
465
493
|
aria-valuemax={max}
|
|
466
494
|
aria-valuenow={singleValue}
|
|
467
|
-
aria-
|
|
495
|
+
aria-labelledby={ariaLabelledby}
|
|
496
|
+
aria-label={ariaLabelledby ? undefined : label || bt('accessibility.slider') || 'Slider'}
|
|
468
497
|
aria-describedby={describedBy}
|
|
469
498
|
aria-disabled={disabled || undefined}
|
|
470
499
|
class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
slotClasses: slotClassesProp = {},
|
|
30
30
|
preset,
|
|
31
31
|
oninput: userOnInput,
|
|
32
|
+
id: idProp,
|
|
32
33
|
'aria-describedby': ariaDescribedby,
|
|
33
34
|
...restProps
|
|
34
35
|
}: TextareaProps = $props();
|
|
@@ -42,9 +43,12 @@
|
|
|
42
43
|
let textareaRef = $state<HTMLTextAreaElement>();
|
|
43
44
|
|
|
44
45
|
// ARIA wiring is shared with every form primitive — see XC-2.
|
|
46
|
+
// A consumer-supplied `id` wins over the generated one, so an external
|
|
47
|
+
// `<label for>` can address the `<textarea>` (mirrors the Input role model).
|
|
45
48
|
const propsId = $props.id();
|
|
49
|
+
const fieldId = $derived(idProp ?? `textarea-${propsId}`);
|
|
46
50
|
const ff = useFormField(() => ({
|
|
47
|
-
fieldId
|
|
51
|
+
fieldId,
|
|
48
52
|
helper,
|
|
49
53
|
error,
|
|
50
54
|
required,
|
|
@@ -26,7 +26,16 @@ export const tooltipVariants = tv({
|
|
|
26
26
|
// guards the inline-override path (an inline duration bypasses the token that
|
|
27
27
|
// reduced-motion collapses to 1ms). The default easing token resolves to
|
|
28
28
|
// Tailwind's implicit transition curve, so the resting fade is unchanged.
|
|
29
|
-
|
|
29
|
+
//
|
|
30
|
+
// display/overlay + `transition-discrete` and the `starting:` before-state
|
|
31
|
+
// are what make the fade actually PLAY in top-layer mode (same mechanism
|
|
32
|
+
// as popoverMotion): without them, showPopover() reveals the chip with no
|
|
33
|
+
// before-state (enter pops to opacity 1) and hidePopover() yanks it to
|
|
34
|
+
// display:none in the same recalc (exit never paints). Verified 2026-07-14
|
|
35
|
+
// — the original transition-opacity fade only ever ran in the rare
|
|
36
|
+
// in-dialog fallback, where visibility is purely opacity-driven.
|
|
37
|
+
'transition-[opacity,display,overlay] transition-discrete',
|
|
38
|
+
'duration-[var(--blocks-tooltip-duration)] ease-[var(--blocks-tooltip-easing)]',
|
|
30
39
|
'motion-reduce:duration-[1ms]',
|
|
31
40
|
'bg-surface-inverted text-text-inverted'
|
|
32
41
|
],
|
|
@@ -34,7 +43,9 @@ export const tooltipVariants = tv({
|
|
|
34
43
|
},
|
|
35
44
|
variants: {
|
|
36
45
|
open: {
|
|
37
|
-
|
|
46
|
+
// `starting:` supplies the @starting-style before-state for the frame
|
|
47
|
+
// showPopover() first renders the chip — the enter half of the fade.
|
|
48
|
+
true: { base: 'opacity-100 starting:opacity-0' },
|
|
38
49
|
false: { base: 'opacity-0' }
|
|
39
50
|
},
|
|
40
51
|
intent: {
|
|
@@ -53,10 +53,19 @@
|
|
|
53
53
|
--color-warm-neutral-950: oklch(0.12 0.006 45);
|
|
54
54
|
|
|
55
55
|
/* Darkened *-500 / *-600 / *-700 so text-on-primary (white) passes WCAG
|
|
56
|
-
AA (4.5:1) on the solid intent backgrounds
|
|
56
|
+
AA (4.5:1) on the solid intent backgrounds **in light mode**, where
|
|
57
|
+
--color-<intent> resolves to the -500/-600 shades. Previous values had
|
|
57
58
|
L=0.65 which produced ~3:1 contrast — below AA. Warning keeps its
|
|
58
59
|
yellow hue at L=0.75 because it pairs with text-on-surface (dark),
|
|
59
|
-
not white.
|
|
60
|
+
not white.
|
|
61
|
+
|
|
62
|
+
Scope, measured (contrast.test.ts, 2026-07-14): this holds for light
|
|
63
|
+
mode only. In dark mode --color-<intent> resolves to the *lighter*
|
|
64
|
+
-400/-300/-200 shades while --color-text-on-primary stays white, so
|
|
65
|
+
every filled intent lands at 1.5–4.1:1. That is a semantic.css issue
|
|
66
|
+
(text-on-primary is not mode-aware), not something these ramps can fix
|
|
67
|
+
— see the KNOWN SHORTFALLS block in contrast.test.ts. Do not "fix" it
|
|
68
|
+
by lightening these ramps; that would break light mode instead. */
|
|
60
69
|
--color-success-50: oklch(0.95 0.03 140);
|
|
61
70
|
--color-success-100: oklch(0.9 0.05 140);
|
|
62
71
|
--color-success-200: oklch(0.82 0.08 140);
|
|
@@ -101,13 +110,31 @@
|
|
|
101
110
|
--color-info-200: oklch(0.84 0.075 220);
|
|
102
111
|
--color-info-300: oklch(0.76 0.105 220);
|
|
103
112
|
--color-info-400: oklch(0.66 0.13 220);
|
|
104
|
-
|
|
113
|
+
/* L=0.54, not 0.55: hue 220 is luminous enough that 0.55 measures 4.41:1
|
|
114
|
+
against text-on-primary (white) — below AA, despite the promise above.
|
|
115
|
+
0.54 is the smallest step that clears it (4.58:1) and keeps the ramp's
|
|
116
|
+
0.12 step down from info-400. Guarded by contrast.test.ts. */
|
|
117
|
+
--color-info-500: oklch(0.54 0.14 220);
|
|
105
118
|
--color-info-600: oklch(0.48 0.13 220);
|
|
106
119
|
--color-info-700: oklch(0.4 0.11 220);
|
|
107
120
|
--color-info-800: oklch(0.32 0.09 220);
|
|
108
121
|
--color-info-900: oklch(0.24 0.07 220);
|
|
109
122
|
--color-info-950: oklch(0.16 0.05 220);
|
|
110
123
|
|
|
124
|
+
/* Sub-xs Type Scale — extends Tailwind's own `--text-*` namespace downwards.
|
|
125
|
+
Tailwind bottoms out at --text-xs (12px), but dense data marks (chart axis
|
|
126
|
+
labels, mini-calendar day cells, kbd hints) need 10–11px. Defining them here
|
|
127
|
+
rather than hardcoding `text-[11px]` at the call site is what makes those
|
|
128
|
+
sizes reachable by a consumer's own @theme override.
|
|
129
|
+
|
|
130
|
+
Deliberately size-only: NO paired `--text-*--line-height`. Tailwind emits
|
|
131
|
+
`line-height` for a scale entry only when the paired key exists, so adding
|
|
132
|
+
one would inject a line-height into every call site that today inherits it
|
|
133
|
+
from the cascade — a rendering change, not a refactor. Consumers who want
|
|
134
|
+
one can add the paired key in their own @theme; the utility picks it up. */
|
|
135
|
+
--text-2xs: 0.6875rem;
|
|
136
|
+
--text-3xs: 0.625rem;
|
|
137
|
+
|
|
111
138
|
/* Border Radius Scale — physical sizes */
|
|
112
139
|
--radius-xs: 0.125rem;
|
|
113
140
|
--radius-sm: 0.25rem;
|
|
@@ -98,8 +98,8 @@
|
|
|
98
98
|
transitions, so they read these through the JS mirror in
|
|
99
99
|
`utils/overlay-tokens.ts` (`getOverlayMotion`) — Svelte transitions need
|
|
100
100
|
numeric input. Tooltip runs a deliberately faster CSS opacity fade off its
|
|
101
|
-
own `--blocks-tooltip-*` tokens below; Popover
|
|
102
|
-
|
|
101
|
+
own `--blocks-tooltip-*` tokens below; Popover (and Menu through it) runs a
|
|
102
|
+
CSS-native fade/scale off the `--blocks-popover-*` tokens below.
|
|
103
103
|
|
|
104
104
|
Override globally with a `BlocksProvider` style block, or per instance via
|
|
105
105
|
the component's `transitionDuration` / `transitionEasing` props.
|
|
@@ -130,6 +130,23 @@
|
|
|
130
130
|
--blocks-tooltip-duration: var(--blocks-duration-fast);
|
|
131
131
|
--blocks-tooltip-easing: var(--blocks-ease-confident);
|
|
132
132
|
|
|
133
|
+
/* === POPOVER MOTION TOKENS (ACC-3 rest) ===
|
|
134
|
+
The CSS-native enter/exit fade+scale for Popover (and Menu, which passes
|
|
135
|
+
the same motion fragment to its inner Popover). Anchored panels should
|
|
136
|
+
feel snappier than the 200ms modal overlays but calmer than an instant
|
|
137
|
+
toggle, so they share the tooltip's `fast`/`confident` pairing. Enter is
|
|
138
|
+
driven by `@starting-style` (the panel un-hides via `showPopover()`, so a
|
|
139
|
+
plain transition has no before-state); exit relies on
|
|
140
|
+
`transition-behavior: allow-discrete` on `display`/`overlay` to keep the
|
|
141
|
+
panel painted in the top layer while it fades. Both default through
|
|
142
|
+
`--blocks-duration-fast`, so `prefers-reduced-motion` below collapses them
|
|
143
|
+
to 1ms for free. Override globally here, or per instance via Popover's
|
|
144
|
+
`transitionDuration` / `transitionEasing` props (which set these inline +
|
|
145
|
+
carry a `motion-reduce` guard, since an inline duration can't see the
|
|
146
|
+
media query). */
|
|
147
|
+
--blocks-popover-duration: var(--blocks-duration-fast);
|
|
148
|
+
--blocks-popover-easing: var(--blocks-ease-confident);
|
|
149
|
+
|
|
133
150
|
/* === COLLAPSE ANIMATION TOKENS (ACC-3) ===
|
|
134
151
|
The CSS-driven grid-template-rows collapse shared by Accordion +
|
|
135
152
|
Collapsible (and their chevron spin). Default to the normal/smooth
|
package/dist/style/semantic.css
CHANGED
|
@@ -54,8 +54,31 @@
|
|
|
54
54
|
--color-text-tertiary: light-dark(var(--color-neutral-600), var(--color-neutral-300));
|
|
55
55
|
--color-text-quaternary: light-dark(var(--color-neutral-500), var(--color-neutral-400));
|
|
56
56
|
--color-text-disabled: light-dark(var(--color-neutral-300), var(--color-neutral-600));
|
|
57
|
+
/* The two on-colors below pair text with a *fill* rather than a surface.
|
|
58
|
+
Their fills are dark in light mode and light in dark mode, so the label
|
|
59
|
+
has to travel the opposite way — white, then near-black. They are
|
|
60
|
+
deliberately NOT aliased to one another (`text-on-primary: var(
|
|
61
|
+
--color-text-on-dark)`), even though they carry the same value today —
|
|
62
|
+
they answer to different fills and must stay independently overridable:
|
|
63
|
+
|
|
64
|
+
- text-on-dark is the partner of the avatar identity palette, whose 12
|
|
65
|
+
slots hold ONE lightness per mode (0.45 / 0.72) by construction.
|
|
66
|
+
- text-on-primary is the partner of the intent fills, whose ramps every
|
|
67
|
+
theme re-tunes, and which move again on hover/active.
|
|
68
|
+
|
|
69
|
+
Aliasing would couple them asymmetrically: a consumer re-tuning the avatar
|
|
70
|
+
palette's on-color would silently repaint every filled Button/Badge label.
|
|
71
|
+
Their equality is a consequence of both fill families straddling the same
|
|
72
|
+
lightness midpoint — not a definition. */
|
|
57
73
|
--color-text-on-dark: light-dark(var(--color-neutral-0), var(--color-neutral-900));
|
|
58
|
-
|
|
74
|
+
/* Dark mode pairs the *lighter* -400/-500 intent fills with dark text (the
|
|
75
|
+
Material-3 `onPrimary` pattern). White here was unconditional until
|
|
76
|
+
2026-07-14, which put 125 of the 126 dark-mode intent fills under WCAG AA
|
|
77
|
+
— down to 1.51:1, and getting *worse* on press. The mode-aware branch
|
|
78
|
+
clears all 108 fills this token governs; `warning` is not one of them (it
|
|
79
|
+
pairs with text-on-surface and is still under AA in dark mode — see the
|
|
80
|
+
KNOWN SHORTFALLS block in style/contrast.test.ts, which guards all of it). */
|
|
81
|
+
--color-text-on-primary: light-dark(var(--color-neutral-0), var(--color-neutral-900));
|
|
59
82
|
--color-text-on-surface: light-dark(var(--color-neutral-900), var(--color-neutral-100));
|
|
60
83
|
|
|
61
84
|
/* === PRIMARY INTENT ===
|
|
@@ -152,6 +175,54 @@
|
|
|
152
175
|
--color-chart-5: light-dark(var(--color-info-500), var(--color-info-400));
|
|
153
176
|
--color-chart-6: light-dark(var(--color-danger-500), var(--color-danger-400));
|
|
154
177
|
|
|
178
|
+
/* === AVATAR IDENTITY PALETTE ===
|
|
179
|
+
An *unordered* identity palette: Avatar's `randomColor` hashes a name to a
|
|
180
|
+
fixed slot, so the same person always keeps the same hue. Deliberately NOT
|
|
181
|
+
the chart palette and NOT the intent tokens:
|
|
182
|
+
|
|
183
|
+
- chart-* only has 6 slots and derives from the intent ramps, so a brand
|
|
184
|
+
that retunes `secondary` would silently reshuffle people's identities.
|
|
185
|
+
Its dark values are the *-400 tints (L≈0.66) — light enough that white
|
|
186
|
+
initials drop to ~2.6:1, below AA. Charts colour marks; avatars carry
|
|
187
|
+
text on top, which is a different contrast contract.
|
|
188
|
+
- intent tokens are semantic (a person is not "danger").
|
|
189
|
+
|
|
190
|
+
Construction: a single lightness per mode, 12 hues at an even 30° spacing.
|
|
191
|
+
Because OKLCH lightness is perceptual, holding L constant means every slot
|
|
192
|
+
shares one guaranteed contrast partner — `--color-text-on-dark`, which
|
|
193
|
+
flips white → neutral-900 with the mode exactly as these fills flip dark →
|
|
194
|
+
light. So legibility is a property of the construction, not 12 separate
|
|
195
|
+
checks. Even spacing also beats the 15 hardcoded hex values this replaced,
|
|
196
|
+
which clustered (red/orange/amber/yellow within ~20°) and blurred when
|
|
197
|
+
avatars sat side by side.
|
|
198
|
+
|
|
199
|
+
L=0.45 (light) / L=0.72 (dark) mirror the foundation ramps' *-700 / *-300
|
|
200
|
+
steps, so the palette reads as part of the same chassis. Rebrand by
|
|
201
|
+
overriding these 12 tokens; the text partner follows automatically. */
|
|
202
|
+
--color-avatar-1: light-dark(oklch(0.45 0.13 25), oklch(0.72 0.12 25));
|
|
203
|
+
--color-avatar-2: light-dark(oklch(0.45 0.13 55), oklch(0.72 0.12 55));
|
|
204
|
+
--color-avatar-3: light-dark(oklch(0.45 0.13 85), oklch(0.72 0.12 85));
|
|
205
|
+
--color-avatar-4: light-dark(oklch(0.45 0.13 115), oklch(0.72 0.12 115));
|
|
206
|
+
--color-avatar-5: light-dark(oklch(0.45 0.13 145), oklch(0.72 0.12 145));
|
|
207
|
+
--color-avatar-6: light-dark(oklch(0.45 0.13 175), oklch(0.72 0.12 175));
|
|
208
|
+
--color-avatar-7: light-dark(oklch(0.45 0.13 205), oklch(0.72 0.12 205));
|
|
209
|
+
--color-avatar-8: light-dark(oklch(0.45 0.13 235), oklch(0.72 0.12 235));
|
|
210
|
+
--color-avatar-9: light-dark(oklch(0.45 0.13 265), oklch(0.72 0.12 265));
|
|
211
|
+
--color-avatar-10: light-dark(oklch(0.45 0.13 295), oklch(0.72 0.12 295));
|
|
212
|
+
--color-avatar-11: light-dark(oklch(0.45 0.13 325), oklch(0.72 0.12 325));
|
|
213
|
+
--color-avatar-12: light-dark(oklch(0.45 0.13 355), oklch(0.72 0.12 355));
|
|
214
|
+
|
|
215
|
+
/* === LIVE / "NOW" ACCENT ===
|
|
216
|
+
Single-purpose accent for live/now markers — the Calendar current-time
|
|
217
|
+
line and dot, and future live/recording indicators. Red is the
|
|
218
|
+
convention (Google Calendar et al.), but the *purpose* is not `danger`:
|
|
219
|
+
a now-line states "you are here", not "something is wrong", and the two
|
|
220
|
+
must stay independently themeable. Defaults ride the danger foundation
|
|
221
|
+
ramp, so dark mode and per-theme danger re-tunes flow through
|
|
222
|
+
automatically; override `--color-live` alone to restyle live indicators
|
|
223
|
+
without touching error styling. */
|
|
224
|
+
--color-live: light-dark(var(--color-danger-500), var(--color-danger-400));
|
|
225
|
+
|
|
155
226
|
/* === BORDERS ===
|
|
156
227
|
border-hairline is the quietest tier — structural
|
|
157
228
|
trennlinien (row dividers, Card header/footer separators, inline
|
|
@@ -17,7 +17,16 @@
|
|
|
17
17
|
--color-primary-200: oklch(0.84 0.008 240);
|
|
18
18
|
--color-primary-300: oklch(0.76 0.01 240);
|
|
19
19
|
--color-primary-400: oklch(0.65 0.012 240);
|
|
20
|
-
|
|
20
|
+
/* L 0.58, not the 0.53 this ramp's spacing would suggest: primary-500 is the
|
|
21
|
+
dark-mode `--color-primary` fill, and dark mode carries dark text on it
|
|
22
|
+
(`--color-text-on-primary`). At 0.53 this grey measured 3.74:1 against
|
|
23
|
+
neutral-900 — the one intent fill in the whole system that missed AA after
|
|
24
|
+
the on-color became mode-aware, because a desaturated mid-grey is the
|
|
25
|
+
hardest case: no lightness clears AA against BOTH white and neutral-900
|
|
26
|
+
(proved in style/contrast.test.ts). 0.58 is the lowest step on this
|
|
27
|
+
ramp's 0.01 grid that clears it (4.59:1; the true crossing is 0.574 =
|
|
28
|
+
4.50:1, i.e. no margin at all). Monotonicity holds (0.65 > 0.58 > 0.43). */
|
|
29
|
+
--color-primary-500: oklch(0.58 0.012 240);
|
|
21
30
|
--color-primary-600: oklch(0.43 0.012 240);
|
|
22
31
|
--color-primary-700: oklch(0.36 0.01 240);
|
|
23
32
|
--color-primary-800: oklch(0.29 0.008 240);
|
|
@@ -26,7 +26,11 @@
|
|
|
26
26
|
--color-secondary-200: oklch(0.82 0.08 190);
|
|
27
27
|
--color-secondary-300: oklch(0.74 0.11 190);
|
|
28
28
|
--color-secondary-400: oklch(0.66 0.13 190);
|
|
29
|
-
|
|
29
|
+
/* L=0.54, not the foundation ramp's 0.55: at hue 190 (teal) the same
|
|
30
|
+
lightness measures 4.39:1 against text-on-primary (white) — below AA.
|
|
31
|
+
Lightness is not contrast-neutral across hues in OKLCH. Guarded by
|
|
32
|
+
contrast.test.ts. */
|
|
33
|
+
--color-secondary-500: oklch(0.54 0.12 190);
|
|
30
34
|
--color-secondary-600: oklch(0.48 0.12 190);
|
|
31
35
|
--color-secondary-700: oklch(0.4 0.1 190);
|
|
32
36
|
--color-secondary-800: oklch(0.32 0.08 190);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `composeHandlers` — run a component's own DOM event handler *and* a
|
|
3
|
+
* consumer-supplied one that arrived through `restProps`.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* The library's `restProps` contract (see docs/COMPONENT-API-CONVENTIONS.md)
|
|
8
|
+
* spreads `{...restProps}` **first**, so the component's own attributes win: a
|
|
9
|
+
* consumer can't silently cancel state the component owns. Applied naively to
|
|
10
|
+
* event handlers that rule would go too far in the other direction — a
|
|
11
|
+
* `restProps`-first spread makes the component's `onkeydown` clobber the
|
|
12
|
+
* *consumer's*, which is just the original bug pointed the other way.
|
|
13
|
+
*
|
|
14
|
+
* Neither side should lose. Handlers are additive by nature (the DOM itself
|
|
15
|
+
* allows many listeners per event), so the component destructures the handler
|
|
16
|
+
* out of `restProps` and composes it: **internal first, consumer second**.
|
|
17
|
+
*
|
|
18
|
+
* ## Ordering: internal first
|
|
19
|
+
*
|
|
20
|
+
* 1. The component's behaviour is then unconditional — it cannot be skipped by
|
|
21
|
+
* a consumer handler that throws, which is exactly what "internal wins"
|
|
22
|
+
* has to mean for a dismiss/focus-trap path.
|
|
23
|
+
* 2. The consumer observes the event *after* the component reacted, so
|
|
24
|
+
* `event.defaultPrevented` tells them whether the component claimed it
|
|
25
|
+
* (e.g. Dialog preventDefaults `Escape` when it closes on it).
|
|
26
|
+
*
|
|
27
|
+
* ## `preventDefault` is deliberately NOT a veto
|
|
28
|
+
*
|
|
29
|
+
* The consumer's handler runs after the internal one, so calling
|
|
30
|
+
* `event.preventDefault()` there cannot suppress the component's behaviour —
|
|
31
|
+
* by design. Overloading `preventDefault` as "also disable this component's
|
|
32
|
+
* dismissal" would re-create the silent-disable bug through a different door:
|
|
33
|
+
* `preventDefault` has an established DOM meaning (suppress the *browser's*
|
|
34
|
+
* default action), and a consumer calling it for that reason would lose
|
|
35
|
+
* dismissal without ever asking to. Opting out of a behaviour is spelled with
|
|
36
|
+
* the named, discoverable prop that already exists for it — `closeOnEscape`,
|
|
37
|
+
* `closeOnBackdropClick` — not with a magic event side-effect.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```svelte
|
|
41
|
+
* <script lang="ts">
|
|
42
|
+
* let { onkeydown: onkeydownProp, ...restProps }: Props = $props();
|
|
43
|
+
* function handleKeydown(event: KeyboardEvent) { ... }
|
|
44
|
+
* </script>
|
|
45
|
+
*
|
|
46
|
+
* <dialog {...restProps} onkeydown={composeHandlers(handleKeydown, onkeydownProp)}>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Structural mirror of svelte/elements' internal `EventHandler` type, which is
|
|
51
|
+
* declared there but not exported. Matching the shape keeps the composed result
|
|
52
|
+
* assignable to the `on*` props of `HTMLAttributes` (`MouseEventHandler<T>`,
|
|
53
|
+
* `KeyboardEventHandler<T>`, …).
|
|
54
|
+
*/
|
|
55
|
+
type DomEventHandler<TEvent extends Event, TElement extends EventTarget> = (event: TEvent & {
|
|
56
|
+
currentTarget: EventTarget & TElement;
|
|
57
|
+
}) => unknown;
|
|
58
|
+
/**
|
|
59
|
+
* Combine an internal handler with an optional consumer handler from `restProps`.
|
|
60
|
+
*
|
|
61
|
+
* The internal handler always runs first and always runs; the consumer handler
|
|
62
|
+
* runs afterwards when present. See the module doc for the ordering and
|
|
63
|
+
* `preventDefault` rationale.
|
|
64
|
+
*
|
|
65
|
+
* @param internal The component's own handler. Runs first, unconditionally.
|
|
66
|
+
* @param consumer The consumer's handler from `restProps`. May be `null`/`undefined`.
|
|
67
|
+
* @returns A handler assignable to the element's `on*` attribute.
|
|
68
|
+
*/
|
|
69
|
+
export declare function composeHandlers<TEvent extends Event, TElement extends EventTarget = Element>(internal: (event: TEvent & {
|
|
70
|
+
currentTarget: EventTarget & TElement;
|
|
71
|
+
}) => unknown, consumer: DomEventHandler<TEvent, TElement> | null | undefined): DomEventHandler<TEvent, TElement>;
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `composeHandlers` — run a component's own DOM event handler *and* a
|
|
3
|
+
* consumer-supplied one that arrived through `restProps`.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* The library's `restProps` contract (see docs/COMPONENT-API-CONVENTIONS.md)
|
|
8
|
+
* spreads `{...restProps}` **first**, so the component's own attributes win: a
|
|
9
|
+
* consumer can't silently cancel state the component owns. Applied naively to
|
|
10
|
+
* event handlers that rule would go too far in the other direction — a
|
|
11
|
+
* `restProps`-first spread makes the component's `onkeydown` clobber the
|
|
12
|
+
* *consumer's*, which is just the original bug pointed the other way.
|
|
13
|
+
*
|
|
14
|
+
* Neither side should lose. Handlers are additive by nature (the DOM itself
|
|
15
|
+
* allows many listeners per event), so the component destructures the handler
|
|
16
|
+
* out of `restProps` and composes it: **internal first, consumer second**.
|
|
17
|
+
*
|
|
18
|
+
* ## Ordering: internal first
|
|
19
|
+
*
|
|
20
|
+
* 1. The component's behaviour is then unconditional — it cannot be skipped by
|
|
21
|
+
* a consumer handler that throws, which is exactly what "internal wins"
|
|
22
|
+
* has to mean for a dismiss/focus-trap path.
|
|
23
|
+
* 2. The consumer observes the event *after* the component reacted, so
|
|
24
|
+
* `event.defaultPrevented` tells them whether the component claimed it
|
|
25
|
+
* (e.g. Dialog preventDefaults `Escape` when it closes on it).
|
|
26
|
+
*
|
|
27
|
+
* ## `preventDefault` is deliberately NOT a veto
|
|
28
|
+
*
|
|
29
|
+
* The consumer's handler runs after the internal one, so calling
|
|
30
|
+
* `event.preventDefault()` there cannot suppress the component's behaviour —
|
|
31
|
+
* by design. Overloading `preventDefault` as "also disable this component's
|
|
32
|
+
* dismissal" would re-create the silent-disable bug through a different door:
|
|
33
|
+
* `preventDefault` has an established DOM meaning (suppress the *browser's*
|
|
34
|
+
* default action), and a consumer calling it for that reason would lose
|
|
35
|
+
* dismissal without ever asking to. Opting out of a behaviour is spelled with
|
|
36
|
+
* the named, discoverable prop that already exists for it — `closeOnEscape`,
|
|
37
|
+
* `closeOnBackdropClick` — not with a magic event side-effect.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```svelte
|
|
41
|
+
* <script lang="ts">
|
|
42
|
+
* let { onkeydown: onkeydownProp, ...restProps }: Props = $props();
|
|
43
|
+
* function handleKeydown(event: KeyboardEvent) { ... }
|
|
44
|
+
* </script>
|
|
45
|
+
*
|
|
46
|
+
* <dialog {...restProps} onkeydown={composeHandlers(handleKeydown, onkeydownProp)}>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Combine an internal handler with an optional consumer handler from `restProps`.
|
|
51
|
+
*
|
|
52
|
+
* The internal handler always runs first and always runs; the consumer handler
|
|
53
|
+
* runs afterwards when present. See the module doc for the ordering and
|
|
54
|
+
* `preventDefault` rationale.
|
|
55
|
+
*
|
|
56
|
+
* @param internal The component's own handler. Runs first, unconditionally.
|
|
57
|
+
* @param consumer The consumer's handler from `restProps`. May be `null`/`undefined`.
|
|
58
|
+
* @returns A handler assignable to the element's `on*` attribute.
|
|
59
|
+
*/
|
|
60
|
+
export function composeHandlers(internal, consumer) {
|
|
61
|
+
return (event) => {
|
|
62
|
+
internal(event);
|
|
63
|
+
consumer?.(event);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -123,7 +123,7 @@ const foundationColors = {
|
|
|
123
123
|
'200': 'oklch(0.84 0.075 220)',
|
|
124
124
|
'300': 'oklch(0.76 0.105 220)',
|
|
125
125
|
'400': 'oklch(0.66 0.13 220)',
|
|
126
|
-
'500': 'oklch(0.
|
|
126
|
+
'500': 'oklch(0.54 0.14 220)',
|
|
127
127
|
'600': 'oklch(0.48 0.13 220)',
|
|
128
128
|
'700': 'oklch(0.4 0.11 220)',
|
|
129
129
|
'800': 'oklch(0.32 0.09 220)',
|