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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-04 10:04:18 +02:00 committed by GitHub
parent 59f4a1dfca
commit ece3638ce7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 117 additions and 2 deletions

View File

@ -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, (name: string) => [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);
});
}
});

View File

@ -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));