Skip to content

Commit d951065

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(studio): color-variant swatch picker for color fields (#1833)
* feat(studio): color-variant swatch picker for color fields Color fields (`colorVariant` on metrics / dashboard widgets, etc.) are a fixed semantic palette but were edited as text dropdowns — the admin couldn't see the actual color. Add a shared swatch picker (colored circles, selected ring) used everywhere: - color-variant-field: shared COLOR_VARIANTS palette + ColorVariantPicker. - widgets: 'color-picker' widget (enum -> swatch row; free string -> native hex input) + registry entry. - SchemaForm: auto-detect color fields by name convention (color/colorVariant/ *Color) so generic forms get the picker without per-form wiring. - DashboardWidgetInspector: Color Variant now a swatch row. - block-config + PageBlockInspector: new 'color' field kind; object-metric Color renders as swatches. Verified live: page object-metric Color and dashboard widget Color Variant both render the swatch row with the correct color selected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(studio): allow the new 'color' block-config field kind --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2ead723 commit d951065

7 files changed

Lines changed: 172 additions & 20 deletions

File tree

packages/app-shell/src/views/metadata-admin/SchemaForm.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,22 @@ function detectIconWidget(name: string, schema: JsonSchema | undefined): string
295295
return undefined;
296296
}
297297

298+
/**
299+
* Detect the color swatch picker by NAME CONVENTION: a string field named
300+
* `color`, `colorVariant`, or `*Color`. An enum gets the semantic swatch row;
301+
* a free string gets the native hex picker. Mirrors the icon/field-ref
302+
* conventions so color fields are consistent across every metadata type.
303+
*/
304+
function detectColorWidget(name: string, schema: JsonSchema | undefined): string | undefined {
305+
const isString =
306+
schema?.type === 'string' ||
307+
Array.isArray(schema?.enum) ||
308+
(Array.isArray(schema?.anyOf) && (schema!.anyOf as JsonSchema[]).some((b) => b?.type === 'string'));
309+
if (!isString) return undefined;
310+
if (name === 'color' || name === 'colorVariant' || /Color$/.test(name)) return 'color-picker';
311+
return undefined;
312+
}
313+
298314
/* -------------------------------------------------------------------------- */
299315
/* FormView spec (subset) */
300316
/* -------------------------------------------------------------------------- */
@@ -758,6 +774,10 @@ function FieldRow({
758774
else {
759775
const iconWidget = detectIconWidget(name, schema);
760776
if (iconWidget) widget = iconWidget;
777+
else {
778+
const colorWidget = detectColorWidget(name, schema);
779+
if (colorWidget) widget = colorWidget;
780+
}
761781
}
762782
}
763783

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Shared color-variant swatch picker.
5+
*
6+
* Metadata color fields (`colorVariant` on metrics / dashboard widgets, badge
7+
* tones, …) are a fixed SEMANTIC palette, not arbitrary hex. A row of colored
8+
* swatches is far more scannable than a text dropdown — the admin sees the
9+
* actual color, like Linear/Notion label pickers. Reused by the generic
10+
* SchemaForm `color-picker` widget and the curated inspectors so every color
11+
* field looks and behaves the same.
12+
*/
13+
14+
import * as React from 'react';
15+
import { cn } from '@object-ui/components';
16+
17+
export interface ColorVariant { value: string; label: string; css: string }
18+
19+
/** Canonical semantic palette (mirrors the renderer's colorVariant tokens). */
20+
export const COLOR_VARIANTS: ColorVariant[] = [
21+
{ value: 'default', label: 'Default', css: '#9ca3af' },
22+
{ value: 'blue', label: 'Blue', css: '#3b82f6' },
23+
{ value: 'teal', label: 'Teal', css: '#14b8a6' },
24+
{ value: 'orange', label: 'Orange', css: '#f97316' },
25+
{ value: 'purple', label: 'Purple', css: '#a855f7' },
26+
{ value: 'success', label: 'Success', css: '#22c55e' },
27+
{ value: 'warning', label: 'Warning', css: '#f59e0b' },
28+
{ value: 'danger', label: 'Danger', css: '#ef4444' },
29+
// common aliases so non-canonical tokens still get a sensible swatch
30+
{ value: 'green', label: 'Green', css: '#22c55e' },
31+
{ value: 'red', label: 'Red', css: '#ef4444' },
32+
{ value: 'amber', label: 'Amber', css: '#f59e0b' },
33+
];
34+
35+
const CSS_BY_VALUE: Record<string, string> = Object.fromEntries(COLOR_VARIANTS.map((c) => [c.value, c.css]));
36+
37+
/** Resolve a token (or hex) to a CSS color; unknown tokens fall back to neutral. */
38+
export function colorVariantCss(value: string | undefined): string {
39+
if (!value) return '#9ca3af';
40+
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)) return value;
41+
return CSS_BY_VALUE[value] ?? '#9ca3af';
42+
}
43+
44+
export function ColorVariantPicker({ value, onChange, disabled, options }: {
45+
value: string | undefined;
46+
onChange: (v: string) => void;
47+
disabled?: boolean;
48+
/** Restrict/relabel the choices; defaults to the full canonical palette. */
49+
options?: Array<{ value: string; label?: string }>;
50+
}) {
51+
const opts: ColorVariant[] = options
52+
? options.map((o) => ({ value: o.value, label: o.label ?? o.value, css: colorVariantCss(o.value) }))
53+
: COLOR_VARIANTS.slice(0, 8); // canonical 8 (skip aliases in the default row)
54+
const current = value ?? 'default';
55+
return (
56+
<div className="flex flex-wrap gap-1.5" role="radiogroup">
57+
{opts.map((o) => {
58+
const on = current === o.value;
59+
return (
60+
<button
61+
key={o.value}
62+
type="button"
63+
role="radio"
64+
aria-checked={on}
65+
aria-label={o.label}
66+
title={o.label}
67+
disabled={disabled}
68+
onClick={() => onChange(o.value)}
69+
className={cn(
70+
'flex h-7 w-7 items-center justify-center rounded-full border transition-all',
71+
on
72+
? 'border-transparent ring-2 ring-foreground/60 ring-offset-1 ring-offset-background'
73+
: 'border-border hover:scale-110',
74+
disabled && 'cursor-not-allowed opacity-50',
75+
)}
76+
>
77+
<span className="h-4 w-4 rounded-full" style={{ backgroundColor: o.css }} />
78+
</button>
79+
);
80+
})}
81+
</div>
82+
);
83+
}

packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
*/
1818

1919
import * as React from 'react';
20+
import { ColorVariantPicker } from '../color-variant-field';
2021
import { X } from 'lucide-react';
2122
import {
2223
Button,
@@ -296,26 +297,12 @@ export function DashboardWidgetInspector({
296297
</Field>
297298

298299
<Field id="widget-color" label={t('engine.inspector.widget.color', locale)}>
299-
<Select
300+
<ColorVariantPicker
300301
value={widget.colorVariant ?? 'default'}
301-
onValueChange={(v) =>
302-
patchWidget({
303-
colorVariant: v as DashboardWidgetSchema['colorVariant'],
304-
})
305-
}
302+
onChange={(v) => patchWidget({ colorVariant: v as DashboardWidgetSchema['colorVariant'] })}
306303
disabled={readOnly}
307-
>
308-
<SelectTrigger id="widget-color">
309-
<SelectValue />
310-
</SelectTrigger>
311-
<SelectContent>
312-
{COLORS.map((c) => (
313-
<SelectItem key={c} value={c}>
314-
{c}
315-
</SelectItem>
316-
))}
317-
</SelectContent>
318-
</Select>
304+
options={COLORS.map((c) => ({ value: c }))}
305+
/>
319306
</Field>
320307

321308
<div className="grid grid-cols-2 gap-3">

packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
moveArray,
2727
} from './_shared';
2828
import { BLOCK_CONFIG, blockHasConfig, type BlockPropField } from '../previews/block-config';
29+
import { ColorVariantPicker } from '../color-variant-field';
2930
import { useObjectOptions } from '../previews/useObjectOptions';
3031
import { useObjectFields } from '../previews/useObjectFields';
3132
import {
@@ -384,6 +385,18 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
384385
<InspectorCheckboxField key={k} label={f.label} value={!!read(f.name)}
385386
onCommit={(v) => write(f.name, v)} disabled={readOnly} />
386387
);
388+
case 'color':
389+
return (
390+
<div key={k} className="space-y-1">
391+
<Label className="text-xs text-muted-foreground">{f.label}</Label>
392+
<ColorVariantPicker
393+
value={read(f.name) != null ? String(read(f.name)) : undefined}
394+
onChange={(v) => write(f.name, v)}
395+
disabled={readOnly}
396+
options={f.options}
397+
/>
398+
</div>
399+
);
387400
case 'select':
388401
return (
389402
<InspectorSelectField key={k} label={f.label}

packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe('block-config', () => {
4444
it('every field (incl. nested array items) has a name, label and valid kind', () => {
4545
const kinds = new Set([
4646
'text', 'number', 'boolean', 'select', 'string-list', 'array',
47-
'object-picker', 'field-picker', 'field-list',
47+
'object-picker', 'field-picker', 'field-list', 'color',
4848
]);
4949
const check = (f: any, path: string) => {
5050
expect(f.name, `${path}.name`).toBeTruthy();

packages/app-shell/src/views/metadata-admin/previews/block-config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type BlockPropField =
2626
| { name: string; label: string; kind: 'select'; options: Array<{ value: string; label: string }> }
2727
| { name: string; label: string; kind: 'string-list'; placeholder?: string }
2828
| { name: string; label: string; kind: 'array'; itemFields: BlockPropField[]; addLabel?: string }
29+
| { name: string; label: string; kind: 'color'; options?: Array<{ value: string; label: string }> }
2930
// Schema-driven pickers — dropdowns populated from the live metadata.
3031
| { name: string; label: string; kind: 'object-picker'; placeholder?: string }
3132
| ({ name: string; label: string; kind: 'field-picker'; placeholder?: string } & ObjectSource)
@@ -92,7 +93,7 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
9293
{ name: 'description', label: 'Description', kind: 'text' },
9394
{ name: 'icon', label: 'Icon', kind: 'text', placeholder: 'lucide icon name' },
9495
{
95-
name: 'colorVariant', label: 'Color', kind: 'select',
96+
name: 'colorVariant', label: 'Color', kind: 'color',
9697
options: [
9798
{ value: 'default', label: 'Default' },
9899
{ value: 'blue', label: 'Blue' },

packages/app-shell/src/views/metadata-admin/widgets.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { ChevronDown, ChevronsUpDown, ChevronUp, Plus, Search, Trash2 } from 'lu
4141
// @ts-ignore - lucide-react has no `exports` field; subpath types live alongside dynamic.mjs
4242
import { iconNames } from 'lucide-react/dynamic.mjs';
4343
import { detectLocale, t } from './i18n';
44+
import { ColorVariantPicker } from './color-variant-field';
4445

4546
export interface WidgetContext {
4647
/** Names of all object metadata records (for `ref:object`). */
@@ -1584,6 +1585,52 @@ function FilterBuilderWidget({ value, onChange, readOnly, context }: WidgetProps
15841585
);
15851586
}
15861587

1588+
/* -------------------------------------------------------------------------- */
1589+
/* color-picker — semantic swatch row (enum) or native hex input (free color) */
1590+
/* -------------------------------------------------------------------------- */
1591+
1592+
function ColorPickerWidget({ value, onChange, readOnly, schema, fieldSpec }: WidgetProps) {
1593+
const enumOpts: Array<{ value: string; label?: string }> | null =
1594+
Array.isArray(fieldSpec?.options) && fieldSpec!.options!.length
1595+
? fieldSpec!.options!.map((o) => ({ value: o.value, label: o.label }))
1596+
: Array.isArray(schema?.enum)
1597+
? (schema!.enum as unknown[]).filter((v): v is string => typeof v === 'string').map((v) => ({ value: v }))
1598+
: null;
1599+
1600+
if (enumOpts && enumOpts.length) {
1601+
return (
1602+
<ColorVariantPicker
1603+
value={value == null ? undefined : String(value)}
1604+
onChange={(v) => onChange(v)}
1605+
disabled={readOnly}
1606+
options={enumOpts}
1607+
/>
1608+
);
1609+
}
1610+
1611+
// Free color → native picker + hex text.
1612+
const v = value == null ? '' : String(value);
1613+
return (
1614+
<div className="flex items-center gap-2">
1615+
<input
1616+
type="color"
1617+
value={/^#([0-9a-f]{6})$/i.test(v) ? v : '#000000'}
1618+
disabled={readOnly}
1619+
onChange={(e) => onChange(e.target.value)}
1620+
className="h-8 w-10 shrink-0 rounded border border-input bg-background p-0.5 disabled:opacity-60"
1621+
aria-label="Color"
1622+
/>
1623+
<Input
1624+
value={v}
1625+
placeholder="#RRGGBB"
1626+
disabled={readOnly}
1627+
onChange={(e) => onChange(e.target.value || undefined)}
1628+
className="h-8 text-sm font-mono"
1629+
/>
1630+
</div>
1631+
);
1632+
}
1633+
15871634
export const WIDGETS: Record<string, WidgetRenderer> = {
15881635
'ref:object': RefObjectWidget,
15891636
'filter-mode': FilterModeWidget,
@@ -1595,6 +1642,7 @@ export const WIDGETS: Record<string, WidgetRenderer> = {
15951642
'filter-builder': FilterBuilderWidget,
15961643
'view-ref': ViewRefWidget,
15971644
'icon': IconPickerWidget,
1645+
'color-picker': ColorPickerWidget,
15981646
'master-detail': MasterDetailWidget,
15991647
'string-tags': StringTagsWidget,
16001648
'multiselect': MultiSelectWidget,

0 commit comments

Comments
 (0)