@vsreact/core 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.
- package/build.ts +24 -0
- package/package.json +31 -0
- package/src/animation.test.tsx +22 -0
- package/src/animation.ts +70 -0
- package/src/bridge.ts +66 -0
- package/src/controls.tsx +194 -0
- package/src/hostConfig.test.tsx +120 -0
- package/src/hostConfig.ts +161 -0
- package/src/index.ts +24 -0
- package/src/native.ts +18 -0
- package/src/parameters.ts +60 -0
- package/src/phase2.test.tsx +100 -0
- package/src/primitives.tsx +75 -0
- package/src/render.tsx +37 -0
- package/src/runtime.ts +89 -0
- package/src/theme.ts +84 -0
- package/src/tw.test.ts +113 -0
- package/src/tw.ts +275 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// vsreact public API. runtime must load first — it installs the
|
|
2
|
+
// timer/console/microtask shims react depends on inside QuickJS.
|
|
3
|
+
import "./runtime";
|
|
4
|
+
import "./bridge";
|
|
5
|
+
|
|
6
|
+
export { View, Text, Image, TextInput, NativeView } from "./primitives";
|
|
7
|
+
export type {
|
|
8
|
+
CommonProps,
|
|
9
|
+
TextProps,
|
|
10
|
+
ImageProps,
|
|
11
|
+
TextInputProps,
|
|
12
|
+
NativeViewProps,
|
|
13
|
+
} from "./primitives";
|
|
14
|
+
export { render, unmount } from "./render";
|
|
15
|
+
export { native } from "./native";
|
|
16
|
+
export { configureTheme, tw } from "./tw";
|
|
17
|
+
export type { Style, ResolvedClasses } from "./tw";
|
|
18
|
+
export { useTween, lerp, Easing } from "./animation";
|
|
19
|
+
export type { TweenOptions, EasingFn } from "./animation";
|
|
20
|
+
export { useParameter } from "./parameters";
|
|
21
|
+
export type { ParameterState, ParameterHandle } from "./parameters";
|
|
22
|
+
export { Knob, Slider, ParamKnob, ParamSlider, dragToValue } from "./controls";
|
|
23
|
+
export type { KnobProps, SliderProps, ParamKnobProps, ParamSliderProps } from "./controls";
|
|
24
|
+
export type { DragEventPayload } from "./primitives";
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// App-level messaging with the C++ host.
|
|
2
|
+
|
|
3
|
+
import { addNativeListener, type EventHandler } from "./bridge";
|
|
4
|
+
|
|
5
|
+
export const native = {
|
|
6
|
+
/** Synchronously invokes a C++ handler registered in RootOptions.onNativeCall. */
|
|
7
|
+
call(name: string, args?: unknown): any {
|
|
8
|
+
const fn = (globalThis as Record<string, any>).__vsreact_nativeCall;
|
|
9
|
+
if (typeof fn !== "function") return undefined;
|
|
10
|
+
const json = fn(name, JSON.stringify(args ?? null));
|
|
11
|
+
return json ? JSON.parse(json) : undefined;
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
/** Subscribes to events pushed from C++ via RootView::sendNativeEvent. */
|
|
15
|
+
on(name: string, cb: (payload: any) => void): () => void {
|
|
16
|
+
return addNativeListener(name, cb as EventHandler);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// useParameter — two-way binding to a juce::AudioProcessorValueTreeState
|
|
2
|
+
// parameter through vsreact::ParameterBridge. Values are normalized 0..1.
|
|
3
|
+
|
|
4
|
+
import { useCallback, useEffect, useState } from "react";
|
|
5
|
+
import { native } from "./native";
|
|
6
|
+
|
|
7
|
+
export interface ParameterState {
|
|
8
|
+
value: number;
|
|
9
|
+
text: string;
|
|
10
|
+
name: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ParameterHandle extends ParameterState {
|
|
15
|
+
set: (normalized: number) => void;
|
|
16
|
+
begin: () => void;
|
|
17
|
+
end: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useParameter(id: string): ParameterHandle {
|
|
21
|
+
const [state, setState] = useState<ParameterState>(() => {
|
|
22
|
+
const initial = native.call("param:get", { id });
|
|
23
|
+
return initial && typeof initial.value === "number"
|
|
24
|
+
? {
|
|
25
|
+
value: initial.value,
|
|
26
|
+
text: String(initial.text ?? ""),
|
|
27
|
+
name: String(initial.name ?? id),
|
|
28
|
+
label: String(initial.label ?? ""),
|
|
29
|
+
}
|
|
30
|
+
: { value: 0, text: "", name: id, label: "" };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
useEffect(
|
|
34
|
+
() =>
|
|
35
|
+
native.on("param", (p) => {
|
|
36
|
+
if (p?.id === id)
|
|
37
|
+
setState((s) => ({ ...s, value: Number(p.value), text: String(p.text ?? "") }));
|
|
38
|
+
}),
|
|
39
|
+
[id],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const set = useCallback(
|
|
43
|
+
(normalized: number) => {
|
|
44
|
+
const value = Math.min(1, Math.max(0, normalized));
|
|
45
|
+
setState((s) => ({ ...s, value }));
|
|
46
|
+
native.call("param:set", { id, value });
|
|
47
|
+
},
|
|
48
|
+
[id],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const begin = useCallback(() => {
|
|
52
|
+
native.call("param:begin", { id });
|
|
53
|
+
}, [id]);
|
|
54
|
+
|
|
55
|
+
const end = useCallback(() => {
|
|
56
|
+
native.call("param:end", { id });
|
|
57
|
+
}, [id]);
|
|
58
|
+
|
|
59
|
+
return { ...state, set, begin, end };
|
|
60
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const batches: unknown[][][] = [];
|
|
4
|
+
const nativeCalls: Array<{ name: string; args: any }> = [];
|
|
5
|
+
let paramGetResult: any = { value: 0.5, text: "0.0 dB", name: "Gain", label: "dB" };
|
|
6
|
+
|
|
7
|
+
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
8
|
+
batches.push(JSON.parse(json));
|
|
9
|
+
};
|
|
10
|
+
(globalThis as Record<string, any>).__vsreact_nativeCall = (name: string, argsJson: string) => {
|
|
11
|
+
const args = JSON.parse(argsJson);
|
|
12
|
+
nativeCalls.push({ name, args });
|
|
13
|
+
if (name === "param:get") return JSON.stringify(paramGetResult);
|
|
14
|
+
return "null";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
import { render, unmount, View, tw, dragToValue, ParamKnob } from "./index";
|
|
18
|
+
|
|
19
|
+
const allOps = () => batches.flat();
|
|
20
|
+
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
21
|
+
const dispatch = (msg: unknown) =>
|
|
22
|
+
(globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
unmount();
|
|
26
|
+
batches.length = 0;
|
|
27
|
+
nativeCalls.length = 0;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("phase 2", () => {
|
|
31
|
+
test("overflow-y-scroll resolves", () => {
|
|
32
|
+
expect(tw("overflow-y-scroll").style).toEqual({ overflow: "scroll" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("dragToValue clamps and follows dy", () => {
|
|
36
|
+
expect(dragToValue(0.5, 0)).toBe(0.5);
|
|
37
|
+
expect(dragToValue(0.5, -40)).toBeCloseTo(0.7);
|
|
38
|
+
expect(dragToValue(0.5, 40)).toBeCloseTo(0.3);
|
|
39
|
+
expect(dragToValue(0.9, -100)).toBe(1);
|
|
40
|
+
expect(dragToValue(0.1, 100)).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("drag props register listeners and receive payloads", () => {
|
|
44
|
+
const seen: any[] = [];
|
|
45
|
+
|
|
46
|
+
render(<View onDrag={(e) => seen.push(e)} className="w-4" />);
|
|
47
|
+
|
|
48
|
+
const nodeId = (opsNamed("create")[0] as any)[1];
|
|
49
|
+
const props: any = opsNamed("setProps").find((op: any) => op[1] === nodeId);
|
|
50
|
+
expect(props[2].listeners).toEqual(["drag"]);
|
|
51
|
+
|
|
52
|
+
dispatch({ kind: "event", nodeId, type: "drag", payload: { dx: 3, dy: -7, x: 10, y: 20 } });
|
|
53
|
+
expect(seen).toEqual([{ dx: 3, dy: -7, x: 10, y: 20 }]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("ParamKnob fetches the param, renders arc styles, and sets on drag", () => {
|
|
57
|
+
render(<ParamKnob paramId="gain" />);
|
|
58
|
+
|
|
59
|
+
expect(nativeCalls.some((c) => c.name === "param:get" && c.args.id === "gain")).toBe(true);
|
|
60
|
+
|
|
61
|
+
// The knob's arc view: find setProps carrying arcValueEnd for value 0.5 -> 0deg.
|
|
62
|
+
const arcProps: any = opsNamed("setProps").find(
|
|
63
|
+
(op: any) => op[2]?.style?.arcValueEnd !== undefined,
|
|
64
|
+
);
|
|
65
|
+
expect(arcProps).toBeDefined();
|
|
66
|
+
expect(arcProps[2].style.arcValueEnd).toBeCloseTo(0);
|
|
67
|
+
expect(arcProps[2].listeners).toEqual(
|
|
68
|
+
expect.arrayContaining(["dragstart", "drag", "dragend"]),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const knobId = arcProps[1];
|
|
72
|
+
batches.length = 0;
|
|
73
|
+
|
|
74
|
+
// Drag begins a gesture, moves the value, ends the gesture.
|
|
75
|
+
dispatch({ kind: "event", nodeId: knobId, type: "dragstart", payload: { dx: 0, dy: 0, x: 0, y: 0 } });
|
|
76
|
+
dispatch({ kind: "event", nodeId: knobId, type: "drag", payload: { dx: 0, dy: -40, x: 0, y: -40 } });
|
|
77
|
+
dispatch({ kind: "event", nodeId: knobId, type: "dragend", payload: { dx: 0, dy: -40, x: 0, y: -40 } });
|
|
78
|
+
|
|
79
|
+
expect(nativeCalls.some((c) => c.name === "param:begin")).toBe(true);
|
|
80
|
+
const setCall = nativeCalls.find((c) => c.name === "param:set");
|
|
81
|
+
expect(setCall?.args.value).toBeCloseTo(0.7);
|
|
82
|
+
expect(nativeCalls.some((c) => c.name === "param:end")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("param events from C++ update the knob", async () => {
|
|
86
|
+
render(<ParamKnob paramId="gain" />);
|
|
87
|
+
// Let passive effects flush so the native.on("param") subscription exists.
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
89
|
+
batches.length = 0;
|
|
90
|
+
|
|
91
|
+
dispatch({ kind: "native", name: "param", payload: { id: "gain", value: 0.25, text: "-12 dB" } });
|
|
92
|
+
|
|
93
|
+
const arcProps: any = opsNamed("setProps").find(
|
|
94
|
+
(op: any) => op[2]?.style?.arcValueEnd !== undefined,
|
|
95
|
+
);
|
|
96
|
+
expect(arcProps).toBeDefined();
|
|
97
|
+
// 0.25 -> -135 + 270*0.25 = -67.5
|
|
98
|
+
expect(arcProps[2].style.arcValueEnd).toBeCloseTo(-67.5);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createElement, type ReactNode } from "react";
|
|
2
|
+
import type { Style } from "./tw";
|
|
3
|
+
|
|
4
|
+
export interface DragEventPayload {
|
|
5
|
+
/** Delta from the drag start, in root coordinates. */
|
|
6
|
+
dx: number;
|
|
7
|
+
dy: number;
|
|
8
|
+
/** Current pointer position in root coordinates. */
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CommonProps {
|
|
14
|
+
className?: string;
|
|
15
|
+
style?: Style;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
/** Resets the scroll offset of an overflow-y-scroll container. */
|
|
18
|
+
scrollTop?: number;
|
|
19
|
+
onClick?: () => void;
|
|
20
|
+
onMouseEnter?: () => void;
|
|
21
|
+
onMouseLeave?: () => void;
|
|
22
|
+
onMouseDown?: () => void;
|
|
23
|
+
onMouseUp?: () => void;
|
|
24
|
+
onDragStart?: (e: DragEventPayload) => void;
|
|
25
|
+
onDrag?: (e: DragEventPayload) => void;
|
|
26
|
+
onDragEnd?: (e: DragEventPayload) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function View(props: CommonProps) {
|
|
30
|
+
return createElement("vs-view", props);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TextProps extends Omit<CommonProps, "children"> {
|
|
34
|
+
children?: ReactNode; // strings/numbers (and fragments of them)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Text(props: TextProps) {
|
|
38
|
+
return createElement("vs-text", props);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ImageProps extends CommonProps {
|
|
42
|
+
src: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function Image(props: ImageProps) {
|
|
46
|
+
return createElement("vs-image", props);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TextInputProps extends Omit<CommonProps, "children" | "onChange" | "onSubmit"> {
|
|
50
|
+
value?: string;
|
|
51
|
+
defaultValue?: string;
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
onChange?: (value: string) => void;
|
|
55
|
+
onSubmit?: (value: string) => void;
|
|
56
|
+
onFocus?: () => void;
|
|
57
|
+
onBlur?: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function TextInput({ onChange, onSubmit, ...rest }: TextInputProps) {
|
|
61
|
+
return createElement("vs-textinput", {
|
|
62
|
+
...rest,
|
|
63
|
+
onChange: onChange ? (payload: any) => onChange(String(payload?.value ?? "")) : undefined,
|
|
64
|
+
onSubmit: onSubmit ? (payload: any) => onSubmit(String(payload?.value ?? "")) : undefined,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface NativeViewProps extends CommonProps {
|
|
69
|
+
/** Id of a component factory registered in the C++ NativeRegistry. */
|
|
70
|
+
nativeId: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function NativeView(props: NativeViewProps) {
|
|
74
|
+
return createElement("vs-native", props);
|
|
75
|
+
}
|
package/src/render.tsx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import Reconciler from "react-reconciler";
|
|
2
|
+
import { LegacyRoot } from "react-reconciler/constants";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { hostConfig } from "./hostConfig";
|
|
5
|
+
import { flushOps } from "./bridge";
|
|
6
|
+
|
|
7
|
+
const reconciler = Reconciler(hostConfig as any);
|
|
8
|
+
|
|
9
|
+
let container: ReturnType<typeof reconciler.createContainer> | null = null;
|
|
10
|
+
|
|
11
|
+
/** Mounts (or re-renders) the app into the plugin window. */
|
|
12
|
+
export function render(element: ReactNode): void {
|
|
13
|
+
if (!container) {
|
|
14
|
+
container = reconciler.createContainer(
|
|
15
|
+
{},
|
|
16
|
+
LegacyRoot,
|
|
17
|
+
null,
|
|
18
|
+
false,
|
|
19
|
+
null,
|
|
20
|
+
"vsreact",
|
|
21
|
+
(error: unknown) => console.error("[vsreact] recoverable error:", error),
|
|
22
|
+
null,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
reconciler.updateContainer(element, container, null, null);
|
|
27
|
+
flushOps();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Unmounts the current app (used by hot reload teardown). */
|
|
31
|
+
export function unmount(): void {
|
|
32
|
+
if (container) {
|
|
33
|
+
reconciler.updateContainer(null, container, null, null);
|
|
34
|
+
flushOps();
|
|
35
|
+
container = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Environment shims for QuickJS. This module MUST be imported before react —
|
|
2
|
+
// it provides the timer/console/microtask facilities react and the scheduler
|
|
3
|
+
// expect. The __vsreact_* natives are registered by the C++ host; when absent
|
|
4
|
+
// (e.g. under `bun test`) the shims fall back to the platform implementations.
|
|
5
|
+
|
|
6
|
+
export type NativeFns = {
|
|
7
|
+
__vsreact_flush(json: string): void;
|
|
8
|
+
__vsreact_nativeCall(name: string, argsJson: string): string;
|
|
9
|
+
__vsreact_log(level: string, msg: string): void;
|
|
10
|
+
__vsreact_setTimer(id: number, ms: number): void;
|
|
11
|
+
__vsreact_clearTimer(id: number): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const g = globalThis as typeof globalThis & Partial<NativeFns> & Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
export const isHosted = typeof g.__vsreact_setTimer === "function";
|
|
17
|
+
|
|
18
|
+
interface TimerEntry {
|
|
19
|
+
cb: (...args: unknown[]) => void;
|
|
20
|
+
args: unknown[];
|
|
21
|
+
intervalMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let nextTimerId = 1;
|
|
25
|
+
const timers = new Map<number, TimerEntry>();
|
|
26
|
+
|
|
27
|
+
/** Called by bridge.ts when C++ dispatches {kind:"timer",id}. */
|
|
28
|
+
export function fireTimer(id: number): void {
|
|
29
|
+
const entry = timers.get(id);
|
|
30
|
+
if (!entry) return;
|
|
31
|
+
|
|
32
|
+
if (entry.intervalMs !== undefined) g.__vsreact_setTimer!(id, entry.intervalMs);
|
|
33
|
+
else timers.delete(id);
|
|
34
|
+
|
|
35
|
+
entry.cb(...entry.args);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stringify(value: unknown): string {
|
|
39
|
+
if (typeof value === "string") return value;
|
|
40
|
+
if (value instanceof Error) return `${value.message}\n${value.stack ?? ""}`;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.stringify(value) ?? String(value);
|
|
43
|
+
} catch {
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isHosted) {
|
|
49
|
+
g.setTimeout = ((cb: TimerEntry["cb"], ms = 0, ...args: unknown[]) => {
|
|
50
|
+
const id = nextTimerId++;
|
|
51
|
+
timers.set(id, { cb, args });
|
|
52
|
+
g.__vsreact_setTimer!(id, Math.max(0, ms | 0));
|
|
53
|
+
return id;
|
|
54
|
+
}) as typeof setTimeout;
|
|
55
|
+
|
|
56
|
+
g.setInterval = ((cb: TimerEntry["cb"], ms = 0, ...args: unknown[]) => {
|
|
57
|
+
const id = nextTimerId++;
|
|
58
|
+
const intervalMs = Math.max(1, ms | 0);
|
|
59
|
+
timers.set(id, { cb, args, intervalMs });
|
|
60
|
+
g.__vsreact_setTimer!(id, intervalMs);
|
|
61
|
+
return id;
|
|
62
|
+
}) as typeof setInterval;
|
|
63
|
+
|
|
64
|
+
g.clearTimeout = g.clearInterval = ((id?: number) => {
|
|
65
|
+
if (id !== undefined && timers.delete(id)) g.__vsreact_clearTimer!(id);
|
|
66
|
+
}) as typeof clearTimeout;
|
|
67
|
+
|
|
68
|
+
const makeLog =
|
|
69
|
+
(level: string) =>
|
|
70
|
+
(...args: unknown[]) =>
|
|
71
|
+
g.__vsreact_log!(level, args.map(stringify).join(" "));
|
|
72
|
+
|
|
73
|
+
g.console = {
|
|
74
|
+
log: makeLog("log"),
|
|
75
|
+
info: makeLog("info"),
|
|
76
|
+
debug: makeLog("debug"),
|
|
77
|
+
warn: makeLog("warn"),
|
|
78
|
+
error: makeLog("error"),
|
|
79
|
+
} as Console;
|
|
80
|
+
|
|
81
|
+
g.queueMicrotask ??= (cb: VoidFunction) => {
|
|
82
|
+
Promise.resolve().then(cb);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
g.performance ??= { now: () => Date.now() } as Performance;
|
|
86
|
+
g.process ??= { env: { NODE_ENV: "production" } } as unknown as typeof process;
|
|
87
|
+
g.self ??= g as unknown as Window & typeof globalThis;
|
|
88
|
+
g.global ??= g;
|
|
89
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type ThemeColors = Record<string, string>;
|
|
2
|
+
|
|
3
|
+
// Tailwind v3 palettes (the curated subset VSReacT ships with).
|
|
4
|
+
const palettes: Record<string, Record<string, string>> = {
|
|
5
|
+
zinc: {
|
|
6
|
+
"50": "#fafafa", "100": "#f4f4f5", "200": "#e4e4e7", "300": "#d4d4d8",
|
|
7
|
+
"400": "#a1a1aa", "500": "#71717a", "600": "#52525b", "700": "#3f3f46",
|
|
8
|
+
"800": "#27272a", "900": "#18181b", "950": "#09090b",
|
|
9
|
+
},
|
|
10
|
+
neutral: {
|
|
11
|
+
"50": "#fafafa", "100": "#f5f5f5", "200": "#e5e5e5", "300": "#d4d4d4",
|
|
12
|
+
"400": "#a3a3a3", "500": "#737373", "600": "#525252", "700": "#404040",
|
|
13
|
+
"800": "#262626", "900": "#171717", "950": "#0a0a0a",
|
|
14
|
+
},
|
|
15
|
+
lime: {
|
|
16
|
+
"50": "#f7fee7", "100": "#ecfccb", "200": "#d9f99d", "300": "#bef264",
|
|
17
|
+
"400": "#a3e635", "500": "#84cc16", "600": "#65a30d", "700": "#4d7c0f",
|
|
18
|
+
"800": "#3f6212", "900": "#365314", "950": "#1a2e05",
|
|
19
|
+
},
|
|
20
|
+
red: {
|
|
21
|
+
"50": "#fef2f2", "100": "#fee2e2", "200": "#fecaca", "300": "#fca5a5",
|
|
22
|
+
"400": "#f87171", "500": "#ef4444", "600": "#dc2626", "700": "#b91c1c",
|
|
23
|
+
"800": "#991b1b", "900": "#7f1d1d", "950": "#450a0a",
|
|
24
|
+
},
|
|
25
|
+
amber: {
|
|
26
|
+
"50": "#fffbeb", "100": "#fef3c7", "200": "#fde68a", "300": "#fcd34d",
|
|
27
|
+
"400": "#fbbf24", "500": "#f59e0b", "600": "#d97706", "700": "#b45309",
|
|
28
|
+
"800": "#92400e", "900": "#78350f", "950": "#451a03",
|
|
29
|
+
},
|
|
30
|
+
emerald: {
|
|
31
|
+
"50": "#ecfdf5", "100": "#d1fae5", "200": "#a7f3d0", "300": "#6ee7b7",
|
|
32
|
+
"400": "#34d399", "500": "#10b981", "600": "#059669", "700": "#047857",
|
|
33
|
+
"800": "#065f46", "900": "#064e3b", "950": "#022c22",
|
|
34
|
+
},
|
|
35
|
+
sky: {
|
|
36
|
+
"50": "#f0f9ff", "100": "#e0f2fe", "200": "#bae6fd", "300": "#7dd3fc",
|
|
37
|
+
"400": "#38bdf8", "500": "#0ea5e9", "600": "#0284c7", "700": "#0369a1",
|
|
38
|
+
"800": "#075985", "900": "#0c4a6e", "950": "#082f49",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let themeColors: ThemeColors = {};
|
|
43
|
+
|
|
44
|
+
export function setThemeColors(colors: ThemeColors): void {
|
|
45
|
+
themeColors = { ...themeColors };
|
|
46
|
+
for (const [name, value] of Object.entries(colors))
|
|
47
|
+
themeColors[name] = value.toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Resolves a color name ("zinc-900", "white", "accent", "lime-400/20",
|
|
51
|
+
"[#c6f135]") to "#rrggbb"/"#rrggbbaa", or undefined if unknown. */
|
|
52
|
+
export function resolveColor(name: string): string | undefined {
|
|
53
|
+
let base = name;
|
|
54
|
+
let alpha: string | undefined;
|
|
55
|
+
|
|
56
|
+
const slash = name.lastIndexOf("/");
|
|
57
|
+
if (slash > 0) {
|
|
58
|
+
const pct = Number(name.slice(slash + 1));
|
|
59
|
+
if (Number.isFinite(pct)) {
|
|
60
|
+
base = name.slice(0, slash);
|
|
61
|
+
alpha = Math.round((pct / 100) * 255)
|
|
62
|
+
.toString(16)
|
|
63
|
+
.padStart(2, "0");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let hex: string | undefined;
|
|
68
|
+
|
|
69
|
+
if (base.startsWith("[#") && base.endsWith("]")) hex = base.slice(1, -1).toLowerCase();
|
|
70
|
+
else if (base === "white") hex = "#ffffff";
|
|
71
|
+
else if (base === "black") hex = "#000000";
|
|
72
|
+
else if (base === "transparent") hex = "#00000000";
|
|
73
|
+
else if (themeColors[base]) hex = themeColors[base];
|
|
74
|
+
else {
|
|
75
|
+
const dash = base.lastIndexOf("-");
|
|
76
|
+
if (dash > 0) {
|
|
77
|
+
const palette = palettes[base.slice(0, dash)];
|
|
78
|
+
hex = palette?.[base.slice(dash + 1)];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (hex === undefined) return undefined;
|
|
83
|
+
return alpha !== undefined && hex.length === 7 ? hex + alpha : hex;
|
|
84
|
+
}
|
package/src/tw.test.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { configureTheme, tw } from "./tw";
|
|
3
|
+
|
|
4
|
+
describe("tw resolver", () => {
|
|
5
|
+
test("layout, palette color, radius, border with opacity suffix", () => {
|
|
6
|
+
const r = tw("flex-1 flex-row bg-zinc-950 rounded-xl border border-lime-400/20 p-4");
|
|
7
|
+
expect(r.style).toEqual({
|
|
8
|
+
flex: 1,
|
|
9
|
+
flexDirection: "row",
|
|
10
|
+
backgroundColor: "#09090b",
|
|
11
|
+
borderRadius: 12,
|
|
12
|
+
borderWidth: 1,
|
|
13
|
+
borderColor: "#a3e63533",
|
|
14
|
+
padding: 16,
|
|
15
|
+
});
|
|
16
|
+
expect(r.hoverStyle).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("hover variant routes to hoverStyle", () => {
|
|
20
|
+
const r = tw("bg-zinc-900 hover:bg-lime-300 active:bg-lime-500 focus:border-lime-400");
|
|
21
|
+
expect(r.style).toEqual({ backgroundColor: "#18181b" });
|
|
22
|
+
expect(r.hoverStyle).toEqual({ backgroundColor: "#bef264" });
|
|
23
|
+
expect(r.activeStyle).toEqual({ backgroundColor: "#84cc16" });
|
|
24
|
+
expect(r.focusStyle).toEqual({ borderColor: "#a3e635" });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("theme tokens", () => {
|
|
28
|
+
configureTheme({ colors: { accent: "#C6F135", well: "#0C0E0C" } });
|
|
29
|
+
const r = tw("bg-well text-accent border-accent/35");
|
|
30
|
+
expect(r.style.backgroundColor).toBe("#0c0e0c");
|
|
31
|
+
expect(r.style.color).toBe("#c6f135");
|
|
32
|
+
expect(r.style.borderColor).toBe("#c6f13559");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("arbitrary values", () => {
|
|
36
|
+
const r = tw("w-[123] h-[45%] bg-[#C6F135] p-[7]");
|
|
37
|
+
expect(r.style).toEqual({
|
|
38
|
+
width: 123,
|
|
39
|
+
height: "45%",
|
|
40
|
+
backgroundColor: "#c6f135",
|
|
41
|
+
padding: 7,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("spacing scale, fractions, full, directional padding/margin", () => {
|
|
46
|
+
const r = tw("w-full h-1/2 px-3 py-1.5 mt-2 -mb-1 gap-2 gap-x-4");
|
|
47
|
+
expect(r.style).toEqual({
|
|
48
|
+
width: "100%",
|
|
49
|
+
height: "50%",
|
|
50
|
+
paddingLeft: 12,
|
|
51
|
+
paddingRight: 12,
|
|
52
|
+
paddingTop: 6,
|
|
53
|
+
paddingBottom: 6,
|
|
54
|
+
marginTop: 8,
|
|
55
|
+
marginBottom: -4,
|
|
56
|
+
gap: 8,
|
|
57
|
+
columnGap: 16,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("text utilities", () => {
|
|
62
|
+
const r = tw("text-sm font-bold text-center text-zinc-400 leading-5 tracking-wide");
|
|
63
|
+
expect(r.style).toEqual({
|
|
64
|
+
fontSize: 14,
|
|
65
|
+
fontWeight: 700,
|
|
66
|
+
textAlign: "center",
|
|
67
|
+
color: "#a1a1aa",
|
|
68
|
+
lineHeight: 20,
|
|
69
|
+
letterSpacing: 0.4,
|
|
70
|
+
});
|
|
71
|
+
expect(tw("font-mono").style).toEqual({ fontFamily: "monospace" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("alignment, position, shadow, opacity, overflow, cursor", () => {
|
|
75
|
+
const r = tw(
|
|
76
|
+
"items-center justify-between absolute inset-0 top-2 shadow-lg opacity-50 overflow-hidden cursor-pointer",
|
|
77
|
+
);
|
|
78
|
+
expect(r.style).toEqual({
|
|
79
|
+
alignItems: "center",
|
|
80
|
+
justifyContent: "space-between",
|
|
81
|
+
position: "absolute",
|
|
82
|
+
left: 0,
|
|
83
|
+
right: 0,
|
|
84
|
+
bottom: 0,
|
|
85
|
+
top: 8,
|
|
86
|
+
shadowColor: "#00000066",
|
|
87
|
+
shadowRadius: 12,
|
|
88
|
+
shadowOffsetY: 4,
|
|
89
|
+
opacity: 0.5,
|
|
90
|
+
overflow: "hidden",
|
|
91
|
+
cursor: "pointer",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("per-corner radius", () => {
|
|
96
|
+
const r = tw("rounded-t-lg rounded-br-full");
|
|
97
|
+
expect(r.style).toEqual({
|
|
98
|
+
borderTopLeftRadius: 8,
|
|
99
|
+
borderTopRightRadius: 8,
|
|
100
|
+
borderBottomRightRadius: 9999,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("unknown classes are ignored", () => {
|
|
105
|
+
const r = tw("bg-zinc-900 not-a-real-class");
|
|
106
|
+
expect(r.style).toEqual({ backgroundColor: "#18181b" });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("later classes win", () => {
|
|
110
|
+
const r = tw("bg-zinc-900 bg-zinc-800");
|
|
111
|
+
expect(r.style.backgroundColor).toBe("#27272a");
|
|
112
|
+
});
|
|
113
|
+
});
|