@xenosystem/elements-react 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +41 -0
  3. package/dist/XenoElement.d.ts +39 -0
  4. package/dist/XenoElement.js +25 -0
  5. package/dist/containers/Avatar.d.ts +42 -0
  6. package/dist/containers/Avatar.js +29 -0
  7. package/dist/containers/Badges.d.ts +61 -0
  8. package/dist/containers/Badges.js +43 -0
  9. package/dist/containers/Card.d.ts +24 -0
  10. package/dist/containers/Card.js +5 -0
  11. package/dist/containers/Chip.d.ts +34 -0
  12. package/dist/containers/Chip.js +17 -0
  13. package/dist/containers/DataTable.d.ts +28 -0
  14. package/dist/containers/DataTable.js +13 -0
  15. package/dist/containers/ListRow.d.ts +37 -0
  16. package/dist/containers/ListRow.js +40 -0
  17. package/dist/containers/MessageBubble.d.ts +33 -0
  18. package/dist/containers/MessageBubble.js +9 -0
  19. package/dist/containers/Panel.d.ts +24 -0
  20. package/dist/containers/Panel.js +6 -0
  21. package/dist/containers/Table.d.ts +39 -0
  22. package/dist/containers/Table.js +20 -0
  23. package/dist/containers/Tile.d.ts +19 -0
  24. package/dist/containers/Tile.js +7 -0
  25. package/dist/content/Callout.d.ts +32 -0
  26. package/dist/content/Callout.js +10 -0
  27. package/dist/content/Caret.d.ts +9 -0
  28. package/dist/content/Caret.js +5 -0
  29. package/dist/content/CodeBlock.d.ts +36 -0
  30. package/dist/content/CodeBlock.js +50 -0
  31. package/dist/content/Collapsible.d.ts +24 -0
  32. package/dist/content/Collapsible.js +19 -0
  33. package/dist/content/InlineCode.d.ts +12 -0
  34. package/dist/content/InlineCode.js +5 -0
  35. package/dist/content/ModelPicker.d.ts +45 -0
  36. package/dist/content/ModelPicker.js +124 -0
  37. package/dist/content/SourceCard.d.ts +23 -0
  38. package/dist/content/SourceCard.js +18 -0
  39. package/dist/content/SourcesDisclosure.d.ts +26 -0
  40. package/dist/content/SourcesDisclosure.js +20 -0
  41. package/dist/controls/Button.d.ts +39 -0
  42. package/dist/controls/Button.js +16 -0
  43. package/dist/controls/ConfirmButton.d.ts +22 -0
  44. package/dist/controls/ConfirmButton.js +22 -0
  45. package/dist/controls/IconButton.d.ts +45 -0
  46. package/dist/controls/IconButton.js +15 -0
  47. package/dist/controls/Switch.d.ts +11 -0
  48. package/dist/controls/Switch.js +23 -0
  49. package/dist/controls/TextInput.d.ts +13 -0
  50. package/dist/controls/TextInput.js +6 -0
  51. package/dist/controls/Textarea.d.ts +8 -0
  52. package/dist/controls/Textarea.js +5 -0
  53. package/dist/controls/ToggleButton.d.ts +23 -0
  54. package/dist/controls/ToggleButton.js +23 -0
  55. package/dist/controls/util.d.ts +48 -0
  56. package/dist/controls/util.js +23 -0
  57. package/dist/forms/Checkbox.d.ts +18 -0
  58. package/dist/forms/Checkbox.js +28 -0
  59. package/dist/forms/RadioGroup.d.ts +51 -0
  60. package/dist/forms/RadioGroup.js +83 -0
  61. package/dist/index.d.ts +75 -0
  62. package/dist/index.js +81 -0
  63. package/dist/layout/ResizablePanel.d.ts +40 -0
  64. package/dist/layout/ResizablePanel.js +108 -0
  65. package/dist/nav/Sidebar.d.ts +60 -0
  66. package/dist/nav/Sidebar.js +84 -0
  67. package/dist/overlays/DateTimePicker.d.ts +71 -0
  68. package/dist/overlays/DateTimePicker.js +191 -0
  69. package/dist/overlays/Menu.d.ts +28 -0
  70. package/dist/overlays/Menu.js +32 -0
  71. package/dist/overlays/MenuItem.d.ts +71 -0
  72. package/dist/overlays/MenuItem.js +15 -0
  73. package/dist/overlays/Modal.d.ts +36 -0
  74. package/dist/overlays/Modal.js +27 -0
  75. package/dist/overlays/PickerField.d.ts +32 -0
  76. package/dist/overlays/PickerField.js +40 -0
  77. package/dist/overlays/PillFilter.d.ts +25 -0
  78. package/dist/overlays/PillFilter.js +41 -0
  79. package/dist/overlays/Popover.d.ts +32 -0
  80. package/dist/overlays/Popover.js +38 -0
  81. package/dist/overlays/Reveal.d.ts +20 -0
  82. package/dist/overlays/Reveal.js +45 -0
  83. package/dist/overlays/SegmentedControl.d.ts +23 -0
  84. package/dist/overlays/SegmentedControl.js +43 -0
  85. package/dist/overlays/Tabs.d.ts +32 -0
  86. package/dist/overlays/Tabs.js +142 -0
  87. package/dist/overlays/Tooltip.d.ts +23 -0
  88. package/dist/overlays/Tooltip.js +61 -0
  89. package/dist/status/ProgressBar.d.ts +20 -0
  90. package/dist/status/ProgressBar.js +11 -0
  91. package/dist/status/Spinner.d.ts +16 -0
  92. package/dist/status/Spinner.js +12 -0
  93. package/dist/status/StatusIndicator.d.ts +19 -0
  94. package/dist/status/StatusIndicator.js +11 -0
  95. package/dist/status/StepTimeline.d.ts +26 -0
  96. package/dist/status/StepTimeline.js +16 -0
  97. package/dist/status/ThinkingCube.d.ts +24 -0
  98. package/dist/status/ThinkingCube.js +11 -0
  99. package/dist/useDialog.d.ts +45 -0
  100. package/dist/useDialog.js +122 -0
  101. package/dist/useGooPill.d.ts +57 -0
  102. package/dist/useGooPill.js +76 -0
  103. package/dist/useMenu.d.ts +50 -0
  104. package/dist/useMenu.js +119 -0
  105. package/dist/useTabs.d.ts +88 -0
  106. package/dist/useTabs.js +93 -0
  107. package/package.json +58 -0
  108. package/src/chrome-separated.css +132 -0
  109. package/src/chrome-unified.css +72 -0
  110. package/src/containers/Badges.css +116 -0
  111. package/src/containers/avatar.css +70 -0
  112. package/src/containers/card.css +75 -0
  113. package/src/containers/chip.css +103 -0
  114. package/src/containers/list-row.css +132 -0
  115. package/src/containers/message-bubble.css +112 -0
  116. package/src/containers/panel.css +120 -0
  117. package/src/containers/table.css +102 -0
  118. package/src/containers/tile.css +17 -0
  119. package/src/content/callout.css +77 -0
  120. package/src/content/caret.css +30 -0
  121. package/src/content/code-block.css +159 -0
  122. package/src/content/collapsible.css +80 -0
  123. package/src/content/inline-code.css +14 -0
  124. package/src/content/model-picker.css +219 -0
  125. package/src/content/source-card.css +99 -0
  126. package/src/content/sources-disclosure.css +118 -0
  127. package/src/controls.css +453 -0
  128. package/src/fonts.css +34 -0
  129. package/src/forms/Checkbox.css +114 -0
  130. package/src/forms/RadioGroup.css +115 -0
  131. package/src/goo.css +97 -0
  132. package/src/icon-motion.css +1909 -0
  133. package/src/layout/ResizablePanel.css +79 -0
  134. package/src/nav/sidebar.css +296 -0
  135. package/src/overlays/date-time-picker.css +238 -0
  136. package/src/overlays/menu.css +195 -0
  137. package/src/overlays/modal.css +167 -0
  138. package/src/overlays/picker-field.css +81 -0
  139. package/src/overlays/pill-filter.css +129 -0
  140. package/src/overlays/reveal.css +56 -0
  141. package/src/overlays/segmented-control.css +185 -0
  142. package/src/overlays/tabs.css +143 -0
  143. package/src/overlays/tooltip.css +126 -0
  144. package/src/scrollbar.css +184 -0
  145. package/src/size.css +179 -0
  146. package/src/status/progress-bar.css +88 -0
  147. package/src/status/spinner.css +33 -0
  148. package/src/status/statusindicator.css +65 -0
  149. package/src/status/steptimeline.css +137 -0
  150. package/src/status/thinkingcube.css +75 -0
  151. package/src/xeno-element.css +60 -0
  152. package/src/xeno-elements.css +71 -0
  153. package/src/xeno-theme.css +99 -0
@@ -0,0 +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
+ }
package/src/fonts.css ADDED
@@ -0,0 +1,34 @@
1
+ /*
2
+ * The XENO typeface — vendored, not merely declared.
3
+ *
4
+ * `font-family: 'Inter'` alone SILENTLY falls back when Inter is not installed, and on Windows that
5
+ * means Segoe UI: a fine reading face and the wrong one for dense dark chrome. The app then looks
6
+ * identical to having declared nothing, which is most of why an interface reads as cheap. It is a
7
+ * live bug in xeno-hub today. Vendoring the woff2 means it works offline and needs no CSP exception.
8
+ *
9
+ * OPT-IN, deliberately. This is a library, not an app: forcing ~100 kB of font into every consumer's
10
+ * bundle is not ours to decide, and a product with its own licensed face must be able to say no. Same
11
+ * shape as the `motion` entry point — the platform SHIPS it so nobody re-derives it, and the consumer
12
+ * chooses.
13
+ *
14
+ * import '@xenosystem/elements-react/fonts.css'
15
+ *
16
+ * A product that skips this gets the fallback stack below and should know that it has.
17
+ */
18
+ @import '@fontsource-variable/inter/index.css';
19
+
20
+ .xeno {
21
+ /*
22
+ * `--xeno-font-*` and NOT `--xeno-text-*`. That prefix is already the colour ramp, and reusing it
23
+ * would silently overwrite `--xeno-text` app-wide — a documented failure, not a hypothetical.
24
+ */
25
+ --xeno-font-sans: 'Inter Variable', 'Inter', -apple-system, 'Segoe UI', system-ui, sans-serif;
26
+ --xeno-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
27
+
28
+ font-family: var(--xeno-font-sans);
29
+ /* cv11 disambiguates 1/l/I — the one that matters most in dense UI and in code-adjacent labels. */
30
+ font-feature-settings: 'cv02' 1, 'cv03' 1, 'cv04' 1, 'cv11' 1, 'ss01' 1;
31
+ font-optical-sizing: auto;
32
+ /* Inter runs a touch wide at UI sizes. */
33
+ letter-spacing: -0.006em;
34
+ }
@@ -0,0 +1,114 @@
1
+ /*
2
+ * `<Checkbox>` — rounded-SQUARE box + optional label. CSS-first: paint is keyed off `data-selection`
3
+ * (off|on|mixed) and `data-availability`. Monochrome shell — only theme tokens, no literal colours.
4
+ */
5
+ .xeno-checkbox {
6
+ display: inline-flex;
7
+ align-items: center;
8
+ gap: 8px;
9
+ margin: 0;
10
+ padding: 0;
11
+ background: transparent;
12
+ border: none;
13
+ color: var(--xeno-text);
14
+ font-family: inherit;
15
+ font-size: 14px;
16
+ line-height: 1;
17
+ text-align: left;
18
+ cursor: pointer;
19
+ user-select: none;
20
+ }
21
+
22
+ .xeno-checkbox-box {
23
+ display: inline-flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ flex: none;
27
+ width: 17px;
28
+ height: 17px;
29
+ /* The stamped glyph inherits this via currentColor; only visible once the box is filled. */
30
+ color: var(--xeno-on-accent);
31
+ background: var(--xeno-canvas);
32
+ border: 1px solid var(--xeno-border);
33
+ border-radius: var(--xeno-radius-sm);
34
+ transition:
35
+ background var(--xeno-dur) var(--xeno-ease),
36
+ border-color var(--xeno-dur) var(--xeno-ease);
37
+ }
38
+
39
+ .xeno-checkbox:hover:not(:disabled) .xeno-checkbox-box {
40
+ border-color: var(--xeno-muted);
41
+ }
42
+
43
+ .xeno-checkbox[data-selection='on'] .xeno-checkbox-box,
44
+ .xeno-checkbox[data-selection='mixed'] .xeno-checkbox-box {
45
+ background: var(--xeno-text);
46
+ border-color: var(--xeno-text);
47
+ }
48
+
49
+ /* ── Ticking ──────────────────────────────────────────────────────────────────────────
50
+ * The tick DRAWS itself, heel to tip. Nothing new is authored here: `xeno.check` already carries that
51
+ * animation in `icon-motion.css`, where it plays on hover — the glyph's own rule sets `--part-anim`,
52
+ * `--part-dur` and the `stroke-dasharray` it needs. All this does is play the SAME animation on the
53
+ * event that actually matters for a checkbox, which is the tick appearing.
54
+ *
55
+ * Reading it through `var(--part-anim)` rather than naming the keyframe keeps the two loosely coupled:
56
+ * redraw the check glyph's animation and the checkbox follows it, with no edit here.
57
+ *
58
+ * The 900ms the gallery uses is a showcase pace; a control that answers a click wants ~a third of that.
59
+ *
60
+ * Everything is gated on `data-motion="ready"`, which the component sets only after `checked` has
61
+ * genuinely changed — otherwise a form rendered with ticked boxes draws all of them on load. */
62
+ .xeno-checkbox[data-motion='ready'][data-selection='on'] .xeno-part[data-part='0'] {
63
+ --part-dur: 340ms;
64
+ animation: var(--part-anim) var(--part-dur) var(--xeno-icon-ease);
65
+ }
66
+ /* The box answers with it: a quick compress and overshoot, so the fill lands with the tick instead of
67
+ snapping into place a beat ahead of it. */
68
+ .xeno-checkbox[data-motion='ready'][data-selection='on'] .xeno-checkbox-box,
69
+ .xeno-checkbox[data-motion='ready'][data-selection='mixed'] .xeno-checkbox-box {
70
+ animation: xeno-checkbox-stamp 280ms var(--xeno-ease-overshoot, cubic-bezier(0.34, 1.4, 0.5, 1));
71
+ }
72
+ @keyframes xeno-checkbox-stamp {
73
+ 0% { scale: 0.86; }
74
+ 60% { scale: 1.06; }
75
+ 100% { scale: 1; }
76
+ }
77
+ /* Indeterminate has no stroke to draw — the bar just arrives, widening from the middle. */
78
+ .xeno-checkbox[data-motion='ready'][data-selection='mixed'] .xeno-part[data-part='0'] {
79
+ animation: xeno-checkbox-dash 240ms var(--xeno-ease, cubic-bezier(0.22, 1, 0.36, 1));
80
+ }
81
+ @keyframes xeno-checkbox-dash {
82
+ 0% { scale: 0 1; opacity: 0; }
83
+ 100% { scale: 1 1; opacity: 1; }
84
+ }
85
+
86
+ .xeno-checkbox-label {
87
+ color: var(--xeno-text);
88
+ }
89
+
90
+ .xeno-checkbox:focus-visible {
91
+ outline: none;
92
+ }
93
+ .xeno-checkbox:focus-visible .xeno-checkbox-box {
94
+ box-shadow:
95
+ 0 0 0 2px var(--xeno-canvas),
96
+ 0 0 0 4px var(--xeno-border);
97
+ }
98
+
99
+ .xeno-checkbox:disabled,
100
+ .xeno-checkbox[data-availability='disabled'] {
101
+ opacity: 0.4;
102
+ cursor: not-allowed;
103
+ }
104
+
105
+ @media (prefers-reduced-motion: reduce) {
106
+ .xeno-checkbox-box {
107
+ transition: none;
108
+ }
109
+ /* The tick is still there — it just does not draw itself in. */
110
+ .xeno-checkbox-box,
111
+ .xeno-checkbox .xeno-part {
112
+ animation: none !important;
113
+ }
114
+ }