@preset.nz/ux-kit 0.0.0-stage → 0.1.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -2
  3. package/THIRD_PARTY_NOTICES.md +253 -0
  4. package/package.json +50 -3
  5. package/src/chat/index.ts +9 -0
  6. package/src/chat/message.tsx +80 -0
  7. package/src/chat/prompt-input.tsx +121 -0
  8. package/src/chat/reasoning.tsx +182 -0
  9. package/src/chat/scroller.tsx +147 -0
  10. package/src/chat/suggestion.tsx +65 -0
  11. package/src/chat/tool.tsx +143 -0
  12. package/src/chat/types.ts +8 -0
  13. package/src/components/avatar.tsx +35 -0
  14. package/src/components/badge.tsx +40 -0
  15. package/src/components/breadcrumb.tsx +100 -0
  16. package/src/components/button.tsx +56 -0
  17. package/src/components/card.tsx +75 -0
  18. package/src/components/checkbox.tsx +25 -0
  19. package/src/components/color-field.tsx +261 -0
  20. package/src/components/color-swatch.tsx +52 -0
  21. package/src/components/context-menu.tsx +249 -0
  22. package/src/components/dialog.tsx +133 -0
  23. package/src/components/dropdown-menu.tsx +249 -0
  24. package/src/components/empty-state.tsx +35 -0
  25. package/src/components/input.tsx +20 -0
  26. package/src/components/label.tsx +19 -0
  27. package/src/components/navigation-menu.tsx +150 -0
  28. package/src/components/number-field.tsx +432 -0
  29. package/src/components/popover.tsx +75 -0
  30. package/src/components/select.tsx +189 -0
  31. package/src/components/separator.tsx +21 -0
  32. package/src/components/sheet.tsx +132 -0
  33. package/src/components/side-panel.tsx +189 -0
  34. package/src/components/sidebar.tsx +698 -0
  35. package/src/components/skeleton.tsx +14 -0
  36. package/src/components/slider.tsx +71 -0
  37. package/src/components/snackbar.tsx +143 -0
  38. package/src/components/status-bar.tsx +45 -0
  39. package/src/components/tabs.tsx +75 -0
  40. package/src/components/textarea.tsx +18 -0
  41. package/src/components/toggle-group.tsx +86 -0
  42. package/src/components/toggle.tsx +43 -0
  43. package/src/components/toolbar.tsx +304 -0
  44. package/src/components/tooltip.tsx +54 -0
  45. package/src/graphics/adsr.ts +78 -0
  46. package/src/graphics/curve-editor.tsx +567 -0
  47. package/src/graphics/curve-edits.ts +146 -0
  48. package/src/graphics/index.ts +16 -0
  49. package/src/graphics/scale.ts +25 -0
  50. package/src/graphics/use-canvas.ts +56 -0
  51. package/src/graphics/use-tokens.ts +35 -0
  52. package/src/icons.ts +3 -0
  53. package/src/index.css +199 -0
  54. package/src/index.ts +35 -0
  55. package/src/lib/use-mobile.ts +18 -0
  56. package/src/lib/utils.ts +6 -0
  57. package/src/shadcn-tailwind.css +665 -0
@@ -0,0 +1,146 @@
1
+ import {
2
+ type Basis,
3
+ type Curve,
4
+ type CurvePoint,
5
+ clamp,
6
+ invlerp,
7
+ TENSION_STRENGTH,
8
+ } from "@preset.nz/math"
9
+
10
+ /**
11
+ * Limits on a curve, applied to every proposed edit: a drag, a nudge, an added or removed
12
+ * point, a basis or sustain change. Return the curve to show (adjusted if need be), or `null`
13
+ * to refuse the edit. `prev` is the curve before the edit, or the curve at the start of a
14
+ * drag. An ADSR envelope is a curve with limits (`constrainAdsr`), not a different editor.
15
+ */
16
+ export type CurveConstraint = (next: Curve, prev: Curve) => Curve | null
17
+
18
+ export interface Domain {
19
+ x: readonly [number, number]
20
+ y: readonly [number, number]
21
+ }
22
+
23
+ /** `next` through the constraint, or `next` itself without one. */
24
+ export function propose(next: Curve, prev: Curve, constrain?: CurveConstraint): Curve | null {
25
+ return constrain ? constrain(next, prev) : next
26
+ }
27
+
28
+ /**
29
+ * Point `i` of `start` moved to `(x, y)`, kept inside the domain. After the constraint, the
30
+ * moved point is held between its neighbours, so the points stay sorted while indices stay put.
31
+ */
32
+ export function movePoint(
33
+ start: Curve,
34
+ i: number,
35
+ x: number,
36
+ y: number,
37
+ domain: Domain,
38
+ constrain?: CurveConstraint,
39
+ ): Curve | null {
40
+ const points = start.points.map((p) => ({ ...p }))
41
+ const p = points[i]
42
+ if (!p) return null
43
+ p.x = clamp(x, domain.x[0], domain.x[1])
44
+ p.y = clamp(y, domain.y[0], domain.y[1])
45
+ const next = propose({ ...start, points }, start, constrain)
46
+ return next && keepOrder(next, i)
47
+ }
48
+
49
+ /** Point `i` held between its neighbours. Equal x is allowed: it makes a step. */
50
+ function keepOrder(curve: Curve, i: number): Curve {
51
+ const pts = curve.points
52
+ const p = pts[i]
53
+ if (!p) return curve
54
+ const lo = pts[i - 1]?.x ?? -Infinity
55
+ const hi = pts[i + 1]?.x ?? Infinity
56
+ if (p.x >= lo && p.x <= hi) return curve
57
+ const points = pts.map((q, j) => (j === i ? { ...q, x: clamp(q.x, lo, hi) } : q))
58
+ return { ...curve, points }
59
+ }
60
+
61
+ /**
62
+ * A point added at `(x, y)`, in order. It takes the basis and tension of the segment it
63
+ * splits, so the curve around it keeps its character. Returns the new curve and the new
64
+ * point's index.
65
+ */
66
+ export function addPoint(
67
+ curve: Curve,
68
+ x: number,
69
+ y: number,
70
+ domain: Domain,
71
+ constrain?: CurveConstraint,
72
+ ): { curve: Curve; index: number } | null {
73
+ const px = clamp(x, domain.x[0], domain.x[1])
74
+ const py = clamp(y, domain.y[0], domain.y[1])
75
+ let index = curve.points.findIndex((p) => p.x > px)
76
+ if (index < 0) index = curve.points.length
77
+ const before = curve.points[index - 1] ?? curve.points[index]
78
+ const point: CurvePoint = { x: px, y: py, basis: before?.basis ?? "linear" }
79
+ if (before?.tension !== undefined) point.tension = before.tension
80
+ const points = [...curve.points.slice(0, index), point, ...curve.points.slice(index)]
81
+ const next: Curve = { ...curve, points }
82
+ if (curve.sustain !== undefined && index <= curve.sustain) next.sustain = curve.sustain + 1
83
+ const out = propose(next, curve, constrain)
84
+ return out && { curve: out, index }
85
+ }
86
+
87
+ /** Point `i` removed. The last point can't be. Removing the sustain point clears sustain. */
88
+ export function removePoint(curve: Curve, i: number, constrain?: CurveConstraint): Curve | null {
89
+ if (curve.points.length <= 1 || !curve.points[i]) return null
90
+ const next: Curve = { points: curve.points.filter((_, j) => j !== i) }
91
+ const s = curve.sustain
92
+ if (s !== undefined && s !== i) next.sustain = s > i ? s - 1 : s
93
+ return propose(next, curve, constrain)
94
+ }
95
+
96
+ /** Point `i`'s basis. Tension only means something on a linear segment, so others drop it. */
97
+ export function setBasis(
98
+ curve: Curve,
99
+ i: number,
100
+ basis: Basis,
101
+ constrain?: CurveConstraint,
102
+ ): Curve | null {
103
+ const points = curve.points.map((p, j) => {
104
+ if (j !== i) return p
105
+ const q: CurvePoint = { ...p, basis }
106
+ if (basis !== "linear") delete q.tension
107
+ return q
108
+ })
109
+ return propose({ ...curve, points }, curve, constrain)
110
+ }
111
+
112
+ /** Sustain on point `i`, or off when it is already there. */
113
+ export function toggleSustain(curve: Curve, i: number, constrain?: CurveConstraint): Curve | null {
114
+ const next: Curve = { points: curve.points }
115
+ if (curve.sustain !== i) next.sustain = i
116
+ return propose(next, curve, constrain)
117
+ }
118
+
119
+ /** The tension of the segment from point `i`, -1..1. Zero removes it. */
120
+ export function setTension(
121
+ curve: Curve,
122
+ i: number,
123
+ tension: number,
124
+ constrain?: CurveConstraint,
125
+ ): Curve | null {
126
+ const t = clamp(tension, -1, 1)
127
+ const points = curve.points.map((p, j) => {
128
+ if (j !== i) return p
129
+ const q: CurvePoint = { ...p }
130
+ if (Math.abs(t) < 1e-3) delete q.tension
131
+ else q.tension = t
132
+ return q
133
+ })
134
+ return propose({ ...curve, points }, curve, constrain)
135
+ }
136
+
137
+ /**
138
+ * The tension that puts a linear segment's midpoint at `y`. The warp at t = 0.5 is
139
+ * `1 / (e^(k/2) + 1)`, so `k = 2 ln(1/w − 1)` where `w` is how far up the segment `y` sits.
140
+ * `null` on a flat segment, where the midpoint can't move.
141
+ */
142
+ export function tensionForMidpoint(y0: number, y1: number, y: number): number | null {
143
+ if (Math.abs(y1 - y0) < 1e-9) return null
144
+ const w = clamp(invlerp(y, y0, y1), 0.005, 0.995)
145
+ return clamp((2 * Math.log(1 / w - 1)) / TENSION_STRENGTH, -1, 1)
146
+ }
@@ -0,0 +1,16 @@
1
+ export { type Adsr, adsrCurve, adsrOf, constrainAdsr } from "./adsr"
2
+ export { type CurveEditKind, CurveEditor, type CurveEditorProps } from "./curve-editor"
3
+ export {
4
+ addPoint,
5
+ type CurveConstraint,
6
+ type Domain,
7
+ movePoint,
8
+ removePoint,
9
+ setBasis,
10
+ setTension,
11
+ tensionForMidpoint,
12
+ toggleSustain,
13
+ } from "./curve-edits"
14
+ export { type LinearScale, linearScale } from "./scale"
15
+ export { type Size, useCanvasDraw, useSize } from "./use-canvas"
16
+ export { useTokenColours } from "./use-tokens"
@@ -0,0 +1,25 @@
1
+ import { fitUnclamped } from "@preset.nz/math"
2
+
3
+ /**
4
+ * One axis's mapping between data and pixels. Both layers of a view draw through it, and
5
+ * pointer events come back through `invert`, so a handle can't drift off its line.
6
+ */
7
+ export interface LinearScale {
8
+ (v: number): number
9
+ invert: (px: number) => number
10
+ domain: readonly [number, number]
11
+ range: readonly [number, number]
12
+ }
13
+
14
+ export function linearScale(
15
+ domain: readonly [number, number],
16
+ range: readonly [number, number],
17
+ ): LinearScale {
18
+ const [d0, d1] = domain
19
+ const [r0, r1] = range
20
+ const scale = ((v: number) => fitUnclamped(v, d0, d1, r0, r1)) as LinearScale
21
+ scale.invert = (px: number) => fitUnclamped(px, r0, r1, d0, d1)
22
+ scale.domain = domain
23
+ scale.range = range
24
+ return scale
25
+ }
@@ -0,0 +1,56 @@
1
+ import { type RefObject, useEffect, useLayoutEffect, useRef, useState } from "react"
2
+
3
+ export interface Size {
4
+ width: number
5
+ height: number
6
+ }
7
+
8
+ /** An element's size in CSS pixels, kept current by a `ResizeObserver`. */
9
+ export function useSize(ref: RefObject<HTMLElement | null>): Size {
10
+ const [size, setSize] = useState<Size>({ width: 0, height: 0 })
11
+ useEffect(() => {
12
+ const el = ref.current
13
+ if (!el) return
14
+ const observer = new ResizeObserver(([entry]) => {
15
+ if (!entry) return
16
+ const { width, height } = entry.contentRect
17
+ setSize((s) => (s.width === width && s.height === height ? s : { width, height }))
18
+ })
19
+ observer.observe(el)
20
+ return () => observer.disconnect()
21
+ }, [ref])
22
+ return size
23
+ }
24
+
25
+ /**
26
+ * Draws on a canvas at the device's pixel ratio, at most once per animation frame. `draw`
27
+ * gets a context already scaled to CSS pixels. It runs again whenever `deps` change.
28
+ */
29
+ export function useCanvasDraw(
30
+ canvas: RefObject<HTMLCanvasElement | null>,
31
+ size: Size,
32
+ draw: (ctx: CanvasRenderingContext2D, size: Size) => void,
33
+ deps: readonly unknown[],
34
+ ) {
35
+ const latest = useRef(draw)
36
+ useLayoutEffect(() => {
37
+ latest.current = draw
38
+ })
39
+ useEffect(() => {
40
+ const el = canvas.current
41
+ if (!el || size.width === 0 || size.height === 0) return
42
+ const frame = requestAnimationFrame(() => {
43
+ const dpr = window.devicePixelRatio || 1
44
+ const w = Math.round(size.width * dpr)
45
+ const h = Math.round(size.height * dpr)
46
+ if (el.width !== w) el.width = w
47
+ if (el.height !== h) el.height = h
48
+ const ctx = el.getContext("2d")
49
+ if (!ctx) return
50
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
51
+ ctx.clearRect(0, 0, size.width, size.height)
52
+ latest.current(ctx, size)
53
+ })
54
+ return () => cancelAnimationFrame(frame)
55
+ }, [canvas, size, ...deps]) // eslint-disable-line react-hooks/exhaustive-deps
56
+ }
@@ -0,0 +1,35 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ /**
4
+ * The current values of some colour tokens, for drawing on a canvas, which can't read CSS
5
+ * variables. Read again when the theme changes: the `.dark` class or a style on `<html>`, or
6
+ * the system appearance.
7
+ */
8
+ export function useTokenColours<K extends string>(names: readonly K[]): Record<K, string> {
9
+ const key = names.join(",")
10
+ const [colours, setColours] = useState(() => read(names))
11
+ useEffect(() => {
12
+ const list = key.split(",") as K[]
13
+ const update = () => setColours(read(list))
14
+ update()
15
+ const observer = new MutationObserver(update)
16
+ observer.observe(document.documentElement, {
17
+ attributes: true,
18
+ attributeFilter: ["class", "style", "data-theme"],
19
+ })
20
+ const media = window.matchMedia("(prefers-color-scheme: dark)")
21
+ media.addEventListener("change", update)
22
+ return () => {
23
+ observer.disconnect()
24
+ media.removeEventListener("change", update)
25
+ }
26
+ }, [key])
27
+ return colours
28
+ }
29
+
30
+ function read<K extends string>(names: readonly K[]): Record<K, string> {
31
+ const style = getComputedStyle(document.documentElement)
32
+ const out = {} as Record<K, string>
33
+ for (const name of names) out[name] = style.getPropertyValue(`--${name}`).trim() || "currentColor"
34
+ return out
35
+ }
package/src/icons.ts ADDED
@@ -0,0 +1,3 @@
1
+ // Phosphor is the kit's icon set. Re-exported here so apps import icons from
2
+ // the kit and one version of the package serves them all.
3
+ export * from "@phosphor-icons/react"
package/src/index.css ADDED
@@ -0,0 +1,199 @@
1
+ /* @preset.nz/ux-kit: tokens, palettes and fonts.
2
+ Import this after tailwindcss, and add an @source line for the kit's src:
3
+
4
+ @import "tailwindcss";
5
+ @source "../node_modules/@preset.nz/ux-kit/src";
6
+ @import "@preset.nz/ux-kit/index.css";
7
+
8
+ Seeded from Strata's index.css. Dark mode is a .dark class on <html>. */
9
+ @import "tw-animate-css";
10
+ @import "./shadcn-tailwind.css";
11
+
12
+ /* Two families only (Georg 2026-10-03): Geist for the interface, JetBrains Mono for values and code. */
13
+ @import "@fontsource-variable/geist";
14
+ @import "@fontsource-variable/jetbrains-mono";
15
+
16
+ @custom-variant dark (&:is(.dark *));
17
+
18
+ @theme inline {
19
+ /* Headings use the interface face; hierarchy comes from size and weight. */
20
+ --font-heading: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
21
+ /* Body text face. */
22
+ --font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
23
+ --font-mono: 'JetBrains Mono Variable', monospace;
24
+ --color-sidebar-ring: var(--sidebar-ring);
25
+ --color-sidebar-border: var(--sidebar-border);
26
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
27
+ --color-sidebar-accent: var(--sidebar-accent);
28
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
29
+ --color-sidebar-primary: var(--sidebar-primary);
30
+ --color-sidebar-foreground: var(--sidebar-foreground);
31
+ --color-sidebar: var(--sidebar);
32
+ --color-chart-5: var(--chart-5);
33
+ --color-chart-4: var(--chart-4);
34
+ --color-chart-3: var(--chart-3);
35
+ --color-chart-2: var(--chart-2);
36
+ --color-chart-1: var(--chart-1);
37
+ --color-ring: var(--ring);
38
+ --color-input: var(--input);
39
+ --color-border: var(--border);
40
+ --color-destructive: var(--destructive);
41
+ --color-destructive-foreground: var(--destructive-foreground);
42
+ --color-warning: var(--warning);
43
+ --color-selection: var(--selection);
44
+ --color-warning-foreground: var(--warning-foreground);
45
+ --color-accent-foreground: var(--accent-foreground);
46
+ --color-accent: var(--accent);
47
+ --color-muted-foreground: var(--muted-foreground);
48
+ --color-muted: var(--muted);
49
+ --color-secondary-foreground: var(--secondary-foreground);
50
+ --color-secondary: var(--secondary);
51
+ --color-primary-foreground: var(--primary-foreground);
52
+ --color-primary: var(--primary);
53
+ --color-popover-foreground: var(--popover-foreground);
54
+ --color-popover: var(--popover);
55
+ --color-card-foreground: var(--card-foreground);
56
+ --color-card: var(--card);
57
+ --color-foreground: var(--foreground);
58
+ --color-background: var(--background);
59
+ --radius-sm: calc(var(--radius) * 0.6);
60
+ --radius-md: calc(var(--radius) * 0.8);
61
+ --radius-lg: var(--radius);
62
+ --radius-xl: calc(var(--radius) * 1.4);
63
+ --radius-2xl: calc(var(--radius) * 1.8);
64
+ --radius-3xl: calc(var(--radius) * 2.2);
65
+ --radius-4xl: calc(var(--radius) * 2.6);
66
+ }
67
+
68
+ :root {
69
+ /* Petrol-tinted neutrals; no background or fill is pure white or black (Georg 2026-10-03). */
70
+ --background: oklch(0.972 0.006 215);
71
+ --foreground: oklch(0.2 0.012 215);
72
+ --card: oklch(0.985 0.004 215);
73
+ --card-foreground: oklch(0.2 0.012 215);
74
+ --popover: oklch(0.985 0.004 215);
75
+ --popover-foreground: oklch(0.2 0.012 215);
76
+ --primary: oklch(0.5 0.085 210);
77
+ --primary-foreground: oklch(0.97 0.008 215);
78
+ --secondary: oklch(0.935 0.006 215);
79
+ --secondary-foreground: oklch(0.25 0.012 215);
80
+ --muted: oklch(0.935 0.006 215);
81
+ --muted-foreground: oklch(0.5 0.012 215);
82
+ --accent: oklch(0.93 0.025 210);
83
+ --accent-foreground: oklch(0.3 0.05 210);
84
+ --destructive: oklch(0.55 0.14 27);
85
+ --destructive-foreground: oklch(0.97 0.008 215);
86
+ --warning: oklch(0.7 0.12 70);
87
+ --warning-foreground: oklch(0.25 0.04 70);
88
+ --border: oklch(0.87 0.008 215);
89
+ --input: oklch(0.87 0.008 215);
90
+ --selection: oklch(0.66 0.04 210); /* quieter than primary: selected things, not actions */
91
+ --ring: oklch(0.5 0.085 210);
92
+ --chart-1: oklch(0.5 0.085 210);
93
+ --chart-2: oklch(0.7 0.12 70);
94
+ --chart-3: oklch(0.62 0.07 180);
95
+ --chart-4: oklch(0.42 0.05 240);
96
+ --chart-5: oklch(0.6 0.01 215);
97
+ --sidebar: oklch(0.955 0.006 215);
98
+ --sidebar-foreground: oklch(0.2 0.012 215);
99
+ --sidebar-primary: oklch(0.5 0.085 210);
100
+ --sidebar-primary-foreground: oklch(0.97 0.008 215);
101
+ --sidebar-accent: oklch(0.92 0.025 210);
102
+ --sidebar-accent-foreground: oklch(0.3 0.05 210);
103
+ --sidebar-border: oklch(0.87 0.008 215);
104
+ --sidebar-ring: oklch(0.5 0.085 210);
105
+ --radius: 0.125rem; /* 2px: industrial, not soft SaaS (Georg 2026-10-03; iterating) */
106
+ }
107
+
108
+ .dark {
109
+ /* Petrol-tinted neutrals; no background or fill is pure white or black (Georg 2026-10-03). */
110
+ --background: oklch(0.19 0.01 215);
111
+ --foreground: oklch(0.93 0.006 215);
112
+ --card: oklch(0.23 0.012 215);
113
+ --card-foreground: oklch(0.93 0.006 215);
114
+ --popover: oklch(0.23 0.012 215);
115
+ --popover-foreground: oklch(0.93 0.006 215);
116
+ --primary: oklch(0.68 0.09 205);
117
+ --primary-foreground: oklch(0.18 0.02 215);
118
+ --secondary: oklch(0.28 0.012 215);
119
+ --secondary-foreground: oklch(0.93 0.006 215);
120
+ --muted: oklch(0.28 0.012 215);
121
+ --muted-foreground: oklch(0.68 0.012 215);
122
+ --accent: oklch(0.32 0.04 210);
123
+ --accent-foreground: oklch(0.93 0.006 215);
124
+ --destructive: oklch(0.66 0.13 25);
125
+ --destructive-foreground: oklch(0.18 0.02 215);
126
+ --warning: oklch(0.76 0.11 70);
127
+ --warning-foreground: oklch(0.22 0.04 70);
128
+ --border: oklch(0.32 0.012 215);
129
+ --input: oklch(0.35 0.012 215);
130
+ --selection: oklch(0.56 0.045 205);
131
+ --ring: oklch(0.68 0.09 205);
132
+ --chart-1: oklch(0.68 0.09 205);
133
+ --chart-2: oklch(0.76 0.11 70);
134
+ --chart-3: oklch(0.7 0.07 175);
135
+ --chart-4: oklch(0.6 0.06 245);
136
+ --chart-5: oklch(0.55 0.01 215);
137
+ --sidebar: oklch(0.215 0.012 215);
138
+ --sidebar-foreground: oklch(0.93 0.006 215);
139
+ --sidebar-primary: oklch(0.68 0.09 205);
140
+ --sidebar-primary-foreground: oklch(0.18 0.02 215);
141
+ --sidebar-accent: oklch(0.3 0.035 210);
142
+ --sidebar-accent-foreground: oklch(0.93 0.006 215);
143
+ --sidebar-border: oklch(0.32 0.012 215);
144
+ --sidebar-ring: oklch(0.68 0.09 205);
145
+ }
146
+
147
+ @layer base {
148
+ * {
149
+ @apply border-border outline-ring/50;
150
+ }
151
+ body {
152
+ @apply bg-background text-foreground;
153
+ }
154
+ html {
155
+ @apply font-sans;
156
+ }
157
+
158
+ /* Make it feel like a native desktop app, not a webpage. */
159
+ html,
160
+ body {
161
+ user-select: none;
162
+ -webkit-user-select: none;
163
+ cursor: default;
164
+ overscroll-behavior: none;
165
+ }
166
+
167
+ /* Restore text selection where typing happens. */
168
+ input,
169
+ textarea,
170
+ [contenteditable] {
171
+ user-select: text;
172
+ -webkit-user-select: text;
173
+ }
174
+
175
+ /* Opt-in class for selectable content (filenames, error messages, etc). */
176
+ .select-text {
177
+ user-select: text;
178
+ -webkit-user-select: text;
179
+ }
180
+
181
+ /* No drag-image ghost when grabbing an <img>. */
182
+ img {
183
+ -webkit-user-drag: none;
184
+ user-drag: none;
185
+ }
186
+ }
187
+
188
+ /* Selection is a faint tint with a 1px edge; focus is the ring. Never two rings (ux-patterns.md,
189
+ Georg 2026-10-03). Put `selectable` on a row, card or item and set `data-selected` (or
190
+ aria-selected="true") on the selected one. The edge is a background gradient, not a border or a
191
+ shadow, so it never shifts the layout and never competes with a focus ring's box-shadow. */
192
+ @utility selectable {
193
+ &:is([data-selected], [aria-selected="true"]) {
194
+ background-color: color-mix(in oklab, var(--selection) 12%, transparent);
195
+ background-image: linear-gradient(var(--selection), var(--selection));
196
+ background-size: 1px 100%;
197
+ background-repeat: no-repeat;
198
+ }
199
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export * from "./components/avatar"
2
+ export * from "./components/badge"
3
+ export * from "./components/breadcrumb"
4
+ export * from "./components/button"
5
+ export * from "./components/card"
6
+ export * from "./components/checkbox"
7
+ export * from "./components/color-field"
8
+ export * from "./components/color-swatch"
9
+ export * from "./components/context-menu"
10
+ export * from "./components/dialog"
11
+ export * from "./components/dropdown-menu"
12
+ export * from "./components/empty-state"
13
+ export * from "./components/input"
14
+ export * from "./components/label"
15
+ export * from "./components/navigation-menu"
16
+ export * from "./components/number-field"
17
+ export * from "./components/popover"
18
+ export * from "./components/select"
19
+ export * from "./components/separator"
20
+ export * from "./components/sheet"
21
+ export * from "./components/side-panel"
22
+ export * from "./components/sidebar"
23
+ export * from "./components/skeleton"
24
+ export * from "./components/slider"
25
+ export * from "./components/snackbar"
26
+ export * from "./components/status-bar"
27
+ export * from "./components/tabs"
28
+ export * from "./components/textarea"
29
+ export * from "./components/toggle"
30
+ export * from "./components/toggle-group"
31
+ export * from "./components/toolbar"
32
+ export * from "./components/tooltip"
33
+ export * as Icons from "./icons"
34
+ export { useIsMobile } from "./lib/use-mobile"
35
+ export { cn } from "./lib/utils"
@@ -0,0 +1,18 @@
1
+ import * as React from "react"
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+ const QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
5
+
6
+ function subscribe(onChange: () => void) {
7
+ const mql = window.matchMedia(QUERY)
8
+ mql.addEventListener("change", onChange)
9
+ return () => mql.removeEventListener("change", onChange)
10
+ }
11
+
12
+ export function useIsMobile() {
13
+ return React.useSyncExternalStore(
14
+ subscribe,
15
+ () => window.matchMedia(QUERY).matches,
16
+ () => false,
17
+ )
18
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }