@preset.nz/ux-kit 0.0.0-stage → 0.1.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -2
  3. package/package.json +51 -3
  4. package/src/components/avatar.tsx +35 -0
  5. package/src/components/badge.tsx +42 -0
  6. package/src/components/breadcrumb.tsx +102 -0
  7. package/src/components/button.tsx +56 -0
  8. package/src/components/card.tsx +92 -0
  9. package/src/components/checkbox.tsx +27 -0
  10. package/src/components/color-field.tsx +248 -0
  11. package/src/components/color-swatch.tsx +52 -0
  12. package/src/components/context-menu.tsx +249 -0
  13. package/src/components/dialog.tsx +137 -0
  14. package/src/components/dropdown-menu.tsx +249 -0
  15. package/src/components/empty-state.tsx +32 -0
  16. package/src/components/input.tsx +20 -0
  17. package/src/components/label.tsx +18 -0
  18. package/src/components/navigation-menu.tsx +144 -0
  19. package/src/components/number-field.tsx +428 -0
  20. package/src/components/popover.tsx +82 -0
  21. package/src/components/select.tsx +201 -0
  22. package/src/components/separator.tsx +25 -0
  23. package/src/components/sheet.tsx +124 -0
  24. package/src/components/side-panel.tsx +166 -0
  25. package/src/components/sidebar.tsx +735 -0
  26. package/src/components/skeleton.tsx +14 -0
  27. package/src/components/slider.tsx +75 -0
  28. package/src/components/snackbar.tsx +141 -0
  29. package/src/components/status-bar.tsx +38 -0
  30. package/src/components/tabs.tsx +78 -0
  31. package/src/components/textarea.tsx +18 -0
  32. package/src/components/toggle-group.tsx +86 -0
  33. package/src/components/toggle.tsx +43 -0
  34. package/src/components/toolbar.tsx +307 -0
  35. package/src/components/tooltip.tsx +54 -0
  36. package/src/graphics/adsr.ts +70 -0
  37. package/src/graphics/curve-editor.tsx +525 -0
  38. package/src/graphics/curve-edits.ts +129 -0
  39. package/src/graphics/index.ts +16 -0
  40. package/src/graphics/scale.ts +22 -0
  41. package/src/graphics/use-canvas.ts +56 -0
  42. package/src/graphics/use-tokens.ts +32 -0
  43. package/src/icons.ts +3 -0
  44. package/src/index.css +199 -0
  45. package/src/index.ts +37 -0
  46. package/src/lib/use-mobile.ts +18 -0
  47. package/src/lib/utils.ts +6 -0
@@ -0,0 +1,56 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState, type RefObject } 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,32 @@
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, { attributes: true, attributeFilter: ["class", "style", "data-theme"] })
17
+ const media = window.matchMedia("(prefers-color-scheme: dark)")
18
+ media.addEventListener("change", update)
19
+ return () => {
20
+ observer.disconnect()
21
+ media.removeEventListener("change", update)
22
+ }
23
+ }, [key])
24
+ return colours
25
+ }
26
+
27
+ function read<K extends string>(names: readonly K[]): Record<K, string> {
28
+ const style = getComputedStyle(document.documentElement)
29
+ const out = {} as Record<K, string>
30
+ for (const name of names) out[name] = style.getPropertyValue(`--${name}`).trim() || "currentColor"
31
+ return out
32
+ }
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,37 @@
1
+ export { cn } from "./lib/utils"
2
+ export { useIsMobile } from "./lib/use-mobile"
3
+
4
+ export * from "./components/avatar"
5
+ export * from "./components/badge"
6
+ export * from "./components/breadcrumb"
7
+ export * from "./components/button"
8
+ export * from "./components/card"
9
+ export * from "./components/checkbox"
10
+ export * from "./components/color-field"
11
+ export * from "./components/color-swatch"
12
+ export * from "./components/context-menu"
13
+ export * from "./components/dialog"
14
+ export * from "./components/dropdown-menu"
15
+ export * from "./components/empty-state"
16
+ export * from "./components/input"
17
+ export * from "./components/label"
18
+ export * from "./components/navigation-menu"
19
+ export * from "./components/number-field"
20
+ export * from "./components/popover"
21
+ export * from "./components/select"
22
+ export * from "./components/side-panel"
23
+ export * from "./components/separator"
24
+ export * from "./components/sheet"
25
+ export * from "./components/sidebar"
26
+ export * from "./components/skeleton"
27
+ export * from "./components/slider"
28
+ export * from "./components/snackbar"
29
+ export * from "./components/status-bar"
30
+ export * from "./components/tabs"
31
+ export * from "./components/textarea"
32
+ export * from "./components/toggle"
33
+ export * from "./components/toggle-group"
34
+ export * from "./components/toolbar"
35
+ export * from "./components/tooltip"
36
+
37
+ export * as Icons from "./icons"
@@ -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 { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }