From 28a44853e33575626eb695fe36b7a9e2415a9510 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Wed, 14 Oct 2026 08:44:38 +0200 Subject: [PATCH] Colour widget: commit the value being released The brightness column changes and commits in one handler, so the commit read the render's draft (still null) and sent nothing; the queued draft then stuck and rode along with the next release. The pending colour lives in a ref now. color-drag.spec gains a column drag. Co-Authored-By: Claude Opus 5.5 --- .../src/components/Dashboard/ColorWidget.tsx | 16 ++++-- frontend/tests/color-drag.spec.ts | 52 +++++++++++++++++-- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Dashboard/ColorWidget.tsx b/frontend/src/components/Dashboard/ColorWidget.tsx index ae01e05..4fde7b6 100644 --- a/frontend/src/components/Dashboard/ColorWidget.tsx +++ b/frontend/src/components/Dashboard/ColorWidget.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useRef, useState } from "react" import { usePublish } from "./publish" import { useUi } from "./ui" @@ -46,6 +46,10 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) { const { target, value, send, pulse, locked } = usePublish(widget, dashboard) // While dragging, the disc follows the finger rather than the engine. const [draft, setDraft] = useState(null) + // What a release sends. The brightness column changes and commits in one + // handler, before `setDraft` has rendered, so the state would still be the + // last render's — read the value from here, which is set the moment it moves. + const pending = useRef(null) if (!target) return

Pick a message.

const format = colorFormatOf(widget) @@ -59,10 +63,14 @@ export function ColorWidget({ widget, dashboard }: WidgetProps) { name={widget.title || target} hsv={current} disabled={locked} - onChange={setDraft} + onChange={(hsv) => { + pending.current = hsv + setDraft(hsv) + }} onCommit={() => { - if (draft === null) return - send(encodeColor(draft, format)) + if (pending.current === null) return + send(encodeColor(pending.current, format)) + pending.current = null setDraft(null) }} /> diff --git a/frontend/tests/color-drag.spec.ts b/frontend/tests/color-drag.spec.ts index ffd395c..6edf754 100644 --- a/frontend/tests/color-drag.spec.ts +++ b/frontend/tests/color-drag.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, type Page, test } from "@playwright/test" import { api, apiPage, deleteAll } from "./utils/api" @@ -19,6 +19,7 @@ const dashboard = `${flowName}_d` const target = `${flowName}.tint` test.use({ storageState: "playwright/.auth/user.json" }) +test.describe.configure({ mode: "serial" }) test.beforeAll(async ({ browser }) => { const page = await apiPage(browser) @@ -86,12 +87,12 @@ test.afterAll(async ({ browser }) => { await deleteAll(browser, [`/dashboards/${dashboard}`, `/flows/${flowName}`]) }) -test("a dragged disc publishes once, where it was let go", async ({ page }) => { +/** Open the panel, settled, and collect every publish the widget makes. */ +async function openBoard(page: Page) { await page.goto(`/view/${dashboard}`) - const disc = page.getByTestId("color-wheel") - await disc.waitFor({ timeout: 20000 }) + await page.getByTestId("color-wheel").waitFor({ timeout: 20000 }) // A tile fades and scales in, so where the disc is drawn is only settled - // once that has finished — and the gesture below is aimed in pixels. + // once that has finished — and the gestures below are aimed in pixels. await page.waitForFunction( () => [...document.querySelectorAll(".widget-cell")].every( @@ -111,7 +112,12 @@ test("a dragged disc publishes once, where it was let go", async ({ page }) => { ) sent.push(request.postDataJSON()?.value) }) + return sent +} +test("a dragged disc publishes once, where it was let go", async ({ page }) => { + const sent = await openBoard(page) + const disc = page.getByTestId("color-wheel") const box = (await disc.boundingBox())! const middle = { x: box.x + box.width / 2, y: box.y + box.height / 2 } @@ -169,3 +175,39 @@ test("a dragged disc publishes once, where it was let go", async ({ page }) => { ) .toEqual(sent[0]) }) + +test("a dragged brightness column publishes where it was let go", async ({ + page, +}) => { + const sent = await openBoard(page) + const column = page.getByRole("slider", { name: "Tint brightness" }) + const before = Number(await column.inputValue()) + const box = (await column.boundingBox())! + const x = box.x + box.width / 2 + const bottom = box.y + box.height - 2 + const end = box.y + box.height * 0.3 + + // From the foot of the column up to three tenths from its top. The column + // reports nothing while it moves, so the release alone decides what is + // sent — and it has to send what the column was let go at. That is read + // off the column rather than worked out from the pixels: Playwright's + // WebKit reads a `writing-mode` range across, not along. + await page.mouse.move(x, bottom) + await page.mouse.down() + for (let step = 1; step <= 8; step++) { + await page.mouse.move(x, bottom - ((bottom - end) * step) / 8, { + steps: 2, + }) + } + const letGo = Number(await column.inputValue()) + expect(letGo, "the drag never moved the column").not.toBe(before) + await page.mouse.up() + + await expect + .poll(() => sent.length, { + timeout: 10000, + message: "letting go of the column published nothing", + }) + .toBe(1) + expect(sent[0][2], "the release sent another brightness").toBe(letGo) +})