@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.
- package/LICENSE +21 -0
- package/README.md +90 -2
- package/package.json +51 -3
- package/src/components/avatar.tsx +35 -0
- package/src/components/badge.tsx +42 -0
- package/src/components/breadcrumb.tsx +102 -0
- package/src/components/button.tsx +56 -0
- package/src/components/card.tsx +92 -0
- package/src/components/checkbox.tsx +27 -0
- package/src/components/color-field.tsx +248 -0
- package/src/components/color-swatch.tsx +52 -0
- package/src/components/context-menu.tsx +249 -0
- package/src/components/dialog.tsx +137 -0
- package/src/components/dropdown-menu.tsx +249 -0
- package/src/components/empty-state.tsx +32 -0
- package/src/components/input.tsx +20 -0
- package/src/components/label.tsx +18 -0
- package/src/components/navigation-menu.tsx +144 -0
- package/src/components/number-field.tsx +428 -0
- package/src/components/popover.tsx +82 -0
- package/src/components/select.tsx +201 -0
- package/src/components/separator.tsx +25 -0
- package/src/components/sheet.tsx +124 -0
- package/src/components/side-panel.tsx +166 -0
- package/src/components/sidebar.tsx +735 -0
- package/src/components/skeleton.tsx +14 -0
- package/src/components/slider.tsx +75 -0
- package/src/components/snackbar.tsx +141 -0
- package/src/components/status-bar.tsx +38 -0
- package/src/components/tabs.tsx +78 -0
- package/src/components/textarea.tsx +18 -0
- package/src/components/toggle-group.tsx +86 -0
- package/src/components/toggle.tsx +43 -0
- package/src/components/toolbar.tsx +307 -0
- package/src/components/tooltip.tsx +54 -0
- package/src/graphics/adsr.ts +70 -0
- package/src/graphics/curve-editor.tsx +525 -0
- package/src/graphics/curve-edits.ts +129 -0
- package/src/graphics/index.ts +16 -0
- package/src/graphics/scale.ts +22 -0
- package/src/graphics/use-canvas.ts +56 -0
- package/src/graphics/use-tokens.ts +32 -0
- package/src/icons.ts +3 -0
- package/src/index.css +199 -0
- package/src/index.ts +37 -0
- package/src/lib/use-mobile.ts +18 -0
- 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
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
|
+
}
|