@adea-ai/ui 0.93.2 → 0.95.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.
Files changed (68) hide show
  1. package/README.md +43 -1
  2. package/dist/NOTICE +10 -0
  3. package/dist/components/composites/action-button/action-button.d.ts.map +1 -1
  4. package/dist/components/composites/action-button/action-button.js +2 -0
  5. package/dist/components/composites/action-button/action-button.js.map +1 -1
  6. package/dist/components/composites/scene-controls/index.d.ts +3 -0
  7. package/dist/components/composites/scene-controls/index.d.ts.map +1 -0
  8. package/dist/components/composites/scene-controls/index.js +2 -0
  9. package/dist/components/composites/scene-controls/scene-controls.d.ts +41 -0
  10. package/dist/components/composites/scene-controls/scene-controls.d.ts.map +1 -0
  11. package/dist/components/composites/scene-controls/scene-controls.js +291 -0
  12. package/dist/components/composites/scene-controls/scene-controls.js.map +1 -0
  13. package/dist/components/layout/side-rail/side-rail.d.ts +1 -1
  14. package/dist/components/layout/side-rail/side-rail.d.ts.map +1 -1
  15. package/dist/components/layout/side-rail/side-rail.js +8 -8
  16. package/dist/components/layout/side-rail/side-rail.js.map +1 -1
  17. package/dist/components/ui/button/button.d.ts +2 -1
  18. package/dist/components/ui/button/button.d.ts.map +1 -1
  19. package/dist/components/ui/button/button.js +9 -3
  20. package/dist/components/ui/button/button.js.map +1 -1
  21. package/dist/components/ui/toggle/toggle.d.ts +1 -1
  22. package/dist/components/ui/toggle/toggle.d.ts.map +1 -1
  23. package/dist/components/ui/toggle/toggle.js +3 -1
  24. package/dist/components/ui/toggle/toggle.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -1
  28. package/dist/lib/tokens.d.ts.map +1 -1
  29. package/dist/lib/tokens.js +11 -1
  30. package/dist/lib/tokens.js.map +1 -1
  31. package/dist/lib/utils.d.ts.map +1 -1
  32. package/dist/lib/utils.js +2 -0
  33. package/dist/lib/utils.js.map +1 -1
  34. package/dist/lib/variants.d.ts +5 -2
  35. package/dist/lib/variants.d.ts.map +1 -1
  36. package/dist/lib/variants.js +7 -3
  37. package/dist/lib/variants.js.map +1 -1
  38. package/dist/lint/index.d.ts.map +1 -1
  39. package/dist/lint.js +2 -1
  40. package/dist/lint.js.map +1 -1
  41. package/dist/r/registry.json +24 -0
  42. package/dist/r/scene-controls.json +24 -0
  43. package/dist/r/src/components/composites/action-button/action-button.tsx +2 -0
  44. package/dist/r/src/components/composites/scene-controls/index.ts +7 -0
  45. package/dist/r/src/components/composites/scene-controls/scene-controls.tsx +282 -0
  46. package/dist/r/src/components/layout/side-rail/side-rail.tsx +18 -10
  47. package/dist/r/src/components/ui/button/button.tsx +20 -2
  48. package/dist/r/src/components/ui/toggle/toggle.tsx +2 -0
  49. package/dist/r/src/lib/tokens.ts +11 -1
  50. package/dist/r/src/lib/utils.ts +2 -0
  51. package/dist/r/src/lib/variants.ts +5 -2
  52. package/dist/r/src/styles/base.css +29 -9
  53. package/dist/r/src/styles/theme.css +13 -6
  54. package/package.json +2 -1
  55. package/registry.json +24 -0
  56. package/src/components/composites/action-button/action-button.tsx +2 -0
  57. package/src/components/composites/scene-controls/index.ts +7 -0
  58. package/src/components/composites/scene-controls/scene-controls.tsx +282 -0
  59. package/src/components/layout/side-rail/side-rail.tsx +18 -10
  60. package/src/components/ui/button/button.tsx +20 -2
  61. package/src/components/ui/toggle/toggle.tsx +2 -0
  62. package/src/index.ts +1 -0
  63. package/src/lib/tokens.ts +11 -1
  64. package/src/lib/utils.ts +2 -0
  65. package/src/lib/variants.ts +5 -2
  66. package/src/lint/index.ts +1 -0
  67. package/src/styles/base.css +29 -9
  68. package/src/styles/theme.css +13 -6
package/registry.json CHANGED
@@ -1962,6 +1962,30 @@
1962
1962
  }
1963
1963
  ]
1964
1964
  },
1965
+ {
1966
+ "name": "scene-controls",
1967
+ "type": "registry:ui",
1968
+ "title": "Scene Controls",
1969
+ "description": "Shared host-neutral scene movement, jump and zoom controls.",
1970
+ "dependencies": [
1971
+ "lucide-solid@^1.48.0"
1972
+ ],
1973
+ "registryDependencies": [
1974
+ "@adea-ai/ui/action-button"
1975
+ ],
1976
+ "files": [
1977
+ {
1978
+ "path": "src/components/composites/scene-controls/index.ts",
1979
+ "type": "registry:ui",
1980
+ "target": "components/composites/scene-controls/index.ts"
1981
+ },
1982
+ {
1983
+ "path": "src/components/composites/scene-controls/scene-controls.tsx",
1984
+ "type": "registry:ui",
1985
+ "target": "components/composites/scene-controls/scene-controls.tsx"
1986
+ }
1987
+ ]
1988
+ },
1965
1989
  {
1966
1990
  "name": "settings",
1967
1991
  "type": "registry:ui",
@@ -84,6 +84,7 @@ export function ActionButton<T extends ValidComponent = 'button'>(props: ActionB
84
84
  'as',
85
85
  'variant',
86
86
  'size',
87
+ 'touchTarget',
87
88
  'class',
88
89
  'children',
89
90
  'disabled',
@@ -106,6 +107,7 @@ export function ActionButton<T extends ValidComponent = 'button'>(props: ActionB
106
107
  ...rest,
107
108
  variant: local.variant,
108
109
  size: local.size,
110
+ touchTarget: local.touchTarget,
109
111
  class: [local.class, local.tooltip && disabled() && 'aria-disabled:pointer-events-auto']
110
112
  .filter(Boolean)
111
113
  .join(' '),
@@ -0,0 +1,7 @@
1
+ /** Shared host-neutral scene movement, jump and zoom controls. */
2
+ export {
3
+ SceneControls,
4
+ type SceneControlLabels,
5
+ type SceneControlsProps,
6
+ type SceneMovementDirection,
7
+ } from './scene-controls'
@@ -0,0 +1,282 @@
1
+ import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ZoomIn, ZoomOut } from 'lucide-solid'
2
+ import { createSignal, onCleanup, Show, type JSX } from 'solid-js'
3
+ import { ActionButton } from '../action-button'
4
+
5
+ /** A host-neutral direction reported while a movement control is held. */
6
+ export type SceneMovementDirection = 'forward' | 'left' | 'backward' | 'right'
7
+
8
+ /** Accessible names and tooltip text for the shared scene controls. */
9
+ export type SceneControlLabels = {
10
+ movementGroup: string
11
+ forward: string
12
+ forwardTooltip: string
13
+ left: string
14
+ leftTooltip: string
15
+ backward: string
16
+ backwardTooltip: string
17
+ right: string
18
+ rightTooltip: string
19
+ jump: string
20
+ jumpTooltip: string
21
+ zoomGroup: string
22
+ zoomOut: string
23
+ zoomIn: string
24
+ }
25
+
26
+ const DEFAULT_LABELS: SceneControlLabels = {
27
+ movementGroup: 'Scene movement controls',
28
+ forward: 'Move forward',
29
+ forwardTooltip: 'Hold to move forward',
30
+ left: 'Move left',
31
+ leftTooltip: 'Hold to move left',
32
+ backward: 'Move backward',
33
+ backwardTooltip: 'Hold to move backward',
34
+ right: 'Move right',
35
+ rightTooltip: 'Hold to move right',
36
+ jump: 'Jump',
37
+ jumpTooltip: 'Hold to jump',
38
+ zoomGroup: 'Camera zoom',
39
+ zoomOut: 'Zoom out',
40
+ zoomIn: 'Zoom in',
41
+ }
42
+
43
+ const pointerSource = (pointerId: number) => `pointer:${pointerId}`
44
+ const keySource = (key: string) => `key:${key}`
45
+
46
+ export type SceneControlsProps = {
47
+ /** Called on the transition into and out of each held movement direction. */
48
+ onMovementChange?: (direction: SceneMovementDirection, pressed: boolean) => void
49
+ /** Called when the jump action becomes pressed or released. */
50
+ onJumpChange?: (pressed: boolean) => void
51
+ /** Called once when zoom in is activated. */
52
+ onZoomIn?: () => void
53
+ /** Called once when zoom out is activated. */
54
+ onZoomOut?: () => void
55
+ /** Override the default English accessible names and tooltip copy. */
56
+ labels?: Partial<SceneControlLabels>
57
+ }
58
+
59
+ type HoldActionProps = {
60
+ label: string
61
+ tooltip: string
62
+ createChangeCallback: () => (pressed: boolean) => void
63
+ children: JSX.Element
64
+ }
65
+
66
+ /**
67
+ * A held action that reports one down/up pair across pointer and keyboard input.
68
+ *
69
+ * The arrangement, rounded scrim group surfaces and release policy translate
70
+ * Adea's on-screen scene controls; host key synthesis and scene APIs are replaced
71
+ * by callbacks.
72
+ */
73
+ function HoldAction(props: HoldActionProps) {
74
+ const [pressed, setPressed] = createSignal(false)
75
+ const sources = new Set<string>()
76
+ let activeChangeCallback: ((pressed: boolean) => void) | undefined
77
+
78
+ const press = (source: string) => {
79
+ if (sources.has(source)) return
80
+ const wasPressed = sources.size > 0
81
+ sources.add(source)
82
+ if (!wasPressed) {
83
+ setPressed(true)
84
+ activeChangeCallback = props.createChangeCallback()
85
+ activeChangeCallback(true)
86
+ }
87
+ }
88
+
89
+ const release = (source: string) => {
90
+ if (!sources.delete(source) || sources.size > 0) return
91
+ setPressed(false)
92
+ const callback = activeChangeCallback
93
+ activeChangeCallback = undefined
94
+ callback?.(false)
95
+ }
96
+
97
+ const releaseAll = () => {
98
+ if (sources.size === 0) return
99
+ sources.clear()
100
+ setPressed(false)
101
+ const callback = activeChangeCallback
102
+ activeChangeCallback = undefined
103
+ callback?.(false)
104
+ }
105
+
106
+ const onWindowPointerUp = (event: PointerEvent) => release(pointerSource(event.pointerId))
107
+ const onWindowBlur = () => releaseAll()
108
+
109
+ if (typeof window !== 'undefined') {
110
+ window.addEventListener('pointerup', onWindowPointerUp)
111
+ window.addEventListener('blur', onWindowBlur)
112
+ }
113
+
114
+ onCleanup(() => {
115
+ releaseAll()
116
+ if (typeof window !== 'undefined') {
117
+ window.removeEventListener('pointerup', onWindowPointerUp)
118
+ window.removeEventListener('blur', onWindowBlur)
119
+ }
120
+ })
121
+
122
+ return (
123
+ <ActionButton
124
+ type="button"
125
+ variant="secondary"
126
+ size="icon-2xl"
127
+ aria-label={props.label}
128
+ aria-pressed={pressed()}
129
+ tooltip={props.tooltip}
130
+ class="touch-none select-none"
131
+ onContextMenu={(event) => event.preventDefault()}
132
+ onPointerDown={(event) => {
133
+ if (event.button !== 0) return
134
+ event.preventDefault()
135
+ const source = pointerSource(event.pointerId)
136
+ if (sources.has(source)) return
137
+ try {
138
+ event.currentTarget.setPointerCapture(event.pointerId)
139
+ } catch {
140
+ // Pointer capture is not available in every embedded browser. The
141
+ // window pointerup fallback still closes this held action.
142
+ }
143
+ press(source)
144
+ }}
145
+ onPointerUp={(event) => release(pointerSource(event.pointerId))}
146
+ onPointerCancel={(event) => release(pointerSource(event.pointerId))}
147
+ onLostPointerCapture={(event) => release(pointerSource(event.pointerId))}
148
+ onKeyDown={(event) => {
149
+ if (event.key !== ' ' && event.key !== 'Enter') return
150
+ event.preventDefault()
151
+ press(keySource(event.key))
152
+ }}
153
+ onKeyUp={(event) => {
154
+ if (event.key !== ' ' && event.key !== 'Enter') return
155
+ event.preventDefault()
156
+ release(keySource(event.key))
157
+ }}
158
+ onBlur={releaseAll}
159
+ onClick={(event) => {
160
+ // Assistive technology can activate a button without pointer or keyboard
161
+ // down/up events. Treat that activation as one short held action.
162
+ if (event.detail !== 0 || sources.size > 0) return
163
+ press('activation')
164
+ release('activation')
165
+ }}
166
+ >
167
+ {props.children}
168
+ </ActionButton>
169
+ )
170
+ }
171
+
172
+ /**
173
+ * Shared movement, jump and zoom controls for a host-rendered scene.
174
+ *
175
+ * Movement and jump callbacks receive pressed/released state from pointer and
176
+ * keyboard input. Zoom callbacks fire on activation. The host owns positioning,
177
+ * scene state and camera behavior.
178
+ */
179
+ export function SceneControls(props: SceneControlsProps) {
180
+ const labels = () => ({ ...DEFAULT_LABELS, ...props.labels })
181
+
182
+ return (
183
+ <div class="flex min-w-0 max-w-full flex-wrap items-end gap-3">
184
+ <Show when={props.onMovementChange}>
185
+ <div
186
+ role="group"
187
+ aria-label={labels().movementGroup}
188
+ class="glass-scrim grid min-w-0 max-w-full grid-cols-3 gap-1.5 rounded-3xl p-2"
189
+ >
190
+ <span aria-hidden="true" />
191
+ <HoldAction
192
+ label={labels().forward}
193
+ tooltip={labels().forwardTooltip}
194
+ createChangeCallback={() => {
195
+ const callback = props.onMovementChange
196
+ return (pressed) => callback?.('forward', pressed)
197
+ }}
198
+ >
199
+ <ArrowUp aria-hidden="true" />
200
+ </HoldAction>
201
+ <span aria-hidden="true" />
202
+ <HoldAction
203
+ label={labels().left}
204
+ tooltip={labels().leftTooltip}
205
+ createChangeCallback={() => {
206
+ const callback = props.onMovementChange
207
+ return (pressed) => callback?.('left', pressed)
208
+ }}
209
+ >
210
+ <ArrowLeft aria-hidden="true" />
211
+ </HoldAction>
212
+ <HoldAction
213
+ label={labels().backward}
214
+ tooltip={labels().backwardTooltip}
215
+ createChangeCallback={() => {
216
+ const callback = props.onMovementChange
217
+ return (pressed) => callback?.('backward', pressed)
218
+ }}
219
+ >
220
+ <ArrowDown aria-hidden="true" />
221
+ </HoldAction>
222
+ <HoldAction
223
+ label={labels().right}
224
+ tooltip={labels().rightTooltip}
225
+ createChangeCallback={() => {
226
+ const callback = props.onMovementChange
227
+ return (pressed) => callback?.('right', pressed)
228
+ }}
229
+ >
230
+ <ArrowRight aria-hidden="true" />
231
+ </HoldAction>
232
+ </div>
233
+ </Show>
234
+ <div class="ml-auto flex min-w-0 max-w-full flex-col items-end gap-2">
235
+ <Show when={props.onZoomIn || props.onZoomOut}>
236
+ <div
237
+ role="group"
238
+ aria-label={labels().zoomGroup}
239
+ class="glass-scrim flex min-w-0 max-w-full flex-wrap gap-1.5 rounded-2xl p-1.5"
240
+ >
241
+ <Show when={props.onZoomOut}>
242
+ <ActionButton
243
+ type="button"
244
+ variant="secondary"
245
+ size="icon-2xl"
246
+ aria-label={labels().zoomOut}
247
+ tooltip={labels().zoomOut}
248
+ onClick={() => props.onZoomOut?.()}
249
+ >
250
+ <ZoomOut aria-hidden="true" />
251
+ </ActionButton>
252
+ </Show>
253
+ <Show when={props.onZoomIn}>
254
+ <ActionButton
255
+ type="button"
256
+ variant="secondary"
257
+ size="icon-2xl"
258
+ aria-label={labels().zoomIn}
259
+ tooltip={labels().zoomIn}
260
+ onClick={() => props.onZoomIn?.()}
261
+ >
262
+ <ZoomIn aria-hidden="true" />
263
+ </ActionButton>
264
+ </Show>
265
+ </div>
266
+ </Show>
267
+ <Show when={props.onJumpChange}>
268
+ <HoldAction
269
+ label={labels().jump}
270
+ tooltip={labels().jumpTooltip}
271
+ createChangeCallback={() => {
272
+ const callback = props.onJumpChange
273
+ return (pressed) => callback?.(pressed)
274
+ }}
275
+ >
276
+ <ArrowUp aria-hidden="true" />
277
+ </HoldAction>
278
+ </Show>
279
+ </div>
280
+ </div>
281
+ )
282
+ }
@@ -14,7 +14,7 @@ import { SideRailContext, createSideRailValue } from './side-rail-context'
14
14
  * component rather than something each view arranges.
15
15
  *
16
16
  * Two forms, one component. Collapsed it is an icon column; expanded it carries
17
- * labels. Both widths come from `--rail-width` (74px) and `--rail-width-expanded`
17
+ * labels. Both widths come from `--rail-width` (56px) and `--rail-width-expanded`
18
18
  * (236px), so an app cannot pick its own and end up a few pixels out of step with
19
19
  * the app beside it.
20
20
  *
@@ -145,16 +145,24 @@ export const sideRailItemClass = [
145
145
  * element, no listener, and it works on whatever element a caller renders the row
146
146
  * as — an anchor, a button, a router's own link.
147
147
  *
148
- * The collapsed rail opts out: a 1.02 scale on a 74px column pushes the row past
148
+ * The collapsed rail opts out: a 1.02 scale on a 56px column pushes the row past
149
149
  * the rail's own edge, which reads as a glitch rather than as feedback.
150
150
  */
151
151
  'hover:scale-[1.02] active:scale-[0.97] group-data-[collapsed=true]/rail:hover:scale-100 group-data-[collapsed=true]/rail:active:scale-[0.98]',
152
- '[&_svg]:size-4 [&_svg]:shrink-0',
152
+ '[&_svg]:size-5 [&_svg]:shrink-0',
153
153
  ].join(' ')
154
154
 
155
+ /*
156
+ * The rail's states are accent-driven: the icon carries `--primary` on hover and
157
+ * on the active destination, and both states draw the accent's own tint rather
158
+ * than a neutral fill, so the rail reads as the app's accent chroma instead of a
159
+ * grey column. The active label stays on `sidebar-foreground` because the accent
160
+ * presets guarantee their 3:1 interaction minimum against the surface, not the
161
+ * 4.5:1 small text owes; the icon is a graphic and rides the accent.
162
+ */
155
163
  export const sideRailItemStateClass = {
156
- active: 'bg-sidebar-accent text-sidebar-accent-foreground',
157
- idle: 'text-sidebar-muted-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-foreground',
164
+ active: 'bg-primary-subtle text-sidebar-foreground [&_svg]:text-primary',
165
+ idle: 'text-sidebar-muted-foreground hover:bg-primary-subtle/60 hover:text-primary',
158
166
  }
159
167
 
160
168
  /**
@@ -259,7 +267,7 @@ export function SideRailItem<T extends ValidComponent = 'a'>(props: SideRailItem
259
267
  {...(rest as Record<string, unknown>)}
260
268
  >
261
269
  <Show when={local.badge}>
262
- <span class="relative flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
270
+ <span class="relative flex size-5 shrink-0 items-center justify-center [&_svg]:size-5">
263
271
  {local.children}
264
272
  <span class="absolute -top-1 -end-1 flex items-center justify-center">
265
273
  {local.badge}
@@ -267,7 +275,7 @@ export function SideRailItem<T extends ValidComponent = 'a'>(props: SideRailItem
267
275
  </span>
268
276
  </Show>
269
277
  <Show when={!local.badge}>
270
- <span class="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
278
+ <span class="flex size-5 shrink-0 items-center justify-center [&_svg]:size-5">
271
279
  {local.children}
272
280
  </span>
273
281
  </Show>
@@ -307,7 +315,7 @@ export function SideRailItem<T extends ValidComponent = 'a'>(props: SideRailItem
307
315
  }}
308
316
  >
309
317
  <span
310
- class={cn('flex size-4 shrink-0 items-center justify-center [&_svg]:size-4', {
318
+ class={cn('flex size-5 shrink-0 items-center justify-center [&_svg]:size-5', {
311
319
  'text-primary': local.active,
312
320
  })}
313
321
  >
@@ -368,7 +376,7 @@ export function SideRailButton(
368
376
  )}
369
377
  {...rest}
370
378
  >
371
- <span class="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
379
+ <span class="flex size-5 shrink-0 items-center justify-center [&_svg]:size-5">
372
380
  {local.children}
373
381
  </span>
374
382
  <span class="min-w-0 flex-1 truncate group-data-[collapsed=true]/rail:sr-only">
@@ -397,7 +405,7 @@ export function SideRailButton(
397
405
  height: `${position().height}px`,
398
406
  }}
399
407
  >
400
- <span class="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
408
+ <span class="flex size-5 shrink-0 items-center justify-center [&_svg]:size-5">
401
409
  {local.children}
402
410
  </span>
403
411
  {local.label}
@@ -47,6 +47,7 @@ export const buttonVariants = cva(
47
47
  md: `${controlSize.md} [&_svg]:size-4`,
48
48
  lg: `${controlSize.lg} [&_svg]:size-4`,
49
49
  xl: `${controlSize.xl} [&_svg]:size-5`,
50
+ '2xl': `${controlSize['2xl']} [&_svg]:size-6`,
50
51
  /* Square, for an icon with no label. The accessible name then has to
51
52
  come from `aria-label` or a visually hidden label — an icon-only
52
53
  button with neither is the accessibility finding the Storybook lane
@@ -57,6 +58,11 @@ export const buttonVariants = cva(
57
58
  'icon-md': 'size-control-md p-0 [&_svg]:size-4',
58
59
  'icon-lg': 'size-control-lg p-0 [&_svg]:size-4',
59
60
  'icon-xl': 'size-control-xl p-0 [&_svg]:size-5',
61
+ 'icon-2xl': 'size-control-2xl p-0 [&_svg]:size-6',
62
+ },
63
+ /** Opt in to a stable touch target on devices with any coarse pointer. */
64
+ touchTarget: {
65
+ comfortable: 'touch-target-comfortable',
60
66
  },
61
67
  },
62
68
  defaultVariants: {
@@ -76,13 +82,25 @@ export type ButtonProps<T extends ValidComponent = 'button'> = PolymorphicProps<
76
82
  >
77
83
 
78
84
  export function Button<T extends ValidComponent = 'button'>(props: ButtonProps<T>) {
79
- const [local, rest] = splitProps(props as ButtonProps, ['variant', 'size', 'class'])
85
+ const [local, rest] = splitProps(props as ButtonProps, [
86
+ 'variant',
87
+ 'size',
88
+ 'touchTarget',
89
+ 'class',
90
+ ])
80
91
 
81
92
  return (
82
93
  <Polymorphic
83
94
  as="button"
84
95
  type="button"
85
- class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
96
+ class={cn(
97
+ buttonVariants({
98
+ variant: local.variant,
99
+ size: local.size,
100
+ touchTarget: local.touchTarget,
101
+ }),
102
+ local.class
103
+ )}
86
104
  {...(rest as ComponentProps<'button'>)}
87
105
  />
88
106
  )
@@ -34,12 +34,14 @@ export const toggleVariants = cva(
34
34
  md: `${controlSize.md} [&_svg]:size-4`,
35
35
  lg: `${controlSize.lg} [&_svg]:size-4`,
36
36
  xl: `${controlSize.xl} [&_svg]:size-5`,
37
+ '2xl': `${controlSize['2xl']} [&_svg]:size-6`,
37
38
  'icon-2xs': 'size-control-2xs p-0 [&_svg]:size-3',
38
39
  'icon-xs': 'size-control-xs p-0 [&_svg]:size-3.5',
39
40
  'icon-sm': 'size-control-sm p-0 [&_svg]:size-3.5',
40
41
  'icon-md': 'size-control-md p-0 [&_svg]:size-4',
41
42
  'icon-lg': 'size-control-lg p-0 [&_svg]:size-4',
42
43
  'icon-xl': 'size-control-xl p-0 [&_svg]:size-5',
44
+ 'icon-2xl': 'size-control-2xl p-0 [&_svg]:size-6',
43
45
  },
44
46
  },
45
47
  defaultVariants: { variant: 'default', size: 'md' },
package/src/index.ts CHANGED
@@ -104,6 +104,7 @@ export * from './components/composites/catalog-browser'
104
104
  export * from './components/composites/list-row'
105
105
  export * from './components/composites/tree'
106
106
  export * from './components/composites/settings'
107
+ export * from './components/composites/scene-controls'
107
108
  export * from './components/composites/stat'
108
109
  export * from './components/composites/appearance-editor'
109
110
  export * from './components/composites/update-dialog'
package/src/lib/tokens.ts CHANGED
@@ -480,6 +480,11 @@ export const densityTokens: TokenDefinition[] = [
480
480
  kind: 'dimension',
481
481
  description: '40px — a hero control in an empty state.',
482
482
  },
483
+ {
484
+ name: 'control-height-2xl',
485
+ kind: 'dimension',
486
+ description: '48px — a stable touch target, unchanged by compact density.',
487
+ },
483
488
  {
484
489
  name: 'control-padding-2xs',
485
490
  kind: 'dimension',
@@ -510,10 +515,15 @@ export const densityTokens: TokenDefinition[] = [
510
515
  kind: 'dimension',
511
516
  description: 'Inline padding that pairs with the 40px control.',
512
517
  },
518
+ {
519
+ name: 'control-padding-2xl',
520
+ kind: 'dimension',
521
+ description: 'Inline padding that pairs with the stable 48px touch target.',
522
+ },
513
523
  { name: 'row-height-sm', kind: 'dimension', description: '28px — a dense list row.' },
514
524
  { name: 'row-height-md', kind: 'dimension', description: '32px — the default list row.' },
515
525
  { name: 'row-height-lg', kind: 'dimension', description: '36px — a two-line list row.' },
516
- { name: 'rail-width', kind: 'dimension', description: '58px — the icon-only rail.' },
526
+ { name: 'rail-width', kind: 'dimension', description: '56px — the icon-only rail.' },
517
527
  { name: 'rail-width-expanded', kind: 'dimension', description: '236px — the labelled rail.' },
518
528
  {
519
529
  name: 'rail-item-height',
package/src/lib/utils.ts CHANGED
@@ -36,6 +36,7 @@ export const cn: CnFunction = createCn({
36
36
  'control-md',
37
37
  'control-lg',
38
38
  'control-xl',
39
+ 'control-2xl',
39
40
  'row-sm',
40
41
  'row-md',
41
42
  'row-lg',
@@ -61,6 +62,7 @@ export const cn: CnFunction = createCn({
61
62
  'control-md',
62
63
  'control-lg',
63
64
  'control-xl',
65
+ 'control-2xl',
64
66
  'rail-item',
65
67
  ],
66
68
  },
@@ -11,7 +11,7 @@
11
11
  * rather than inventing synonyms:
12
12
  *
13
13
  * variant what the control *is for* — primary, secondary, destructive, …
14
- * size how much room it takes — 2xs, xs, sm, md, lg, xl
14
+ * size how much room it takes — 2xs, xs, sm, md, lg, xl, 2xl
15
15
  * tone what it *communicates* — neutral, success, warning, …
16
16
  *
17
17
  * A component that needs a fourth axis is usually two components.
@@ -32,6 +32,8 @@ export const controlSize = {
32
32
  md: 'h-control-md px-control-md text-sm gap-2',
33
33
  lg: 'h-control-lg px-control-lg text-sm gap-2',
34
34
  xl: 'h-control-xl px-control-xl text-base gap-2.5',
35
+ /** A stable 48px touch target; compact density intentionally leaves it alone. */
36
+ '2xl': 'h-control-2xl px-control-2xl text-lg gap-3',
35
37
  } as const
36
38
 
37
39
  /** Square counterparts for icon-only controls: the height, with equal width. */
@@ -42,9 +44,10 @@ export const controlSizeIcon = {
42
44
  md: 'size-control-md p-0',
43
45
  lg: 'size-control-lg p-0',
44
46
  xl: 'size-control-xl p-0',
47
+ '2xl': 'size-control-2xl p-0',
45
48
  } as const
46
49
 
47
- export const controlSizes = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl'] as const
50
+ export const controlSizes = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const
48
51
  export type ControlSize = (typeof controlSizes)[number]
49
52
 
50
53
  /**
package/src/lint/index.ts CHANGED
@@ -460,6 +460,7 @@ const ICON_BUTTON_SIZES: ReadonlySet<string> = new Set([
460
460
  'icon-md',
461
461
  'icon-lg',
462
462
  'icon-xl',
463
+ 'icon-2xl',
463
464
  ])
464
465
 
465
466
  const staticJsxValues = (value: any): any[] =>
@@ -250,6 +250,18 @@
250
250
  }
251
251
  }
252
252
 
253
+ /**
254
+ * Opt into the stable 2xl control rung only when a coarse pointer is available.
255
+ * `any-pointer` intentionally includes hybrid devices without changing the
256
+ * glyph or desktop density of the selected Button size.
257
+ */
258
+ @utility touch-target-comfortable {
259
+ @media (any-pointer: coarse) {
260
+ min-inline-size: var(--control-height-2xl);
261
+ min-block-size: var(--control-height-2xl);
262
+ }
263
+ }
264
+
253
265
  /**
254
266
  * The drag region of a frameless desktop window.
255
267
  *
@@ -422,9 +434,9 @@
422
434
  }
423
435
  }
424
436
 
425
- /* The glass treatment used by the top bar, the rail, and floating overlays.
426
- `--glass-*` are private: a consumer chooses *whether* to be glassy through
427
- the component prop, not by reaching for these. */
437
+ /* Glass treatments used by the top bar, rail, scene-control groups and floating
438
+ overlays. Consumers use the owning component; shared composites use the
439
+ named utility instead of restyling a primitive. */
428
440
  @utility glass-panel {
429
441
  background: color-mix(in oklch, var(--chrome) calc(var(--chrome-alpha) * 100%), transparent);
430
442
  backdrop-filter: blur(18px) saturate(1.6);
@@ -438,6 +450,12 @@
438
450
  -webkit-backdrop-filter: blur(12px) saturate(1.4);
439
451
  }
440
452
 
453
+ @utility glass-scrim {
454
+ background: color-mix(in oklch, var(--scrim) 20%, transparent);
455
+ backdrop-filter: blur(2px);
456
+ -webkit-backdrop-filter: blur(2px);
457
+ }
458
+
441
459
  /* The focus ring. Applied by components that cannot use the global outline
442
460
  because they need the ring to follow their own radius. */
443
461
  @utility focus-ring {
@@ -657,7 +675,8 @@
657
675
 
658
676
  @media (prefers-reduced-transparency: reduce) {
659
677
  .glass-panel,
660
- .glass-surface {
678
+ .glass-surface,
679
+ .glass-scrim {
661
680
  backdrop-filter: none !important;
662
681
  -webkit-backdrop-filter: none !important;
663
682
  background: var(--background) !important;
@@ -670,18 +689,19 @@
670
689
  }
671
690
 
672
691
  .glass-panel,
673
- .glass-surface {
692
+ .glass-surface,
693
+ .glass-scrim {
674
694
  background: var(--background) !important;
675
695
  border: 1px solid var(--foreground) !important;
676
696
  }
677
697
  }
678
698
 
679
- /* An engine without backdrop-filter would render the glass tint alone, which
680
- is 3-8% alpha — effectively a transparent panel. Fall back to a solid
681
- surface instead, keeping borders and shadows so panels stay delimited. */
699
+ /* Without backdrop-filter there is no blurred context behind a glass surface.
700
+ Fall back to a solid surface so glass panels and scrim groups stay legible. */
682
701
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
683
702
  .glass-panel,
684
- .glass-surface {
703
+ .glass-surface,
704
+ .glass-scrim {
685
705
  background: var(--background) !important;
686
706
  }
687
707
  }