@walwal-harness/cli 7.1.41 → 7.1.43
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/apps/harness-dashboard/app/api/metrics/route.ts +17 -0
- package/apps/harness-dashboard/app/brick.css +482 -0
- package/apps/harness-dashboard/app/layout.tsx +13 -0
- package/apps/harness-dashboard/app/page.tsx +2 -2
- package/apps/harness-dashboard/components/Scene.tsx +156 -35
- package/apps/harness-dashboard/components/brick/BrickDashboard.tsx +149 -0
- package/apps/harness-dashboard/components/brick/reports.tsx +291 -0
- package/apps/harness-dashboard/components/brick/tweaks.tsx +738 -0
- package/apps/harness-dashboard/components/brick/ui.tsx +463 -0
- package/apps/harness-dashboard/components/brick/views-graph.tsx +305 -0
- package/apps/harness-dashboard/components/brick/views-grid.tsx +543 -0
- package/apps/harness-dashboard/components/brick/views-timeline.tsx +238 -0
- package/apps/harness-dashboard/lib/brick/adapter.ts +423 -0
- package/apps/harness-dashboard/lib/brick/contract.ts +247 -0
- package/apps/harness-dashboard/lib/metrics/sampler.ts +545 -0
- package/package.json +1 -1
- package/scripts/harness-worker-dispatch.sh +31 -7
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/* =============================================================
|
|
4
|
+
walwal-harness · BRICK OFFICE — Tweaks panel
|
|
5
|
+
Ported from the design-handoff mockup (harness/tweaks-panel.jsx).
|
|
6
|
+
|
|
7
|
+
Differences from the mockup (intentional, for the real Next.js app):
|
|
8
|
+
- No host edit-mode protocol (postMessage __edit_mode_*). That was a
|
|
9
|
+
design-tool artifact. Here the panel is self-contained: it opens by
|
|
10
|
+
default, the ✕ dismisses it, and a floating pill (.twk-fab) re-opens it.
|
|
11
|
+
- useTweaks persists to localStorage (SSR-safe: defaults on first render,
|
|
12
|
+
hydrate from storage in an effect to avoid hydration mismatch).
|
|
13
|
+
- All CSS lives in app/brick.css (the .twk-* block) — no inline <style>.
|
|
14
|
+
|
|
15
|
+
The four real tweaks drive the shell:
|
|
16
|
+
agentLayout card|list -> GridView layout
|
|
17
|
+
density compact|comfy -> [data-density] on the shell root
|
|
18
|
+
motion active|calm|off -> live-motion intensity passed to the adapter/views
|
|
19
|
+
surface midnight|slate -> [data-tone] on the shell root
|
|
20
|
+
============================================================= */
|
|
21
|
+
|
|
22
|
+
import React, {
|
|
23
|
+
useCallback,
|
|
24
|
+
useEffect,
|
|
25
|
+
useMemo,
|
|
26
|
+
useRef,
|
|
27
|
+
useState,
|
|
28
|
+
} from "react";
|
|
29
|
+
|
|
30
|
+
/* ---- tweak value model ------------------------------------------------- */
|
|
31
|
+
|
|
32
|
+
export type AgentLayout = "card" | "list";
|
|
33
|
+
export type TweakDensity = "compact" | "comfy";
|
|
34
|
+
export type TweakMotion = "active" | "calm" | "off";
|
|
35
|
+
export type TweakSurface = "midnight" | "slate";
|
|
36
|
+
|
|
37
|
+
/** The persisted tweak state. */
|
|
38
|
+
export interface TweakValues {
|
|
39
|
+
agentLayout: AgentLayout;
|
|
40
|
+
density: TweakDensity;
|
|
41
|
+
motion: TweakMotion;
|
|
42
|
+
surface: TweakSurface;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const TWEAK_DEFAULTS: TweakValues = {
|
|
46
|
+
agentLayout: "card",
|
|
47
|
+
density: "compact",
|
|
48
|
+
motion: "active",
|
|
49
|
+
surface: "slate",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const STORAGE_KEY = "brick.tweaks.v1";
|
|
53
|
+
|
|
54
|
+
/** Setter overloads: setTweak('key', value) or setTweak({ partial }). */
|
|
55
|
+
export type SetTweak = {
|
|
56
|
+
<K extends keyof TweakValues>(key: K, value: TweakValues[K]): void;
|
|
57
|
+
(edits: Partial<TweakValues>): void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function readStored(): Partial<TweakValues> | null {
|
|
61
|
+
if (typeof window === "undefined") return null;
|
|
62
|
+
try {
|
|
63
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
64
|
+
if (!raw) return null;
|
|
65
|
+
const parsed: unknown = JSON.parse(raw);
|
|
66
|
+
if (parsed && typeof parsed === "object") {
|
|
67
|
+
return parsed as Partial<TweakValues>;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// ignore corrupt/blocked storage
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── useTweaks ───────────────────────────────────────────────────────────────
|
|
76
|
+
// Single source of truth for tweak values. setTweak persists to localStorage.
|
|
77
|
+
// SSR-safe: starts from defaults (so server and first client render match),
|
|
78
|
+
// then hydrates from storage in an effect.
|
|
79
|
+
export function useTweaks(
|
|
80
|
+
defaults: TweakValues = TWEAK_DEFAULTS
|
|
81
|
+
): [TweakValues, SetTweak] {
|
|
82
|
+
const [values, setValues] = useState<TweakValues>(defaults);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const stored = readStored();
|
|
86
|
+
if (stored) setValues((prev) => ({ ...prev, ...stored }));
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const setTweak = useCallback<SetTweak>(
|
|
90
|
+
(keyOrEdits: keyof TweakValues | Partial<TweakValues>, val?: unknown) => {
|
|
91
|
+
const edits: Partial<TweakValues> =
|
|
92
|
+
typeof keyOrEdits === "object" && keyOrEdits !== null
|
|
93
|
+
? keyOrEdits
|
|
94
|
+
: ({ [keyOrEdits]: val } as Partial<TweakValues>);
|
|
95
|
+
setValues((prev) => {
|
|
96
|
+
const next = { ...prev, ...edits };
|
|
97
|
+
try {
|
|
98
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore blocked storage
|
|
101
|
+
}
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
[]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return [values, setTweak];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
|
112
|
+
// Floating, draggable shell. Open by default; ✕ dismisses to a floating pill
|
|
113
|
+
// that re-opens it. Clamps itself to the viewport on resize.
|
|
114
|
+
|
|
115
|
+
const PAD = 16;
|
|
116
|
+
|
|
117
|
+
interface TweaksPanelProps {
|
|
118
|
+
title?: string;
|
|
119
|
+
children?: React.ReactNode;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function TweaksPanel({
|
|
123
|
+
title = "Tweaks",
|
|
124
|
+
children,
|
|
125
|
+
}: TweaksPanelProps): React.JSX.Element {
|
|
126
|
+
const [open, setOpen] = useState(true);
|
|
127
|
+
const dragRef = useRef<HTMLDivElement | null>(null);
|
|
128
|
+
const offsetRef = useRef<{ x: number; y: number }>({ x: PAD, y: PAD });
|
|
129
|
+
|
|
130
|
+
const clampToViewport = useCallback(() => {
|
|
131
|
+
const panel = dragRef.current;
|
|
132
|
+
if (!panel) return;
|
|
133
|
+
const w = panel.offsetWidth;
|
|
134
|
+
const h = panel.offsetHeight;
|
|
135
|
+
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
|
136
|
+
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
|
137
|
+
offsetRef.current = {
|
|
138
|
+
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
|
139
|
+
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
|
140
|
+
};
|
|
141
|
+
panel.style.right = `${offsetRef.current.x}px`;
|
|
142
|
+
panel.style.bottom = `${offsetRef.current.y}px`;
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!open) return;
|
|
147
|
+
clampToViewport();
|
|
148
|
+
if (typeof ResizeObserver === "undefined") {
|
|
149
|
+
window.addEventListener("resize", clampToViewport);
|
|
150
|
+
return () => window.removeEventListener("resize", clampToViewport);
|
|
151
|
+
}
|
|
152
|
+
const ro = new ResizeObserver(clampToViewport);
|
|
153
|
+
ro.observe(document.documentElement);
|
|
154
|
+
return () => ro.disconnect();
|
|
155
|
+
}, [open, clampToViewport]);
|
|
156
|
+
|
|
157
|
+
const onDragStart = (e: React.MouseEvent) => {
|
|
158
|
+
const panel = dragRef.current;
|
|
159
|
+
if (!panel) return;
|
|
160
|
+
const r = panel.getBoundingClientRect();
|
|
161
|
+
const sx = e.clientX;
|
|
162
|
+
const sy = e.clientY;
|
|
163
|
+
const startRight = window.innerWidth - r.right;
|
|
164
|
+
const startBottom = window.innerHeight - r.bottom;
|
|
165
|
+
const move = (ev: MouseEvent) => {
|
|
166
|
+
offsetRef.current = {
|
|
167
|
+
x: startRight - (ev.clientX - sx),
|
|
168
|
+
y: startBottom - (ev.clientY - sy),
|
|
169
|
+
};
|
|
170
|
+
clampToViewport();
|
|
171
|
+
};
|
|
172
|
+
const up = () => {
|
|
173
|
+
window.removeEventListener("mousemove", move);
|
|
174
|
+
window.removeEventListener("mouseup", up);
|
|
175
|
+
};
|
|
176
|
+
window.addEventListener("mousemove", move);
|
|
177
|
+
window.addEventListener("mouseup", up);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (!open) {
|
|
181
|
+
return (
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
className="twk-fab"
|
|
185
|
+
aria-label="Open tweaks"
|
|
186
|
+
onClick={() => setOpen(true)}
|
|
187
|
+
>
|
|
188
|
+
{title}
|
|
189
|
+
</button>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
ref={dragRef}
|
|
196
|
+
className="twk-panel"
|
|
197
|
+
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}
|
|
198
|
+
>
|
|
199
|
+
<div className="twk-hd" onMouseDown={onDragStart}>
|
|
200
|
+
<b>{title}</b>
|
|
201
|
+
<button
|
|
202
|
+
className="twk-x"
|
|
203
|
+
type="button"
|
|
204
|
+
aria-label="Close tweaks"
|
|
205
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
206
|
+
onClick={() => setOpen(false)}
|
|
207
|
+
>
|
|
208
|
+
✕
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="twk-body">{children}</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Layout helpers ──────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
interface TweakSectionProps {
|
|
219
|
+
label: string;
|
|
220
|
+
children?: React.ReactNode;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function TweakSection({
|
|
224
|
+
label,
|
|
225
|
+
children,
|
|
226
|
+
}: TweakSectionProps): React.JSX.Element {
|
|
227
|
+
return (
|
|
228
|
+
<>
|
|
229
|
+
<div className="twk-sect">{label}</div>
|
|
230
|
+
{children}
|
|
231
|
+
</>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface TweakRowProps {
|
|
236
|
+
label: string;
|
|
237
|
+
value?: React.ReactNode;
|
|
238
|
+
children?: React.ReactNode;
|
|
239
|
+
inline?: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function TweakRow({
|
|
243
|
+
label,
|
|
244
|
+
value,
|
|
245
|
+
children,
|
|
246
|
+
inline = false,
|
|
247
|
+
}: TweakRowProps): React.JSX.Element {
|
|
248
|
+
return (
|
|
249
|
+
<div className={inline ? "twk-row twk-row-h" : "twk-row"}>
|
|
250
|
+
<div className="twk-lbl">
|
|
251
|
+
<span>{label}</span>
|
|
252
|
+
{value != null && <span className="twk-val">{value}</span>}
|
|
253
|
+
</div>
|
|
254
|
+
{children}
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Controls ────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/** An option for the segmented / select / color controls. */
|
|
262
|
+
export type TweakOption<T> = T | { value: T; label: string };
|
|
263
|
+
|
|
264
|
+
interface TweakSliderProps {
|
|
265
|
+
label: string;
|
|
266
|
+
value: number;
|
|
267
|
+
min?: number;
|
|
268
|
+
max?: number;
|
|
269
|
+
step?: number;
|
|
270
|
+
unit?: string;
|
|
271
|
+
onChange: (v: number) => void;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function TweakSlider({
|
|
275
|
+
label,
|
|
276
|
+
value,
|
|
277
|
+
min = 0,
|
|
278
|
+
max = 100,
|
|
279
|
+
step = 1,
|
|
280
|
+
unit = "",
|
|
281
|
+
onChange,
|
|
282
|
+
}: TweakSliderProps): React.JSX.Element {
|
|
283
|
+
return (
|
|
284
|
+
<TweakRow label={label} value={`${value}${unit}`}>
|
|
285
|
+
<input
|
|
286
|
+
type="range"
|
|
287
|
+
className="twk-slider"
|
|
288
|
+
min={min}
|
|
289
|
+
max={max}
|
|
290
|
+
step={step}
|
|
291
|
+
value={value}
|
|
292
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
293
|
+
/>
|
|
294
|
+
</TweakRow>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface TweakToggleProps {
|
|
299
|
+
label: string;
|
|
300
|
+
value: boolean;
|
|
301
|
+
onChange: (v: boolean) => void;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function TweakToggle({
|
|
305
|
+
label,
|
|
306
|
+
value,
|
|
307
|
+
onChange,
|
|
308
|
+
}: TweakToggleProps): React.JSX.Element {
|
|
309
|
+
return (
|
|
310
|
+
<div className="twk-row twk-row-h">
|
|
311
|
+
<div className="twk-lbl">
|
|
312
|
+
<span>{label}</span>
|
|
313
|
+
</div>
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
className="twk-toggle"
|
|
317
|
+
data-on={value ? "1" : "0"}
|
|
318
|
+
role="switch"
|
|
319
|
+
aria-checked={value}
|
|
320
|
+
onClick={() => onChange(!value)}
|
|
321
|
+
>
|
|
322
|
+
<i />
|
|
323
|
+
</button>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function optLabel<T>(o: TweakOption<T>): string {
|
|
329
|
+
return typeof o === "object" && o !== null && "label" in o
|
|
330
|
+
? (o as { label: string }).label
|
|
331
|
+
: String(o);
|
|
332
|
+
}
|
|
333
|
+
function optValue<T>(o: TweakOption<T>): T {
|
|
334
|
+
return typeof o === "object" && o !== null && "value" in o
|
|
335
|
+
? (o as { value: T }).value
|
|
336
|
+
: (o as T);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface TweakRadioProps<T> {
|
|
340
|
+
label: string;
|
|
341
|
+
value: T;
|
|
342
|
+
options: ReadonlyArray<TweakOption<T>>;
|
|
343
|
+
onChange: (v: T) => void;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function TweakRadio<T>({
|
|
347
|
+
label,
|
|
348
|
+
value,
|
|
349
|
+
options,
|
|
350
|
+
onChange,
|
|
351
|
+
}: TweakRadioProps<T>): React.JSX.Element {
|
|
352
|
+
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
353
|
+
const [dragging, setDragging] = useState(false);
|
|
354
|
+
// The active value is read by pointer-move handlers attached for the lifetime
|
|
355
|
+
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
|
356
|
+
const valueRef = useRef<T>(value);
|
|
357
|
+
valueRef.current = value;
|
|
358
|
+
|
|
359
|
+
// Segments wrap mid-word once per-segment width runs out (~16 chars for 2
|
|
360
|
+
// options, ~10 for 3). Past that (or >3 options) fall back to a dropdown.
|
|
361
|
+
const maxLen = options.reduce((m, o) => Math.max(m, optLabel(o).length), 0);
|
|
362
|
+
const fitMap: Record<number, number> = { 2: 16, 3: 10 };
|
|
363
|
+
const fitsAsSegments = maxLen <= (fitMap[options.length] ?? 0);
|
|
364
|
+
|
|
365
|
+
const opts = useMemo(
|
|
366
|
+
() =>
|
|
367
|
+
options.map((o) => ({ value: optValue(o), label: optLabel(o) })),
|
|
368
|
+
[options]
|
|
369
|
+
);
|
|
370
|
+
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
|
371
|
+
const n = opts.length;
|
|
372
|
+
|
|
373
|
+
const segAt = useCallback(
|
|
374
|
+
(clientX: number): T => {
|
|
375
|
+
const track = trackRef.current;
|
|
376
|
+
if (!track) return valueRef.current;
|
|
377
|
+
const r = track.getBoundingClientRect();
|
|
378
|
+
const inner = r.width - 4;
|
|
379
|
+
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
|
380
|
+
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
|
381
|
+
},
|
|
382
|
+
[n, opts]
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
if (!fitsAsSegments) {
|
|
386
|
+
return (
|
|
387
|
+
<TweakSelect
|
|
388
|
+
label={label}
|
|
389
|
+
value={value}
|
|
390
|
+
options={options}
|
|
391
|
+
onChange={onChange}
|
|
392
|
+
/>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const onPointerDown = (e: React.PointerEvent) => {
|
|
397
|
+
setDragging(true);
|
|
398
|
+
const v0 = segAt(e.clientX);
|
|
399
|
+
if (v0 !== valueRef.current) onChange(v0);
|
|
400
|
+
const move = (ev: PointerEvent) => {
|
|
401
|
+
if (!trackRef.current) return;
|
|
402
|
+
const v = segAt(ev.clientX);
|
|
403
|
+
if (v !== valueRef.current) onChange(v);
|
|
404
|
+
};
|
|
405
|
+
const up = () => {
|
|
406
|
+
setDragging(false);
|
|
407
|
+
window.removeEventListener("pointermove", move);
|
|
408
|
+
window.removeEventListener("pointerup", up);
|
|
409
|
+
};
|
|
410
|
+
window.addEventListener("pointermove", move);
|
|
411
|
+
window.addEventListener("pointerup", up);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<TweakRow label={label}>
|
|
416
|
+
<div
|
|
417
|
+
ref={trackRef}
|
|
418
|
+
role="radiogroup"
|
|
419
|
+
onPointerDown={onPointerDown}
|
|
420
|
+
className={dragging ? "twk-seg dragging" : "twk-seg"}
|
|
421
|
+
>
|
|
422
|
+
<div
|
|
423
|
+
className="twk-seg-thumb"
|
|
424
|
+
style={{
|
|
425
|
+
left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
|
426
|
+
width: `calc((100% - 4px) / ${n})`,
|
|
427
|
+
}}
|
|
428
|
+
/>
|
|
429
|
+
{opts.map((o) => (
|
|
430
|
+
<button
|
|
431
|
+
key={String(o.value)}
|
|
432
|
+
type="button"
|
|
433
|
+
role="radio"
|
|
434
|
+
aria-checked={o.value === value}
|
|
435
|
+
onClick={() => onChange(o.value)}
|
|
436
|
+
>
|
|
437
|
+
{o.label}
|
|
438
|
+
</button>
|
|
439
|
+
))}
|
|
440
|
+
</div>
|
|
441
|
+
</TweakRow>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
interface TweakSelectProps<T> {
|
|
446
|
+
label: string;
|
|
447
|
+
value: T;
|
|
448
|
+
options: ReadonlyArray<TweakOption<T>>;
|
|
449
|
+
onChange: (v: T) => void;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function TweakSelect<T>({
|
|
453
|
+
label,
|
|
454
|
+
value,
|
|
455
|
+
options,
|
|
456
|
+
onChange,
|
|
457
|
+
}: TweakSelectProps<T>): React.JSX.Element {
|
|
458
|
+
// <select> emits strings; map the chosen string back to the original
|
|
459
|
+
// option value so the control stays type-preserving.
|
|
460
|
+
const resolve = (s: string): T => {
|
|
461
|
+
const m = options.find((o) => String(optValue(o)) === s);
|
|
462
|
+
return m === undefined ? (s as unknown as T) : optValue(m);
|
|
463
|
+
};
|
|
464
|
+
return (
|
|
465
|
+
<TweakRow label={label}>
|
|
466
|
+
<select
|
|
467
|
+
className="twk-field"
|
|
468
|
+
value={String(value)}
|
|
469
|
+
onChange={(e) => onChange(resolve(e.target.value))}
|
|
470
|
+
>
|
|
471
|
+
{options.map((o) => {
|
|
472
|
+
const v = optValue(o);
|
|
473
|
+
return (
|
|
474
|
+
<option key={String(v)} value={String(v)}>
|
|
475
|
+
{optLabel(o)}
|
|
476
|
+
</option>
|
|
477
|
+
);
|
|
478
|
+
})}
|
|
479
|
+
</select>
|
|
480
|
+
</TweakRow>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
interface TweakTextProps {
|
|
485
|
+
label: string;
|
|
486
|
+
value: string;
|
|
487
|
+
placeholder?: string;
|
|
488
|
+
onChange: (v: string) => void;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function TweakText({
|
|
492
|
+
label,
|
|
493
|
+
value,
|
|
494
|
+
placeholder,
|
|
495
|
+
onChange,
|
|
496
|
+
}: TweakTextProps): React.JSX.Element {
|
|
497
|
+
return (
|
|
498
|
+
<TweakRow label={label}>
|
|
499
|
+
<input
|
|
500
|
+
className="twk-field"
|
|
501
|
+
type="text"
|
|
502
|
+
value={value}
|
|
503
|
+
placeholder={placeholder}
|
|
504
|
+
onChange={(e) => onChange(e.target.value)}
|
|
505
|
+
/>
|
|
506
|
+
</TweakRow>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
interface TweakNumberProps {
|
|
511
|
+
label: string;
|
|
512
|
+
value: number;
|
|
513
|
+
min?: number;
|
|
514
|
+
max?: number;
|
|
515
|
+
step?: number;
|
|
516
|
+
unit?: string;
|
|
517
|
+
onChange: (v: number) => void;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function TweakNumber({
|
|
521
|
+
label,
|
|
522
|
+
value,
|
|
523
|
+
min,
|
|
524
|
+
max,
|
|
525
|
+
step = 1,
|
|
526
|
+
unit = "",
|
|
527
|
+
onChange,
|
|
528
|
+
}: TweakNumberProps): React.JSX.Element {
|
|
529
|
+
const clamp = (n: number): number => {
|
|
530
|
+
if (min != null && n < min) return min;
|
|
531
|
+
if (max != null && n > max) return max;
|
|
532
|
+
return n;
|
|
533
|
+
};
|
|
534
|
+
const startRef = useRef<{ x: number; val: number }>({ x: 0, val: 0 });
|
|
535
|
+
const onScrubStart = (e: React.PointerEvent) => {
|
|
536
|
+
e.preventDefault();
|
|
537
|
+
startRef.current = { x: e.clientX, val: value };
|
|
538
|
+
const decimals = (String(step).split(".")[1] ?? "").length;
|
|
539
|
+
const move = (ev: PointerEvent) => {
|
|
540
|
+
const dx = ev.clientX - startRef.current.x;
|
|
541
|
+
const raw = startRef.current.val + dx * step;
|
|
542
|
+
const snapped = Math.round(raw / step) * step;
|
|
543
|
+
onChange(clamp(Number(snapped.toFixed(decimals))));
|
|
544
|
+
};
|
|
545
|
+
const up = () => {
|
|
546
|
+
window.removeEventListener("pointermove", move);
|
|
547
|
+
window.removeEventListener("pointerup", up);
|
|
548
|
+
};
|
|
549
|
+
window.addEventListener("pointermove", move);
|
|
550
|
+
window.addEventListener("pointerup", up);
|
|
551
|
+
};
|
|
552
|
+
return (
|
|
553
|
+
<div className="twk-num">
|
|
554
|
+
<span className="twk-num-lbl" onPointerDown={onScrubStart}>
|
|
555
|
+
{label}
|
|
556
|
+
</span>
|
|
557
|
+
<input
|
|
558
|
+
type="number"
|
|
559
|
+
value={value}
|
|
560
|
+
min={min}
|
|
561
|
+
max={max}
|
|
562
|
+
step={step}
|
|
563
|
+
onChange={(e) => onChange(clamp(Number(e.target.value)))}
|
|
564
|
+
/>
|
|
565
|
+
{unit && <span className="twk-num-unit">{unit}</span>}
|
|
566
|
+
</div>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Relative-luminance contrast pick — a checkmark over a swatch must read on
|
|
571
|
+
// both dark and light fills. Hex input only (#rgb / #rrggbb).
|
|
572
|
+
function twkIsLight(hex: string): boolean {
|
|
573
|
+
const h = String(hex).replace("#", "");
|
|
574
|
+
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, "0");
|
|
575
|
+
const n = parseInt(x.slice(0, 6), 16);
|
|
576
|
+
if (Number.isNaN(n)) return true;
|
|
577
|
+
const r = (n >> 16) & 255;
|
|
578
|
+
const g = (n >> 8) & 255;
|
|
579
|
+
const b = n & 255;
|
|
580
|
+
return r * 299 + g * 587 + b * 114 > 148000;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function TwkCheck({ light }: { light: boolean }): React.JSX.Element {
|
|
584
|
+
return (
|
|
585
|
+
<svg viewBox="0 0 14 14" aria-hidden="true">
|
|
586
|
+
<path
|
|
587
|
+
d="M3 7.2 5.8 10 11 4.2"
|
|
588
|
+
fill="none"
|
|
589
|
+
strokeWidth="2.2"
|
|
590
|
+
strokeLinecap="round"
|
|
591
|
+
strokeLinejoin="round"
|
|
592
|
+
stroke={light ? "rgba(0,0,0,.78)" : "#fff"}
|
|
593
|
+
/>
|
|
594
|
+
</svg>
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** A color tweak option: a single hex string or a 1-5 hex palette array. */
|
|
599
|
+
export type ColorOption = string | string[];
|
|
600
|
+
|
|
601
|
+
interface TweakColorProps {
|
|
602
|
+
label: string;
|
|
603
|
+
value: ColorOption;
|
|
604
|
+
options?: ReadonlyArray<ColorOption>;
|
|
605
|
+
onChange: (v: ColorOption) => void;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function TweakColor({
|
|
609
|
+
label,
|
|
610
|
+
value,
|
|
611
|
+
options,
|
|
612
|
+
onChange,
|
|
613
|
+
}: TweakColorProps): React.JSX.Element {
|
|
614
|
+
if (!options || !options.length) {
|
|
615
|
+
return (
|
|
616
|
+
<div className="twk-row twk-row-h">
|
|
617
|
+
<div className="twk-lbl">
|
|
618
|
+
<span>{label}</span>
|
|
619
|
+
</div>
|
|
620
|
+
<input
|
|
621
|
+
type="color"
|
|
622
|
+
className="twk-swatch"
|
|
623
|
+
value={typeof value === "string" ? value : (value[0] ?? "#000000")}
|
|
624
|
+
onChange={(e) => onChange(e.target.value)}
|
|
625
|
+
/>
|
|
626
|
+
</div>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
// Native <input type=color> emits lowercase hex, so compare lowercased.
|
|
630
|
+
const key = (o: ColorOption): string =>
|
|
631
|
+
String(JSON.stringify(o)).toLowerCase();
|
|
632
|
+
const cur = key(value);
|
|
633
|
+
return (
|
|
634
|
+
<TweakRow label={label}>
|
|
635
|
+
<div className="twk-chips" role="radiogroup">
|
|
636
|
+
{options.map((o, i) => {
|
|
637
|
+
const colors = Array.isArray(o) ? o : [o];
|
|
638
|
+
const hero = colors[0] ?? "#000000";
|
|
639
|
+
const sup = colors.slice(1, 5);
|
|
640
|
+
const on = key(o) === cur;
|
|
641
|
+
return (
|
|
642
|
+
<button
|
|
643
|
+
key={i}
|
|
644
|
+
type="button"
|
|
645
|
+
className="twk-chip"
|
|
646
|
+
role="radio"
|
|
647
|
+
aria-checked={on}
|
|
648
|
+
data-on={on ? "1" : "0"}
|
|
649
|
+
aria-label={colors.join(", ")}
|
|
650
|
+
title={colors.join(" · ")}
|
|
651
|
+
style={{ background: hero }}
|
|
652
|
+
onClick={() => onChange(o)}
|
|
653
|
+
>
|
|
654
|
+
{sup.length > 0 && (
|
|
655
|
+
<span>
|
|
656
|
+
{sup.map((c, j) => (
|
|
657
|
+
<i key={j} style={{ background: c }} />
|
|
658
|
+
))}
|
|
659
|
+
</span>
|
|
660
|
+
)}
|
|
661
|
+
{on && <TwkCheck light={twkIsLight(hero)} />}
|
|
662
|
+
</button>
|
|
663
|
+
);
|
|
664
|
+
})}
|
|
665
|
+
</div>
|
|
666
|
+
</TweakRow>
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
interface TweakButtonProps {
|
|
671
|
+
label: string;
|
|
672
|
+
onClick: () => void;
|
|
673
|
+
secondary?: boolean;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export function TweakButton({
|
|
677
|
+
label,
|
|
678
|
+
onClick,
|
|
679
|
+
secondary = false,
|
|
680
|
+
}: TweakButtonProps): React.JSX.Element {
|
|
681
|
+
return (
|
|
682
|
+
<button
|
|
683
|
+
type="button"
|
|
684
|
+
className={secondary ? "twk-btn secondary" : "twk-btn"}
|
|
685
|
+
onClick={onClick}
|
|
686
|
+
>
|
|
687
|
+
{label}
|
|
688
|
+
</button>
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ── BrickTweaks ───────────────────────────────────────────────────────────
|
|
693
|
+
// The wired-up panel for the Brick Office dashboard: the four real tweaks
|
|
694
|
+
// (agent layout, density, motion, surface). Mirrors the mockup App() block.
|
|
695
|
+
|
|
696
|
+
interface BrickTweaksProps {
|
|
697
|
+
values: TweakValues;
|
|
698
|
+
setTweak: SetTweak;
|
|
699
|
+
title?: string;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export function BrickTweaks({
|
|
703
|
+
values,
|
|
704
|
+
setTweak,
|
|
705
|
+
title = "Tweaks",
|
|
706
|
+
}: BrickTweaksProps): React.JSX.Element {
|
|
707
|
+
return (
|
|
708
|
+
<TweaksPanel title={title}>
|
|
709
|
+
<TweakSection label="View" />
|
|
710
|
+
<TweakRadio<AgentLayout>
|
|
711
|
+
label="Agent layout"
|
|
712
|
+
value={values.agentLayout}
|
|
713
|
+
options={["card", "list"]}
|
|
714
|
+
onChange={(v) => setTweak("agentLayout", v)}
|
|
715
|
+
/>
|
|
716
|
+
<TweakRadio<TweakDensity>
|
|
717
|
+
label="Density"
|
|
718
|
+
value={values.density}
|
|
719
|
+
options={["compact", "comfy"]}
|
|
720
|
+
onChange={(v) => setTweak("density", v)}
|
|
721
|
+
/>
|
|
722
|
+
<TweakSection label="Motion" />
|
|
723
|
+
<TweakRadio<TweakMotion>
|
|
724
|
+
label="Live motion"
|
|
725
|
+
value={values.motion}
|
|
726
|
+
options={["active", "calm", "off"]}
|
|
727
|
+
onChange={(v) => setTweak("motion", v)}
|
|
728
|
+
/>
|
|
729
|
+
<TweakSection label="Surface" />
|
|
730
|
+
<TweakRadio<TweakSurface>
|
|
731
|
+
label="Background"
|
|
732
|
+
value={values.surface}
|
|
733
|
+
options={["midnight", "slate"]}
|
|
734
|
+
onChange={(v) => setTweak("surface", v)}
|
|
735
|
+
/>
|
|
736
|
+
</TweaksPanel>
|
|
737
|
+
);
|
|
738
|
+
}
|