From ece3638ce7d046b78335c36232bad4efaaf6ffb9 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:04:18 +0200 Subject: [PATCH] fix(ui-web): teach cn() the design system's text and control scales (#115) * fix(ui-web): teach cn() the design system's text and control scales cn() used plain tailwind-merge, which only knows Tailwind's default scales. It read the custom font sizes (text-ui-label, text-ui-caption, ...) as text colors, so of a size and a color in one class list only the last survived: - Small, extra-small and large buttons lost their variant's text color. A small primary button rendered foreground text on the primary fill (ink on ink in the light theme, near-white on white in the dark theme), and destructive, secondary and link buttons lost their colors the same way. - Default-size and icon buttons, tab triggers and the typography helpers lost their font size and inherited the parent's instead. The control and icon spacing scales (h-control-sm, size-icon-md, ...) were unknown as well, so they never merged with other sizes: an explicit h-auto or h-9 on a Button, Input or Toggle lost to the component's own height, and large buttons and toggles drew their icons at the medium size. cn() now extends tailwind-merge with the text and spacing keys styles.css adds under @theme. A unit test probes cn() with every @theme key in styles.css, so a new key that is not registered fails the test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: look up the home main conversation in the scheduling journey Since every project has its own main conversation, the journey that schedules for a specialist found the user's main conversation with findFirst over all of them. With the Lab project's conversation also matching, it picked either one, so the check failed about one run in three. The lookup now names the home space, where the specialist joined and the routine is created. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude --- packages/ui-web/src/lib/utils.test.ts | 80 +++++++++++++++++++++++++++ packages/ui-web/src/lib/utils.ts | 39 ++++++++++++- 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 packages/ui-web/src/lib/utils.test.ts diff --git a/packages/ui-web/src/lib/utils.test.ts b/packages/ui-web/src/lib/utils.test.ts new file mode 100644 index 0000000..4452e9a --- /dev/null +++ b/packages/ui-web/src/lib/utils.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { buttonVariants } from "../components/ui/button.js"; +import { cn } from "./utils.js"; + +describe("cn", () => { + it("keeps a text color next to a ui text size", () => { + expect(cn("text-primary-foreground", "text-ui-label")).toBe( + "text-primary-foreground text-ui-label", + ); + expect(cn("text-ui-body-sm", "text-muted-foreground")).toBe( + "text-ui-body-sm text-muted-foreground", + ); + }); + + it("merges two text sizes to the last one", () => { + expect(cn("text-ui-body-sm", "text-ui-label")).toBe("text-ui-label"); + expect(cn("text-sm", "text-ui-caption")).toBe("text-ui-caption"); + expect(cn("text-ui-caption", "text-lg")).toBe("text-lg"); + }); + + it("merges two text colors to the last one", () => { + expect(cn("text-ui-label text-primary-foreground", "text-destructive")).toBe( + "text-ui-label text-destructive", + ); + }); + + it("merges ui control sizes with other sizes", () => { + expect(cn("h-control-sm", "h-8")).toBe("h-8"); + expect(cn("h-auto", "h-control-md")).toBe("h-control-md"); + expect(cn("[&_svg]:size-icon-md", "[&_svg]:size-icon-lg")).toBe("[&_svg]:size-icon-lg"); + }); + + it("keeps small buttons readable", () => { + for (const size of ["xs", "sm", "default", "lg"] as const) { + const classes = cn(buttonVariants({ variant: "default", size })).split(" "); + expect(classes, size).toContain("text-primary-foreground"); + expect(classes.filter((name) => name.startsWith("text-ui-")).length, size).toBe(1); + } + expect(cn(buttonVariants({ variant: "destructive", size: "sm" })).split(" ")).toEqual( + expect.arrayContaining(["text-destructive", "text-ui-label"]), + ); + }); +}); + +/** One merge check per `@theme` namespace: [earlier classes, later classes, expected]. */ +const NAMESPACE_PROBES: Record [string, string, string]> = { + color: (name) => ["text-ui-body text-foreground", `text-${name}`, `text-ui-body text-${name}`], + radius: (name) => ["rounded-sm", `rounded-${name}`, `rounded-${name}`], + spacing: (name) => [ + "h-4 p-2 gap-2", + `h-${name} p-${name} gap-${name}`, + `h-${name} p-${name} gap-${name}`, + ], + text: (name) => ["text-foreground text-sm", `text-${name}`, `text-foreground text-${name}`], +}; + +describe("cn knows every @theme key in styles.css", () => { + const css = readFileSync(new URL("../styles.css", import.meta.url), "utf8"); + const theme = css.match(/@theme inline \{([\s\S]*?)\n\}/)?.[1] ?? ""; + const keys = [...theme.matchAll(/^\s*--([a-z0-9-]+):/gm)] + .map((match) => match[1] ?? "") + .filter((key) => !key.includes("--")); + + it("finds the theme keys", () => { + expect(keys).toContain("text-ui-label"); + expect(keys).toContain("spacing-control-sm"); + }); + + for (const key of keys) { + it(`--${key}`, () => { + const namespace = Object.keys(NAMESPACE_PROBES).find((ns) => key.startsWith(`${ns}-`)); + expect(namespace, `add a probe for the @theme namespace of --${key}`).toBeDefined(); + const [earlier, later, expected] = NAMESPACE_PROBES[namespace ?? ""]!( + key.slice(`${namespace}-`.length), + ); + expect(cn(earlier, later)).toBe(expected); + }); + } +}); diff --git a/packages/ui-web/src/lib/utils.ts b/packages/ui-web/src/lib/utils.ts index 365058c..7cdcf9e 100644 --- a/packages/ui-web/src/lib/utils.ts +++ b/packages/ui-web/src/lib/utils.ts @@ -1,5 +1,40 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; +import type { ClassValue } from "clsx"; +import { clsx } from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; + +/** + * The scales `styles.css` adds under `@theme`. tailwind-merge only knows Tailwind's + * defaults, so without them it reads `text-ui-label` as a text color (dropping the + * real color) and never merges `h-control-sm` with `h-8`. `utils.test.ts` fails + * when `@theme` gains a key that is missing here. + */ +const twMerge = extendTailwindMerge({ + extend: { + theme: { + text: [ + "ui-caption", + "ui-label", + "ui-body-sm", + "ui-body", + "ui-reading", + "ui-title-sm", + "ui-title", + ], + spacing: [ + "control-xs", + "control-sm", + "control-md", + "control-lg", + "icon-sm", + "icon-md", + "icon-lg", + "rk-shell", + "rk-sidebar-x", + "rk-sidebar-y", + ], + }, + }, +}); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));