@xenosystem/elements-react 0.0.1 → 0.0.3
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/LICENSE +18 -18
- package/README.md +41 -41
- package/package.json +58 -58
- package/src/chrome-separated.css +132 -132
- package/src/chrome-unified.css +72 -72
- package/src/containers/Badges.css +116 -116
- package/src/containers/avatar.css +70 -70
- package/src/containers/card.css +75 -75
- package/src/containers/chip.css +103 -103
- package/src/containers/list-row.css +132 -132
- package/src/containers/message-bubble.css +112 -112
- package/src/containers/panel.css +120 -120
- package/src/containers/table.css +102 -102
- package/src/containers/tile.css +17 -17
- package/src/content/callout.css +77 -77
- package/src/content/caret.css +30 -30
- package/src/content/code-block.css +159 -159
- package/src/content/collapsible.css +80 -80
- package/src/content/inline-code.css +14 -14
- package/src/content/model-picker.css +219 -219
- package/src/content/source-card.css +99 -99
- package/src/content/sources-disclosure.css +118 -118
- package/src/controls.css +453 -453
- package/src/fonts.css +34 -34
- package/src/forms/Checkbox.css +114 -114
- package/src/forms/RadioGroup.css +115 -115
- package/src/goo.css +97 -97
- package/src/icon-motion.css +1909 -1909
- package/src/layout/ResizablePanel.css +79 -79
- package/src/nav/sidebar.css +296 -296
- package/src/overlays/date-time-picker.css +238 -238
- package/src/overlays/menu.css +195 -195
- package/src/overlays/modal.css +167 -167
- package/src/overlays/picker-field.css +81 -81
- package/src/overlays/pill-filter.css +129 -129
- package/src/overlays/reveal.css +56 -56
- package/src/overlays/segmented-control.css +185 -185
- package/src/overlays/tabs.css +143 -143
- package/src/overlays/tooltip.css +126 -126
- package/src/scrollbar.css +184 -184
- package/src/size.css +179 -179
- package/src/status/progress-bar.css +88 -88
- package/src/status/spinner.css +33 -33
- package/src/status/statusindicator.css +65 -65
- package/src/status/steptimeline.css +137 -137
- package/src/status/thinkingcube.css +75 -75
- package/src/xeno-element.css +60 -60
- package/src/xeno-elements.css +71 -71
- package/src/xeno-theme.css +99 -99
package/src/controls.css
CHANGED
|
@@ -1,453 +1,453 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Tier-1 control styles. CSS-first: appearance is driven by `data-*` state attributes + the theme
|
|
3
|
-
* custom properties from `xeno-theme.css`; size metrics arrive as inline `--xeno-h/-padx/-gap/-font`
|
|
4
|
-
* vars set by the renderer from the size tokens (so sizes cannot drift from the data). Behaviour is
|
|
5
|
-
* hand-rolled in the components (Base UI comes later, SPEC §13). No literal colours here.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/* ── Button / IconButton ─────────────────────────────────────────────────────────── */
|
|
9
|
-
.xeno-btn {
|
|
10
|
-
display: inline-flex;
|
|
11
|
-
align-items: center;
|
|
12
|
-
justify-content: center;
|
|
13
|
-
gap: var(--xeno-gap, 6px);
|
|
14
|
-
height: var(--xeno-h, 32px);
|
|
15
|
-
padding: 0 var(--xeno-padx, 12px);
|
|
16
|
-
font-family: inherit;
|
|
17
|
-
font-size: var(--xeno-font, 14px);
|
|
18
|
-
font-weight: 500;
|
|
19
|
-
line-height: 1;
|
|
20
|
-
white-space: nowrap;
|
|
21
|
-
color: var(--xeno-text);
|
|
22
|
-
background: transparent;
|
|
23
|
-
border: 1px solid transparent;
|
|
24
|
-
border-radius: var(--xeno-radius-control);
|
|
25
|
-
cursor: pointer;
|
|
26
|
-
user-select: none;
|
|
27
|
-
/* The press. It is asymmetric on purpose: going DOWN has to be immediate, because that is the
|
|
28
|
-
acknowledgement that the click landed, while coming back UP is allowed to take four times as long
|
|
29
|
-
and to spring past its rest size on the way. A press that eases down feels laggy; a release that
|
|
30
|
-
snaps feels dead. The two clocks below are what make it read as a physical button rather than a
|
|
31
|
-
rectangle that changed size — `:active` swaps them, and the shorthand is written once.
|
|
32
|
-
`scale` and not `transform`, so the entrance animation (which owns `transform`) and the press can
|
|
33
|
-
never overwrite one another. */
|
|
34
|
-
--xeno-btn-press: 0.96;
|
|
35
|
-
--xeno-btn-press-dur: 260ms;
|
|
36
|
-
--xeno-btn-press-ease: cubic-bezier(0.2, 1.7, 0.4, 1); /* release — overshoots, then settles */
|
|
37
|
-
scale: 1;
|
|
38
|
-
transition:
|
|
39
|
-
background var(--xeno-dur) ease,
|
|
40
|
-
border-color var(--xeno-dur) ease,
|
|
41
|
-
color var(--xeno-dur) ease,
|
|
42
|
-
opacity var(--xeno-dur) ease,
|
|
43
|
-
scale var(--xeno-btn-press-dur) var(--xeno-btn-press-ease);
|
|
44
|
-
}
|
|
45
|
-
.xeno-btn:focus-visible {
|
|
46
|
-
outline: none;
|
|
47
|
-
box-shadow: 0 0 0 2px var(--xeno-canvas), 0 0 0 4px var(--xeno-border);
|
|
48
|
-
}
|
|
49
|
-
.xeno-btn:active:not(:disabled):not([data-availability='disabled']) {
|
|
50
|
-
--xeno-btn-press-dur: 65ms;
|
|
51
|
-
--xeno-btn-press-ease: cubic-bezier(0.3, 0, 0.1, 1); /* press — straight down, no hesitation */
|
|
52
|
-
scale: var(--xeno-btn-press);
|
|
53
|
-
}
|
|
54
|
-
.xeno-btn:disabled,
|
|
55
|
-
.xeno-btn[data-availability='disabled'] {
|
|
56
|
-
opacity: 0.4;
|
|
57
|
-
cursor: not-allowed;
|
|
58
|
-
}
|
|
59
|
-
.xeno-btn[data-availability='busy'] {
|
|
60
|
-
cursor: progress;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/* Emphasis is surface lightness + text brightness, never hue. Both readings live on the chrome axis:
|
|
64
|
-
`separated` uses the lightest SURFACE with bright text (the reference); `unified` keeps the inverted
|
|
65
|
-
fill this set shipped. The component does not know which. */
|
|
66
|
-
.xeno-btn[data-variant='primary'] {
|
|
67
|
-
background: var(--xeno-chrome-btn-primary-bg);
|
|
68
|
-
color: var(--xeno-chrome-btn-primary-fg);
|
|
69
|
-
}
|
|
70
|
-
.xeno-btn[data-variant='primary']:hover:not(:disabled) {
|
|
71
|
-
background:
|
|
72
|
-
linear-gradient(var(--xeno-hover), var(--xeno-hover)),
|
|
73
|
-
var(--xeno-chrome-btn-primary-bg);
|
|
74
|
-
}
|
|
75
|
-
.xeno-btn[data-variant='secondary'] {
|
|
76
|
-
background: var(--xeno-control);
|
|
77
|
-
border-color: var(--xeno-border);
|
|
78
|
-
}
|
|
79
|
-
/* `--xeno-hover` is a TINT, not a surface — it is translucent, so assigning it here replaced the
|
|
80
|
-
opaque `--xeno-control` fill and the button came out DARKER on hover (#2b2b2b → ≈#222224), reading
|
|
81
|
-
as though it had lost its fill. Layering it over the fill is what the token actually means, and it
|
|
82
|
-
resolves correctly in both themes without inventing a `control-hover` colour. `secondary` is the
|
|
83
|
-
only variant this could hit: it is the only one with a fill at rest. */
|
|
84
|
-
.xeno-btn[data-variant='secondary']:hover:not(:disabled) {
|
|
85
|
-
background:
|
|
86
|
-
linear-gradient(var(--xeno-hover), var(--xeno-hover)),
|
|
87
|
-
var(--xeno-control);
|
|
88
|
-
}
|
|
89
|
-
.xeno-btn[data-variant='ghost'] {
|
|
90
|
-
color: var(--xeno-muted);
|
|
91
|
-
}
|
|
92
|
-
.xeno-btn[data-variant='ghost']:hover:not(:disabled) {
|
|
93
|
-
color: var(--xeno-text);
|
|
94
|
-
background: var(--xeno-hover);
|
|
95
|
-
}
|
|
96
|
-
.xeno-btn[data-variant='outline'] {
|
|
97
|
-
border-color: var(--xeno-border);
|
|
98
|
-
}
|
|
99
|
-
.xeno-btn[data-variant='outline']:hover:not(:disabled) {
|
|
100
|
-
background: var(--xeno-hover);
|
|
101
|
-
border-color: var(--xeno-muted);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/* QUIET — the chrome button. A hairline at rest and muted ink, coming forward only when reached for:
|
|
105
|
-
the ink brightens and a hover tint arrives, and the BORDER holds still, because the border is this
|
|
106
|
-
variant's identity rather than its signal. That is the whole difference from `outline`, which reads
|
|
107
|
-
at full strength and lights its border instead. */
|
|
108
|
-
.xeno-btn[data-variant='quiet'] {
|
|
109
|
-
border-color: var(--xeno-border);
|
|
110
|
-
color: var(--xeno-muted);
|
|
111
|
-
}
|
|
112
|
-
.xeno-btn[data-variant='quiet']:hover:not(:disabled) {
|
|
113
|
-
color: var(--xeno-text);
|
|
114
|
-
background: var(--xeno-hover);
|
|
115
|
-
}
|
|
116
|
-
/* ON, the border gets out of the way. A quiet button is `on` while the thing it opened is on screen,
|
|
117
|
-
and for that moment it is not chrome beside a panel but the top edge of one — so it takes a fill and
|
|
118
|
-
drops the outline that was holding it apart. `--xeno-control` and not `--xeno-hover`: hover is a
|
|
119
|
-
translucent tint that says "the pointer is here", and this state outlives the pointer. */
|
|
120
|
-
.xeno-btn[data-variant='quiet'][data-selection='on'] {
|
|
121
|
-
background: var(--xeno-control);
|
|
122
|
-
border-color: transparent;
|
|
123
|
-
}
|
|
124
|
-
.xeno-btn[data-variant='danger'] {
|
|
125
|
-
border-color: var(--xeno-border);
|
|
126
|
-
color: var(--xeno-muted);
|
|
127
|
-
}
|
|
128
|
-
.xeno-btn[data-variant='danger']:hover:not(:disabled) {
|
|
129
|
-
color: var(--xeno-danger);
|
|
130
|
-
border-color: var(--xeno-danger);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/* SELECTION reads as the text colour, whatever the variant's rest is.
|
|
134
|
-
*
|
|
135
|
-
* `ghost` sits at `--xeno-muted` so a row of icon buttons stays quiet; a button that is ON has to come
|
|
136
|
-
* forward, and in a monochrome grammar the only way forward is brightness. Every other axis is spoken
|
|
137
|
-
* for — a fill would make it a different variant, a border a different shape.
|
|
138
|
-
*
|
|
139
|
-
* It lives HERE rather than at the consumer, and that is the point: a product saying this from outside
|
|
140
|
-
* is patching a variant it does not own. It must sit after EVERY variant block: it wins on source order
|
|
141
|
-
* at equal weight, and it used to sit between `ghost` and `outline`, where the three variants below it
|
|
142
|
-
* — `outline`, `quiet`, `danger` — quietly took the ink back. `quiet` is where that showed: a top-bar
|
|
143
|
-
* button with its menu open stayed muted, looking exactly like the ones whose menus were shut.
|
|
144
|
-
*
|
|
145
|
-
* A note on how this was nearly abandoned. Measured through a headless driver it appeared to do
|
|
146
|
-
* nothing — and so did an inline `color: #ff0000 !important`, which is not possible. The button
|
|
147
|
-
* transitions `color` over 0.14s, and the probe was reading `getComputedStyle` on the same tick as the
|
|
148
|
-
* write, which returns the value the transition STARTED from. Any assertion about a colour on a
|
|
149
|
-
* control in this library has to wait for its own transition first. */
|
|
150
|
-
.xeno-btn[data-selection='on'] {
|
|
151
|
-
color: var(--xeno-text);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/* Entrance — the locked "send button" appear: fade + slide in + scale up. Opt-in via data-enter. */
|
|
155
|
-
@keyframes xeno-btn-enter {
|
|
156
|
-
from {
|
|
157
|
-
opacity: 0;
|
|
158
|
-
transform: translateX(6px) scale(0.76);
|
|
159
|
-
}
|
|
160
|
-
to {
|
|
161
|
-
opacity: 1;
|
|
162
|
-
transform: none;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
.xeno-btn[data-enter] {
|
|
166
|
-
animation: xeno-btn-enter var(--xeno-dur-entrance) var(--xeno-ease) both;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/* Confirmed — the copy→check acknowledgement: the swapped glyph brightens and pops in. */
|
|
170
|
-
@keyframes xeno-confirm-pop {
|
|
171
|
-
from {
|
|
172
|
-
transform: scale(0.6);
|
|
173
|
-
}
|
|
174
|
-
to {
|
|
175
|
-
transform: scale(1);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
.xeno-btn[data-confirmed] {
|
|
179
|
-
color: var(--xeno-text);
|
|
180
|
-
}
|
|
181
|
-
.xeno-btn[data-confirmed] .xeno-element {
|
|
182
|
-
animation: xeno-confirm-pop var(--xeno-dur) var(--xeno-ease-overshoot) both;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/* Icon-only: square, no horizontal padding. */
|
|
186
|
-
.xeno-icon-btn {
|
|
187
|
-
width: var(--xeno-h, 32px);
|
|
188
|
-
padding: 0;
|
|
189
|
-
aspect-ratio: 1 / 1;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/* Toggle button (button-style on/off).
|
|
193
|
-
*
|
|
194
|
-
* The "on" fill is not a background any more — it is a LAYER that floods in. It spreads out of the glyph
|
|
195
|
-
* as a growing circle until it has filled the button, and drains back into the glyph when you switch it
|
|
196
|
-
* off: the state does not blink on, it pours in.
|
|
197
|
-
*
|
|
198
|
-
* The flood is CLIPPED, never scaled. A scaled layer changes size, and next to a row of buttons that
|
|
199
|
-
* reads as the button itself swelling — so the geometry here is frozen: `clip-path` only ever reveals
|
|
200
|
-
* more or less of a layer that stays exactly the button's size and never leaves its bounds.
|
|
201
|
-
*
|
|
202
|
-
* It has to be a pseudo-element: only a separate layer can be clipped without taking the label and the
|
|
203
|
-
* glyph with it. `isolation: isolate` makes the button its own stacking context, which pins the
|
|
204
|
-
* `z-index: -1` layer between the button's own background and its content — no z-index on the text (raw
|
|
205
|
-
* text nodes cannot take one) and no dependence on what the button happens to sit inside. */
|
|
206
|
-
.xeno-toggle {
|
|
207
|
-
--xeno-toggle-goo-dur: 320ms;
|
|
208
|
-
/* Decisive ease-out, NOT an overshoot: a bezier that goes past 1 would drive the clip radius negative
|
|
209
|
-
on the way out and the fill would snap away early. */
|
|
210
|
-
--xeno-toggle-goo-ease: var(--xeno-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
|
211
|
-
position: relative;
|
|
212
|
-
isolation: isolate;
|
|
213
|
-
border-color: var(--xeno-border);
|
|
214
|
-
color: var(--xeno-muted);
|
|
215
|
-
}
|
|
216
|
-
.xeno-toggle::before {
|
|
217
|
-
content: '';
|
|
218
|
-
position: absolute;
|
|
219
|
-
z-index: -1;
|
|
220
|
-
/* -1px so the layer reaches under the border, exactly where a background would have painted. */
|
|
221
|
-
inset: -1px;
|
|
222
|
-
border-radius: inherit;
|
|
223
|
-
background: var(--xeno-control);
|
|
224
|
-
/* The source of the flood: the leading edge, where the glyph sits. Radius 0 = nothing showing. */
|
|
225
|
-
clip-path: circle(0% at var(--xeno-padx, 12px) 50%);
|
|
226
|
-
}
|
|
227
|
-
.xeno-toggle[data-selection='on'] {
|
|
228
|
-
color: var(--xeno-text);
|
|
229
|
-
}
|
|
230
|
-
/* 150% of the box's own diagonal reference — enough to reach the far corner from an off-centre origin. */
|
|
231
|
-
.xeno-toggle[data-selection='on']::before {
|
|
232
|
-
clip-path: circle(150% at var(--xeno-padx, 12px) 50%);
|
|
233
|
-
}
|
|
234
|
-
/* Hover reads through both states: the fill covers any background tint once the toggle is on, so the
|
|
235
|
-
affordance lives on the border, which is visible either way. */
|
|
236
|
-
.xeno-toggle:hover:not(:disabled) {
|
|
237
|
-
color: var(--xeno-text);
|
|
238
|
-
background: var(--xeno-hover);
|
|
239
|
-
border-color: var(--xeno-muted);
|
|
240
|
-
}
|
|
241
|
-
/* A fixed button, all the way: the toggle also opts out of the press every other button gets. Nothing
|
|
242
|
-
about it changes size — the fill is the only thing that moves. (Opting out means holding `scale` at 1,
|
|
243
|
-
since the press rides on `scale`, not `transform`.) */
|
|
244
|
-
.xeno-toggle:active:not(:disabled) {
|
|
245
|
-
scale: 1;
|
|
246
|
-
}
|
|
247
|
-
/* Both labels stacked in ONE grid cell, so the button is sized to the longer of the two in either state.
|
|
248
|
-
This — not the animation — is what makes a Pin/Pinned toggle look like it resizes itself. */
|
|
249
|
-
.xeno-toggle-labels {
|
|
250
|
-
display: grid;
|
|
251
|
-
align-items: center;
|
|
252
|
-
justify-items: center;
|
|
253
|
-
}
|
|
254
|
-
.xeno-toggle-label {
|
|
255
|
-
grid-area: 1 / 1;
|
|
256
|
-
transition: opacity var(--xeno-dur) ease;
|
|
257
|
-
}
|
|
258
|
-
.xeno-toggle[data-selection='off'] .xeno-toggle-label[data-when='on'],
|
|
259
|
-
.xeno-toggle[data-selection='on'] .xeno-toggle-label[data-when='off'] {
|
|
260
|
-
opacity: 0;
|
|
261
|
-
visibility: hidden;
|
|
262
|
-
}
|
|
263
|
-
/* Gated on `data-motion="ready"`, set only once `pressed` has actually changed — otherwise every toggle
|
|
264
|
-
that mounts in the on state would pour itself in on page load. No fill-mode: each track ends on the
|
|
265
|
-
resting value above, so the static rules take over with nothing to jump. */
|
|
266
|
-
.xeno-toggle[data-motion='ready'][data-selection='on']::before {
|
|
267
|
-
animation: xeno-toggle-goo-in var(--xeno-toggle-goo-dur) var(--xeno-toggle-goo-ease);
|
|
268
|
-
}
|
|
269
|
-
.xeno-toggle[data-motion='ready'][data-selection='off']::before {
|
|
270
|
-
animation: xeno-toggle-goo-out var(--xeno-toggle-goo-dur) var(--xeno-toggle-goo-ease);
|
|
271
|
-
}
|
|
272
|
-
@keyframes xeno-toggle-goo-in {
|
|
273
|
-
from { clip-path: circle(0% at var(--xeno-padx, 12px) 50%); }
|
|
274
|
-
to { clip-path: circle(150% at var(--xeno-padx, 12px) 50%); }
|
|
275
|
-
}
|
|
276
|
-
@keyframes xeno-toggle-goo-out {
|
|
277
|
-
from { clip-path: circle(150% at var(--xeno-padx, 12px) 50%); }
|
|
278
|
-
to { clip-path: circle(0% at var(--xeno-padx, 12px) 50%); }
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/* ── Switch (track + knob, both rounded SQUARES) ─────────────────────────────────── */
|
|
282
|
-
/* The knob does not glide, it THROWS: it overshoots the far end, swings back past it and settles —
|
|
283
|
-
two bounces, the way a sprung switch actually behaves. The track keeps its own clock (0ms — the
|
|
284
|
-
colour snaps the instant the state flips) so the flip reads as decided while the knob is still
|
|
285
|
-
catching up; that separation is the whole effect. Position rides on `translate`, not `transform`,
|
|
286
|
-
so the keyframes and the press affordance never fight over one property.
|
|
287
|
-
Travel = 30px content box (36 − 2px border − 4px padding) − 14px knob = 16px. */
|
|
288
|
-
.xeno-switch {
|
|
289
|
-
--xeno-switch-travel: 16px;
|
|
290
|
-
--xeno-switch-dur: 350ms;
|
|
291
|
-
--xeno-switch-track-dur: 0ms;
|
|
292
|
-
/* Overshoot past the end, then the smaller swing back. */
|
|
293
|
-
--xeno-switch-ov1: 1.1px;
|
|
294
|
-
--xeno-switch-ov2: 0px;
|
|
295
|
-
--xeno-switch-ease: cubic-bezier(0.34, 1.35, 0.64, 1);
|
|
296
|
-
display: inline-flex;
|
|
297
|
-
align-items: center;
|
|
298
|
-
width: 36px;
|
|
299
|
-
height: 20px;
|
|
300
|
-
padding: 2px;
|
|
301
|
-
background: var(--xeno-control);
|
|
302
|
-
border: 1px solid var(--xeno-border);
|
|
303
|
-
border-radius: var(--xeno-radius-md);
|
|
304
|
-
cursor: pointer;
|
|
305
|
-
transition: background var(--xeno-switch-track-dur) var(--xeno-switch-ease);
|
|
306
|
-
}
|
|
307
|
-
.xeno-switch-knob {
|
|
308
|
-
width: 14px;
|
|
309
|
-
height: 14px;
|
|
310
|
-
translate: 0 0;
|
|
311
|
-
will-change: translate;
|
|
312
|
-
background: var(--xeno-muted);
|
|
313
|
-
border-radius: var(--xeno-radius-xs);
|
|
314
|
-
transition:
|
|
315
|
-
translate var(--xeno-dur) var(--xeno-ease-expo),
|
|
316
|
-
width var(--xeno-dur) var(--xeno-ease-expo),
|
|
317
|
-
background var(--xeno-dur) ease;
|
|
318
|
-
}
|
|
319
|
-
/* Press affordance — the knob stretches while held (iOS-style), reduce-safe. */
|
|
320
|
-
.xeno-switch:active:not(:disabled):not([data-availability='disabled']) .xeno-switch-knob {
|
|
321
|
-
width: 18px;
|
|
322
|
-
}
|
|
323
|
-
.xeno-switch[data-selection='on']:active:not(:disabled):not([data-availability='disabled']) .xeno-switch-knob {
|
|
324
|
-
translate: 12px 0;
|
|
325
|
-
}
|
|
326
|
-
.xeno-switch[data-selection='on'] {
|
|
327
|
-
background: var(--xeno-text);
|
|
328
|
-
}
|
|
329
|
-
.xeno-switch[data-selection='on'] .xeno-switch-knob {
|
|
330
|
-
translate: var(--xeno-switch-travel) 0;
|
|
331
|
-
background: var(--xeno-on-accent);
|
|
332
|
-
}
|
|
333
|
-
/* The throw itself. Gated on `data-motion="ready"`, which the component only sets once the user has
|
|
334
|
-
actually toggled it — otherwise every switch that mounts in the "off" state would bounce on load.
|
|
335
|
-
No fill-mode on purpose: each keyframe track ends exactly on the resting value above, so once it
|
|
336
|
-
finishes the static rules take over with no jump, and `:active` can stretch the knob again. */
|
|
337
|
-
.xeno-switch[data-motion='ready'][data-selection='on'] .xeno-switch-knob {
|
|
338
|
-
animation: xeno-switch-throw-on var(--xeno-switch-dur) var(--xeno-switch-ease);
|
|
339
|
-
}
|
|
340
|
-
.xeno-switch[data-motion='ready'][data-selection='off'] .xeno-switch-knob {
|
|
341
|
-
animation: xeno-switch-throw-off var(--xeno-switch-dur) var(--xeno-switch-ease);
|
|
342
|
-
}
|
|
343
|
-
@keyframes xeno-switch-throw-on {
|
|
344
|
-
0% { translate: 0 0; }
|
|
345
|
-
55% { translate: calc(var(--xeno-switch-travel) + var(--xeno-switch-ov1)) 0; }
|
|
346
|
-
80% { translate: calc(var(--xeno-switch-travel) - var(--xeno-switch-ov2)) 0; }
|
|
347
|
-
100% { translate: var(--xeno-switch-travel) 0; }
|
|
348
|
-
}
|
|
349
|
-
@keyframes xeno-switch-throw-off {
|
|
350
|
-
0% { translate: var(--xeno-switch-travel) 0; }
|
|
351
|
-
55% { translate: calc(0px - var(--xeno-switch-ov1)) 0; }
|
|
352
|
-
80% { translate: var(--xeno-switch-ov2) 0; }
|
|
353
|
-
100% { translate: 0 0; }
|
|
354
|
-
}
|
|
355
|
-
.xeno-switch:focus-visible {
|
|
356
|
-
outline: none;
|
|
357
|
-
box-shadow: 0 0 0 2px var(--xeno-canvas), 0 0 0 4px var(--xeno-border);
|
|
358
|
-
}
|
|
359
|
-
.xeno-switch:disabled,
|
|
360
|
-
.xeno-switch[data-availability='disabled'] {
|
|
361
|
-
opacity: 0.4;
|
|
362
|
-
cursor: not-allowed;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/* ── TextInput / SearchInput ─────────────────────────────────────────────────────── */
|
|
366
|
-
.xeno-input {
|
|
367
|
-
display: inline-flex;
|
|
368
|
-
align-items: center;
|
|
369
|
-
gap: var(--xeno-gap, 6px);
|
|
370
|
-
height: var(--xeno-h, 32px);
|
|
371
|
-
padding: 0 var(--xeno-padx, 12px);
|
|
372
|
-
background: var(--xeno-canvas);
|
|
373
|
-
border: 1px solid var(--xeno-border);
|
|
374
|
-
border-radius: var(--xeno-radius-md);
|
|
375
|
-
color: var(--xeno-text);
|
|
376
|
-
transition: border-color var(--xeno-dur) ease;
|
|
377
|
-
}
|
|
378
|
-
.xeno-input:focus-within {
|
|
379
|
-
border-color: var(--xeno-muted);
|
|
380
|
-
}
|
|
381
|
-
.xeno-input[data-availability='disabled'] {
|
|
382
|
-
opacity: 0.4;
|
|
383
|
-
}
|
|
384
|
-
.xeno-input-icon {
|
|
385
|
-
display: inline-flex;
|
|
386
|
-
color: var(--xeno-muted);
|
|
387
|
-
}
|
|
388
|
-
.xeno-input-field {
|
|
389
|
-
flex: 1;
|
|
390
|
-
/* Fill the wrapper's height. Centred at its own natural height instead, the field left a dead band
|
|
391
|
-
along the top and bottom of a box that LOOKS like a text field and does not focus when tapped —
|
|
392
|
-
4px of it at 32px, and 10px once a touch surface takes the wrapper to 44. A field's target should
|
|
393
|
-
be the box you can see. */
|
|
394
|
-
align-self: stretch;
|
|
395
|
-
min-width: 0;
|
|
396
|
-
border: none;
|
|
397
|
-
background: transparent;
|
|
398
|
-
color: inherit;
|
|
399
|
-
font: inherit;
|
|
400
|
-
font-size: var(--xeno-font, 14px);
|
|
401
|
-
outline: none;
|
|
402
|
-
}
|
|
403
|
-
.xeno-input-field::placeholder {
|
|
404
|
-
color: var(--xeno-muted);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/* ── Textarea ────────────────────────────────────────────────────────────────────── */
|
|
408
|
-
.xeno-textarea {
|
|
409
|
-
width: 100%;
|
|
410
|
-
padding: 10px 12px;
|
|
411
|
-
background: var(--xeno-canvas);
|
|
412
|
-
border: 1px solid var(--xeno-border);
|
|
413
|
-
border-radius: var(--xeno-radius-card);
|
|
414
|
-
color: var(--xeno-text);
|
|
415
|
-
font: inherit;
|
|
416
|
-
font-size: 15px;
|
|
417
|
-
line-height: 1.5;
|
|
418
|
-
resize: vertical;
|
|
419
|
-
outline: none;
|
|
420
|
-
transition: border-color var(--xeno-dur) ease;
|
|
421
|
-
}
|
|
422
|
-
.xeno-textarea::placeholder {
|
|
423
|
-
color: var(--xeno-muted);
|
|
424
|
-
}
|
|
425
|
-
.xeno-textarea:focus {
|
|
426
|
-
border-color: var(--xeno-muted);
|
|
427
|
-
}
|
|
428
|
-
.xeno-textarea[data-availability='disabled'] {
|
|
429
|
-
opacity: 0.4;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
@media (prefers-reduced-motion: reduce) {
|
|
433
|
-
.xeno-btn,
|
|
434
|
-
.xeno-switch,
|
|
435
|
-
.xeno-switch-knob {
|
|
436
|
-
transition: none;
|
|
437
|
-
}
|
|
438
|
-
.xeno-btn[data-enter],
|
|
439
|
-
.xeno-btn[data-confirmed] .xeno-element,
|
|
440
|
-
.xeno-toggle::before {
|
|
441
|
-
animation: none;
|
|
442
|
-
}
|
|
443
|
-
.xeno-switch:active:not(:disabled) .xeno-switch-knob {
|
|
444
|
-
width: 14px;
|
|
445
|
-
}
|
|
446
|
-
/* No press travel either — with the transition off, a scale change would be a jump, not a press. */
|
|
447
|
-
.xeno-btn:active:not(:disabled) {
|
|
448
|
-
scale: 1;
|
|
449
|
-
}
|
|
450
|
-
.xeno-switch[data-motion='ready'] .xeno-switch-knob {
|
|
451
|
-
animation: none;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* Tier-1 control styles. CSS-first: appearance is driven by `data-*` state attributes + the theme
|
|
3
|
+
* custom properties from `xeno-theme.css`; size metrics arrive as inline `--xeno-h/-padx/-gap/-font`
|
|
4
|
+
* vars set by the renderer from the size tokens (so sizes cannot drift from the data). Behaviour is
|
|
5
|
+
* hand-rolled in the components (Base UI comes later, SPEC §13). No literal colours here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/* ── Button / IconButton ─────────────────────────────────────────────────────────── */
|
|
9
|
+
.xeno-btn {
|
|
10
|
+
display: inline-flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
justify-content: center;
|
|
13
|
+
gap: var(--xeno-gap, 6px);
|
|
14
|
+
height: var(--xeno-h, 32px);
|
|
15
|
+
padding: 0 var(--xeno-padx, 12px);
|
|
16
|
+
font-family: inherit;
|
|
17
|
+
font-size: var(--xeno-font, 14px);
|
|
18
|
+
font-weight: 500;
|
|
19
|
+
line-height: 1;
|
|
20
|
+
white-space: nowrap;
|
|
21
|
+
color: var(--xeno-text);
|
|
22
|
+
background: transparent;
|
|
23
|
+
border: 1px solid transparent;
|
|
24
|
+
border-radius: var(--xeno-radius-control);
|
|
25
|
+
cursor: pointer;
|
|
26
|
+
user-select: none;
|
|
27
|
+
/* The press. It is asymmetric on purpose: going DOWN has to be immediate, because that is the
|
|
28
|
+
acknowledgement that the click landed, while coming back UP is allowed to take four times as long
|
|
29
|
+
and to spring past its rest size on the way. A press that eases down feels laggy; a release that
|
|
30
|
+
snaps feels dead. The two clocks below are what make it read as a physical button rather than a
|
|
31
|
+
rectangle that changed size — `:active` swaps them, and the shorthand is written once.
|
|
32
|
+
`scale` and not `transform`, so the entrance animation (which owns `transform`) and the press can
|
|
33
|
+
never overwrite one another. */
|
|
34
|
+
--xeno-btn-press: 0.96;
|
|
35
|
+
--xeno-btn-press-dur: 260ms;
|
|
36
|
+
--xeno-btn-press-ease: cubic-bezier(0.2, 1.7, 0.4, 1); /* release — overshoots, then settles */
|
|
37
|
+
scale: 1;
|
|
38
|
+
transition:
|
|
39
|
+
background var(--xeno-dur) ease,
|
|
40
|
+
border-color var(--xeno-dur) ease,
|
|
41
|
+
color var(--xeno-dur) ease,
|
|
42
|
+
opacity var(--xeno-dur) ease,
|
|
43
|
+
scale var(--xeno-btn-press-dur) var(--xeno-btn-press-ease);
|
|
44
|
+
}
|
|
45
|
+
.xeno-btn:focus-visible {
|
|
46
|
+
outline: none;
|
|
47
|
+
box-shadow: 0 0 0 2px var(--xeno-canvas), 0 0 0 4px var(--xeno-border);
|
|
48
|
+
}
|
|
49
|
+
.xeno-btn:active:not(:disabled):not([data-availability='disabled']) {
|
|
50
|
+
--xeno-btn-press-dur: 65ms;
|
|
51
|
+
--xeno-btn-press-ease: cubic-bezier(0.3, 0, 0.1, 1); /* press — straight down, no hesitation */
|
|
52
|
+
scale: var(--xeno-btn-press);
|
|
53
|
+
}
|
|
54
|
+
.xeno-btn:disabled,
|
|
55
|
+
.xeno-btn[data-availability='disabled'] {
|
|
56
|
+
opacity: 0.4;
|
|
57
|
+
cursor: not-allowed;
|
|
58
|
+
}
|
|
59
|
+
.xeno-btn[data-availability='busy'] {
|
|
60
|
+
cursor: progress;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Emphasis is surface lightness + text brightness, never hue. Both readings live on the chrome axis:
|
|
64
|
+
`separated` uses the lightest SURFACE with bright text (the reference); `unified` keeps the inverted
|
|
65
|
+
fill this set shipped. The component does not know which. */
|
|
66
|
+
.xeno-btn[data-variant='primary'] {
|
|
67
|
+
background: var(--xeno-chrome-btn-primary-bg);
|
|
68
|
+
color: var(--xeno-chrome-btn-primary-fg);
|
|
69
|
+
}
|
|
70
|
+
.xeno-btn[data-variant='primary']:hover:not(:disabled) {
|
|
71
|
+
background:
|
|
72
|
+
linear-gradient(var(--xeno-hover), var(--xeno-hover)),
|
|
73
|
+
var(--xeno-chrome-btn-primary-bg);
|
|
74
|
+
}
|
|
75
|
+
.xeno-btn[data-variant='secondary'] {
|
|
76
|
+
background: var(--xeno-control);
|
|
77
|
+
border-color: var(--xeno-border);
|
|
78
|
+
}
|
|
79
|
+
/* `--xeno-hover` is a TINT, not a surface — it is translucent, so assigning it here replaced the
|
|
80
|
+
opaque `--xeno-control` fill and the button came out DARKER on hover (#2b2b2b → ≈#222224), reading
|
|
81
|
+
as though it had lost its fill. Layering it over the fill is what the token actually means, and it
|
|
82
|
+
resolves correctly in both themes without inventing a `control-hover` colour. `secondary` is the
|
|
83
|
+
only variant this could hit: it is the only one with a fill at rest. */
|
|
84
|
+
.xeno-btn[data-variant='secondary']:hover:not(:disabled) {
|
|
85
|
+
background:
|
|
86
|
+
linear-gradient(var(--xeno-hover), var(--xeno-hover)),
|
|
87
|
+
var(--xeno-control);
|
|
88
|
+
}
|
|
89
|
+
.xeno-btn[data-variant='ghost'] {
|
|
90
|
+
color: var(--xeno-muted);
|
|
91
|
+
}
|
|
92
|
+
.xeno-btn[data-variant='ghost']:hover:not(:disabled) {
|
|
93
|
+
color: var(--xeno-text);
|
|
94
|
+
background: var(--xeno-hover);
|
|
95
|
+
}
|
|
96
|
+
.xeno-btn[data-variant='outline'] {
|
|
97
|
+
border-color: var(--xeno-border);
|
|
98
|
+
}
|
|
99
|
+
.xeno-btn[data-variant='outline']:hover:not(:disabled) {
|
|
100
|
+
background: var(--xeno-hover);
|
|
101
|
+
border-color: var(--xeno-muted);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* QUIET — the chrome button. A hairline at rest and muted ink, coming forward only when reached for:
|
|
105
|
+
the ink brightens and a hover tint arrives, and the BORDER holds still, because the border is this
|
|
106
|
+
variant's identity rather than its signal. That is the whole difference from `outline`, which reads
|
|
107
|
+
at full strength and lights its border instead. */
|
|
108
|
+
.xeno-btn[data-variant='quiet'] {
|
|
109
|
+
border-color: var(--xeno-border);
|
|
110
|
+
color: var(--xeno-muted);
|
|
111
|
+
}
|
|
112
|
+
.xeno-btn[data-variant='quiet']:hover:not(:disabled) {
|
|
113
|
+
color: var(--xeno-text);
|
|
114
|
+
background: var(--xeno-hover);
|
|
115
|
+
}
|
|
116
|
+
/* ON, the border gets out of the way. A quiet button is `on` while the thing it opened is on screen,
|
|
117
|
+
and for that moment it is not chrome beside a panel but the top edge of one — so it takes a fill and
|
|
118
|
+
drops the outline that was holding it apart. `--xeno-control` and not `--xeno-hover`: hover is a
|
|
119
|
+
translucent tint that says "the pointer is here", and this state outlives the pointer. */
|
|
120
|
+
.xeno-btn[data-variant='quiet'][data-selection='on'] {
|
|
121
|
+
background: var(--xeno-control);
|
|
122
|
+
border-color: transparent;
|
|
123
|
+
}
|
|
124
|
+
.xeno-btn[data-variant='danger'] {
|
|
125
|
+
border-color: var(--xeno-border);
|
|
126
|
+
color: var(--xeno-muted);
|
|
127
|
+
}
|
|
128
|
+
.xeno-btn[data-variant='danger']:hover:not(:disabled) {
|
|
129
|
+
color: var(--xeno-danger);
|
|
130
|
+
border-color: var(--xeno-danger);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* SELECTION reads as the text colour, whatever the variant's rest is.
|
|
134
|
+
*
|
|
135
|
+
* `ghost` sits at `--xeno-muted` so a row of icon buttons stays quiet; a button that is ON has to come
|
|
136
|
+
* forward, and in a monochrome grammar the only way forward is brightness. Every other axis is spoken
|
|
137
|
+
* for — a fill would make it a different variant, a border a different shape.
|
|
138
|
+
*
|
|
139
|
+
* It lives HERE rather than at the consumer, and that is the point: a product saying this from outside
|
|
140
|
+
* is patching a variant it does not own. It must sit after EVERY variant block: it wins on source order
|
|
141
|
+
* at equal weight, and it used to sit between `ghost` and `outline`, where the three variants below it
|
|
142
|
+
* — `outline`, `quiet`, `danger` — quietly took the ink back. `quiet` is where that showed: a top-bar
|
|
143
|
+
* button with its menu open stayed muted, looking exactly like the ones whose menus were shut.
|
|
144
|
+
*
|
|
145
|
+
* A note on how this was nearly abandoned. Measured through a headless driver it appeared to do
|
|
146
|
+
* nothing — and so did an inline `color: #ff0000 !important`, which is not possible. The button
|
|
147
|
+
* transitions `color` over 0.14s, and the probe was reading `getComputedStyle` on the same tick as the
|
|
148
|
+
* write, which returns the value the transition STARTED from. Any assertion about a colour on a
|
|
149
|
+
* control in this library has to wait for its own transition first. */
|
|
150
|
+
.xeno-btn[data-selection='on'] {
|
|
151
|
+
color: var(--xeno-text);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Entrance — the locked "send button" appear: fade + slide in + scale up. Opt-in via data-enter. */
|
|
155
|
+
@keyframes xeno-btn-enter {
|
|
156
|
+
from {
|
|
157
|
+
opacity: 0;
|
|
158
|
+
transform: translateX(6px) scale(0.76);
|
|
159
|
+
}
|
|
160
|
+
to {
|
|
161
|
+
opacity: 1;
|
|
162
|
+
transform: none;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
.xeno-btn[data-enter] {
|
|
166
|
+
animation: xeno-btn-enter var(--xeno-dur-entrance) var(--xeno-ease) both;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* Confirmed — the copy→check acknowledgement: the swapped glyph brightens and pops in. */
|
|
170
|
+
@keyframes xeno-confirm-pop {
|
|
171
|
+
from {
|
|
172
|
+
transform: scale(0.6);
|
|
173
|
+
}
|
|
174
|
+
to {
|
|
175
|
+
transform: scale(1);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
.xeno-btn[data-confirmed] {
|
|
179
|
+
color: var(--xeno-text);
|
|
180
|
+
}
|
|
181
|
+
.xeno-btn[data-confirmed] .xeno-element {
|
|
182
|
+
animation: xeno-confirm-pop var(--xeno-dur) var(--xeno-ease-overshoot) both;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* Icon-only: square, no horizontal padding. */
|
|
186
|
+
.xeno-icon-btn {
|
|
187
|
+
width: var(--xeno-h, 32px);
|
|
188
|
+
padding: 0;
|
|
189
|
+
aspect-ratio: 1 / 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* Toggle button (button-style on/off).
|
|
193
|
+
*
|
|
194
|
+
* The "on" fill is not a background any more — it is a LAYER that floods in. It spreads out of the glyph
|
|
195
|
+
* as a growing circle until it has filled the button, and drains back into the glyph when you switch it
|
|
196
|
+
* off: the state does not blink on, it pours in.
|
|
197
|
+
*
|
|
198
|
+
* The flood is CLIPPED, never scaled. A scaled layer changes size, and next to a row of buttons that
|
|
199
|
+
* reads as the button itself swelling — so the geometry here is frozen: `clip-path` only ever reveals
|
|
200
|
+
* more or less of a layer that stays exactly the button's size and never leaves its bounds.
|
|
201
|
+
*
|
|
202
|
+
* It has to be a pseudo-element: only a separate layer can be clipped without taking the label and the
|
|
203
|
+
* glyph with it. `isolation: isolate` makes the button its own stacking context, which pins the
|
|
204
|
+
* `z-index: -1` layer between the button's own background and its content — no z-index on the text (raw
|
|
205
|
+
* text nodes cannot take one) and no dependence on what the button happens to sit inside. */
|
|
206
|
+
.xeno-toggle {
|
|
207
|
+
--xeno-toggle-goo-dur: 320ms;
|
|
208
|
+
/* Decisive ease-out, NOT an overshoot: a bezier that goes past 1 would drive the clip radius negative
|
|
209
|
+
on the way out and the fill would snap away early. */
|
|
210
|
+
--xeno-toggle-goo-ease: var(--xeno-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
|
211
|
+
position: relative;
|
|
212
|
+
isolation: isolate;
|
|
213
|
+
border-color: var(--xeno-border);
|
|
214
|
+
color: var(--xeno-muted);
|
|
215
|
+
}
|
|
216
|
+
.xeno-toggle::before {
|
|
217
|
+
content: '';
|
|
218
|
+
position: absolute;
|
|
219
|
+
z-index: -1;
|
|
220
|
+
/* -1px so the layer reaches under the border, exactly where a background would have painted. */
|
|
221
|
+
inset: -1px;
|
|
222
|
+
border-radius: inherit;
|
|
223
|
+
background: var(--xeno-control);
|
|
224
|
+
/* The source of the flood: the leading edge, where the glyph sits. Radius 0 = nothing showing. */
|
|
225
|
+
clip-path: circle(0% at var(--xeno-padx, 12px) 50%);
|
|
226
|
+
}
|
|
227
|
+
.xeno-toggle[data-selection='on'] {
|
|
228
|
+
color: var(--xeno-text);
|
|
229
|
+
}
|
|
230
|
+
/* 150% of the box's own diagonal reference — enough to reach the far corner from an off-centre origin. */
|
|
231
|
+
.xeno-toggle[data-selection='on']::before {
|
|
232
|
+
clip-path: circle(150% at var(--xeno-padx, 12px) 50%);
|
|
233
|
+
}
|
|
234
|
+
/* Hover reads through both states: the fill covers any background tint once the toggle is on, so the
|
|
235
|
+
affordance lives on the border, which is visible either way. */
|
|
236
|
+
.xeno-toggle:hover:not(:disabled) {
|
|
237
|
+
color: var(--xeno-text);
|
|
238
|
+
background: var(--xeno-hover);
|
|
239
|
+
border-color: var(--xeno-muted);
|
|
240
|
+
}
|
|
241
|
+
/* A fixed button, all the way: the toggle also opts out of the press every other button gets. Nothing
|
|
242
|
+
about it changes size — the fill is the only thing that moves. (Opting out means holding `scale` at 1,
|
|
243
|
+
since the press rides on `scale`, not `transform`.) */
|
|
244
|
+
.xeno-toggle:active:not(:disabled) {
|
|
245
|
+
scale: 1;
|
|
246
|
+
}
|
|
247
|
+
/* Both labels stacked in ONE grid cell, so the button is sized to the longer of the two in either state.
|
|
248
|
+
This — not the animation — is what makes a Pin/Pinned toggle look like it resizes itself. */
|
|
249
|
+
.xeno-toggle-labels {
|
|
250
|
+
display: grid;
|
|
251
|
+
align-items: center;
|
|
252
|
+
justify-items: center;
|
|
253
|
+
}
|
|
254
|
+
.xeno-toggle-label {
|
|
255
|
+
grid-area: 1 / 1;
|
|
256
|
+
transition: opacity var(--xeno-dur) ease;
|
|
257
|
+
}
|
|
258
|
+
.xeno-toggle[data-selection='off'] .xeno-toggle-label[data-when='on'],
|
|
259
|
+
.xeno-toggle[data-selection='on'] .xeno-toggle-label[data-when='off'] {
|
|
260
|
+
opacity: 0;
|
|
261
|
+
visibility: hidden;
|
|
262
|
+
}
|
|
263
|
+
/* Gated on `data-motion="ready"`, set only once `pressed` has actually changed — otherwise every toggle
|
|
264
|
+
that mounts in the on state would pour itself in on page load. No fill-mode: each track ends on the
|
|
265
|
+
resting value above, so the static rules take over with nothing to jump. */
|
|
266
|
+
.xeno-toggle[data-motion='ready'][data-selection='on']::before {
|
|
267
|
+
animation: xeno-toggle-goo-in var(--xeno-toggle-goo-dur) var(--xeno-toggle-goo-ease);
|
|
268
|
+
}
|
|
269
|
+
.xeno-toggle[data-motion='ready'][data-selection='off']::before {
|
|
270
|
+
animation: xeno-toggle-goo-out var(--xeno-toggle-goo-dur) var(--xeno-toggle-goo-ease);
|
|
271
|
+
}
|
|
272
|
+
@keyframes xeno-toggle-goo-in {
|
|
273
|
+
from { clip-path: circle(0% at var(--xeno-padx, 12px) 50%); }
|
|
274
|
+
to { clip-path: circle(150% at var(--xeno-padx, 12px) 50%); }
|
|
275
|
+
}
|
|
276
|
+
@keyframes xeno-toggle-goo-out {
|
|
277
|
+
from { clip-path: circle(150% at var(--xeno-padx, 12px) 50%); }
|
|
278
|
+
to { clip-path: circle(0% at var(--xeno-padx, 12px) 50%); }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ── Switch (track + knob, both rounded SQUARES) ─────────────────────────────────── */
|
|
282
|
+
/* The knob does not glide, it THROWS: it overshoots the far end, swings back past it and settles —
|
|
283
|
+
two bounces, the way a sprung switch actually behaves. The track keeps its own clock (0ms — the
|
|
284
|
+
colour snaps the instant the state flips) so the flip reads as decided while the knob is still
|
|
285
|
+
catching up; that separation is the whole effect. Position rides on `translate`, not `transform`,
|
|
286
|
+
so the keyframes and the press affordance never fight over one property.
|
|
287
|
+
Travel = 30px content box (36 − 2px border − 4px padding) − 14px knob = 16px. */
|
|
288
|
+
.xeno-switch {
|
|
289
|
+
--xeno-switch-travel: 16px;
|
|
290
|
+
--xeno-switch-dur: 350ms;
|
|
291
|
+
--xeno-switch-track-dur: 0ms;
|
|
292
|
+
/* Overshoot past the end, then the smaller swing back. */
|
|
293
|
+
--xeno-switch-ov1: 1.1px;
|
|
294
|
+
--xeno-switch-ov2: 0px;
|
|
295
|
+
--xeno-switch-ease: cubic-bezier(0.34, 1.35, 0.64, 1);
|
|
296
|
+
display: inline-flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
width: 36px;
|
|
299
|
+
height: 20px;
|
|
300
|
+
padding: 2px;
|
|
301
|
+
background: var(--xeno-control);
|
|
302
|
+
border: 1px solid var(--xeno-border);
|
|
303
|
+
border-radius: var(--xeno-radius-md);
|
|
304
|
+
cursor: pointer;
|
|
305
|
+
transition: background var(--xeno-switch-track-dur) var(--xeno-switch-ease);
|
|
306
|
+
}
|
|
307
|
+
.xeno-switch-knob {
|
|
308
|
+
width: 14px;
|
|
309
|
+
height: 14px;
|
|
310
|
+
translate: 0 0;
|
|
311
|
+
will-change: translate;
|
|
312
|
+
background: var(--xeno-muted);
|
|
313
|
+
border-radius: var(--xeno-radius-xs);
|
|
314
|
+
transition:
|
|
315
|
+
translate var(--xeno-dur) var(--xeno-ease-expo),
|
|
316
|
+
width var(--xeno-dur) var(--xeno-ease-expo),
|
|
317
|
+
background var(--xeno-dur) ease;
|
|
318
|
+
}
|
|
319
|
+
/* Press affordance — the knob stretches while held (iOS-style), reduce-safe. */
|
|
320
|
+
.xeno-switch:active:not(:disabled):not([data-availability='disabled']) .xeno-switch-knob {
|
|
321
|
+
width: 18px;
|
|
322
|
+
}
|
|
323
|
+
.xeno-switch[data-selection='on']:active:not(:disabled):not([data-availability='disabled']) .xeno-switch-knob {
|
|
324
|
+
translate: 12px 0;
|
|
325
|
+
}
|
|
326
|
+
.xeno-switch[data-selection='on'] {
|
|
327
|
+
background: var(--xeno-text);
|
|
328
|
+
}
|
|
329
|
+
.xeno-switch[data-selection='on'] .xeno-switch-knob {
|
|
330
|
+
translate: var(--xeno-switch-travel) 0;
|
|
331
|
+
background: var(--xeno-on-accent);
|
|
332
|
+
}
|
|
333
|
+
/* The throw itself. Gated on `data-motion="ready"`, which the component only sets once the user has
|
|
334
|
+
actually toggled it — otherwise every switch that mounts in the "off" state would bounce on load.
|
|
335
|
+
No fill-mode on purpose: each keyframe track ends exactly on the resting value above, so once it
|
|
336
|
+
finishes the static rules take over with no jump, and `:active` can stretch the knob again. */
|
|
337
|
+
.xeno-switch[data-motion='ready'][data-selection='on'] .xeno-switch-knob {
|
|
338
|
+
animation: xeno-switch-throw-on var(--xeno-switch-dur) var(--xeno-switch-ease);
|
|
339
|
+
}
|
|
340
|
+
.xeno-switch[data-motion='ready'][data-selection='off'] .xeno-switch-knob {
|
|
341
|
+
animation: xeno-switch-throw-off var(--xeno-switch-dur) var(--xeno-switch-ease);
|
|
342
|
+
}
|
|
343
|
+
@keyframes xeno-switch-throw-on {
|
|
344
|
+
0% { translate: 0 0; }
|
|
345
|
+
55% { translate: calc(var(--xeno-switch-travel) + var(--xeno-switch-ov1)) 0; }
|
|
346
|
+
80% { translate: calc(var(--xeno-switch-travel) - var(--xeno-switch-ov2)) 0; }
|
|
347
|
+
100% { translate: var(--xeno-switch-travel) 0; }
|
|
348
|
+
}
|
|
349
|
+
@keyframes xeno-switch-throw-off {
|
|
350
|
+
0% { translate: var(--xeno-switch-travel) 0; }
|
|
351
|
+
55% { translate: calc(0px - var(--xeno-switch-ov1)) 0; }
|
|
352
|
+
80% { translate: var(--xeno-switch-ov2) 0; }
|
|
353
|
+
100% { translate: 0 0; }
|
|
354
|
+
}
|
|
355
|
+
.xeno-switch:focus-visible {
|
|
356
|
+
outline: none;
|
|
357
|
+
box-shadow: 0 0 0 2px var(--xeno-canvas), 0 0 0 4px var(--xeno-border);
|
|
358
|
+
}
|
|
359
|
+
.xeno-switch:disabled,
|
|
360
|
+
.xeno-switch[data-availability='disabled'] {
|
|
361
|
+
opacity: 0.4;
|
|
362
|
+
cursor: not-allowed;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* ── TextInput / SearchInput ─────────────────────────────────────────────────────── */
|
|
366
|
+
.xeno-input {
|
|
367
|
+
display: inline-flex;
|
|
368
|
+
align-items: center;
|
|
369
|
+
gap: var(--xeno-gap, 6px);
|
|
370
|
+
height: var(--xeno-h, 32px);
|
|
371
|
+
padding: 0 var(--xeno-padx, 12px);
|
|
372
|
+
background: var(--xeno-canvas);
|
|
373
|
+
border: 1px solid var(--xeno-border);
|
|
374
|
+
border-radius: var(--xeno-radius-md);
|
|
375
|
+
color: var(--xeno-text);
|
|
376
|
+
transition: border-color var(--xeno-dur) ease;
|
|
377
|
+
}
|
|
378
|
+
.xeno-input:focus-within {
|
|
379
|
+
border-color: var(--xeno-muted);
|
|
380
|
+
}
|
|
381
|
+
.xeno-input[data-availability='disabled'] {
|
|
382
|
+
opacity: 0.4;
|
|
383
|
+
}
|
|
384
|
+
.xeno-input-icon {
|
|
385
|
+
display: inline-flex;
|
|
386
|
+
color: var(--xeno-muted);
|
|
387
|
+
}
|
|
388
|
+
.xeno-input-field {
|
|
389
|
+
flex: 1;
|
|
390
|
+
/* Fill the wrapper's height. Centred at its own natural height instead, the field left a dead band
|
|
391
|
+
along the top and bottom of a box that LOOKS like a text field and does not focus when tapped —
|
|
392
|
+
4px of it at 32px, and 10px once a touch surface takes the wrapper to 44. A field's target should
|
|
393
|
+
be the box you can see. */
|
|
394
|
+
align-self: stretch;
|
|
395
|
+
min-width: 0;
|
|
396
|
+
border: none;
|
|
397
|
+
background: transparent;
|
|
398
|
+
color: inherit;
|
|
399
|
+
font: inherit;
|
|
400
|
+
font-size: var(--xeno-font, 14px);
|
|
401
|
+
outline: none;
|
|
402
|
+
}
|
|
403
|
+
.xeno-input-field::placeholder {
|
|
404
|
+
color: var(--xeno-muted);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* ── Textarea ────────────────────────────────────────────────────────────────────── */
|
|
408
|
+
.xeno-textarea {
|
|
409
|
+
width: 100%;
|
|
410
|
+
padding: 10px 12px;
|
|
411
|
+
background: var(--xeno-canvas);
|
|
412
|
+
border: 1px solid var(--xeno-border);
|
|
413
|
+
border-radius: var(--xeno-radius-card);
|
|
414
|
+
color: var(--xeno-text);
|
|
415
|
+
font: inherit;
|
|
416
|
+
font-size: 15px;
|
|
417
|
+
line-height: 1.5;
|
|
418
|
+
resize: vertical;
|
|
419
|
+
outline: none;
|
|
420
|
+
transition: border-color var(--xeno-dur) ease;
|
|
421
|
+
}
|
|
422
|
+
.xeno-textarea::placeholder {
|
|
423
|
+
color: var(--xeno-muted);
|
|
424
|
+
}
|
|
425
|
+
.xeno-textarea:focus {
|
|
426
|
+
border-color: var(--xeno-muted);
|
|
427
|
+
}
|
|
428
|
+
.xeno-textarea[data-availability='disabled'] {
|
|
429
|
+
opacity: 0.4;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@media (prefers-reduced-motion: reduce) {
|
|
433
|
+
.xeno-btn,
|
|
434
|
+
.xeno-switch,
|
|
435
|
+
.xeno-switch-knob {
|
|
436
|
+
transition: none;
|
|
437
|
+
}
|
|
438
|
+
.xeno-btn[data-enter],
|
|
439
|
+
.xeno-btn[data-confirmed] .xeno-element,
|
|
440
|
+
.xeno-toggle::before {
|
|
441
|
+
animation: none;
|
|
442
|
+
}
|
|
443
|
+
.xeno-switch:active:not(:disabled) .xeno-switch-knob {
|
|
444
|
+
width: 14px;
|
|
445
|
+
}
|
|
446
|
+
/* No press travel either — with the transition off, a scale change would be a jump, not a press. */
|
|
447
|
+
.xeno-btn:active:not(:disabled) {
|
|
448
|
+
scale: 1;
|
|
449
|
+
}
|
|
450
|
+
.xeno-switch[data-motion='ready'] .xeno-switch-knob {
|
|
451
|
+
animation: none;
|
|
452
|
+
}
|
|
453
|
+
}
|