Collapse the button-variant fork and the canvas's duplicate work

LoadingButton kept its own copy of buttonVariants, which had drifted past
`brand` and the icon sizes; it imports Button's now, so the Connect /
Re-pair button can show the loader instead of swapping its own label.

DashboardView mounted a second LookProvider inside the one CanvasSurface
already gives it and dropped `touch` on the way, so every widget on a
touch panel read `useLook().touch` as false. The provider is now mounted
once, by whatever owns the canvas root.

useCanvasRoot resolved the palette twice per render — once for the theme
class, once for the tokens — so useDashboardTheme takes the roles the
caller already has.

The bar assertions in widgets.spec.ts are scoped to the Load tile rather
than the page, which held only because the unbound spare bar draws no
rows.
This commit is contained in:
2026-09-14 15:29:40 +02:00
parent 4faf5312ff
commit 57fd3d1b2c
7 changed files with 50 additions and 60 deletions
@@ -312,6 +312,10 @@ export const isPlaced = (widgets: WidgetDef[]) =>
* is for — so its widgets are one arrangement rather than a stack of headed
* grids. Pages and sections left the schema in 7ff29ca; a document written
* the old way is flattened on read.
*
* Drawn below a `LookProvider` mounted by whatever owns the canvas root — the
* surface, the editor, or the panel's stacked branch — rather than mounting a
* second one here, which could only ever restate what that one already says.
*/
export function DashboardView({
dashboard,
@@ -366,14 +370,10 @@ export function DashboardView({
</PaletteProvider>
)
return (
<LookProvider dashboard={dashboard}>
{editing ? (
grid
) : (
<LockedProvider dashboard={dashboard}>{grid}</LockedProvider>
)}
</LookProvider>
return editing ? (
grid
) : (
<LockedProvider dashboard={dashboard}>{grid}</LockedProvider>
)
}
@@ -45,7 +45,12 @@ export function PanelSurface({
return (
<div className="relative size-full">
{stacked ? (
<DashboardView dashboard={dashboard} stacked />
// Nothing scales a stack, so there is no canvas element here to state
// the look on: this branch mounts what `CanvasSurface` mounts for the
// other one, touch included.
<LookProvider dashboard={dashboard} touch={touch}>
<DashboardView dashboard={dashboard} stacked />
</LookProvider>
) : (
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
@@ -146,12 +146,16 @@ export function useDashboardBackground(
* whether the surface is light or dark is a fact about the palette rather than
* a second setting that could disagree with it. The class still goes on,
* because a chart canvas and the shadcn pieces inside the tile read it.
*
* The roles are passed in rather than resolved here: the caller needs them for
* the tokens anyway, and reading them twice subscribes to the palette's
* message twice.
*/
export function useDashboardTheme(
dashboard: DashboardDef_Output | undefined,
roles: Roles | null,
): "" | "light" | "dark" {
const value = useSetting(dashboard, "theme")
const roles = rolesOf(useDashboardPalette(dashboard))
if (roles) return isLight(roles.background) ? "light" : "dark"
return value === "dark" || value === "light" ? value : ""
}
@@ -62,8 +62,8 @@ export function useCanvasRoot(
touch = false,
) {
const { resolvedTheme } = useTheme()
const stated = useDashboardTheme(dashboard)
const roles = useDashboardRoles(dashboard)
const stated = useDashboardTheme(dashboard, roles)
const look = useDashboardLook(dashboard)
// The tokens are handed to every widget through the context below, so they
// have to outlive a render: a fresh object here is a re-render of the whole
@@ -138,21 +138,16 @@ export function RemoteAccess() {
</p>
</div>
<div>
<Button
<LoadingButton
variant={status.enrolled ? "outline" : "brand"}
className="rounded-full"
disabled={!code.trim() || !portalUrl.trim() || connect.isPending}
loading={connect.isPending}
disabled={!code.trim() || !portalUrl.trim()}
onClick={() => connect.mutate()}
data-testid="connect-portal"
>
{connect.isPending
? status.enrolled
? "Re-pairing…"
: "Connecting…"
: status.enrolled
? "Re-pair"
: "Connect"}
</Button>
{status.enrolled ? "Re-pair" : "Connect"}
</LoadingButton>
</div>
</div>
)
+9 -33
View File
@@ -1,39 +1,9 @@
import { Slot, Slottable } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { buttonVariants } from "@/components/ui/button"
import { FluksioLoader } from "@/components/ui/fluksio-loader"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-none hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-none hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-none hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-none hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-full gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-full px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
@@ -41,6 +11,12 @@ export interface ButtonProps
loading?: boolean
}
/**
* A button that draws the brand loader beside its label while it waits.
*
* Wears `Button`'s own variants rather than a copy of them: a copy drifts, and
* a call site asking for one it never grew loses it without saying so.
*/
function LoadingButton({
className,
loading = false,
@@ -64,4 +40,4 @@ function LoadingButton({
)
}
export { buttonVariants, LoadingButton }
export { LoadingButton }
+16 -6
View File
@@ -302,16 +302,20 @@ async function openPanel(page: Page, name = dashboardName) {
)
}
/** The Load tile, scoped: the panel carries a second bar (the unbound one). */
const loadTile = (page: Page) =>
page.getByTestId("widget-frame").filter({ hasText: "Load" })
test("a bar draws a row per reading", async ({ page }) => {
await openPanel(page)
// The panel's bar is stored the way a bar was written before it had rows —
// one reading with a second nested in it — so drawing two rows is also what
// proves that shape is still read.
const rows = page.getByTestId("bar-row")
const rows = loadTile(page).getByTestId("bar-row")
await expect(rows).toHaveCount(2)
const fills = page.getByTestId("bar-fill")
const fills = loadTile(page).getByTestId("bar-fill")
const width = (index: number) =>
fills
.nth(index)
@@ -355,11 +359,17 @@ test("a reading over its scale fills the track and no more", async ({
// bounding box is not clipped by an ancestor's overflow. Where it settles is
// what "fills the track and no more" is about, so that is what is measured:
// flush with the track, neither short of it nor over.
const track = page.getByTestId("bar-row").nth(1).locator(".dui-bar-track")
const track = loadTile(page)
.getByTestId("bar-row")
.nth(1)
.locator(".dui-bar-track")
await expect
.poll(
async () => {
const fill = await page.getByTestId("bar-fill").nth(1).boundingBox()
const fill = await loadTile(page)
.getByTestId("bar-fill")
.nth(1)
.boundingBox()
const rail = await track.boundingBox()
return Math.round(fill!.width - rail!.width)
},
@@ -705,7 +715,7 @@ for (const scheme of ["light", "dark"] as const) {
)
// Every widget still draws, whichever set is asked to draw it.
await expect(page.getByTestId("widget-frame")).toHaveCount(TILES)
await expect(page.getByTestId("bar-row")).toHaveCount(2)
await expect(loadTile(page).getByTestId("bar-row")).toHaveCount(2)
await page.screenshot({
path: `screenshots/widgets/${scheme}-${look}.png`,
@@ -738,7 +748,7 @@ for (const scheme of ["light", "dark"] as const) {
expect(Math.round(ground.width)).toBe(Math.round(canvas.width))
expect(Math.round(ground.height)).toBe(Math.round(canvas.height))
const primary = await page
const primary = await loadTile(page)
.getByTestId("bar-fill")
.first()
.evaluate((el) => getComputedStyle(el).backgroundColor)