@urbicon-ui/blocks 6.23.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/components/Sparkline/Sparkline.svelte +10 -4
- package/dist/components/Sparkline/index.d.ts +9 -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/Badge/Badge.svelte +8 -2
- package/dist/primitives/Badge/index.d.ts +67 -32
- package/dist/primitives/Button/Button.svelte +12 -25
- package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
- package/dist/primitives/Checkbox/Checkbox.svelte +1 -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 +15 -3
- 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/Pagination/Pagination.svelte +28 -15
- 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.svelte +6 -2
- package/dist/primitives/RadioGroup/RadioItem.svelte +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/Toast/Toaster.svelte +81 -7
- package/dist/primitives/Toggle/Toggle.svelte +6 -2
- 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,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useBlocksI18n } from '../..';
|
|
3
|
+
import { untrack } from 'svelte';
|
|
3
4
|
import { fly } from 'svelte/transition';
|
|
4
5
|
import { getOverlayMotion } from '../../utils';
|
|
5
6
|
import { toastVariants, type ToastVariants } from './toast.variants';
|
|
@@ -88,6 +89,42 @@
|
|
|
88
89
|
|
|
89
90
|
const visibleToasts = $derived(toaster.toasts.slice(-max));
|
|
90
91
|
|
|
92
|
+
// Stranded-pause guard. A toast removed from under the cursor (e.g. its own
|
|
93
|
+
// close button) does not reliably fire `pointerout`, so `pointerInside` can
|
|
94
|
+
// stay stuck `true` and freeze every remaining timer — worst case a fresh
|
|
95
|
+
// toast is then born paused and never counts down. Rather than trust the flag,
|
|
96
|
+
// when the rendered stack SHRINKS we re-derive containment from the real cursor
|
|
97
|
+
// position. The cursor is tracked from the region's own bubbling pointer events
|
|
98
|
+
// (the container is `pointer-events-none`, so these fire only over a toast
|
|
99
|
+
// child, which is exactly when the coords matter).
|
|
100
|
+
let containerEl: HTMLElement | undefined;
|
|
101
|
+
let pointerX = 0;
|
|
102
|
+
let pointerY = 0;
|
|
103
|
+
let prevVisibleCount = 0;
|
|
104
|
+
|
|
105
|
+
function reconcilePointerInside() {
|
|
106
|
+
// Only a stranded `true` needs rescuing; SSR-safe — effects/handlers never
|
|
107
|
+
// run on the server, and `document` is guarded regardless.
|
|
108
|
+
if (typeof document === 'undefined' || !pointerInside || !containerEl) return;
|
|
109
|
+
const hit = document.elementFromPoint(pointerX, pointerY);
|
|
110
|
+
const toastEl = (hit?.closest('[data-toast-id]') ?? null) as HTMLElement | null;
|
|
111
|
+
// Inside only if the cursor sits over a toast that is (a) in THIS region and
|
|
112
|
+
// (b) still live — not the just-removed one, whose node lingers through its
|
|
113
|
+
// fly-out outro.
|
|
114
|
+
pointerInside =
|
|
115
|
+
!!toastEl &&
|
|
116
|
+
containerEl.contains(toastEl) &&
|
|
117
|
+
visibleToasts.some((t) => t.id === toastEl.dataset.toastId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
$effect(() => {
|
|
121
|
+
const count = visibleToasts.length;
|
|
122
|
+
untrack(() => {
|
|
123
|
+
if (count < prevVisibleCount) reconcilePointerInside();
|
|
124
|
+
prevVisibleCount = count;
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
91
128
|
const styles = $derived(
|
|
92
129
|
unstyled
|
|
93
130
|
? {
|
|
@@ -151,10 +188,19 @@
|
|
|
151
188
|
</script>
|
|
152
189
|
|
|
153
190
|
<div
|
|
191
|
+
bind:this={containerEl}
|
|
154
192
|
class={[slot('container'), className].filter(Boolean).join(' ')}
|
|
155
193
|
aria-live="polite"
|
|
156
194
|
aria-relevant="additions removals"
|
|
157
|
-
onpointerover={() =>
|
|
195
|
+
onpointerover={(e) => {
|
|
196
|
+
pointerX = e.clientX;
|
|
197
|
+
pointerY = e.clientY;
|
|
198
|
+
pointerInside = true;
|
|
199
|
+
}}
|
|
200
|
+
onpointermove={(e) => {
|
|
201
|
+
pointerX = e.clientX;
|
|
202
|
+
pointerY = e.clientY;
|
|
203
|
+
}}
|
|
158
204
|
onpointerout={(e) => {
|
|
159
205
|
if (leftRegion(e)) pointerInside = false;
|
|
160
206
|
}}
|
|
@@ -166,7 +212,13 @@
|
|
|
166
212
|
>
|
|
167
213
|
{#each visibleToasts as toast (toast.id)}
|
|
168
214
|
{@const IntentIcon = INTENT_ICON_MAP[toast.intent] ?? INTENT_ICON_MAP.neutral}
|
|
169
|
-
<div
|
|
215
|
+
<div
|
|
216
|
+
class={slot('toast', toast.intent)}
|
|
217
|
+
role="alert"
|
|
218
|
+
aria-atomic="true"
|
|
219
|
+
data-toast-id={toast.id}
|
|
220
|
+
transition:fly={flyParams()}
|
|
221
|
+
>
|
|
170
222
|
{#if toast.loading}
|
|
171
223
|
<span class={slot('icon', toast.intent)}><Spinner size="sm" /></span>
|
|
172
224
|
{:else}
|
|
@@ -216,13 +268,17 @@
|
|
|
216
268
|
{/if}
|
|
217
269
|
|
|
218
270
|
{#if toast.showProgress && Number.isFinite(toast.duration) && toast.duration > 0}
|
|
219
|
-
<!--
|
|
220
|
-
|
|
221
|
-
|
|
271
|
+
<!-- The countdown animation lives in the `<style>` rule below (not inline)
|
|
272
|
+
so `@media (prefers-reduced-motion: reduce)` can reach it — an inline
|
|
273
|
+
`animation` shorthand is invisible to media queries. Only the real
|
|
274
|
+
per-toast duration is passed inline, as a custom property.
|
|
275
|
+
`animation-play-state` (also inline, bound to the store's paused flag)
|
|
276
|
+
freezes the bar with the timer on hover/focus and resumes it in place;
|
|
277
|
+
inline wins over the rule's implicit `running`, and toggling only the
|
|
222
278
|
play-state never restarts the animation. -->
|
|
223
279
|
<div
|
|
224
|
-
class={slot('progress', toast.intent)}
|
|
225
|
-
style="
|
|
280
|
+
class={[slot('progress', toast.intent), 'blocks-toast-progress-bar']}
|
|
281
|
+
style="--toast-progress-duration: {toast.duration}ms; animation-play-state: {toaster.paused
|
|
226
282
|
? 'paused'
|
|
227
283
|
: 'running'};"
|
|
228
284
|
></div>
|
|
@@ -240,4 +296,22 @@
|
|
|
240
296
|
width: 0%;
|
|
241
297
|
}
|
|
242
298
|
}
|
|
299
|
+
|
|
300
|
+
/* The countdown bar animates via this class (not an inline `animation`) so the
|
|
301
|
+
reduced-motion query below can reach it; the per-toast duration arrives as
|
|
302
|
+
the `--toast-progress-duration` custom property. `:global` keeps it off
|
|
303
|
+
Svelte's scoping, consistent with the global keyframe above. */
|
|
304
|
+
:global(.blocks-toast-progress-bar) {
|
|
305
|
+
animation: blocks-toast-progress var(--toast-progress-duration) linear forwards;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* A countdown bar carries no information once it can't animate, so under
|
|
309
|
+
reduced motion we hide it outright rather than leave it frozen. This mirrors
|
|
310
|
+
interaction.css collapsing the motion tokens; the auto-dismiss timing itself
|
|
311
|
+
is unaffected. */
|
|
312
|
+
@media (prefers-reduced-motion: reduce) {
|
|
313
|
+
:global(.blocks-toast-progress-bar) {
|
|
314
|
+
display: none;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
243
317
|
</style>
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
preset,
|
|
30
30
|
withBorder = false,
|
|
31
31
|
'aria-describedby': ariaDescribedby,
|
|
32
|
+
'aria-label': ariaLabel,
|
|
32
33
|
...restProps
|
|
33
34
|
}: ToggleProps = $props();
|
|
34
35
|
|
|
@@ -110,7 +111,11 @@
|
|
|
110
111
|
: styles.control({ class: slotClasses?.control })}
|
|
111
112
|
for={ff.fieldId}
|
|
112
113
|
>
|
|
114
|
+
<!-- aria-label: a visible `label` wins (stays undefined); with no label a
|
|
115
|
+
consumer-supplied aria-label is preferred over the generic i18n fallback
|
|
116
|
+
so external labelling survives the restProps-first spread. -->
|
|
113
117
|
<input
|
|
118
|
+
{...restProps}
|
|
114
119
|
id={ff.fieldId}
|
|
115
120
|
type="checkbox"
|
|
116
121
|
role="switch"
|
|
@@ -121,11 +126,10 @@
|
|
|
121
126
|
{required}
|
|
122
127
|
class="peer sr-only"
|
|
123
128
|
aria-checked={checked}
|
|
124
|
-
aria-label={label ? undefined : bt('accessibility.toggle') || 'Toggle'}
|
|
129
|
+
aria-label={label ? undefined : (ariaLabel ?? (bt('accessibility.toggle') || 'Toggle'))}
|
|
125
130
|
aria-describedby={describedBy}
|
|
126
131
|
aria-invalid={ff.invalid ? 'true' : undefined}
|
|
127
132
|
onchange={handleChange}
|
|
128
|
-
{...restProps}
|
|
129
133
|
/>
|
|
130
134
|
|
|
131
135
|
<span
|
|
@@ -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)',
|