Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,196 changes: 21 additions & 1,175 deletions README.md

Large diffs are not rendered by default.

84 changes: 81 additions & 3 deletions client/src/PropertiesPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ describe("PropertiesPanel — button widget", () => {
onLayoutFieldChange={vi.fn()}
/>,
);
const colorInput = document.querySelector(".prop-field-color-text") as HTMLInputElement;
const colorInput = screen.getByLabelText("color value") as HTMLInputElement;
expect(colorInput.value).toBe("#1e3a8a");
fireEvent.change(colorInput, { target: { value: "#ff0000" } });
expect(onChange).toHaveBeenCalledWith({ ...baseButton, color: "#ff0000" });
Expand Down Expand Up @@ -318,12 +318,90 @@ describe("PropertiesPanel — button widget", () => {
onLayoutFieldChange={vi.fn()}
/>,
);
const text = document.querySelector(".prop-field-color-text") as HTMLInputElement;
const text = screen.getByLabelText("color value") as HTMLInputElement;
expect(text.value).toBe("rebeccapurple");
fireEvent.click(screen.getByLabelText(/use preset #ff0000/i));
expect(onChange).toHaveBeenCalledWith({ ...baseButton, color: "#ff0000" });
});

it.each([
["#fff", "#ffffff"],
["#1E3A8A", "#1e3a8a"],
[" #3fb950 ", "#3fb950"],
])("swatch shows %s as %s (#115)", (stored, shown) => {
// Shorthand, uppercase and padded hex are all colours the swatch *can*
// display, so it must — showing black for `#fff` reads as "the widget is
// black" when it is white. The stored text is left exactly as authored.
render(
<PropertiesPanel
widget={{ ...baseButton, color: stored }}
layoutFields={layoutFields}
onWidgetChange={vi.fn()}
onLayoutFieldChange={vi.fn()}
/>,
);
const swatch = screen.getByLabelText(/color swatch/i) as HTMLInputElement;
expect(swatch.value.toLowerCase()).toBe(shown);
const text = screen.getByLabelText("color value") as HTMLInputElement;
expect(text.value).toBe(stored);
});

it.each(["rebeccapurple", "hsl(220, 70%, 40%)", "#1e3a8a80"])(
"shows %s as a read-only preview, not a lying swatch (#115)",
(color) => {
// `<input type="color">` coerces anything it can't parse to #000000, so
// rendering it here would claim a purple widget is black — and put one
// stray click between the author and a clobbered CSS string. 8-digit hex
// lands here too: the swatch has no alpha channel.
render(
<PropertiesPanel
widget={{ ...baseButton, color }}
layoutFields={layoutFields}
onWidgetChange={vi.fn()}
onLayoutFieldChange={vi.fn()}
/>,
);
expect(screen.queryByLabelText(/color swatch/i)).toBeFalsy();
expect(screen.getByLabelText(`current color ${color}`)).toBeTruthy();
expect((screen.getByLabelText("color value") as HTMLInputElement).value).toBe(color);
},
);

it("offers the picker on an unset colour so one can be chosen (#115 AC1)", () => {
// The empty case is the main path into the picker — a widget with no
// colour is exactly the one an author wants to pick a colour for.
const onChange = vi.fn();
render(
<PropertiesPanel
widget={{ id: "b", kind: "button" }}
layoutFields={layoutFields}
onWidgetChange={onChange}
onLayoutFieldChange={vi.fn()}
/>,
);
const swatch = screen.getByLabelText(/color swatch/i) as HTMLInputElement;
expect(swatch.value.toLowerCase()).toBe("#000000");
expect((screen.getByLabelText("color value") as HTMLInputElement).value).toBe("");
fireEvent.change(swatch, { target: { value: "#3fb950" } });
expect(onChange).toHaveBeenCalledWith({ id: "b", kind: "button", color: "#3fb950" });
});

it("surfaces an already-set colour on a non-button kind so it can be cleared (#115)", () => {
// ADR-0006 keeps `color` button-only, but a hand-authored `color:` on a
// meter is still painted by the canvas — hiding the field would strand it.
const onChange = vi.fn();
render(
<PropertiesPanel
widget={{ id: "m", kind: "meter", color: "#1e3a8a" }}
layoutFields={layoutFields}
onWidgetChange={onChange}
onLayoutFieldChange={vi.fn()}
/>,
);
fireEvent.change(screen.getByLabelText("color value"), { target: { value: "" } });
expect("color" in onChange.mock.calls[0][0]).toBe(false);
});

it("clearing the color text input sets color to null (#115)", () => {
const onChange = vi.fn();
render(
Expand All @@ -334,7 +412,7 @@ describe("PropertiesPanel — button widget", () => {
onLayoutFieldChange={vi.fn()}
/>,
);
const colorInput = document.querySelector(".prop-field-color-text") as HTMLInputElement;
const colorInput = screen.getByLabelText("color value") as HTMLInputElement;
fireEvent.change(colorInput, { target: { value: "" } });
const changed = onChange.mock.calls[0][0];
expect("color" in changed).toBe(false);
Expand Down
85 changes: 61 additions & 24 deletions client/src/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,12 @@ export function PropertiesPanel({
onChange={(icon) => onWidgetChange(updateField(widget, "icon", icon))}
/>

{widget.kind === "button" && (
{/* ADR-0006: `color` is button-only — other kinds let their kind
identity win and ignore it. We still surface the field on any
kind that already carries one, or a hand-authored `color:` on a
meter would be stuck: invisible to the panel, unclearable, yet
still painted by the editor canvas. */}
{(widget.kind === "button" || widget.color != null) && (
<ColorField
color={widget.color}
onChange={(color) => onWidgetChange(updateField(widget, "color", color))}
Expand Down Expand Up @@ -365,16 +370,33 @@ const COLOR_PRESETS = [
"#000000",
] as const;

/** The ``<input type="color">`` swatch always wants a 7-char hex literal;
* non-hex CSS (``hsl(...)``, ``rebeccapurple``) can't be displayed. Return
* an empty string for those cases — the swatch then falls back to its own
* default (browser-chosen, e.g. black) without us forcing a misleading
* "colour is set to black" state. */
function parseHexColor(value: string | null | undefined): string {
/** What the swatch shows when nothing is set. ``<input type="color">`` has no
* "empty" state, so an unset widget has to display *something*; black is the
* browser's own default and reads as "nothing chosen" next to an empty text
* field. */
const UNSET_SWATCH = "#000000";

/** Render ``value`` as the 7-char lowercase hex literal ``<input type="color">``
* requires, or "" when it can't be one losslessly.
*
* Representable: ``#RGB`` shorthand (expanded, ``#fff`` -> ``#ffffff``), plus
* case and surrounding whitespace. Not representable: non-hex CSS
* (``hsl(...)``, ``rebeccapurple``) and ``#RRGGBBAA``, whose alpha the swatch
* has no channel for. Callers must not feed "" to the swatch — the DOM
* coerces an unparseable value to black, so the picker would claim a
* ``rebeccapurple`` widget is black. See {@link ColorField}. */
function toSwatchHex(value: string | null | undefined): string {
if (!value) return "";
const match = /^#([0-9a-fA-F]{6})$/.exec(value.trim());
if (!match) return "";
return `#${match[1].toLowerCase()}`;
const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(value.trim())?.[1];
if (!hex) return "";
const full =
hex.length === 3
? hex
.split("")
.map((c) => c + c)
.join("")
: hex;
return `#${full.toLowerCase()}`;
}

function ColorField({
Expand All @@ -384,33 +406,48 @@ function ColorField({
color: string | null | undefined;
onChange: (color: string | null) => void;
}) {
const handleInputChange = useCallback(
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value || null);
},
[onChange],
);
const handlePreset = useCallback(
(preset: string) => onChange(preset),
[onChange],
);

const swatchHex = toSwatchHex(color);
// A set colour the picker can't hold. Showing the native swatch here would
// both misreport the colour (coerced to black) and put an accidental click
// one confirm away from overwriting the authored CSS string, so we show the
// real colour read-only and leave editing to the text input — the escape
// hatch the schema's any-CSS-string contract needs (#115).
const unrepresentable = !!color && swatchHex === "";

return (
<div className="prop-field">
<span className="prop-field-label">Color</span>
<div className="prop-field-color-row">
<input
className="prop-field-color-swatch"
type="color"
aria-label="color swatch"
value={parseHexColor(color)}
onChange={handleInputChange}
/>
{unrepresentable ? (
<span
className="prop-field-color-preview"
role="img"
aria-label={`current color ${color}`}
title={`${color} — edit as text; the picker holds hex only`}
style={{ background: color }}
/>
) : (
<input
className="prop-field-color-swatch"
type="color"
aria-label="color swatch"
value={swatchHex || UNSET_SWATCH}
onChange={handleColorChange}
/>
)}
<input
className="prop-field-input prop-field-color-text"
type="text"
aria-label="color value"
value={color ?? ""}
onChange={handleInputChange}
onChange={handleColorChange}
placeholder="e.g. #1e3a8a or rebeccapurple"
/>
</div>
Expand All @@ -423,7 +460,7 @@ function ColorField({
style={{ background: preset }}
aria-label={`use preset ${preset}`}
title={preset}
onClick={() => handlePreset(preset)}
onClick={() => onChange(preset)}
/>
))}
</div>
Expand Down
19 changes: 13 additions & 6 deletions client/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2488,20 +2488,26 @@ select.prop-field-input {
gap: 6px;
}

.prop-field-color-swatch {
/* The native picker, and the read-only stand-in shown for a CSS colour the
picker can't hold (named colours, hsl(...), #RRGGBBAA) — same box either
way so the row doesn't jump when the value changes shape. */
.prop-field-color-swatch,
.prop-field-color-preview {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid #30363d;
border-radius: 6px;
background: transparent;
cursor: pointer;
flex-shrink: 0;
}

.prop-field-color-swatch:focus {
outline: 2px solid #58a6ff;
outline-offset: -2px;
.prop-field-color-swatch {
cursor: pointer;
}

.prop-field-color-preview {
cursor: default;
}

.prop-field-color-swatch::-webkit-color-swatch-wrapper {
Expand Down Expand Up @@ -2537,7 +2543,8 @@ select.prop-field-input {
cursor: pointer;
}

.prop-field-color-preset:focus {
.prop-field-color-swatch:focus-visible,
.prop-field-color-preset:focus-visible {
outline: 2px solid #58a6ff;
outline-offset: -2px;
}
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ graph TB
| **Build/test commands** | [Justfile](../Justfile) | All common commands: setup, dev, test, build, smoke. |
| **Testing strategy** | [TESTING.md](TESTING.md) | Testing layers, what each fakes, and the planned desktop-integration tier. |
| **Platform parity** | [PLATFORM-PARITY.md](PLATFORM-PARITY.md) | What works on GNOME / KDE / X11 / macOS — capability matrix and per-backend notes. |
| **README** | [README.md](../README.md) | Human-facing: pitch, screenshots, status, setup, config reference. |
| **User & setup guide** | [GUIDE.md](GUIDE.md) | Install, per-platform setup, layout/configuration walkthrough, client features, dev loop. |
| **README** | [README.md](../README.md) | Human-facing showcase: pitch, screenshots, status, comparison. |

## Code owns behavior

Expand Down
Loading
Loading