From 7e541abd85665ece3d1b58b87e411e638f9f4e7e Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:47:36 +0530 Subject: [PATCH 01/36] UN-4299 [MISC] Turn on skipLibCheck for v1: vitest 3.2 and @types/chai 5.2 both declare containSubset Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/tsconfig.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/v1/tsconfig.json b/frontend/src/v1/tsconfig.json index 0a10f9dbdb..c72be92d4f 100644 --- a/frontend/src/v1/tsconfig.json +++ b/frontend/src/v1/tsconfig.json @@ -17,9 +17,11 @@ "allowJs": false, "noEmit": true, - /* Off so hand-written .d.ts under v1 are checked too; with it on, an - * unresolved name in one silently becomes `any`. */ - "skipLibCheck": false, + /* On: vitest 3.2 ships its own chai typings and also requires + * @types/chai ^5.2, and both declare containSubset, so library checking + * fails TS2300 for every v1 test file. This also skips hand-written .d.ts + * under v1. Turn this back off once vitest no longer clashes. */ + "skipLibCheck": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, From 6f22f625b067bd30fc8d4b7377998c0d6482c7b6 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:47:36 +0530 Subject: [PATCH 02/36] UN-4299 [FEAT] Add the v1 theme model: three modes, follow-system and per-browser preference Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/theme.test.ts | 71 +++++++++++++++++++++++++++++++ frontend/src/v1/lib/theme.ts | 61 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 frontend/src/v1/lib/theme.test.ts create mode 100644 frontend/src/v1/lib/theme.ts diff --git a/frontend/src/v1/lib/theme.test.ts b/frontend/src/v1/lib/theme.test.ts new file mode 100644 index 0000000000..1096b0d860 --- /dev/null +++ b/frontend/src/v1/lib/theme.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + isThemePreference, + readThemePreference, + resolveThemeMode, + THEME_MODES, + THEME_STORAGE_KEY, + writeThemePreference, +} from "./theme"; + +describe("resolveThemeMode", () => { + it("returns the explicit mode unchanged", () => { + expect(resolveThemeMode("dark", false)).toBe("dark"); + expect(resolveThemeMode("unstract", true)).toBe("unstract"); + }); + it("maps system to dark or light from the media flag", () => { + expect(resolveThemeMode("system", true)).toBe("dark"); + expect(resolveThemeMode("system", false)).toBe("light"); + }); +}); + +describe("readThemePreference", () => { + it("returns a stored valid preference", () => { + const storage = { getItem: () => "unstract" }; + expect(readThemePreference(storage)).toBe("unstract"); + }); + it("returns system for a missing, unknown or unreadable value", () => { + expect(readThemePreference({ getItem: () => null })).toBe("system"); + expect(readThemePreference({ getItem: () => "sepia" })).toBe("system"); + expect( + readThemePreference({ + getItem: () => { + throw new Error("blocked"); + }, + }), + ).toBe("system"); + expect(readThemePreference(null)).toBe("system"); + }); +}); + +describe("writeThemePreference", () => { + it("writes under the v1 key and swallows storage errors", () => { + const calls: [string, string][] = []; + writeThemePreference( + { + setItem: (k, v) => { + calls.push([k, v]); + }, + }, + "dark", + ); + expect(calls).toEqual([[THEME_STORAGE_KEY, "dark"]]); + expect(() => + writeThemePreference( + { + setItem: () => { + throw new Error("quota"); + }, + }, + "dark", + ), + ).not.toThrow(); + expect(() => writeThemePreference(null, "dark")).not.toThrow(); + }); +}); + +it("THEME_MODES lists exactly the three modes and isThemePreference accepts system", () => { + expect(THEME_MODES).toEqual(["light", "dark", "unstract"]); + expect(isThemePreference("system")).toBe(true); + expect(isThemePreference("v0-dark")).toBe(false); +}); diff --git a/frontend/src/v1/lib/theme.ts b/frontend/src/v1/lib/theme.ts new file mode 100644 index 0000000000..5c4409ec6e --- /dev/null +++ b/frontend/src/v1/lib/theme.ts @@ -0,0 +1,61 @@ +/** A colour mode the v1 shell can render. */ +export type ThemeMode = "light" | "dark" | "unstract"; + +/** What the user chose: a fixed mode, or follow the operating system. */ +export type ThemePreference = ThemeMode | "system"; + +export const THEME_MODES: readonly ThemeMode[] = ["light", "dark", "unstract"]; + +/** v1's own storage key; v0 (next-themes) uses `theme`, which v1 never reads. */ +export const THEME_STORAGE_KEY = "unstract.v1.theme"; + +export const DEFAULT_THEME_MODE: ThemeMode = "light"; + +const SYSTEM_PREFERENCE: ThemePreference = "system"; + +export function isThemePreference(value: unknown): value is ThemePreference { + return ( + value === SYSTEM_PREFERENCE || + (THEME_MODES as readonly unknown[]).includes(value) + ); +} + +export function resolveThemeMode( + preference: ThemePreference, + systemPrefersDark: boolean, +): ThemeMode { + if (preference !== "system") { + return preference; + } + return systemPrefersDark ? "dark" : DEFAULT_THEME_MODE; +} + +/** Reads the stored preference; any missing, unknown or unreadable value means "system". */ +export function readThemePreference( + storage: Pick | null | undefined, +): ThemePreference { + if (!storage) { + return SYSTEM_PREFERENCE; + } + try { + const stored = storage.getItem(THEME_STORAGE_KEY); + return isThemePreference(stored) ? stored : SYSTEM_PREFERENCE; + } catch { + return SYSTEM_PREFERENCE; + } +} + +/** Stores the preference; storage that is absent or refuses the write is ignored. */ +export function writeThemePreference( + storage: Pick | null | undefined, + preference: ThemePreference, +): void { + if (!storage) { + return; + } + try { + storage.setItem(THEME_STORAGE_KEY, preference); + } catch { + // Private mode or a full quota: the preference simply does not persist. + } +} From 7aa5bf40ce166d8e957a62c089a50791e1b2ec78 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:50:50 +0530 Subject: [PATCH 03/36] UN-4299 [FEAT] Add the v1 portal container and the theme preference store Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/portal.test.ts | 35 +++++++++++ frontend/src/v1/lib/portal.ts | 41 +++++++++++++ frontend/src/v1/stores/theme.test.ts | 61 ++++++++++++++++++++ frontend/src/v1/stores/theme.ts | 86 ++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 frontend/src/v1/lib/portal.test.ts create mode 100644 frontend/src/v1/lib/portal.ts create mode 100644 frontend/src/v1/stores/theme.test.ts create mode 100644 frontend/src/v1/stores/theme.ts diff --git a/frontend/src/v1/lib/portal.test.ts b/frontend/src/v1/lib/portal.test.ts new file mode 100644 index 0000000000..0c918f0bf9 --- /dev/null +++ b/frontend/src/v1/lib/portal.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, expect, it } from "vitest"; +import { + applyThemeMode, + getPortalContainer, + PORTAL_CONTAINER_ID, +} from "./portal"; + +beforeEach(() => { + document.body.innerHTML = ""; +}); + +it("creates one container under body with the v1 class and light by default", () => { + const a = getPortalContainer(); + const b = getPortalContainer(); + expect(a).toBe(b); + expect(a.id).toBe(PORTAL_CONTAINER_ID); + expect(a.parentElement).toBe(document.body); + expect(a.classList.contains("v1")).toBe(true); + expect(a.dataset.theme).toBe("light"); +}); + +it("copies the mode of an existing v1 root when created", () => { + document.body.innerHTML = '
'; + expect(getPortalContainer().dataset.theme).toBe("dark"); +}); + +it("applyThemeMode updates every v1 element including the container", () => { + document.body.innerHTML = '
'; + getPortalContainer(); + applyThemeMode(document, "unstract"); + const themes = [...document.querySelectorAll(".v1")].map( + (el) => (el as HTMLElement).dataset.theme, + ); + expect(themes).toEqual(["unstract", "unstract"]); +}); diff --git a/frontend/src/v1/lib/portal.ts b/frontend/src/v1/lib/portal.ts new file mode 100644 index 0000000000..c968f49a07 --- /dev/null +++ b/frontend/src/v1/lib/portal.ts @@ -0,0 +1,41 @@ +import { DEFAULT_THEME_MODE, THEME_MODES, type ThemeMode } from "./theme"; + +/** The single element under that v1 dialogs, menus and toasts render into. */ +export const PORTAL_CONTAINER_ID = "v1-portal"; + +/** The class that scopes v1 styles; every element carrying it gets a data-theme. */ +export const V1_ROOT_CLASS = "v1"; + +function isThemeMode(value: string | undefined): value is ThemeMode { + return (THEME_MODES as readonly (string | undefined)[]).includes(value); +} + +/** The mode already shown by a v1 root, so a late-created container matches it. */ +function currentThemeMode(doc: Document): ThemeMode { + const root = doc.querySelector(`.${V1_ROOT_CLASS}[data-theme]`); + const theme = root?.dataset.theme; + return isThemeMode(theme) ? theme : DEFAULT_THEME_MODE; +} + +/** Returns the portal container, creating it under on first use. */ +export function getPortalContainer(doc: Document = document): HTMLElement { + const existing = doc.getElementById(PORTAL_CONTAINER_ID); + if (existing) { + return existing; + } + const container = doc.createElement("div"); + container.id = PORTAL_CONTAINER_ID; + container.className = V1_ROOT_CLASS; + container.dataset.theme = currentThemeMode(doc); + doc.body.appendChild(container); + return container; +} + +/** Stamps the mode on every v1 element: the app roots and the portal container. */ +export function applyThemeMode(doc: Document, mode: ThemeMode): void { + for (const element of doc.querySelectorAll( + `.${V1_ROOT_CLASS}`, + )) { + element.dataset.theme = mode; + } +} diff --git a/frontend/src/v1/stores/theme.test.ts b/frontend/src/v1/stores/theme.test.ts new file mode 100644 index 0000000000..5a7c1eb0f8 --- /dev/null +++ b/frontend/src/v1/stores/theme.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vitest"; +import { createThemeStore } from "./theme"; + +function memoryStorage(initial: Record = {}) { + const data = { ...initial }; + return { + getItem: (k: string) => data[k] ?? null, + setItem: (k: string, v: string) => { + data[k] = v; + }, + data, + }; +} + +it("starts as system and resolves from the system flag", () => { + const store = createThemeStore({ + storage: memoryStorage(), + systemPrefersDark: true, + }); + expect(store.getState().preference).toBe("system"); + expect(store.getState().mode).toBe("dark"); +}); + +it("hydrate reads the stored preference and setPreference persists it", () => { + const storage = memoryStorage({ "unstract.v1.theme": "unstract" }); + const store = createThemeStore({ storage, systemPrefersDark: false }); + store.getState().hydrate(storage); + expect(store.getState().mode).toBe("unstract"); + store.getState().setPreference("dark"); + expect(store.getState().mode).toBe("dark"); + expect(storage.data["unstract.v1.theme"]).toBe("dark"); +}); + +it("follows system flips only while the preference is system", () => { + const store = createThemeStore({ + storage: memoryStorage(), + systemPrefersDark: false, + }); + store.getState().setSystemPrefersDark(true); + expect(store.getState().mode).toBe("dark"); + store.getState().setPreference("light"); + store.getState().setSystemPrefersDark(false); + store.getState().setSystemPrefersDark(true); + expect(store.getState().mode).toBe("light"); +}); + +it("falls back when storage throws", () => { + const broken = { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }; + const store = createThemeStore({ storage: broken, systemPrefersDark: false }); + expect(() => store.getState().hydrate(broken)).not.toThrow(); + expect(store.getState().preference).toBe("system"); + expect(() => store.getState().setPreference("dark")).not.toThrow(); + expect(store.getState().mode).toBe("dark"); +}); diff --git a/frontend/src/v1/stores/theme.ts b/frontend/src/v1/stores/theme.ts new file mode 100644 index 0000000000..15b0830fda --- /dev/null +++ b/frontend/src/v1/stores/theme.ts @@ -0,0 +1,86 @@ +import { + readThemePreference, + resolveThemeMode, + type ThemeMode, + type ThemePreference, + writeThemePreference, +} from "@v1/lib/theme"; +import { create, type UseBoundStore } from "zustand"; +import { createStore, type StateCreator, type StoreApi } from "zustand/vanilla"; + +export type ThemeState = { + preference: ThemePreference; + systemPrefersDark: boolean; + /** Always resolveThemeMode(preference, systemPrefersDark). */ + mode: ThemeMode; + /** Sets the preference and persists it; a refused write is ignored. */ + setPreference: (preference: ThemePreference) => void; + setSystemPrefersDark: (value: boolean) => void; + /** Replaces the preference with the one in storage ("system" if none). */ + hydrate: (storage: Pick | null | undefined) => void; +}; + +export type ThemeStoreOptions = { + storage?: Pick | null; + systemPrefersDark?: boolean; +}; + +/** Theme state only; the DOM is updated by whoever subscribes (ThemeProvider). */ +function themeState({ + storage = null, + systemPrefersDark = false, +}: ThemeStoreOptions): StateCreator { + return (set) => ({ + preference: "system", + systemPrefersDark, + mode: resolveThemeMode("system", systemPrefersDark), + setPreference: (preference) => { + writeThemePreference(storage, preference); + set((state) => ({ + preference, + mode: resolveThemeMode(preference, state.systemPrefersDark), + })); + }, + setSystemPrefersDark: (value) => { + set((state) => ({ + systemPrefersDark: value, + mode: resolveThemeMode(state.preference, value), + })); + }, + hydrate: (source) => { + const preference = readThemePreference(source); + set((state) => ({ + preference, + mode: resolveThemeMode(preference, state.systemPrefersDark), + })); + }, + }); +} + +/** A standalone store, for tests and isolated trees. */ +export function createThemeStore( + options: ThemeStoreOptions = {}, +): StoreApi { + return createStore()(themeState(options)); +} + +/** Reading localStorage can throw in sandboxed documents; treat that as no storage. */ +function browserStorage(): Storage | null { + if (typeof window === "undefined") { + return null; + } + try { + return window.localStorage; + } catch { + return null; + } +} + +/** The app's theme store; the provider binds the real prefers-color-scheme query. */ +export const useThemeStore: UseBoundStore> = + create()( + themeState({ storage: browserStorage(), systemPrefersDark: false }), + ); + +export const selectThemeMode = (s: ThemeState) => s.mode; +export const selectThemePreference = (s: ThemeState) => s.preference; From 0c85cd6972ec124990336f2dcaa4c63668eb7415 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:55:35 +0530 Subject: [PATCH 04/36] UN-4299 [FEAT] Add the v1 token source, the generator and tokens.css in three modes Co-Authored-By: Claude Opus 5.5 --- frontend/package.json | 1 + frontend/scripts/build-v1-tokens.mjs | 40 ++ frontend/src/v1/lib/tokens.test.ts | 41 ++ frontend/src/v1/lib/tokens.ts | 59 ++ .../v1/styles/__snapshots__/token-table.md | 77 +++ frontend/src/v1/styles/tokens.css | 232 +++++++ frontend/src/v1/styles/tokens.test.ts | 119 ++++ frontend/src/v1/styles/tokens.ts | 590 ++++++++++++++++++ 8 files changed, 1159 insertions(+) create mode 100644 frontend/scripts/build-v1-tokens.mjs create mode 100644 frontend/src/v1/lib/tokens.test.ts create mode 100644 frontend/src/v1/lib/tokens.ts create mode 100644 frontend/src/v1/styles/__snapshots__/token-table.md create mode 100644 frontend/src/v1/styles/tokens.css create mode 100644 frontend/src/v1/styles/tokens.test.ts create mode 100644 frontend/src/v1/styles/tokens.ts diff --git a/frontend/package.json b/frontend/package.json index 3d7f579cc9..7007b8fc20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -78,6 +78,7 @@ "lint:all": "biome check --write src/", "lint:changed": "(git diff --name-only --diff-filter=ACMR; git ls-files --others --exclude-standard) | grep -E '\\.[jt]sx?$' | sed 's|^frontend/||' | sort -u | xargs -r biome check --write", "typecheck": "tsc --noEmit", + "tokens:v1": "node scripts/build-v1-tokens.mjs", "typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json", "lint:v1": "biome lint src/v1/ && node scripts/check-v1.mjs --code", "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" diff --git a/frontend/scripts/build-v1-tokens.mjs b/frontend/scripts/build-v1-tokens.mjs new file mode 100644 index 0000000000..9983ae1844 --- /dev/null +++ b/frontend/scripts/build-v1-tokens.mjs @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import { registerHooks } from "node:module"; +import path from "node:path"; + +// Node's ESM resolver needs file extensions; v1 source imports siblings without +// them (`./theme`), as Vite and tsc expect. Retry such relative imports as `.ts`. +registerHooks({ + resolve(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (error) { + if (specifier.startsWith(".") && !path.extname(specifier)) { + return nextResolve(`${specifier}.ts`, context); + } + throw error; + } + }, +}); + +const { renderTokensCss } = await import("../src/v1/lib/tokens.ts"); +const { TOKENS } = await import("../src/v1/styles/tokens.ts"); +const out = path.join( + import.meta.dirname, + "..", + "src", + "v1", + "styles", + "tokens.css", +); +const css = renderTokensCss(TOKENS); +if (process.argv.includes("--check")) { + if (fs.readFileSync(out, "utf8") !== css) { + console.error( + "src/v1/styles/tokens.css is stale; run `bun run tokens:v1`.", + ); + process.exit(1); + } +} else { + fs.writeFileSync(out, css); +} diff --git a/frontend/src/v1/lib/tokens.test.ts b/frontend/src/v1/lib/tokens.test.ts new file mode 100644 index 0000000000..5ae7b64759 --- /dev/null +++ b/frontend/src/v1/lib/tokens.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from "vitest"; +import { + renderTokensCss, + renderTokenTable, + sameInEveryMode, + type Token, +} from "./tokens"; + +const radius: Token = { + family: "radius", + name: "radius-sm", + cssVar: "--radius-sm", + utility: "v1:rounded-sm", + values: sameInEveryMode("6px"), +}; +const bg: Token = { + family: "colour", + name: "background", + cssVar: "--background", + utility: "v1:bg-background", + values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#0f0a1f" }, +}; + +it("renders one scoped block per mode with every token in each", () => { + expect(renderTokensCss([bg, radius])).toBe( + "/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.ts. Do not edit. */\n" + + '.v1[data-theme="light"] {\n --background: #fafafa;\n --radius-sm: 6px;\n}\n' + + '.v1[data-theme="dark"] {\n --background: #1a1a1a;\n --radius-sm: 6px;\n}\n' + + '.v1[data-theme="unstract"] {\n --background: #0f0a1f;\n --radius-sm: 6px;\n}\n', + ); +}); + +it("renders the gallery table with one row per token", () => { + const table = renderTokenTable([bg]); + expect(table.split("\n")[0]).toBe( + "| Family | Token | CSS variable | Utility | Light | Dark | Unstract |", + ); + expect(table).toContain( + "| colour | background | `--background` | `v1:bg-background` | #fafafa | #1a1a1a | #0f0a1f |", + ); +}); diff --git a/frontend/src/v1/lib/tokens.ts b/frontend/src/v1/lib/tokens.ts new file mode 100644 index 0000000000..b29852466a --- /dev/null +++ b/frontend/src/v1/lib/tokens.ts @@ -0,0 +1,59 @@ +import { THEME_MODES, type ThemeMode } from "./theme"; + +export type TokenFamily = + | "colour" + | "spacing" + | "radius" + | "type" + | "elevation" + | "focus" + | "font"; + +/** One design token: a runtime CSS variable with a value in every theme mode. */ +export type Token = Readonly<{ + family: TokenFamily; + name: string; + cssVar: `--${string}`; + utility: string; + values: Readonly>; + note?: string; +}>; + +const CSS_HEADER = + "/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.ts. Do not edit. */"; + +const TABLE_HEADER = + "| Family | Token | CSS variable | Utility | Light | Dark | Unstract |"; +const TABLE_SEPARATOR = "| --- | --- | --- | --- | --- | --- | --- |"; + +export function sameInEveryMode( + value: string, +): Readonly> { + return { light: value, dark: value, unstract: value }; +} + +/** One `.v1[data-theme=…]` block per mode, each declaring every token in source order. */ +export function renderTokensCss(tokens: readonly Token[]): string { + const blocks = THEME_MODES.map((mode) => { + const declarations = tokens.map( + (token) => ` ${token.cssVar}: ${token.values[mode]};\n`, + ); + return `.v1[data-theme="${mode}"] {\n${declarations.join("")}}\n`; + }); + return `${CSS_HEADER}\n${blocks.join("")}`; +} + +/** Markdown table of every token, one row each, for the gallery and the dev-spec snapshot. */ +export function renderTokenTable(tokens: readonly Token[]): string { + const rows = tokens.map((token) => { + const cells = [ + token.family, + token.name, + `\`${token.cssVar}\``, + `\`${token.utility}\``, + ...THEME_MODES.map((mode) => token.values[mode]), + ]; + return `| ${cells.join(" | ")} |\n`; + }); + return `${TABLE_HEADER}\n${TABLE_SEPARATOR}\n${rows.join("")}`; +} diff --git a/frontend/src/v1/styles/__snapshots__/token-table.md b/frontend/src/v1/styles/__snapshots__/token-table.md new file mode 100644 index 0000000000..80d0c0782e --- /dev/null +++ b/frontend/src/v1/styles/__snapshots__/token-table.md @@ -0,0 +1,77 @@ +| Family | Token | CSS variable | Utility | Light | Dark | Unstract | +| --- | --- | --- | --- | --- | --- | --- | +| colour | background | `--background` | `v1:bg-background` | #fafafa | #1a1a1a | #f0f0f0 | +| colour | foreground | `--foreground` | `v1:text-foreground` | #1a1a1a | #fafafa | #1a1a1a | +| colour | card | `--card` | `v1:bg-card` | #fcfcfc | #19191a | #ffffff | +| colour | card-foreground | `--card-foreground` | `v1:text-card-foreground` | #1a1a1a | #fafafa | #1a1a1a | +| colour | popover | `--popover` | `v1:bg-popover` | #ffffff | #171717 | #ffffff | +| colour | popover-foreground | `--popover-foreground` | `v1:text-popover-foreground` | #19191a | #fafafa | #19191a | +| colour | primary | `--primary` | `v1:bg-primary` | #6f5cef | #6f5cef | #072b4d | +| colour | primary-foreground | `--primary-foreground` | `v1:text-primary-foreground` | #fafafa | #fafafa | #ffffff | +| colour | primary-muted | `--primary-muted` | `v1:bg-primary-muted` | #f1effd | #2a2445 | #e8eef4 | +| colour | secondary | `--secondary` | `v1:bg-secondary` | #97caf5 | rgb(255 255 255 / 0.06) | #f0f4f8 | +| colour | secondary-foreground | `--secondary-foreground` | `v1:text-secondary-foreground` | #19191a | #fafafa | #072b4d | +| colour | muted | `--muted` | `v1:bg-muted` | #f5f5f5 | #262626 | #f0f0f0 | +| colour | muted-foreground | `--muted-foreground` | `v1:text-muted-foreground` | #737373 | #a1a1a1 | #6b7280 | +| colour | accent | `--accent` | `v1:bg-accent` | #f5f5f5 | #2a2a2d | #e8eef4 | +| colour | accent-foreground | `--accent-foreground` | `v1:text-accent-foreground` | #6161cc | #c1c5fb | #072b4d | +| colour | destructive | `--destructive` | `v1:bg-destructive` | #b41b30 | #e5484d | #b41b30 | +| colour | destructive-foreground | `--destructive-foreground` | `v1:text-destructive-foreground` | #ffffff | #fafafa | #ffffff | +| colour | success | `--success` | `v1:bg-success` | #15803d | #4ade80 | #15803d | +| colour | warning | `--warning` | `v1:bg-warning` | #b45309 | #fbbf24 | #b45309 | +| colour | border | `--border` | `v1:border-border` | #e5e5e5 | #2e2e2e | #e4e7eb | +| colour | input | `--input` | `v1:border-input` | #d3d3d3 | #343434 | #d3d8de | +| colour | ring | `--ring` | `v1:ring-ring` | #6f5cef | #6f5cef | #0e3963 | +| colour | sidebar | `--sidebar` | `v1:bg-sidebar` | #ffffff | #171717 | #0e3963 | +| colour | sidebar-foreground | `--sidebar-foreground` | `v1:text-sidebar-foreground` | #1a1a1a | #fafafa | #ffffff | +| colour | sidebar-accent | `--sidebar-accent` | `v1:bg-sidebar-accent` | #f5f5f5 | #262626 | #1b5183 | +| colour | sidebar-accent-foreground | `--sidebar-accent-foreground` | `v1:text-sidebar-accent-foreground` | #1a1a1a | #fafafa | #ffffff | +| colour | sidebar-border | `--sidebar-border` | `v1:border-sidebar-border` | #e5e5e5 | #2e2e2e | #17436e | +| colour | sidebar-muted-foreground | `--sidebar-muted-foreground` | `v1:text-sidebar-muted-foreground` | #737373 | #a1a1aa | #a9c3da | +| colour | header | `--header` | `v1:bg-header` | #fcfcfc | #0a0a0a | #072b4d | +| colour | header-foreground | `--header-foreground` | `v1:text-header-foreground` | #1a1a1a | #fafafa | #ffffff | +| spacing | space-1 | `--space-1` | `p-1` | 4px | 4px | 4px | +| spacing | space-2 | `--space-2` | `p-2` | 8px | 8px | 8px | +| spacing | space-3 | `--space-3` | `p-3` | 12px | 12px | 12px | +| spacing | space-4 | `--space-4` | `p-4` | 16px | 16px | 16px | +| spacing | space-5 | `--space-5` | `p-5` | 20px | 20px | 20px | +| spacing | space-6 | `--space-6` | `p-6` | 24px | 24px | 24px | +| spacing | space-8 | `--space-8` | `p-8` | 32px | 32px | 32px | +| radius | radius-sm | `--radius-sm` | `v1:rounded-sm` | 6px | 6px | 6px | +| radius | radius-md | `--radius-md` | `v1:rounded-md` | 8px | 8px | 8px | +| radius | radius-lg | `--radius-lg` | `v1:rounded-lg` | 10px | 10px | 10px | +| radius | radius-xl | `--radius-xl` | `v1:rounded-xl` | 14px | 14px | 14px | +| type | type-display-size | `--type-display-size` | `v1:text-display` | 34px | 34px | 34px | +| type | type-display-line-height | `--type-display-line-height` | `v1:text-display` | 46px | 46px | 46px | +| type | type-display-weight | `--type-display-weight` | `v1:text-display` | 600 | 600 | 600 | +| type | type-page-title-size | `--type-page-title-size` | `v1:text-page-title` | 20px | 20px | 20px | +| type | type-page-title-line-height | `--type-page-title-line-height` | `v1:text-page-title` | 28px | 28px | 28px | +| type | type-page-title-weight | `--type-page-title-weight` | `v1:text-page-title` | 600 | 600 | 600 | +| type | type-section-title-size | `--type-section-title-size` | `v1:text-section-title` | 16px | 16px | 16px | +| type | type-section-title-line-height | `--type-section-title-line-height` | `v1:text-section-title` | 24px | 24px | 24px | +| type | type-section-title-weight | `--type-section-title-weight` | `v1:text-section-title` | 600 | 600 | 600 | +| type | type-body-size | `--type-body-size` | `v1:text-body` | 14px | 14px | 14px | +| type | type-body-line-height | `--type-body-line-height` | `v1:text-body` | 20px | 20px | 20px | +| type | type-body-weight | `--type-body-weight` | `v1:text-body` | 400 | 400 | 400 | +| type | type-body-small-size | `--type-body-small-size` | `v1:text-body-small` | 13px | 13px | 13px | +| type | type-body-small-line-height | `--type-body-small-line-height` | `v1:text-body-small` | 18px | 18px | 18px | +| type | type-body-small-weight | `--type-body-small-weight` | `v1:text-body-small` | 400 | 400 | 400 | +| type | type-caption-size | `--type-caption-size` | `v1:text-caption` | 12px | 12px | 12px | +| type | type-caption-line-height | `--type-caption-line-height` | `v1:text-caption` | 16px | 16px | 16px | +| type | type-caption-weight | `--type-caption-weight` | `v1:text-caption` | 400 | 400 | 400 | +| type | type-overline-size | `--type-overline-size` | `v1:text-overline` | 11px | 11px | 11px | +| type | type-overline-line-height | `--type-overline-line-height` | `v1:text-overline` | 16px | 16px | 16px | +| type | type-overline-weight | `--type-overline-weight` | `v1:text-overline` | 600 | 600 | 600 | +| type | type-mono-size | `--type-mono-size` | `v1:text-mono` | 12px | 12px | 12px | +| type | type-mono-line-height | `--type-mono-line-height` | `v1:text-mono` | 22px | 22px | 22px | +| type | type-mono-weight | `--type-mono-weight` | `v1:text-mono` | 400 | 400 | 400 | +| type | type-overline-tracking | `--type-overline-tracking` | `v1:text-overline` | 0.05em | 0.05em | 0.05em | +| elevation | shadow-sm | `--shadow-sm` | `v1:shadow-sm` | 0 1px 2px 0 rgb(0 0 0 / 0.05) | 0 1px 2px 0 rgb(0 0 0 / 0.05) | 0 1px 2px 0 rgb(0 0 0 / 0.05) | +| elevation | shadow-md | `--shadow-md` | `v1:shadow-md` | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | +| elevation | shadow-lg | `--shadow-lg` | `v1:shadow-lg` | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | +| elevation | shadow-xl | `--shadow-xl` | `v1:shadow-xl` | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | +| elevation | scrim | `--scrim` | `v1:bg-scrim` | rgb(0 0 0 / 0.5) | rgb(0 0 0 / 0.5) | rgb(0 0 0 / 0.5) | +| focus | focus-ring-width | `--focus-ring-width` | `v1:focus-ring` | 3px | 3px | 3px | +| focus | focus-ring-alpha | `--focus-ring-alpha` | `v1:focus-ring` | 50% | 50% | 50% | +| font | font-sans | `--font-sans` | `v1:font-sans` | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | +| font | font-mono | `--font-mono` | `v1:font-mono` | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | diff --git a/frontend/src/v1/styles/tokens.css b/frontend/src/v1/styles/tokens.css new file mode 100644 index 0000000000..9f2a0e97a4 --- /dev/null +++ b/frontend/src/v1/styles/tokens.css @@ -0,0 +1,232 @@ +/* Generated by scripts/build-v1-tokens.mjs from src/v1/styles/tokens.ts. Do not edit. */ +.v1[data-theme="light"] { + --background: #fafafa; + --foreground: #1a1a1a; + --card: #fcfcfc; + --card-foreground: #1a1a1a; + --popover: #ffffff; + --popover-foreground: #19191a; + --primary: #6f5cef; + --primary-foreground: #fafafa; + --primary-muted: #f1effd; + --secondary: #97caf5; + --secondary-foreground: #19191a; + --muted: #f5f5f5; + --muted-foreground: #737373; + --accent: #f5f5f5; + --accent-foreground: #6161cc; + --destructive: #b41b30; + --destructive-foreground: #ffffff; + --success: #15803d; + --warning: #b45309; + --border: #e5e5e5; + --input: #d3d3d3; + --ring: #6f5cef; + --sidebar: #ffffff; + --sidebar-foreground: #1a1a1a; + --sidebar-accent: #f5f5f5; + --sidebar-accent-foreground: #1a1a1a; + --sidebar-border: #e5e5e5; + --sidebar-muted-foreground: #737373; + --header: #fcfcfc; + --header-foreground: #1a1a1a; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 14px; + --type-display-size: 34px; + --type-display-line-height: 46px; + --type-display-weight: 600; + --type-page-title-size: 20px; + --type-page-title-line-height: 28px; + --type-page-title-weight: 600; + --type-section-title-size: 16px; + --type-section-title-line-height: 24px; + --type-section-title-weight: 600; + --type-body-size: 14px; + --type-body-line-height: 20px; + --type-body-weight: 400; + --type-body-small-size: 13px; + --type-body-small-line-height: 18px; + --type-body-small-weight: 400; + --type-caption-size: 12px; + --type-caption-line-height: 16px; + --type-caption-weight: 400; + --type-overline-size: 11px; + --type-overline-line-height: 16px; + --type-overline-weight: 600; + --type-mono-size: 12px; + --type-mono-line-height: 22px; + --type-mono-weight: 400; + --type-overline-tracking: 0.05em; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10); + --scrim: rgb(0 0 0 / 0.5); + --focus-ring-width: 3px; + --focus-ring-alpha: 50%; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} +.v1[data-theme="dark"] { + --background: #1a1a1a; + --foreground: #fafafa; + --card: #19191a; + --card-foreground: #fafafa; + --popover: #171717; + --popover-foreground: #fafafa; + --primary: #6f5cef; + --primary-foreground: #fafafa; + --primary-muted: #2a2445; + --secondary: rgb(255 255 255 / 0.06); + --secondary-foreground: #fafafa; + --muted: #262626; + --muted-foreground: #a1a1a1; + --accent: #2a2a2d; + --accent-foreground: #c1c5fb; + --destructive: #e5484d; + --destructive-foreground: #fafafa; + --success: #4ade80; + --warning: #fbbf24; + --border: #2e2e2e; + --input: #343434; + --ring: #6f5cef; + --sidebar: #171717; + --sidebar-foreground: #fafafa; + --sidebar-accent: #262626; + --sidebar-accent-foreground: #fafafa; + --sidebar-border: #2e2e2e; + --sidebar-muted-foreground: #a1a1aa; + --header: #0a0a0a; + --header-foreground: #fafafa; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 14px; + --type-display-size: 34px; + --type-display-line-height: 46px; + --type-display-weight: 600; + --type-page-title-size: 20px; + --type-page-title-line-height: 28px; + --type-page-title-weight: 600; + --type-section-title-size: 16px; + --type-section-title-line-height: 24px; + --type-section-title-weight: 600; + --type-body-size: 14px; + --type-body-line-height: 20px; + --type-body-weight: 400; + --type-body-small-size: 13px; + --type-body-small-line-height: 18px; + --type-body-small-weight: 400; + --type-caption-size: 12px; + --type-caption-line-height: 16px; + --type-caption-weight: 400; + --type-overline-size: 11px; + --type-overline-line-height: 16px; + --type-overline-weight: 600; + --type-mono-size: 12px; + --type-mono-line-height: 22px; + --type-mono-weight: 400; + --type-overline-tracking: 0.05em; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10); + --scrim: rgb(0 0 0 / 0.5); + --focus-ring-width: 3px; + --focus-ring-alpha: 50%; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} +.v1[data-theme="unstract"] { + --background: #f0f0f0; + --foreground: #1a1a1a; + --card: #ffffff; + --card-foreground: #1a1a1a; + --popover: #ffffff; + --popover-foreground: #19191a; + --primary: #072b4d; + --primary-foreground: #ffffff; + --primary-muted: #e8eef4; + --secondary: #f0f4f8; + --secondary-foreground: #072b4d; + --muted: #f0f0f0; + --muted-foreground: #6b7280; + --accent: #e8eef4; + --accent-foreground: #072b4d; + --destructive: #b41b30; + --destructive-foreground: #ffffff; + --success: #15803d; + --warning: #b45309; + --border: #e4e7eb; + --input: #d3d8de; + --ring: #0e3963; + --sidebar: #0e3963; + --sidebar-foreground: #ffffff; + --sidebar-accent: #1b5183; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: #17436e; + --sidebar-muted-foreground: #a9c3da; + --header: #072b4d; + --header-foreground: #ffffff; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 14px; + --type-display-size: 34px; + --type-display-line-height: 46px; + --type-display-weight: 600; + --type-page-title-size: 20px; + --type-page-title-line-height: 28px; + --type-page-title-weight: 600; + --type-section-title-size: 16px; + --type-section-title-line-height: 24px; + --type-section-title-weight: 600; + --type-body-size: 14px; + --type-body-line-height: 20px; + --type-body-weight: 400; + --type-body-small-size: 13px; + --type-body-small-line-height: 18px; + --type-body-small-weight: 400; + --type-caption-size: 12px; + --type-caption-line-height: 16px; + --type-caption-weight: 400; + --type-overline-size: 11px; + --type-overline-line-height: 16px; + --type-overline-weight: 600; + --type-mono-size: 12px; + --type-mono-line-height: 22px; + --type-mono-weight: 400; + --type-overline-tracking: 0.05em; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10); + --scrim: rgb(0 0 0 / 0.5); + --focus-ring-width: 3px; + --focus-ring-alpha: 50%; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} diff --git a/frontend/src/v1/styles/tokens.test.ts b/frontend/src/v1/styles/tokens.test.ts new file mode 100644 index 0000000000..fe53e19d54 --- /dev/null +++ b/frontend/src/v1/styles/tokens.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import path from "node:path"; +import { THEME_MODES } from "@v1/lib/theme"; +import { renderTokensCss, renderTokenTable } from "@v1/lib/tokens"; +import { expect, it } from "vitest"; +import { TOKENS } from "./tokens"; + +it("every token is defined in every mode with a non-empty value", () => { + for (const token of TOKENS) { + for (const mode of THEME_MODES) { + expect(token.values[mode], `${token.name} in ${mode}`).toMatch(/\S/); + } + } +}); + +it("token names and CSS variables are unique and runtime variables never use Tailwind's --v1- namespace", () => { + expect(new Set(TOKENS.map((t) => t.name)).size).toBe(TOKENS.length); + expect(new Set(TOKENS.map((t) => t.cssVar)).size).toBe(TOKENS.length); + expect(TOKENS.filter((t) => t.cssVar.startsWith("--v1-"))).toEqual([]); +}); + +it("covers the families the board defines", () => { + const byFamily = (f: string) => + TOKENS.filter((t) => t.family === f).map((t) => t.name); + expect(byFamily("radius")).toEqual([ + "radius-sm", + "radius-md", + "radius-lg", + "radius-xl", + ]); + expect(byFamily("spacing")).toEqual([ + "space-1", + "space-2", + "space-3", + "space-4", + "space-5", + "space-6", + "space-8", + ]); + expect(byFamily("elevation")).toEqual([ + "shadow-sm", + "shadow-md", + "shadow-lg", + "shadow-xl", + "scrim", + ]); + expect(byFamily("type")).toHaveLength(25); // eight roles × (size, line-height, weight) + overline tracking + expect(byFamily("colour")).toHaveLength(30); // the Midnight Bloom collection minus its four palette entries + for (const name of [ + "background", + "foreground", + "card", + "popover", + "primary", + "primary-muted", + "secondary", + "muted", + "accent", + "destructive", + "success", + "warning", + "border", + "input", + "ring", + "sidebar", + "sidebar-muted-foreground", + "header", + ]) { + expect(byFamily("colour")).toContain(name); + } + for (const name of ["sky-300", "sky-500", "openai-green", "white"]) { + expect(byFamily("colour")).not.toContain(name); + } + expect(byFamily("font")).toEqual(["font-sans", "font-mono"]); + expect(byFamily("focus")).toEqual(["focus-ring-width", "focus-ring-alpha"]); +}); + +it("Unstract mode is the navy theme from the collection, not a copy of light", () => { + const value = (n: string) => TOKENS.find((t) => t.name === n)?.values; + expect(value("primary")).toEqual({ + light: "#6f5cef", + dark: "#6f5cef", + unstract: "#072b4d", + }); + expect(value("sidebar")).toEqual({ + light: "#ffffff", + dark: "#171717", + unstract: "#0e3963", + }); + expect(value("border")).toEqual({ + light: "#e5e5e5", + dark: "#2e2e2e", + unstract: "#e4e7eb", + }); +}); + +it("radii carry the four board values", () => { + const value = (n: string) => TOKENS.find((t) => t.name === n)?.values.light; + expect([ + value("radius-sm"), + value("radius-md"), + value("radius-lg"), + value("radius-xl"), + ]).toEqual(["6px", "8px", "10px", "14px"]); +}); + +it("generated tokens.css matches the source", () => { + const css = fs.readFileSync( + path.resolve(import.meta.dirname, "tokens.css"), + "utf8", + ); + expect(css).toBe(renderTokensCss(TOKENS)); +}); + +it("the token table matches the dev-spec snapshot", async () => { + await expect(renderTokenTable(TOKENS)).toMatchFileSnapshot( + "./__snapshots__/token-table.md", + ); +}); diff --git a/frontend/src/v1/styles/tokens.ts b/frontend/src/v1/styles/tokens.ts new file mode 100644 index 0000000000..d559fd1a6c --- /dev/null +++ b/frontend/src/v1/styles/tokens.ts @@ -0,0 +1,590 @@ +import type { Token } from "@v1/lib/tokens"; + +// Local rather than lib's sameInEveryMode: a type-only import is erased when Node strips types for the generator; a value import through @v1 would not resolve. +const same = (v: string) => ({ light: v, dark: v, unstract: v }) as const; + +/** Every token on the dev-spec board, in board order. Edit here, then run `bun run tokens:v1`. */ +export const TOKENS: readonly Token[] = [ + // Colour: the Midnight Bloom collection, minus its palette entries (sky-300, sky-500, openai-green, white). + { + family: "colour", + name: "background", + cssVar: "--background", + utility: "v1:bg-background", + values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#f0f0f0" }, + note: "page surface", + }, + { + family: "colour", + name: "foreground", + cssVar: "--foreground", + utility: "v1:text-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, + note: "default text", + }, + { + family: "colour", + name: "card", + cssVar: "--card", + utility: "v1:bg-card", + values: { light: "#fcfcfc", dark: "#19191a", unstract: "#ffffff" }, + note: "every card and dialog surface; cards sit on background, not on muted", + }, + { + family: "colour", + name: "card-foreground", + cssVar: "--card-foreground", + utility: "v1:text-card-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, + }, + { + family: "colour", + name: "popover", + cssVar: "--popover", + utility: "v1:bg-popover", + values: { light: "#ffffff", dark: "#171717", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "popover-foreground", + cssVar: "--popover-foreground", + utility: "v1:text-popover-foreground", + values: { light: "#19191a", dark: "#fafafa", unstract: "#19191a" }, + }, + { + family: "colour", + name: "primary", + cssVar: "--primary", + utility: "v1:bg-primary", + values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#072b4d" }, + note: "primary buttons, active tabs, selected icon buttons", + }, + { + family: "colour", + name: "primary-foreground", + cssVar: "--primary-foreground", + utility: "v1:text-primary-foreground", + values: { light: "#fafafa", dark: "#fafafa", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "primary-muted", + cssVar: "--primary-muted", + utility: "v1:bg-primary-muted", + values: { light: "#f1effd", dark: "#2a2445", unstract: "#e8eef4" }, + note: "selected rows, active flow steps, selected model strip; added for Unstract", + }, + { + family: "colour", + name: "secondary", + cssVar: "--secondary", + utility: "v1:bg-secondary", + values: { + light: "#97caf5", + dark: "rgb(255 255 255 / 0.06)", + unstract: "#f0f4f8", + }, + }, + { + family: "colour", + name: "secondary-foreground", + cssVar: "--secondary-foreground", + utility: "v1:text-secondary-foreground", + values: { light: "#19191a", dark: "#fafafa", unstract: "#072b4d" }, + }, + { + family: "colour", + name: "muted", + cssVar: "--muted", + utility: "v1:bg-muted", + values: { light: "#f5f5f5", dark: "#262626", unstract: "#f0f0f0" }, + note: "inset surfaces: table headers, code blocks, dropzones, skeletons", + }, + { + family: "colour", + name: "muted-foreground", + cssVar: "--muted-foreground", + utility: "v1:text-muted-foreground", + values: { light: "#737373", dark: "#a1a1a1", unstract: "#6b7280" }, + note: "secondary text", + }, + { + family: "colour", + name: "accent", + cssVar: "--accent", + utility: "v1:bg-accent", + values: { light: "#f5f5f5", dark: "#2a2a2d", unstract: "#e8eef4" }, + note: "hover surface on rails, menu items and icon buttons", + }, + { + family: "colour", + name: "accent-foreground", + cssVar: "--accent-foreground", + utility: "v1:text-accent-foreground", + values: { light: "#6161cc", dark: "#c1c5fb", unstract: "#072b4d" }, + }, + { + family: "colour", + name: "destructive", + cssVar: "--destructive", + utility: "v1:bg-destructive", + values: { light: "#b41b30", dark: "#e5484d", unstract: "#b41b30" }, + note: "delete actions and failure states; never a warning", + }, + { + family: "colour", + name: "destructive-foreground", + cssVar: "--destructive-foreground", + utility: "v1:text-destructive-foreground", + values: { light: "#ffffff", dark: "#fafafa", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "success", + cssVar: "--success", + utility: "v1:bg-success", + values: { light: "#15803d", dark: "#4ade80", unstract: "#15803d" }, + }, + { + family: "colour", + name: "warning", + cssVar: "--warning", + utility: "v1:bg-warning", + values: { light: "#b45309", dark: "#fbbf24", unstract: "#b45309" }, + }, + { + family: "colour", + name: "border", + cssVar: "--border", + utility: "v1:border-border", + values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#e4e7eb" }, + note: "every 1px rule and control outline", + }, + { + family: "colour", + name: "input", + cssVar: "--input", + utility: "v1:border-input", + values: { light: "#d3d3d3", dark: "#343434", unstract: "#d3d8de" }, + }, + { + family: "colour", + name: "ring", + cssVar: "--ring", + utility: "v1:ring-ring", + values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#0e3963" }, + note: "focus outline colour, paired with a 3px ring at 50% alpha", + }, + { + family: "colour", + name: "sidebar", + cssVar: "--sidebar", + utility: "v1:bg-sidebar", + values: { light: "#ffffff", dark: "#171717", unstract: "#0e3963" }, + note: "the rail and both nav bars", + }, + { + family: "colour", + name: "sidebar-foreground", + cssVar: "--sidebar-foreground", + utility: "v1:text-sidebar-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "sidebar-accent", + cssVar: "--sidebar-accent", + utility: "v1:bg-sidebar-accent", + values: { light: "#f5f5f5", dark: "#262626", unstract: "#1b5183" }, + }, + { + family: "colour", + name: "sidebar-accent-foreground", + cssVar: "--sidebar-accent-foreground", + utility: "v1:text-sidebar-accent-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, + }, + { + family: "colour", + name: "sidebar-border", + cssVar: "--sidebar-border", + utility: "v1:border-sidebar-border", + values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#17436e" }, + }, + { + family: "colour", + name: "sidebar-muted-foreground", + cssVar: "--sidebar-muted-foreground", + utility: "v1:text-sidebar-muted-foreground", + values: { light: "#737373", dark: "#a1a1aa", unstract: "#a9c3da" }, + }, + { + family: "colour", + name: "header", + cssVar: "--header", + utility: "v1:bg-header", + values: { light: "#fcfcfc", dark: "#0a0a0a", unstract: "#072b4d" }, + }, + { + family: "colour", + name: "header-foreground", + cssVar: "--header-foreground", + utility: "v1:text-header-foreground", + values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, + }, + // Spacing: one 4pt scale. A value not on this list is a bug. + { + family: "spacing", + name: "space-1", + cssVar: "--space-1", + utility: "p-1", + values: same("4px"), + note: "icon-to-label inside a dense chip", + }, + { + family: "spacing", + name: "space-2", + cssVar: "--space-2", + utility: "p-2", + values: same("8px"), + note: "label to control, stacked text inside a cell", + }, + { + family: "spacing", + name: "space-3", + cssVar: "--space-3", + utility: "p-3", + values: same("12px"), + note: "control to control inside a group", + }, + { + family: "spacing", + name: "space-4", + cssVar: "--space-4", + utility: "p-4", + values: same("16px"), + note: "card gutter, list row rhythm", + }, + { + family: "spacing", + name: "space-5", + cssVar: "--space-5", + utility: "p-5", + values: same("20px"), + note: "card inner padding, compact", + }, + { + family: "spacing", + name: "space-6", + cssVar: "--space-6", + utility: "p-6", + values: same("24px"), + note: "card inner padding, default \u00b7 page padding y", + }, + { + family: "spacing", + name: "space-8", + cssVar: "--space-8", + utility: "p-8", + values: same("32px"), + note: "page padding x, section break", + }, + // Radius + { + family: "radius", + name: "radius-sm", + cssVar: "--radius-sm", + utility: "v1:rounded-sm", + values: same("6px"), + note: "badge, pill, status chip, small icon button", + }, + { + family: "radius", + name: "radius-md", + cssVar: "--radius-md", + utility: "v1:rounded-md", + values: same("8px"), + note: "button, input, select, textarea, menu item", + }, + { + family: "radius", + name: "radius-lg", + cssVar: "--radius-lg", + utility: "v1:rounded-lg", + values: same("10px"), + note: "card, dialog, menu, popover, dropzone", + }, + { + family: "radius", + name: "radius-xl", + cssVar: "--radius-xl", + utility: "v1:rounded-xl", + values: same("14px"), + note: "empty-state icon badge", + }, + // Type: eight roles, each a size, a line height and a weight. + { + family: "type", + name: "type-display-size", + cssVar: "--type-display-size", + utility: "v1:text-display", + values: same("34px"), + }, + { + family: "type", + name: "type-display-line-height", + cssVar: "--type-display-line-height", + utility: "v1:text-display", + values: same("46px"), + }, + { + family: "type", + name: "type-display-weight", + cssVar: "--type-display-weight", + utility: "v1:text-display", + values: same("600"), + }, + { + family: "type", + name: "type-page-title-size", + cssVar: "--type-page-title-size", + utility: "v1:text-page-title", + values: same("20px"), + }, + { + family: "type", + name: "type-page-title-line-height", + cssVar: "--type-page-title-line-height", + utility: "v1:text-page-title", + values: same("28px"), + }, + { + family: "type", + name: "type-page-title-weight", + cssVar: "--type-page-title-weight", + utility: "v1:text-page-title", + values: same("600"), + }, + { + family: "type", + name: "type-section-title-size", + cssVar: "--type-section-title-size", + utility: "v1:text-section-title", + values: same("16px"), + }, + { + family: "type", + name: "type-section-title-line-height", + cssVar: "--type-section-title-line-height", + utility: "v1:text-section-title", + values: same("24px"), + }, + { + family: "type", + name: "type-section-title-weight", + cssVar: "--type-section-title-weight", + utility: "v1:text-section-title", + values: same("600"), + }, + { + family: "type", + name: "type-body-size", + cssVar: "--type-body-size", + utility: "v1:text-body", + values: same("14px"), + }, + { + family: "type", + name: "type-body-line-height", + cssVar: "--type-body-line-height", + utility: "v1:text-body", + values: same("20px"), + }, + { + family: "type", + name: "type-body-weight", + cssVar: "--type-body-weight", + utility: "v1:text-body", + values: same("400"), + }, + { + family: "type", + name: "type-body-small-size", + cssVar: "--type-body-small-size", + utility: "v1:text-body-small", + values: same("13px"), + }, + { + family: "type", + name: "type-body-small-line-height", + cssVar: "--type-body-small-line-height", + utility: "v1:text-body-small", + values: same("18px"), + }, + { + family: "type", + name: "type-body-small-weight", + cssVar: "--type-body-small-weight", + utility: "v1:text-body-small", + values: same("400"), + }, + { + family: "type", + name: "type-caption-size", + cssVar: "--type-caption-size", + utility: "v1:text-caption", + values: same("12px"), + }, + { + family: "type", + name: "type-caption-line-height", + cssVar: "--type-caption-line-height", + utility: "v1:text-caption", + values: same("16px"), + }, + { + family: "type", + name: "type-caption-weight", + cssVar: "--type-caption-weight", + utility: "v1:text-caption", + values: same("400"), + }, + { + family: "type", + name: "type-overline-size", + cssVar: "--type-overline-size", + utility: "v1:text-overline", + values: same("11px"), + note: "Pair with v1:uppercase.", + }, + { + family: "type", + name: "type-overline-line-height", + cssVar: "--type-overline-line-height", + utility: "v1:text-overline", + values: same("16px"), + note: "Pair with v1:uppercase.", + }, + { + family: "type", + name: "type-overline-weight", + cssVar: "--type-overline-weight", + utility: "v1:text-overline", + values: same("600"), + note: "Pair with v1:uppercase.", + }, + { + family: "type", + name: "type-mono-size", + cssVar: "--type-mono-size", + utility: "v1:text-mono", + values: same("12px"), + note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + }, + { + family: "type", + name: "type-mono-line-height", + cssVar: "--type-mono-line-height", + utility: "v1:text-mono", + values: same("22px"), + note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + }, + { + family: "type", + name: "type-mono-weight", + cssVar: "--type-mono-weight", + utility: "v1:text-mono", + values: same("400"), + note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + }, + { + family: "type", + name: "type-overline-tracking", + cssVar: "--type-overline-tracking", + utility: "v1:text-overline", + values: same("0.05em"), + note: "Pair with v1:uppercase.", + }, + // Elevation: one value per step, the same in every mode. + { + family: "elevation", + name: "shadow-sm", + cssVar: "--shadow-sm", + utility: "v1:shadow-sm", + values: same("0 1px 2px 0 rgb(0 0 0 / 0.05)"), + note: "every card at rest", + }, + { + family: "elevation", + name: "shadow-md", + cssVar: "--shadow-md", + utility: "v1:shadow-md", + values: same( + "0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10)", + ), + note: "dropdown menus, row action menus", + }, + { + family: "elevation", + name: "shadow-lg", + cssVar: "--shadow-lg", + utility: "v1:shadow-lg", + values: same( + "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)", + ), + note: "popovers", + }, + { + family: "elevation", + name: "shadow-xl", + cssVar: "--shadow-xl", + utility: "v1:shadow-xl", + values: same( + "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)", + ), + note: "dialogs", + }, + { + family: "elevation", + name: "scrim", + cssVar: "--scrim", + utility: "v1:bg-scrim", + values: same("rgb(0 0 0 / 0.5)"), + note: "behind every dialog, full viewport", + }, + // Focus: the ring colour is the `ring` token; no offset. + { + family: "focus", + name: "focus-ring-width", + cssVar: "--focus-ring-width", + utility: "v1:focus-ring", + values: same("3px"), + }, + { + family: "focus", + name: "focus-ring-alpha", + cssVar: "--focus-ring-alpha", + utility: "v1:focus-ring", + values: same("50%"), + }, + // Font + { + family: "font", + name: "font-sans", + cssVar: "--font-sans", + utility: "v1:font-sans", + values: same( + '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif', + ), + }, + { + family: "font", + name: "font-mono", + cssVar: "--font-mono", + utility: "v1:font-mono", + values: same( + '"Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + ), + note: "Reserved for model output, JSON and token counts.", + }, +]; From 6393d31fb54cbb4d6388f2a4036c42fba51b4864 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:58:23 +0530 Subject: [PATCH 05/36] UN-4299 [FIX] Prefix the spacing utilities in the token table so they match the v1 Tailwind root Co-Authored-By: Claude Opus 5.5 --- .../src/v1/styles/__snapshots__/token-table.md | 14 +++++++------- frontend/src/v1/styles/tokens.ts | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/frontend/src/v1/styles/__snapshots__/token-table.md b/frontend/src/v1/styles/__snapshots__/token-table.md index 80d0c0782e..11eb778532 100644 --- a/frontend/src/v1/styles/__snapshots__/token-table.md +++ b/frontend/src/v1/styles/__snapshots__/token-table.md @@ -30,13 +30,13 @@ | colour | sidebar-muted-foreground | `--sidebar-muted-foreground` | `v1:text-sidebar-muted-foreground` | #737373 | #a1a1aa | #a9c3da | | colour | header | `--header` | `v1:bg-header` | #fcfcfc | #0a0a0a | #072b4d | | colour | header-foreground | `--header-foreground` | `v1:text-header-foreground` | #1a1a1a | #fafafa | #ffffff | -| spacing | space-1 | `--space-1` | `p-1` | 4px | 4px | 4px | -| spacing | space-2 | `--space-2` | `p-2` | 8px | 8px | 8px | -| spacing | space-3 | `--space-3` | `p-3` | 12px | 12px | 12px | -| spacing | space-4 | `--space-4` | `p-4` | 16px | 16px | 16px | -| spacing | space-5 | `--space-5` | `p-5` | 20px | 20px | 20px | -| spacing | space-6 | `--space-6` | `p-6` | 24px | 24px | 24px | -| spacing | space-8 | `--space-8` | `p-8` | 32px | 32px | 32px | +| spacing | space-1 | `--space-1` | `v1:p-1` | 4px | 4px | 4px | +| spacing | space-2 | `--space-2` | `v1:p-2` | 8px | 8px | 8px | +| spacing | space-3 | `--space-3` | `v1:p-3` | 12px | 12px | 12px | +| spacing | space-4 | `--space-4` | `v1:p-4` | 16px | 16px | 16px | +| spacing | space-5 | `--space-5` | `v1:p-5` | 20px | 20px | 20px | +| spacing | space-6 | `--space-6` | `v1:p-6` | 24px | 24px | 24px | +| spacing | space-8 | `--space-8` | `v1:p-8` | 32px | 32px | 32px | | radius | radius-sm | `--radius-sm` | `v1:rounded-sm` | 6px | 6px | 6px | | radius | radius-md | `--radius-md` | `v1:rounded-md` | 8px | 8px | 8px | | radius | radius-lg | `--radius-lg` | `v1:rounded-lg` | 10px | 10px | 10px | diff --git a/frontend/src/v1/styles/tokens.ts b/frontend/src/v1/styles/tokens.ts index d559fd1a6c..b6cd8f0901 100644 --- a/frontend/src/v1/styles/tokens.ts +++ b/frontend/src/v1/styles/tokens.ts @@ -237,7 +237,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-1", cssVar: "--space-1", - utility: "p-1", + utility: "v1:p-1", values: same("4px"), note: "icon-to-label inside a dense chip", }, @@ -245,7 +245,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-2", cssVar: "--space-2", - utility: "p-2", + utility: "v1:p-2", values: same("8px"), note: "label to control, stacked text inside a cell", }, @@ -253,7 +253,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-3", cssVar: "--space-3", - utility: "p-3", + utility: "v1:p-3", values: same("12px"), note: "control to control inside a group", }, @@ -261,7 +261,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-4", cssVar: "--space-4", - utility: "p-4", + utility: "v1:p-4", values: same("16px"), note: "card gutter, list row rhythm", }, @@ -269,7 +269,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-5", cssVar: "--space-5", - utility: "p-5", + utility: "v1:p-5", values: same("20px"), note: "card inner padding, compact", }, @@ -277,7 +277,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-6", cssVar: "--space-6", - utility: "p-6", + utility: "v1:p-6", values: same("24px"), note: "card inner padding, default \u00b7 page padding y", }, @@ -285,7 +285,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-8", cssVar: "--space-8", - utility: "p-8", + utility: "v1:p-8", values: same("32px"), note: "page padding x, section break", }, From 4a05a268a5658f4a8073dc98ada740193cae4c8d Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 11:59:58 +0530 Subject: [PATCH 06/36] UN-4299 [FEAT] Add cn() configured for the v1 utility prefix Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.test.ts | 13 +++++++++++++ frontend/src/v1/lib/cn.ts | 6 ++++++ 2 files changed, 19 insertions(+) create mode 100644 frontend/src/v1/lib/cn.test.ts create mode 100644 frontend/src/v1/lib/cn.ts diff --git a/frontend/src/v1/lib/cn.test.ts b/frontend/src/v1/lib/cn.test.ts new file mode 100644 index 0000000000..3f7bec2920 --- /dev/null +++ b/frontend/src/v1/lib/cn.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vitest"; +import { cn } from "./cn"; + +it("merges conflicting prefixed utilities, keeping the last", () => { + expect(cn("v1:p-2 v1:p-4")).toBe("v1:p-4"); + expect(cn("v1:bg-background", false, "v1:bg-card")).toBe("v1:bg-card"); +}); + +it("keeps unrelated and variant classes", () => { + expect(cn("v1:flex v1:dark:bg-card", ["v1:gap-2"])).toBe( + "v1:flex v1:dark:bg-card v1:gap-2", + ); +}); diff --git a/frontend/src/v1/lib/cn.ts b/frontend/src/v1/lib/cn.ts new file mode 100644 index 0000000000..cf37ce9c1c --- /dev/null +++ b/frontend/src/v1/lib/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; +const twMerge = extendTailwindMerge({ prefix: "v1" }); +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} From a9307e7e8c91cd2aa60fa422551dee1eaa8e00f9 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:00:44 +0530 Subject: [PATCH 07/36] UN-4299 [FIX] Pin the v1 prefix in cn() with a test that fails without it Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/v1/lib/cn.test.ts b/frontend/src/v1/lib/cn.test.ts index 3f7bec2920..fe48be0654 100644 --- a/frontend/src/v1/lib/cn.test.ts +++ b/frontend/src/v1/lib/cn.test.ts @@ -11,3 +11,7 @@ it("keeps unrelated and variant classes", () => { "v1:flex v1:dark:bg-card v1:gap-2", ); }); + +it("leaves unprefixed classes alone, so the merge is scoped to the v1 prefix", () => { + expect(cn("p-2 p-4")).toBe("p-2 p-4"); +}); From 5a361ae0d26a613cc516137d61f7f2639f80bd60 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:05:36 +0530 Subject: [PATCH 08/36] UN-4299 [FIX] Use "ui" as the v1 utility prefix: Tailwind accepts lowercase letters only Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.test.ts | 10 +- frontend/src/v1/lib/cn.ts | 2 +- frontend/src/v1/lib/tokens.test.ts | 6 +- .../v1/styles/__snapshots__/token-table.md | 150 ++++++++-------- frontend/src/v1/styles/tokens.test.ts | 4 +- frontend/src/v1/styles/tokens.ts | 164 +++++++++--------- 6 files changed, 168 insertions(+), 168 deletions(-) diff --git a/frontend/src/v1/lib/cn.test.ts b/frontend/src/v1/lib/cn.test.ts index fe48be0654..5dd843f1ee 100644 --- a/frontend/src/v1/lib/cn.test.ts +++ b/frontend/src/v1/lib/cn.test.ts @@ -2,16 +2,16 @@ import { expect, it } from "vitest"; import { cn } from "./cn"; it("merges conflicting prefixed utilities, keeping the last", () => { - expect(cn("v1:p-2 v1:p-4")).toBe("v1:p-4"); - expect(cn("v1:bg-background", false, "v1:bg-card")).toBe("v1:bg-card"); + expect(cn("ui:p-2 ui:p-4")).toBe("ui:p-4"); + expect(cn("ui:bg-background", false, "ui:bg-card")).toBe("ui:bg-card"); }); it("keeps unrelated and variant classes", () => { - expect(cn("v1:flex v1:dark:bg-card", ["v1:gap-2"])).toBe( - "v1:flex v1:dark:bg-card v1:gap-2", + expect(cn("ui:flex ui:dark:bg-card", ["ui:gap-2"])).toBe( + "ui:flex ui:dark:bg-card ui:gap-2", ); }); -it("leaves unprefixed classes alone, so the merge is scoped to the v1 prefix", () => { +it("leaves unprefixed classes alone, so the merge is scoped to the ui prefix", () => { expect(cn("p-2 p-4")).toBe("p-2 p-4"); }); diff --git a/frontend/src/v1/lib/cn.ts b/frontend/src/v1/lib/cn.ts index cf37ce9c1c..8592caeb34 100644 --- a/frontend/src/v1/lib/cn.ts +++ b/frontend/src/v1/lib/cn.ts @@ -1,6 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; -const twMerge = extendTailwindMerge({ prefix: "v1" }); +const twMerge = extendTailwindMerge({ prefix: "ui" }); export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } diff --git a/frontend/src/v1/lib/tokens.test.ts b/frontend/src/v1/lib/tokens.test.ts index 5ae7b64759..e082c50ab5 100644 --- a/frontend/src/v1/lib/tokens.test.ts +++ b/frontend/src/v1/lib/tokens.test.ts @@ -10,14 +10,14 @@ const radius: Token = { family: "radius", name: "radius-sm", cssVar: "--radius-sm", - utility: "v1:rounded-sm", + utility: "ui:rounded-sm", values: sameInEveryMode("6px"), }; const bg: Token = { family: "colour", name: "background", cssVar: "--background", - utility: "v1:bg-background", + utility: "ui:bg-background", values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#0f0a1f" }, }; @@ -36,6 +36,6 @@ it("renders the gallery table with one row per token", () => { "| Family | Token | CSS variable | Utility | Light | Dark | Unstract |", ); expect(table).toContain( - "| colour | background | `--background` | `v1:bg-background` | #fafafa | #1a1a1a | #0f0a1f |", + "| colour | background | `--background` | `ui:bg-background` | #fafafa | #1a1a1a | #0f0a1f |", ); }); diff --git a/frontend/src/v1/styles/__snapshots__/token-table.md b/frontend/src/v1/styles/__snapshots__/token-table.md index 11eb778532..11bdfac7e6 100644 --- a/frontend/src/v1/styles/__snapshots__/token-table.md +++ b/frontend/src/v1/styles/__snapshots__/token-table.md @@ -1,77 +1,77 @@ | Family | Token | CSS variable | Utility | Light | Dark | Unstract | | --- | --- | --- | --- | --- | --- | --- | -| colour | background | `--background` | `v1:bg-background` | #fafafa | #1a1a1a | #f0f0f0 | -| colour | foreground | `--foreground` | `v1:text-foreground` | #1a1a1a | #fafafa | #1a1a1a | -| colour | card | `--card` | `v1:bg-card` | #fcfcfc | #19191a | #ffffff | -| colour | card-foreground | `--card-foreground` | `v1:text-card-foreground` | #1a1a1a | #fafafa | #1a1a1a | -| colour | popover | `--popover` | `v1:bg-popover` | #ffffff | #171717 | #ffffff | -| colour | popover-foreground | `--popover-foreground` | `v1:text-popover-foreground` | #19191a | #fafafa | #19191a | -| colour | primary | `--primary` | `v1:bg-primary` | #6f5cef | #6f5cef | #072b4d | -| colour | primary-foreground | `--primary-foreground` | `v1:text-primary-foreground` | #fafafa | #fafafa | #ffffff | -| colour | primary-muted | `--primary-muted` | `v1:bg-primary-muted` | #f1effd | #2a2445 | #e8eef4 | -| colour | secondary | `--secondary` | `v1:bg-secondary` | #97caf5 | rgb(255 255 255 / 0.06) | #f0f4f8 | -| colour | secondary-foreground | `--secondary-foreground` | `v1:text-secondary-foreground` | #19191a | #fafafa | #072b4d | -| colour | muted | `--muted` | `v1:bg-muted` | #f5f5f5 | #262626 | #f0f0f0 | -| colour | muted-foreground | `--muted-foreground` | `v1:text-muted-foreground` | #737373 | #a1a1a1 | #6b7280 | -| colour | accent | `--accent` | `v1:bg-accent` | #f5f5f5 | #2a2a2d | #e8eef4 | -| colour | accent-foreground | `--accent-foreground` | `v1:text-accent-foreground` | #6161cc | #c1c5fb | #072b4d | -| colour | destructive | `--destructive` | `v1:bg-destructive` | #b41b30 | #e5484d | #b41b30 | -| colour | destructive-foreground | `--destructive-foreground` | `v1:text-destructive-foreground` | #ffffff | #fafafa | #ffffff | -| colour | success | `--success` | `v1:bg-success` | #15803d | #4ade80 | #15803d | -| colour | warning | `--warning` | `v1:bg-warning` | #b45309 | #fbbf24 | #b45309 | -| colour | border | `--border` | `v1:border-border` | #e5e5e5 | #2e2e2e | #e4e7eb | -| colour | input | `--input` | `v1:border-input` | #d3d3d3 | #343434 | #d3d8de | -| colour | ring | `--ring` | `v1:ring-ring` | #6f5cef | #6f5cef | #0e3963 | -| colour | sidebar | `--sidebar` | `v1:bg-sidebar` | #ffffff | #171717 | #0e3963 | -| colour | sidebar-foreground | `--sidebar-foreground` | `v1:text-sidebar-foreground` | #1a1a1a | #fafafa | #ffffff | -| colour | sidebar-accent | `--sidebar-accent` | `v1:bg-sidebar-accent` | #f5f5f5 | #262626 | #1b5183 | -| colour | sidebar-accent-foreground | `--sidebar-accent-foreground` | `v1:text-sidebar-accent-foreground` | #1a1a1a | #fafafa | #ffffff | -| colour | sidebar-border | `--sidebar-border` | `v1:border-sidebar-border` | #e5e5e5 | #2e2e2e | #17436e | -| colour | sidebar-muted-foreground | `--sidebar-muted-foreground` | `v1:text-sidebar-muted-foreground` | #737373 | #a1a1aa | #a9c3da | -| colour | header | `--header` | `v1:bg-header` | #fcfcfc | #0a0a0a | #072b4d | -| colour | header-foreground | `--header-foreground` | `v1:text-header-foreground` | #1a1a1a | #fafafa | #ffffff | -| spacing | space-1 | `--space-1` | `v1:p-1` | 4px | 4px | 4px | -| spacing | space-2 | `--space-2` | `v1:p-2` | 8px | 8px | 8px | -| spacing | space-3 | `--space-3` | `v1:p-3` | 12px | 12px | 12px | -| spacing | space-4 | `--space-4` | `v1:p-4` | 16px | 16px | 16px | -| spacing | space-5 | `--space-5` | `v1:p-5` | 20px | 20px | 20px | -| spacing | space-6 | `--space-6` | `v1:p-6` | 24px | 24px | 24px | -| spacing | space-8 | `--space-8` | `v1:p-8` | 32px | 32px | 32px | -| radius | radius-sm | `--radius-sm` | `v1:rounded-sm` | 6px | 6px | 6px | -| radius | radius-md | `--radius-md` | `v1:rounded-md` | 8px | 8px | 8px | -| radius | radius-lg | `--radius-lg` | `v1:rounded-lg` | 10px | 10px | 10px | -| radius | radius-xl | `--radius-xl` | `v1:rounded-xl` | 14px | 14px | 14px | -| type | type-display-size | `--type-display-size` | `v1:text-display` | 34px | 34px | 34px | -| type | type-display-line-height | `--type-display-line-height` | `v1:text-display` | 46px | 46px | 46px | -| type | type-display-weight | `--type-display-weight` | `v1:text-display` | 600 | 600 | 600 | -| type | type-page-title-size | `--type-page-title-size` | `v1:text-page-title` | 20px | 20px | 20px | -| type | type-page-title-line-height | `--type-page-title-line-height` | `v1:text-page-title` | 28px | 28px | 28px | -| type | type-page-title-weight | `--type-page-title-weight` | `v1:text-page-title` | 600 | 600 | 600 | -| type | type-section-title-size | `--type-section-title-size` | `v1:text-section-title` | 16px | 16px | 16px | -| type | type-section-title-line-height | `--type-section-title-line-height` | `v1:text-section-title` | 24px | 24px | 24px | -| type | type-section-title-weight | `--type-section-title-weight` | `v1:text-section-title` | 600 | 600 | 600 | -| type | type-body-size | `--type-body-size` | `v1:text-body` | 14px | 14px | 14px | -| type | type-body-line-height | `--type-body-line-height` | `v1:text-body` | 20px | 20px | 20px | -| type | type-body-weight | `--type-body-weight` | `v1:text-body` | 400 | 400 | 400 | -| type | type-body-small-size | `--type-body-small-size` | `v1:text-body-small` | 13px | 13px | 13px | -| type | type-body-small-line-height | `--type-body-small-line-height` | `v1:text-body-small` | 18px | 18px | 18px | -| type | type-body-small-weight | `--type-body-small-weight` | `v1:text-body-small` | 400 | 400 | 400 | -| type | type-caption-size | `--type-caption-size` | `v1:text-caption` | 12px | 12px | 12px | -| type | type-caption-line-height | `--type-caption-line-height` | `v1:text-caption` | 16px | 16px | 16px | -| type | type-caption-weight | `--type-caption-weight` | `v1:text-caption` | 400 | 400 | 400 | -| type | type-overline-size | `--type-overline-size` | `v1:text-overline` | 11px | 11px | 11px | -| type | type-overline-line-height | `--type-overline-line-height` | `v1:text-overline` | 16px | 16px | 16px | -| type | type-overline-weight | `--type-overline-weight` | `v1:text-overline` | 600 | 600 | 600 | -| type | type-mono-size | `--type-mono-size` | `v1:text-mono` | 12px | 12px | 12px | -| type | type-mono-line-height | `--type-mono-line-height` | `v1:text-mono` | 22px | 22px | 22px | -| type | type-mono-weight | `--type-mono-weight` | `v1:text-mono` | 400 | 400 | 400 | -| type | type-overline-tracking | `--type-overline-tracking` | `v1:text-overline` | 0.05em | 0.05em | 0.05em | -| elevation | shadow-sm | `--shadow-sm` | `v1:shadow-sm` | 0 1px 2px 0 rgb(0 0 0 / 0.05) | 0 1px 2px 0 rgb(0 0 0 / 0.05) | 0 1px 2px 0 rgb(0 0 0 / 0.05) | -| elevation | shadow-md | `--shadow-md` | `v1:shadow-md` | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | -| elevation | shadow-lg | `--shadow-lg` | `v1:shadow-lg` | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | -| elevation | shadow-xl | `--shadow-xl` | `v1:shadow-xl` | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | -| elevation | scrim | `--scrim` | `v1:bg-scrim` | rgb(0 0 0 / 0.5) | rgb(0 0 0 / 0.5) | rgb(0 0 0 / 0.5) | -| focus | focus-ring-width | `--focus-ring-width` | `v1:focus-ring` | 3px | 3px | 3px | -| focus | focus-ring-alpha | `--focus-ring-alpha` | `v1:focus-ring` | 50% | 50% | 50% | -| font | font-sans | `--font-sans` | `v1:font-sans` | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | -| font | font-mono | `--font-mono` | `v1:font-mono` | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | +| colour | background | `--background` | `ui:bg-background` | #fafafa | #1a1a1a | #f0f0f0 | +| colour | foreground | `--foreground` | `ui:text-foreground` | #1a1a1a | #fafafa | #1a1a1a | +| colour | card | `--card` | `ui:bg-card` | #fcfcfc | #19191a | #ffffff | +| colour | card-foreground | `--card-foreground` | `ui:text-card-foreground` | #1a1a1a | #fafafa | #1a1a1a | +| colour | popover | `--popover` | `ui:bg-popover` | #ffffff | #171717 | #ffffff | +| colour | popover-foreground | `--popover-foreground` | `ui:text-popover-foreground` | #19191a | #fafafa | #19191a | +| colour | primary | `--primary` | `ui:bg-primary` | #6f5cef | #6f5cef | #072b4d | +| colour | primary-foreground | `--primary-foreground` | `ui:text-primary-foreground` | #fafafa | #fafafa | #ffffff | +| colour | primary-muted | `--primary-muted` | `ui:bg-primary-muted` | #f1effd | #2a2445 | #e8eef4 | +| colour | secondary | `--secondary` | `ui:bg-secondary` | #97caf5 | rgb(255 255 255 / 0.06) | #f0f4f8 | +| colour | secondary-foreground | `--secondary-foreground` | `ui:text-secondary-foreground` | #19191a | #fafafa | #072b4d | +| colour | muted | `--muted` | `ui:bg-muted` | #f5f5f5 | #262626 | #f0f0f0 | +| colour | muted-foreground | `--muted-foreground` | `ui:text-muted-foreground` | #737373 | #a1a1a1 | #6b7280 | +| colour | accent | `--accent` | `ui:bg-accent` | #f5f5f5 | #2a2a2d | #e8eef4 | +| colour | accent-foreground | `--accent-foreground` | `ui:text-accent-foreground` | #6161cc | #c1c5fb | #072b4d | +| colour | destructive | `--destructive` | `ui:bg-destructive` | #b41b30 | #e5484d | #b41b30 | +| colour | destructive-foreground | `--destructive-foreground` | `ui:text-destructive-foreground` | #ffffff | #fafafa | #ffffff | +| colour | success | `--success` | `ui:bg-success` | #15803d | #4ade80 | #15803d | +| colour | warning | `--warning` | `ui:bg-warning` | #b45309 | #fbbf24 | #b45309 | +| colour | border | `--border` | `ui:border-border` | #e5e5e5 | #2e2e2e | #e4e7eb | +| colour | input | `--input` | `ui:border-input` | #d3d3d3 | #343434 | #d3d8de | +| colour | ring | `--ring` | `ui:ring-ring` | #6f5cef | #6f5cef | #0e3963 | +| colour | sidebar | `--sidebar` | `ui:bg-sidebar` | #ffffff | #171717 | #0e3963 | +| colour | sidebar-foreground | `--sidebar-foreground` | `ui:text-sidebar-foreground` | #1a1a1a | #fafafa | #ffffff | +| colour | sidebar-accent | `--sidebar-accent` | `ui:bg-sidebar-accent` | #f5f5f5 | #262626 | #1b5183 | +| colour | sidebar-accent-foreground | `--sidebar-accent-foreground` | `ui:text-sidebar-accent-foreground` | #1a1a1a | #fafafa | #ffffff | +| colour | sidebar-border | `--sidebar-border` | `ui:border-sidebar-border` | #e5e5e5 | #2e2e2e | #17436e | +| colour | sidebar-muted-foreground | `--sidebar-muted-foreground` | `ui:text-sidebar-muted-foreground` | #737373 | #a1a1aa | #a9c3da | +| colour | header | `--header` | `ui:bg-header` | #fcfcfc | #0a0a0a | #072b4d | +| colour | header-foreground | `--header-foreground` | `ui:text-header-foreground` | #1a1a1a | #fafafa | #ffffff | +| spacing | space-1 | `--space-1` | `ui:p-1` | 4px | 4px | 4px | +| spacing | space-2 | `--space-2` | `ui:p-2` | 8px | 8px | 8px | +| spacing | space-3 | `--space-3` | `ui:p-3` | 12px | 12px | 12px | +| spacing | space-4 | `--space-4` | `ui:p-4` | 16px | 16px | 16px | +| spacing | space-5 | `--space-5` | `ui:p-5` | 20px | 20px | 20px | +| spacing | space-6 | `--space-6` | `ui:p-6` | 24px | 24px | 24px | +| spacing | space-8 | `--space-8` | `ui:p-8` | 32px | 32px | 32px | +| radius | radius-sm | `--radius-sm` | `ui:rounded-sm` | 6px | 6px | 6px | +| radius | radius-md | `--radius-md` | `ui:rounded-md` | 8px | 8px | 8px | +| radius | radius-lg | `--radius-lg` | `ui:rounded-lg` | 10px | 10px | 10px | +| radius | radius-xl | `--radius-xl` | `ui:rounded-xl` | 14px | 14px | 14px | +| type | type-display-size | `--type-display-size` | `ui:text-display` | 34px | 34px | 34px | +| type | type-display-line-height | `--type-display-line-height` | `ui:text-display` | 46px | 46px | 46px | +| type | type-display-weight | `--type-display-weight` | `ui:text-display` | 600 | 600 | 600 | +| type | type-page-title-size | `--type-page-title-size` | `ui:text-page-title` | 20px | 20px | 20px | +| type | type-page-title-line-height | `--type-page-title-line-height` | `ui:text-page-title` | 28px | 28px | 28px | +| type | type-page-title-weight | `--type-page-title-weight` | `ui:text-page-title` | 600 | 600 | 600 | +| type | type-section-title-size | `--type-section-title-size` | `ui:text-section-title` | 16px | 16px | 16px | +| type | type-section-title-line-height | `--type-section-title-line-height` | `ui:text-section-title` | 24px | 24px | 24px | +| type | type-section-title-weight | `--type-section-title-weight` | `ui:text-section-title` | 600 | 600 | 600 | +| type | type-body-size | `--type-body-size` | `ui:text-body` | 14px | 14px | 14px | +| type | type-body-line-height | `--type-body-line-height` | `ui:text-body` | 20px | 20px | 20px | +| type | type-body-weight | `--type-body-weight` | `ui:text-body` | 400 | 400 | 400 | +| type | type-body-small-size | `--type-body-small-size` | `ui:text-body-small` | 13px | 13px | 13px | +| type | type-body-small-line-height | `--type-body-small-line-height` | `ui:text-body-small` | 18px | 18px | 18px | +| type | type-body-small-weight | `--type-body-small-weight` | `ui:text-body-small` | 400 | 400 | 400 | +| type | type-caption-size | `--type-caption-size` | `ui:text-caption` | 12px | 12px | 12px | +| type | type-caption-line-height | `--type-caption-line-height` | `ui:text-caption` | 16px | 16px | 16px | +| type | type-caption-weight | `--type-caption-weight` | `ui:text-caption` | 400 | 400 | 400 | +| type | type-overline-size | `--type-overline-size` | `ui:text-overline` | 11px | 11px | 11px | +| type | type-overline-line-height | `--type-overline-line-height` | `ui:text-overline` | 16px | 16px | 16px | +| type | type-overline-weight | `--type-overline-weight` | `ui:text-overline` | 600 | 600 | 600 | +| type | type-mono-size | `--type-mono-size` | `ui:text-mono` | 12px | 12px | 12px | +| type | type-mono-line-height | `--type-mono-line-height` | `ui:text-mono` | 22px | 22px | 22px | +| type | type-mono-weight | `--type-mono-weight` | `ui:text-mono` | 400 | 400 | 400 | +| type | type-overline-tracking | `--type-overline-tracking` | `ui:text-overline` | 0.05em | 0.05em | 0.05em | +| elevation | shadow-sm | `--shadow-sm` | `ui:shadow-sm` | 0 1px 2px 0 rgb(0 0 0 / 0.05) | 0 1px 2px 0 rgb(0 0 0 / 0.05) | 0 1px 2px 0 rgb(0 0 0 / 0.05) | +| elevation | shadow-md | `--shadow-md` | `ui:shadow-md` | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | 0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10) | +| elevation | shadow-lg | `--shadow-lg` | `ui:shadow-lg` | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | 0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10) | +| elevation | shadow-xl | `--shadow-xl` | `ui:shadow-xl` | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | 0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10) | +| elevation | scrim | `--scrim` | `ui:bg-scrim` | rgb(0 0 0 / 0.5) | rgb(0 0 0 / 0.5) | rgb(0 0 0 / 0.5) | +| focus | focus-ring-width | `--focus-ring-width` | `ui:focus-ring` | 3px | 3px | 3px | +| focus | focus-ring-alpha | `--focus-ring-alpha` | `ui:focus-ring` | 50% | 50% | 50% | +| font | font-sans | `--font-sans` | `ui:font-sans` | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif | +| font | font-mono | `--font-mono` | `ui:font-mono` | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace | diff --git a/frontend/src/v1/styles/tokens.test.ts b/frontend/src/v1/styles/tokens.test.ts index fe53e19d54..ea9085ef31 100644 --- a/frontend/src/v1/styles/tokens.test.ts +++ b/frontend/src/v1/styles/tokens.test.ts @@ -13,10 +13,10 @@ it("every token is defined in every mode with a non-empty value", () => { } }); -it("token names and CSS variables are unique and runtime variables never use Tailwind's --v1- namespace", () => { +it("token names and CSS variables are unique and runtime variables never use Tailwind's --ui- namespace", () => { expect(new Set(TOKENS.map((t) => t.name)).size).toBe(TOKENS.length); expect(new Set(TOKENS.map((t) => t.cssVar)).size).toBe(TOKENS.length); - expect(TOKENS.filter((t) => t.cssVar.startsWith("--v1-"))).toEqual([]); + expect(TOKENS.filter((t) => t.cssVar.startsWith("--ui-"))).toEqual([]); }); it("covers the families the board defines", () => { diff --git a/frontend/src/v1/styles/tokens.ts b/frontend/src/v1/styles/tokens.ts index b6cd8f0901..69f44bd195 100644 --- a/frontend/src/v1/styles/tokens.ts +++ b/frontend/src/v1/styles/tokens.ts @@ -10,7 +10,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "background", cssVar: "--background", - utility: "v1:bg-background", + utility: "ui:bg-background", values: { light: "#fafafa", dark: "#1a1a1a", unstract: "#f0f0f0" }, note: "page surface", }, @@ -18,7 +18,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "foreground", cssVar: "--foreground", - utility: "v1:text-foreground", + utility: "ui:text-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, note: "default text", }, @@ -26,7 +26,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "card", cssVar: "--card", - utility: "v1:bg-card", + utility: "ui:bg-card", values: { light: "#fcfcfc", dark: "#19191a", unstract: "#ffffff" }, note: "every card and dialog surface; cards sit on background, not on muted", }, @@ -34,28 +34,28 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "card-foreground", cssVar: "--card-foreground", - utility: "v1:text-card-foreground", + utility: "ui:text-card-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#1a1a1a" }, }, { family: "colour", name: "popover", cssVar: "--popover", - utility: "v1:bg-popover", + utility: "ui:bg-popover", values: { light: "#ffffff", dark: "#171717", unstract: "#ffffff" }, }, { family: "colour", name: "popover-foreground", cssVar: "--popover-foreground", - utility: "v1:text-popover-foreground", + utility: "ui:text-popover-foreground", values: { light: "#19191a", dark: "#fafafa", unstract: "#19191a" }, }, { family: "colour", name: "primary", cssVar: "--primary", - utility: "v1:bg-primary", + utility: "ui:bg-primary", values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#072b4d" }, note: "primary buttons, active tabs, selected icon buttons", }, @@ -63,14 +63,14 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "primary-foreground", cssVar: "--primary-foreground", - utility: "v1:text-primary-foreground", + utility: "ui:text-primary-foreground", values: { light: "#fafafa", dark: "#fafafa", unstract: "#ffffff" }, }, { family: "colour", name: "primary-muted", cssVar: "--primary-muted", - utility: "v1:bg-primary-muted", + utility: "ui:bg-primary-muted", values: { light: "#f1effd", dark: "#2a2445", unstract: "#e8eef4" }, note: "selected rows, active flow steps, selected model strip; added for Unstract", }, @@ -78,7 +78,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "secondary", cssVar: "--secondary", - utility: "v1:bg-secondary", + utility: "ui:bg-secondary", values: { light: "#97caf5", dark: "rgb(255 255 255 / 0.06)", @@ -89,14 +89,14 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "secondary-foreground", cssVar: "--secondary-foreground", - utility: "v1:text-secondary-foreground", + utility: "ui:text-secondary-foreground", values: { light: "#19191a", dark: "#fafafa", unstract: "#072b4d" }, }, { family: "colour", name: "muted", cssVar: "--muted", - utility: "v1:bg-muted", + utility: "ui:bg-muted", values: { light: "#f5f5f5", dark: "#262626", unstract: "#f0f0f0" }, note: "inset surfaces: table headers, code blocks, dropzones, skeletons", }, @@ -104,7 +104,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "muted-foreground", cssVar: "--muted-foreground", - utility: "v1:text-muted-foreground", + utility: "ui:text-muted-foreground", values: { light: "#737373", dark: "#a1a1a1", unstract: "#6b7280" }, note: "secondary text", }, @@ -112,7 +112,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "accent", cssVar: "--accent", - utility: "v1:bg-accent", + utility: "ui:bg-accent", values: { light: "#f5f5f5", dark: "#2a2a2d", unstract: "#e8eef4" }, note: "hover surface on rails, menu items and icon buttons", }, @@ -120,14 +120,14 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "accent-foreground", cssVar: "--accent-foreground", - utility: "v1:text-accent-foreground", + utility: "ui:text-accent-foreground", values: { light: "#6161cc", dark: "#c1c5fb", unstract: "#072b4d" }, }, { family: "colour", name: "destructive", cssVar: "--destructive", - utility: "v1:bg-destructive", + utility: "ui:bg-destructive", values: { light: "#b41b30", dark: "#e5484d", unstract: "#b41b30" }, note: "delete actions and failure states; never a warning", }, @@ -135,28 +135,28 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "destructive-foreground", cssVar: "--destructive-foreground", - utility: "v1:text-destructive-foreground", + utility: "ui:text-destructive-foreground", values: { light: "#ffffff", dark: "#fafafa", unstract: "#ffffff" }, }, { family: "colour", name: "success", cssVar: "--success", - utility: "v1:bg-success", + utility: "ui:bg-success", values: { light: "#15803d", dark: "#4ade80", unstract: "#15803d" }, }, { family: "colour", name: "warning", cssVar: "--warning", - utility: "v1:bg-warning", + utility: "ui:bg-warning", values: { light: "#b45309", dark: "#fbbf24", unstract: "#b45309" }, }, { family: "colour", name: "border", cssVar: "--border", - utility: "v1:border-border", + utility: "ui:border-border", values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#e4e7eb" }, note: "every 1px rule and control outline", }, @@ -164,14 +164,14 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "input", cssVar: "--input", - utility: "v1:border-input", + utility: "ui:border-input", values: { light: "#d3d3d3", dark: "#343434", unstract: "#d3d8de" }, }, { family: "colour", name: "ring", cssVar: "--ring", - utility: "v1:ring-ring", + utility: "ui:ring-ring", values: { light: "#6f5cef", dark: "#6f5cef", unstract: "#0e3963" }, note: "focus outline colour, paired with a 3px ring at 50% alpha", }, @@ -179,7 +179,7 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "sidebar", cssVar: "--sidebar", - utility: "v1:bg-sidebar", + utility: "ui:bg-sidebar", values: { light: "#ffffff", dark: "#171717", unstract: "#0e3963" }, note: "the rail and both nav bars", }, @@ -187,49 +187,49 @@ export const TOKENS: readonly Token[] = [ family: "colour", name: "sidebar-foreground", cssVar: "--sidebar-foreground", - utility: "v1:text-sidebar-foreground", + utility: "ui:text-sidebar-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, }, { family: "colour", name: "sidebar-accent", cssVar: "--sidebar-accent", - utility: "v1:bg-sidebar-accent", + utility: "ui:bg-sidebar-accent", values: { light: "#f5f5f5", dark: "#262626", unstract: "#1b5183" }, }, { family: "colour", name: "sidebar-accent-foreground", cssVar: "--sidebar-accent-foreground", - utility: "v1:text-sidebar-accent-foreground", + utility: "ui:text-sidebar-accent-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, }, { family: "colour", name: "sidebar-border", cssVar: "--sidebar-border", - utility: "v1:border-sidebar-border", + utility: "ui:border-sidebar-border", values: { light: "#e5e5e5", dark: "#2e2e2e", unstract: "#17436e" }, }, { family: "colour", name: "sidebar-muted-foreground", cssVar: "--sidebar-muted-foreground", - utility: "v1:text-sidebar-muted-foreground", + utility: "ui:text-sidebar-muted-foreground", values: { light: "#737373", dark: "#a1a1aa", unstract: "#a9c3da" }, }, { family: "colour", name: "header", cssVar: "--header", - utility: "v1:bg-header", + utility: "ui:bg-header", values: { light: "#fcfcfc", dark: "#0a0a0a", unstract: "#072b4d" }, }, { family: "colour", name: "header-foreground", cssVar: "--header-foreground", - utility: "v1:text-header-foreground", + utility: "ui:text-header-foreground", values: { light: "#1a1a1a", dark: "#fafafa", unstract: "#ffffff" }, }, // Spacing: one 4pt scale. A value not on this list is a bug. @@ -237,7 +237,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-1", cssVar: "--space-1", - utility: "v1:p-1", + utility: "ui:p-1", values: same("4px"), note: "icon-to-label inside a dense chip", }, @@ -245,7 +245,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-2", cssVar: "--space-2", - utility: "v1:p-2", + utility: "ui:p-2", values: same("8px"), note: "label to control, stacked text inside a cell", }, @@ -253,7 +253,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-3", cssVar: "--space-3", - utility: "v1:p-3", + utility: "ui:p-3", values: same("12px"), note: "control to control inside a group", }, @@ -261,7 +261,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-4", cssVar: "--space-4", - utility: "v1:p-4", + utility: "ui:p-4", values: same("16px"), note: "card gutter, list row rhythm", }, @@ -269,7 +269,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-5", cssVar: "--space-5", - utility: "v1:p-5", + utility: "ui:p-5", values: same("20px"), note: "card inner padding, compact", }, @@ -277,7 +277,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-6", cssVar: "--space-6", - utility: "v1:p-6", + utility: "ui:p-6", values: same("24px"), note: "card inner padding, default \u00b7 page padding y", }, @@ -285,7 +285,7 @@ export const TOKENS: readonly Token[] = [ family: "spacing", name: "space-8", cssVar: "--space-8", - utility: "v1:p-8", + utility: "ui:p-8", values: same("32px"), note: "page padding x, section break", }, @@ -294,7 +294,7 @@ export const TOKENS: readonly Token[] = [ family: "radius", name: "radius-sm", cssVar: "--radius-sm", - utility: "v1:rounded-sm", + utility: "ui:rounded-sm", values: same("6px"), note: "badge, pill, status chip, small icon button", }, @@ -302,7 +302,7 @@ export const TOKENS: readonly Token[] = [ family: "radius", name: "radius-md", cssVar: "--radius-md", - utility: "v1:rounded-md", + utility: "ui:rounded-md", values: same("8px"), note: "button, input, select, textarea, menu item", }, @@ -310,7 +310,7 @@ export const TOKENS: readonly Token[] = [ family: "radius", name: "radius-lg", cssVar: "--radius-lg", - utility: "v1:rounded-lg", + utility: "ui:rounded-lg", values: same("10px"), note: "card, dialog, menu, popover, dropzone", }, @@ -318,7 +318,7 @@ export const TOKENS: readonly Token[] = [ family: "radius", name: "radius-xl", cssVar: "--radius-xl", - utility: "v1:rounded-xl", + utility: "ui:rounded-xl", values: same("14px"), note: "empty-state icon badge", }, @@ -327,190 +327,190 @@ export const TOKENS: readonly Token[] = [ family: "type", name: "type-display-size", cssVar: "--type-display-size", - utility: "v1:text-display", + utility: "ui:text-display", values: same("34px"), }, { family: "type", name: "type-display-line-height", cssVar: "--type-display-line-height", - utility: "v1:text-display", + utility: "ui:text-display", values: same("46px"), }, { family: "type", name: "type-display-weight", cssVar: "--type-display-weight", - utility: "v1:text-display", + utility: "ui:text-display", values: same("600"), }, { family: "type", name: "type-page-title-size", cssVar: "--type-page-title-size", - utility: "v1:text-page-title", + utility: "ui:text-page-title", values: same("20px"), }, { family: "type", name: "type-page-title-line-height", cssVar: "--type-page-title-line-height", - utility: "v1:text-page-title", + utility: "ui:text-page-title", values: same("28px"), }, { family: "type", name: "type-page-title-weight", cssVar: "--type-page-title-weight", - utility: "v1:text-page-title", + utility: "ui:text-page-title", values: same("600"), }, { family: "type", name: "type-section-title-size", cssVar: "--type-section-title-size", - utility: "v1:text-section-title", + utility: "ui:text-section-title", values: same("16px"), }, { family: "type", name: "type-section-title-line-height", cssVar: "--type-section-title-line-height", - utility: "v1:text-section-title", + utility: "ui:text-section-title", values: same("24px"), }, { family: "type", name: "type-section-title-weight", cssVar: "--type-section-title-weight", - utility: "v1:text-section-title", + utility: "ui:text-section-title", values: same("600"), }, { family: "type", name: "type-body-size", cssVar: "--type-body-size", - utility: "v1:text-body", + utility: "ui:text-body", values: same("14px"), }, { family: "type", name: "type-body-line-height", cssVar: "--type-body-line-height", - utility: "v1:text-body", + utility: "ui:text-body", values: same("20px"), }, { family: "type", name: "type-body-weight", cssVar: "--type-body-weight", - utility: "v1:text-body", + utility: "ui:text-body", values: same("400"), }, { family: "type", name: "type-body-small-size", cssVar: "--type-body-small-size", - utility: "v1:text-body-small", + utility: "ui:text-body-small", values: same("13px"), }, { family: "type", name: "type-body-small-line-height", cssVar: "--type-body-small-line-height", - utility: "v1:text-body-small", + utility: "ui:text-body-small", values: same("18px"), }, { family: "type", name: "type-body-small-weight", cssVar: "--type-body-small-weight", - utility: "v1:text-body-small", + utility: "ui:text-body-small", values: same("400"), }, { family: "type", name: "type-caption-size", cssVar: "--type-caption-size", - utility: "v1:text-caption", + utility: "ui:text-caption", values: same("12px"), }, { family: "type", name: "type-caption-line-height", cssVar: "--type-caption-line-height", - utility: "v1:text-caption", + utility: "ui:text-caption", values: same("16px"), }, { family: "type", name: "type-caption-weight", cssVar: "--type-caption-weight", - utility: "v1:text-caption", + utility: "ui:text-caption", values: same("400"), }, { family: "type", name: "type-overline-size", cssVar: "--type-overline-size", - utility: "v1:text-overline", + utility: "ui:text-overline", values: same("11px"), - note: "Pair with v1:uppercase.", + note: "Pair with ui:uppercase.", }, { family: "type", name: "type-overline-line-height", cssVar: "--type-overline-line-height", - utility: "v1:text-overline", + utility: "ui:text-overline", values: same("16px"), - note: "Pair with v1:uppercase.", + note: "Pair with ui:uppercase.", }, { family: "type", name: "type-overline-weight", cssVar: "--type-overline-weight", - utility: "v1:text-overline", + utility: "ui:text-overline", values: same("600"), - note: "Pair with v1:uppercase.", + note: "Pair with ui:uppercase.", }, { family: "type", name: "type-mono-size", cssVar: "--type-mono-size", - utility: "v1:text-mono", + utility: "ui:text-mono", values: same("12px"), - note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + note: "Pair with ui:font-mono. Reserved for model output, JSON and token counts.", }, { family: "type", name: "type-mono-line-height", cssVar: "--type-mono-line-height", - utility: "v1:text-mono", + utility: "ui:text-mono", values: same("22px"), - note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + note: "Pair with ui:font-mono. Reserved for model output, JSON and token counts.", }, { family: "type", name: "type-mono-weight", cssVar: "--type-mono-weight", - utility: "v1:text-mono", + utility: "ui:text-mono", values: same("400"), - note: "Pair with v1:font-mono. Reserved for model output, JSON and token counts.", + note: "Pair with ui:font-mono. Reserved for model output, JSON and token counts.", }, { family: "type", name: "type-overline-tracking", cssVar: "--type-overline-tracking", - utility: "v1:text-overline", + utility: "ui:text-overline", values: same("0.05em"), - note: "Pair with v1:uppercase.", + note: "Pair with ui:uppercase.", }, // Elevation: one value per step, the same in every mode. { family: "elevation", name: "shadow-sm", cssVar: "--shadow-sm", - utility: "v1:shadow-sm", + utility: "ui:shadow-sm", values: same("0 1px 2px 0 rgb(0 0 0 / 0.05)"), note: "every card at rest", }, @@ -518,7 +518,7 @@ export const TOKENS: readonly Token[] = [ family: "elevation", name: "shadow-md", cssVar: "--shadow-md", - utility: "v1:shadow-md", + utility: "ui:shadow-md", values: same( "0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10)", ), @@ -528,7 +528,7 @@ export const TOKENS: readonly Token[] = [ family: "elevation", name: "shadow-lg", cssVar: "--shadow-lg", - utility: "v1:shadow-lg", + utility: "ui:shadow-lg", values: same( "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)", ), @@ -538,7 +538,7 @@ export const TOKENS: readonly Token[] = [ family: "elevation", name: "shadow-xl", cssVar: "--shadow-xl", - utility: "v1:shadow-xl", + utility: "ui:shadow-xl", values: same( "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)", ), @@ -548,7 +548,7 @@ export const TOKENS: readonly Token[] = [ family: "elevation", name: "scrim", cssVar: "--scrim", - utility: "v1:bg-scrim", + utility: "ui:bg-scrim", values: same("rgb(0 0 0 / 0.5)"), note: "behind every dialog, full viewport", }, @@ -557,14 +557,14 @@ export const TOKENS: readonly Token[] = [ family: "focus", name: "focus-ring-width", cssVar: "--focus-ring-width", - utility: "v1:focus-ring", + utility: "ui:focus-ring", values: same("3px"), }, { family: "focus", name: "focus-ring-alpha", cssVar: "--focus-ring-alpha", - utility: "v1:focus-ring", + utility: "ui:focus-ring", values: same("50%"), }, // Font @@ -572,7 +572,7 @@ export const TOKENS: readonly Token[] = [ family: "font", name: "font-sans", cssVar: "--font-sans", - utility: "v1:font-sans", + utility: "ui:font-sans", values: same( '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif', ), @@ -581,7 +581,7 @@ export const TOKENS: readonly Token[] = [ family: "font", name: "font-mono", cssVar: "--font-mono", - utility: "v1:font-mono", + utility: "ui:font-mono", values: same( '"Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', ), From 0e2b7a0f8085853a152514fffbeeac3d8765034f Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:07:13 +0530 Subject: [PATCH 09/36] UN-4299 [FEAT] Add the prefixed v1 Tailwind root with fonts, modes, theme mapping and the focus utility Co-Authored-By: Claude Opus 5.5 --- frontend/bun.lock | 7 +- frontend/package.json | 1 + frontend/src/v1/styles/base.css | 25 ++++++ frontend/src/v1/styles/index.css | 113 ++++++++++++++++++++++++++++ frontend/src/v1/styles/root.test.ts | 92 ++++++++++++++++++++++ 5 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 frontend/src/v1/styles/base.css create mode 100644 frontend/src/v1/styles/index.css create mode 100644 frontend/src/v1/styles/root.test.ts diff --git a/frontend/bun.lock b/frontend/bun.lock index 3616692ef3..bdb5fa847e 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -67,6 +67,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.13", + "@tailwindcss/node": "4.3.3", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", @@ -1400,8 +1401,6 @@ "redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], - "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], - "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], "remark-gfm": ["remark-gfm@3.0.1", "", { "dependencies": { "@types/mdast": "^3.0.0", "mdast-util-gfm": "^2.0.0", "micromark-extension-gfm": "^2.0.0", "unified": "^10.0.0" } }, "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig=="], @@ -1630,6 +1629,8 @@ "@reduxjs/toolkit/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + "@reduxjs/toolkit/redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "@svgr/hast-util-to-babel-ast/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -1682,8 +1683,6 @@ "recharts/react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], - "redux-thunk/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], - "refractor/@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], diff --git a/frontend/package.json b/frontend/package.json index 7007b8fc20..fc04c1348b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -97,6 +97,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.13", + "@tailwindcss/node": "4.3.3", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4", diff --git a/frontend/src/v1/styles/base.css b/frontend/src/v1/styles/base.css new file mode 100644 index 0000000000..4ae40107cd --- /dev/null +++ b/frontend/src/v1/styles/base.css @@ -0,0 +1,25 @@ +/* What preflight would give the v1 tree, scoped to .v1 so v0 is untouched. */ +@layer base { + .v1 { + min-height: 100%; + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + font-size: var(--type-body-size); + line-height: var(--type-body-line-height); + font-weight: var(--type-body-weight); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + .v1 *, + .v1 ::before, + .v1 ::after { + border-color: var(--border); + } + .v1 code, + .v1 kbd, + .v1 pre, + .v1 samp { + font-family: var(--font-mono); + } +} diff --git a/frontend/src/v1/styles/index.css b/frontend/src/v1/styles/index.css new file mode 100644 index 0000000000..c412f0c426 --- /dev/null +++ b/frontend/src/v1/styles/index.css @@ -0,0 +1,113 @@ +/* The v1 Tailwind root. Prefixed `ui` (Tailwind accepts letters only, so not + * `v1`): utilities are `ui:…` and theme variables `--ui-…`, so they never + * collide with v0's, which compiles from src/index.css in the same document. + * Preflight is deliberately not imported: the document already has v0's, and + * a second copy would reset v0's border colours. P9 adds it when v0 goes. */ +@layer theme, base, components, utilities; +@import "tailwindcss/theme.css" layer(theme) prefix(ui); +@import "tailwindcss/utilities.css" layer(utilities) prefix(ui) source(none); + +@import "@fontsource/inter/400.css"; +@import "@fontsource/inter/500.css"; +@import "@fontsource/inter/600.css"; +@import "@fontsource/geist-mono/400.css"; + +@import "./tokens.css"; +@import "./base.css"; + +/* Scan only the v1 tree (source(none) above turns off automatic detection). */ +@source "../"; + +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); +@custom-variant unstract (&:where( + [data-theme="unstract"], + [data-theme="unstract"] * + )); + +/* Every theme key reads a runtime token from tokens.css; the stock namespaces + * are reset so only the token-backed utilities exist. */ +@theme inline { + --color-*: initial; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-primary-muted: var(--primary-muted); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-warning: var(--warning); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-muted-foreground: var(--sidebar-muted-foreground); + --color-header: var(--header); + --color-header-foreground: var(--header-foreground); + --color-scrim: var(--scrim); + + --radius-*: initial; + --radius-sm: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: var(--radius-xl); + + --shadow-*: initial; + --shadow-sm: var(--shadow-sm); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + + --text-*: initial; + --text-display: var(--type-display-size); + --text-display--line-height: var(--type-display-line-height); + --text-display--font-weight: var(--type-display-weight); + --text-page-title: var(--type-page-title-size); + --text-page-title--line-height: var(--type-page-title-line-height); + --text-page-title--font-weight: var(--type-page-title-weight); + --text-section-title: var(--type-section-title-size); + --text-section-title--line-height: var(--type-section-title-line-height); + --text-section-title--font-weight: var(--type-section-title-weight); + --text-body: var(--type-body-size); + --text-body--line-height: var(--type-body-line-height); + --text-body--font-weight: var(--type-body-weight); + --text-body-small: var(--type-body-small-size); + --text-body-small--line-height: var(--type-body-small-line-height); + --text-body-small--font-weight: var(--type-body-small-weight); + --text-caption: var(--type-caption-size); + --text-caption--line-height: var(--type-caption-line-height); + --text-caption--font-weight: var(--type-caption-weight); + --text-overline: var(--type-overline-size); + --text-overline--line-height: var(--type-overline-line-height); + --text-overline--font-weight: var(--type-overline-weight); + --text-mono: var(--type-mono-size); + --text-mono--line-height: var(--type-mono-line-height); + --text-mono--font-weight: var(--type-mono-weight); + --text-overline--letter-spacing: var(--type-overline-tracking); + + --font-*: initial; + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); +} + +/* Board section 6: "border → ring, plus 3px ring at 50% outside". */ +@utility focus-ring { + outline: 0; + border-color: var(--ring); + box-shadow: 0 0 0 var(--focus-ring-width) + color-mix(in srgb, var(--ring) var(--focus-ring-alpha), transparent); +} diff --git a/frontend/src/v1/styles/root.test.ts b/frontend/src/v1/styles/root.test.ts new file mode 100644 index 0000000000..29a58cb5a3 --- /dev/null +++ b/frontend/src/v1/styles/root.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import path from "node:path"; +import { compile } from "@tailwindcss/node"; +import { beforeAll, expect, it } from "vitest"; +import { TOKENS } from "./tokens"; + +let css = ""; +beforeAll(async () => { + const base = path.resolve(import.meta.dirname); + const source = fs.readFileSync(path.join(base, "index.css"), "utf8"); + const compiler = await compile(source, { + base, + onDependency: () => undefined, + }); + css = compiler.build([ + "ui:bg-background", + "ui:text-foreground", + "ui:rounded-md", + "ui:shadow-md", + "ui:bg-scrim", + "ui:text-body", + "ui:text-overline", + "ui:font-mono", + "ui:focus-ring", + "ui:dark:bg-card", + "ui:unstract:bg-card", + "ui:p-5", + "bg-background", + "flex", + "ui:bg-red-500", + "ui:text-sm", + "ui:shadow-2xl", + "ui:rounded-2xl", + ]); +}); + +it("emits only prefixed utilities bound to runtime variables", () => { + expect(css).toMatch( + /\.ui\\:bg-background\s*\{[^}]*background-color:\s*var\(--background\)/, + ); + expect(css).toMatch( + /\.ui\\:rounded-md\s*\{[^}]*border-radius:\s*var\(--radius-md\)/, + ); + expect(css).toMatch(/\.ui\\:shadow-md\s*\{/); + expect(css).toMatch( + /\.ui\\:text-body\s*\{[^}]*font-size:\s*var\(--type-body-size\)/, + ); + expect(css).toMatch( + // Tailwind wraps the tracking in --tw-tracking so a tracking-* utility can override it. + /\.ui\\:text-overline\s*\{[^}]*letter-spacing:\s*var\((--tw-tracking,\s*var\()?--type-overline-tracking\)/, + ); + expect(css).toMatch(/\.ui\\:focus-ring\s*\{/); + expect(css).not.toMatch(/(^|[^\\:])\.bg-background\b/); + expect(css).not.toMatch(/(^|[^\\:])\.flex\b/); +}); + +it("has no stock palette, text scale, shadow scale or radius scale", () => { + expect(css).not.toContain("bg-red-500"); + expect(css).not.toContain("text-sm"); + expect(css).not.toContain("shadow-2xl"); + expect(css).not.toContain("rounded-2xl"); +}); + +it("dark and unstract variants key on data-theme, never on prefers-color-scheme", () => { + expect(css).toMatch(/\[data-theme="?dark"?\]/); + expect(css).toMatch(/\[data-theme="?unstract"?\]/); + expect(css).not.toContain("prefers-color-scheme"); +}); + +it("every emitted rule is a ui: utility, scoped to .v1, or Tailwind's :root/property defaults", () => { + const selectors = [...css.matchAll(/^\s*([^@{}\n][^{}\n]*)\{/gm)].map((m) => + m[1].trim(), + ); + const unscoped = selectors.filter( + (s) => + !/^(\.v1\b|\.ui\\:|:root, :host|\*, ::before, ::after, ::backdrop)/.test( + s, + ) && !s.startsWith("@"), + ); + expect(unscoped).toEqual([]); +}); + +it("declares no !important, and every custom property is a Tailwind ui variable or a runtime token", () => { + expect(css).not.toContain("!important"); + const tokenVars = new Set(TOKENS.map((t) => t.cssVar)); + const declared = [...css.matchAll(/^\s*(--[\w-]+):/gm)].map((m) => m[1]); + const strays = declared.filter( + (v) => + !v.startsWith("--ui-") && !v.startsWith("--tw-") && !tokenVars.has(v), + ); + expect(strays).toEqual([]); +}); From d737fa8fb6f92c844896a5716b5f4d4b59041875 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:09:48 +0530 Subject: [PATCH 10/36] UN-4299 [FIX] Exclude the generated tokens.css from Biome formatting so biome ci passes src/v1/styles/tokens.css is written by scripts/build-v1-tokens.mjs and checked byte-for-byte by styles/tokens.test.ts; the formatter would rewrap it and break the check. Co-Authored-By: Claude Opus 5.5 --- frontend/biome.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/biome.json b/frontend/biome.json index 6799918af6..df57f0f1a7 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -177,6 +177,10 @@ } } }, + { + "includes": ["src/v1/styles/tokens.css"], + "formatter": { "enabled": false } + }, { "includes": ["src/**", "!src/v1/**"], "linter": { From a9acf5e04dfa5f7f86f646b210b3830f376b18ea Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:10:29 +0530 Subject: [PATCH 11/36] UN-4299 [FIX] Sort the imports in cn.ts so biome ci passes Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/v1/lib/cn.ts b/frontend/src/v1/lib/cn.ts index 8592caeb34..6693302d90 100644 --- a/frontend/src/v1/lib/cn.ts +++ b/frontend/src/v1/lib/cn.ts @@ -1,5 +1,6 @@ -import { clsx, type ClassValue } from "clsx"; +import { type ClassValue, clsx } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; + const twMerge = extendTailwindMerge({ prefix: "ui" }); export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); From 54469cba684fcba503a9ae644a80c69ec0871dd8 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:14:08 +0530 Subject: [PATCH 12/36] UN-4299 [FIX] Make the root compile test fail on unscoped selector lists and on missing variants Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/styles/root.test.ts | 118 +++++++++++++++++++--------- 1 file changed, 81 insertions(+), 37 deletions(-) diff --git a/frontend/src/v1/styles/root.test.ts b/frontend/src/v1/styles/root.test.ts index 29a58cb5a3..773252256e 100644 --- a/frontend/src/v1/styles/root.test.ts +++ b/frontend/src/v1/styles/root.test.ts @@ -4,34 +4,76 @@ import { compile } from "@tailwindcss/node"; import { beforeAll, expect, it } from "vitest"; import { TOKENS } from "./tokens"; -let css = ""; -beforeAll(async () => { - const base = path.resolve(import.meta.dirname); - const source = fs.readFileSync(path.join(base, "index.css"), "utf8"); - const compiler = await compile(source, { +const base = path.resolve(import.meta.dirname); +const source = fs.readFileSync(path.join(base, "index.css"), "utf8"); +const CANDIDATES = [ + "ui:bg-background", + "ui:text-foreground", + "ui:rounded-md", + "ui:shadow-md", + "ui:bg-scrim", + "ui:text-body", + "ui:text-overline", + "ui:font-mono", + "ui:focus-ring", + "ui:dark:bg-card", + "ui:unstract:bg-card", + "ui:p-5", + "bg-background", + "flex", + "ui:bg-red-500", + "ui:text-sm", + "ui:shadow-2xl", + "ui:rounded-2xl", +]; + +async function build(stylesheet: string): Promise { + const compiler = await compile(stylesheet, { base, onDependency: () => undefined, }); - css = compiler.build([ - "ui:bg-background", - "ui:text-foreground", - "ui:rounded-md", - "ui:shadow-md", - "ui:bg-scrim", - "ui:text-body", - "ui:text-overline", - "ui:font-mono", - "ui:focus-ring", - "ui:dark:bg-card", - "ui:unstract:bg-card", - "ui:p-5", - "bg-background", - "flex", - "ui:bg-red-500", - "ui:text-sm", - "ui:shadow-2xl", - "ui:rounded-2xl", - ]); + return compiler.build(CANDIDATES); +} + +/** Split a selector list on its top-level commas (not those inside :where() or [..]). */ +function selectorParts(list: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const ch of list) { + if (ch === "(" || ch === "[") { + depth++; + } + if (ch === ")" || ch === "]") { + depth--; + } + if (ch === "," && depth === 0) { + parts.push(current.trim()); + current = ""; + } else { + current += ch; + } + } + parts.push(current.trim()); + return parts; +} + +// Each part must be a .v1-scoped selector, a ui: utility, or Tailwind's +// :root/:host theme layer and *, ::before, ::after, ::backdrop property defaults. +const ALLOWED_PART = + /^(\.v1(?=[\s.:[#>~+]|$)|\.ui\\:|:root$|:host$|\*$|::before$|::after$|::backdrop$)/; + +function unscopedParts(output: string): string[] { + return [...output.matchAll(/^\s*([^@{}\n][^{}\n]*)\{/gm)] + .map((m) => m[1].trim()) + .filter((list) => !list.startsWith("@")) + .flatMap(selectorParts) + .filter((part) => !ALLOWED_PART.test(part)); +} + +let css = ""; +beforeAll(async () => { + css = await build(source); }); it("emits only prefixed utilities bound to runtime variables", () => { @@ -62,22 +104,24 @@ it("has no stock palette, text scale, shadow scale or radius scale", () => { }); it("dark and unstract variants key on data-theme, never on prefers-color-scheme", () => { - expect(css).toMatch(/\[data-theme="?dark"?\]/); - expect(css).toMatch(/\[data-theme="?unstract"?\]/); + // Name the variant rules and their binding: tokens.css alone also contains + // [data-theme=…] selectors, so matching those would pass with no variants. + expect(css).toMatch( + /\.ui\\:dark\\:bg-card:where\(\[data-theme="?dark"?\],\s*\[data-theme="?dark"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + ); + expect(css).toMatch( + /\.ui\\:unstract\\:bg-card:where\(\[data-theme="?unstract"?\],\s*\[data-theme="?unstract"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + ); expect(css).not.toContain("prefers-color-scheme"); }); -it("every emitted rule is a ui: utility, scoped to .v1, or Tailwind's :root/property defaults", () => { - const selectors = [...css.matchAll(/^\s*([^@{}\n][^{}\n]*)\{/gm)].map((m) => - m[1].trim(), - ); - const unscoped = selectors.filter( - (s) => - !/^(\.v1\b|\.ui\\:|:root, :host|\*, ::before, ::after, ::backdrop)/.test( - s, - ) && !s.startsWith("@"), +it("every emitted selector is a ui: utility, scoped to .v1, or Tailwind's :root/property defaults", async () => { + expect(unscopedParts(css)).toEqual([]); + // Negative proof: a leak hidden after a scoped selector in a list is caught. + const leaky = await build( + `${source}\n.v1 p, body { color: var(--foreground); }\n`, ); - expect(unscoped).toEqual([]); + expect(unscopedParts(leaky)).toEqual(["body"]); }); it("declares no !important, and every custom property is a Tailwind ui variable or a runtime token", () => { From 43c9ced657bb0f312a26d0925d6a7626e85f2088 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:18:16 +0530 Subject: [PATCH 13/36] UN-4299 [FEAT] Add ThemeProvider: the v1 root, follow-system binding and portal sync Co-Authored-By: Claude Opus 5.5 --- .../src/v1/app/theme/ThemeProvider.test.tsx | 88 +++++++++++++++++++ frontend/src/v1/app/theme/ThemeProvider.tsx | 57 ++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 frontend/src/v1/app/theme/ThemeProvider.test.tsx create mode 100644 frontend/src/v1/app/theme/ThemeProvider.tsx diff --git a/frontend/src/v1/app/theme/ThemeProvider.test.tsx b/frontend/src/v1/app/theme/ThemeProvider.test.tsx new file mode 100644 index 0000000000..1b3026fafe --- /dev/null +++ b/frontend/src/v1/app/theme/ThemeProvider.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import { getPortalContainer } from "@v1/lib/portal"; +import { useThemeStore } from "@v1/stores/theme"; +import { act } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ThemeProvider } from "./ThemeProvider"; + +type Listener = (e: { matches: boolean }) => void; +let listeners: Listener[] = []; +let matches = false; + +beforeEach(() => { + listeners = []; + matches = false; + document.body.innerHTML = ""; + window.localStorage.clear(); + useThemeStore.setState({ + preference: "system", + systemPrefersDark: false, + mode: "light", + }); + vi.stubGlobal("matchMedia", (query: string) => ({ + media: query, + matches, + addEventListener: (_: string, l: Listener) => { + listeners.push(l); + }, + removeEventListener: (_: string, l: Listener) => { + listeners = listeners.filter((x) => x !== l); + }, + })); +}); +afterEach(() => vi.unstubAllGlobals()); + +it("renders the v1 root with the stored mode and syncs the portal container", () => { + window.localStorage.setItem("unstract.v1.theme", "unstract"); + render( + + hi + , + ); + const root = screen.getByText("hi").parentElement as HTMLElement; + expect(root.classList.contains("v1")).toBe(true); + expect(root.dataset.uiVersion).toBe("v1"); + expect(root.dataset.theme).toBe("unstract"); + expect(getPortalContainer().dataset.theme).toBe("unstract"); +}); + +it("follows the system preference while set to system and stops after an explicit choice", () => { + render( + + hi + , + ); + const root = screen.getByText("hi").parentElement as HTMLElement; + act(() => { + for (const l of listeners) { + l({ matches: true }); + } + }); + expect(root.dataset.theme).toBe("dark"); + act(() => { + useThemeStore.getState().setPreference("light"); + }); + act(() => { + for (const l of listeners) { + l({ matches: false }); + } + }); + act(() => { + for (const l of listeners) { + l({ matches: true }); + } + }); + expect(root.dataset.theme).toBe("light"); + expect(getPortalContainer().dataset.theme).toBe("light"); +}); + +it("removes the media listener on unmount", () => { + const { unmount } = render( + + hi + , + ); + expect(listeners).toHaveLength(1); + unmount(); + expect(listeners).toHaveLength(0); +}); diff --git a/frontend/src/v1/app/theme/ThemeProvider.tsx b/frontend/src/v1/app/theme/ThemeProvider.tsx new file mode 100644 index 0000000000..99990ec49e --- /dev/null +++ b/frontend/src/v1/app/theme/ThemeProvider.tsx @@ -0,0 +1,57 @@ +import "@v1/styles/index.css"; + +import { cn } from "@v1/lib/cn"; +import { applyThemeMode } from "@v1/lib/portal"; +import { selectThemeMode, useThemeStore } from "@v1/stores/theme"; +import { type JSX, type ReactNode, useEffect } from "react"; + +export type ThemeProviderProps = Readonly<{ + children: ReactNode; + className?: string; +}>; + +const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; + +/** Reading localStorage can throw in sandboxed documents; treat that as no storage. */ +function readLocalStorage(): Storage | null { + try { + return window.localStorage; + } catch { + return null; + } +} + +/** The v1 root: hydrates the theme store, follows the OS scheme, and stamps the mode. */ +export function ThemeProvider({ + children, + className, +}: ThemeProviderProps): JSX.Element { + const mode = useThemeStore(selectThemeMode); + + useEffect(() => { + const { hydrate, setSystemPrefersDark } = useThemeStore.getState(); + hydrate(readLocalStorage()); + if (typeof window.matchMedia !== "function") { + return undefined; + } + const query = window.matchMedia(DARK_SCHEME_QUERY); + setSystemPrefersDark(query.matches); + const onChange = (event: { matches: boolean }) => { + setSystemPrefersDark(event.matches); + }; + query.addEventListener("change", onChange); + return () => { + query.removeEventListener("change", onChange); + }; + }, []); + + useEffect(() => { + applyThemeMode(document, mode); + }, [mode]); + + return ( +
+ {children} +
+ ); +} From cc6126c013d6cdf1d6208e91d3bc74d5ecd43973 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:19:23 +0530 Subject: [PATCH 14/36] UN-4299 [FIX] Apply the stored theme before first paint with useLayoutEffect Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/app/theme/ThemeProvider.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/v1/app/theme/ThemeProvider.tsx b/frontend/src/v1/app/theme/ThemeProvider.tsx index 99990ec49e..09b2f83929 100644 --- a/frontend/src/v1/app/theme/ThemeProvider.tsx +++ b/frontend/src/v1/app/theme/ThemeProvider.tsx @@ -3,7 +3,7 @@ import "@v1/styles/index.css"; import { cn } from "@v1/lib/cn"; import { applyThemeMode } from "@v1/lib/portal"; import { selectThemeMode, useThemeStore } from "@v1/stores/theme"; -import { type JSX, type ReactNode, useEffect } from "react"; +import { type JSX, type ReactNode, useLayoutEffect } from "react"; export type ThemeProviderProps = Readonly<{ children: ReactNode; @@ -28,7 +28,8 @@ export function ThemeProvider({ }: ThemeProviderProps): JSX.Element { const mode = useThemeStore(selectThemeMode); - useEffect(() => { + // Layout effect: a saved dark or unstract preference must not paint a light frame first. + useLayoutEffect(() => { const { hydrate, setSystemPrefersDark } = useThemeStore.getState(); hydrate(readLocalStorage()); if (typeof window.matchMedia !== "function") { @@ -45,7 +46,7 @@ export function ThemeProvider({ }; }, []); - useEffect(() => { + useLayoutEffect(() => { applyThemeMode(document, mode); }, [mode]); From 0e13d94dde42a79333b6094b8ed5f3e2a0cddf60 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:30:06 +0530 Subject: [PATCH 15/36] UN-4299 [FEAT] Enforce the v1 styling policy: raw colours, pixels, inline styles, CSS imports and !important fail lint Co-Authored-By: Claude Opus 5.5 --- frontend/package.json | 2 +- frontend/scripts/check-v1-styles.mjs | 451 ++++++++++++++++++ frontend/src/v1/README.md | 20 +- .../v1-boundary/boundary.fixtures.mjs | 13 + .../src/v1/styles/extra.css | 3 + .../v1-allowed-imports/src/v1/styles/index.ts | 2 +- .../src/v1/styles/tokens.css | 3 - .../src/v1/pages/demo/index.ts | 1 + .../v1-important-css/src/v1/styles/extra.css | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/styles/extra.css | 5 + .../src/v1/components/ui/Box.tsx | 11 + .../src/v1/styles/extra.css | 5 + .../src/v1/styles/extra.css | 3 + .../src/v1/styles/extra.css | 3 + .../src/v1/components/ui/Box.tsx | 3 + 20 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 frontend/scripts/check-v1-styles.mjs create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/extra.css delete mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-css-import-from-page/src/v1/pages/demo/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-important-css/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-important-utility/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-inline-style/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-arbitrary/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-palette/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-color-scheme-media/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-unscoped-selector/src/v1/styles/extra.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-utility/src/v1/components/ui/Box.tsx diff --git a/frontend/package.json b/frontend/package.json index fc04c1348b..e401581158 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -80,7 +80,7 @@ "typecheck": "tsc --noEmit", "tokens:v1": "node scripts/build-v1-tokens.mjs", "typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json", - "lint:v1": "biome lint src/v1/ && node scripts/check-v1.mjs --code", + "lint:v1": "biome lint src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs", "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" }, "browserslist": { diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs new file mode 100644 index 0000000000..ae7518be11 --- /dev/null +++ b/frontend/scripts/check-v1-styles.mjs @@ -0,0 +1,451 @@ +#!/usr/bin/env node +/* + * Styling policy for the v1 frontend (src/v1/). Run by `lint:v1` after + * check-v1.mjs; the rules and their reasons are in src/v1/README.md. + * + * S1 No inline style (JSX `style=`). + * S2 No raw colour: hex, rgb()/hsl()/oklch()/oklab()/color(), or a + * Tailwind palette utility (`-red-500`, `-white`). + * S3 No raw pixel value: `[13px]` arbitrary values, or px/rem/em/pt in CSS. + * S4 No !important: a `!` class modifier, or `!important` in CSS. + * S5 CSS is imported only from app/ and styles/. + * S6 Every CSS selector starts with `.v1`, except inside @theme, @utility, + * @custom-variant, @font-face and @keyframes. + * S7 CSS never reads prefers-color-scheme; data-theme sets the mode. + * S8 Every Tailwind utility carries the `ui:` prefix. v0's stylesheet also + * scans src/v1, so an unprefixed class would render with v0's theme. + * + * In TypeScript, S2 to S4 and S8 read only the contents of string and template + * literals, so a non-null `!` or a hex-looking identifier never matches. Test + * files and styles/tokens.ts (the raw values, by design) are skipped for them. + * In CSS, comments are blanked first; the generated styles/tokens.css is + * skipped for S2 and S3 only. + * + * S8 checks a list of utility stems, not a Tailwind parse: a missing stem is a + * false negative. A bare stem counts only when it is a whole utility (`flex`), + * so a string such as "font" or "top" is not flagged. + * + * blankComments and its helpers are copied from check-v1.mjs, which runs on + * import and so cannot be imported; keep the two in step. + * + * It always checks the src/v1 beside this script, like + * check-v1.mjs; the fixtures in test-fixtures/v1-boundary/ rely on that. + */ +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); + +const CODE_EXT = /\.(ts|tsx|mts|cts)$/; +const CSS_EXT = /\.css$/; +const TEST_FILE = /\.(test|spec)\.(ts|tsx|mts|cts)$/; +const RAW_VALUES_TS = "styles/tokens.ts"; +const GENERATED_CSS = "styles/tokens.css"; +// Folders whose files may import a .css file. +const CSS_IMPORTERS = new Set(["app", "styles"]); + +const MSG = { + inlineStyle: "No inline style under v1; style with tokens and Tailwind.", + colour: "No raw colour under v1; use a token from styles/tokens.ts.", + pixel: "No raw pixel value under v1; use a token or the spacing scale.", + important: "No !important under v1.", + cssImport: "CSS is imported only from app/ and styles/ under v1.", + unscoped: "Every v1 selector is scoped to .v1.", + colourScheme: + "v1 never reads prefers-color-scheme in CSS; mode comes from data-theme.", + unprefixed: + "No unprefixed Tailwind utility under v1; write ui:. v0's stylesheet also scans src/v1, so an unprefixed class would render with v0's theme.", +}; + +const PALETTE = + "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose"; +const RAW_COLOUR_RES = [ + /#[0-9a-fA-F]{3,8}(?![\w-])/g, + /(? + lines + .join(" ") + .split(" ") + .map((s) => s.replace("-", "\\-")) + .join("|"); +// A leading `-` is a negative value (`-mt-2`); a trailing `!` is important. +const UTILITY_RE = new RegExp( + `^-?(?:(?:${stems(WHOLE_STEMS)})(?:-|!?$)|(?:${stems(PREFIX_STEMS)})-)`, +); +// One leading variant: `hover:`, `md:`, `data-[state=open]:`, `[&>*]:`. +const VARIANT_RE = /^(?:[\w-]*\[[^\]]*\]|[\w-]+):/; + +/* + * Blanks comments, keeping line numbers so matches can be reported. Copied + * from check-v1.mjs (which runs on import, so it cannot be imported); keep + * the two in step. Strings and template literals are tracked, so a `/*` or + * `//` inside one does not swallow the code after it. + */ +function blankComments(src) { + let out = ""; + let i = 0; + // "`" = inside a template literal; "{" = inside a `${}` or a brace in one. + const stack = []; + while (i < src.length) { + let end; + if (stack.at(-1) === "`") { + end = i + templateStep(src, i, stack); + } else if (src[i] === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) { + end = commentEnd(src, i); + out += src.slice(i, end).replace(/[^\n]/g, " "); + i = end; + continue; + } else if (src[i] === '"' || src[i] === "'") { + end = stringEnd(src, i); + } else { + codeStep(src[i], stack); + end = i + 1; + } + out += src.slice(i, end); + i = end; + } + return out; +} + +/* CSS has only block comments, and a `//` in a url() is not one. */ +function blankCssComments(src) { + return src.replace(/\/\*[\s\S]*?(?:\*\/|$)/g, (c) => + c.replace(/[^\n]/g, " "), + ); +} + +/* Index just past the line or block comment that starts at i. */ +function commentEnd(src, i) { + if (src[i + 1] === "/") { + const newline = src.indexOf("\n", i); + return newline === -1 ? src.length : newline; + } + const close = src.indexOf("*/", i + 2); + return close === -1 ? src.length : close + 2; +} + +/* Index just past the '...' or "..." string that starts at i. An unclosed + * string ends at the newline. */ +function stringEnd(src, i) { + let j = i + 1; + while (j < src.length && src[j] !== src[i] && src[j] !== "\n") { + j += src[j] === "\\" ? 2 : 1; + } + return j + 1; +} + +/* One step inside a template literal: how many characters to copy. Closing + * the literal or opening `${` updates the stack. */ +function templateStep(src, i, stack) { + if (src[i] === "\\") { + return 2; + } + if (src[i] === "`") { + stack.pop(); + return 1; + } + if (src[i] === "$" && src[i + 1] === "{") { + stack.push("{"); + return 2; + } + return 1; +} + +/* Tracks template literals and the braces inside their `${}` expressions. */ +function codeStep(c, stack) { + if (c === "`") { + stack.push("`"); + } else if (c === "{" && stack.length > 0) { + stack.push("{"); + } else if (c === "}" && stack.length > 0) { + stack.pop(); + } +} + +/* + * The text of every string and template literal in comment-blanked source, + * as { text, index } with index the offset of the text's first character. + * Code outside literals is replaced by spaces, and a `${}` expression ends one + * piece of template text and starts the next (its own literals are yielded + * too), so a match can never span code. + */ +function literals(src) { + const pieces = []; + const stack = []; + let piece = null; + let i = 0; + while (i < src.length) { + if (stack.at(-1) === "`") { + piece ??= { text: "", index: i }; + const step = templateStep(src, i, stack); + if (stack.at(-1) !== "`") { + // The literal closed, or a `${` opened. + pieces.push(piece); + piece = null; + } else { + piece.text += src.slice(i, i + step); + } + i += step; + } else if (src[i] === '"' || src[i] === "'") { + const end = stringEnd(src, i); + const close = src[end - 1] === src[i] ? end - 1 : end; + pieces.push({ text: src.slice(i + 1, close), index: i + 1 }); + i = end; + } else { + codeStep(src[i], stack); + i += 1; + } + } + return pieces.filter((p) => p.text); +} + +// A specifier: a quoted or backtick string with no interpolation. +const SPEC = "[\"'`]([^\"'`$]+)[\"'`]"; +// The import forms check-v1.mjs reads, minus Vite globs. +const IMPORT_RES = [ + new RegExp( + String.raw`\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?${SPEC}`, + "g", + ), + new RegExp(String.raw`\bimport\s*\(\s*${SPEC}\s*[,)]`, "g"), + new RegExp(String.raw`\brequire\s*\(\s*${SPEC}\s*\)`, "g"), +]; +const CSS_SPEC = /\.css(?:\?.*)?$/; + +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +/* Every whitespace-separated token in a literal, with its offset in the file. */ +function* tokens({ text, index }) { + for (const m of text.matchAll(/\S+/g)) { + yield { token: m[0], index: index + m.index }; + } +} + +/* A token that looks like a Tailwind utility without the `ui:` prefix. */ +function isUnprefixedUtility(token) { + let rest = token.startsWith("!") ? token.slice(1) : token; + if (rest.startsWith("ui:")) { + return false; + } + for (let m = rest.match(VARIANT_RE); m; m = rest.match(VARIANT_RE)) { + rest = rest.slice(m[0].length); + if (rest.startsWith("ui:")) { + return false; + } + } + return UTILITY_RE.test(rest); +} + +/* A class token with the `!` important modifier at either end. */ +function isImportantClass(token) { + return /^![\w-[]|[\w\])]!$/.test(token) && /[-:]/.test(token); +} + +// At-rules whose blocks hold theme keys or declarations, not v1 selectors. +const OPAQUE_AT_RULES = new Set([ + "theme", + "utility", + "custom-variant", + "font-face", + "keyframes", +]); + +/* Splits a selector list on the commas outside (), [] and strings. */ +function splitSelectors(prelude) { + const parts = []; + let depth = 0; + let quote = null; + let start = 0; + for (let i = 0; i < prelude.length; i += 1) { + const c = prelude[i]; + if (quote) { + quote = c === quote ? null : quote; + } else if (c === '"' || c === "'") { + quote = c; + } else if (c === "(" || c === "[") { + depth += 1; + } else if (c === ")" || c === "]") { + depth -= 1; + } else if (c === "," && depth === 0) { + parts.push(prelude.slice(start, i)); + start = i + 1; + } + } + parts.push(prelude.slice(start)); + return parts.map((p) => p.trim()); +} + +/* + * The selector lists that must be scoped: every block prelude that is not an + * at-rule, not inside an opaque at-rule and not nested in another style rule + * (a nested rule is already scoped by its parent). Grouping at-rules such as + * @layer and @media are transparent. Returns { prelude, index }. + */ +function topLevelSelectors(css) { + const found = []; + // Per open block: "opaque", "group" (transparent at-rule) or "rule". + const stack = []; + let start = 0; + let quote = null; + for (let i = 0; i < css.length; i += 1) { + const c = css[i]; + if (quote) { + quote = c === quote ? null : quote; + continue; + } + if (c === '"' || c === "'") { + quote = c; + } else if (c === ";" || c === "}") { + if (c === "}") { + stack.pop(); + } + start = i + 1; + } else if (c === "{") { + const raw = css.slice(start, i); + const prelude = raw.trim(); + const index = start + raw.length - raw.trimStart().length; + const at = prelude.match(/^@([\w-]+)/); + if (stack.includes("opaque") || (at && OPAQUE_AT_RULES.has(at[1]))) { + stack.push("opaque"); + } else if (at) { + stack.push("group"); + } else { + if (!stack.includes("rule")) { + found.push({ prelude, index }); + } + stack.push("rule"); + } + start = i + 1; + } + } + return found; +} + +/* Every file under dir, relative and `/`-separated. Symlinks are reported by + * check-v1.mjs, so they are skipped here. */ +function walk(dir) { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((d) => d.isFile()) + .map((d) => + path + .relative(dir, path.join(d.parentPath, d.name)) + .replaceAll(path.sep, "/"), + ) + .toSorted((a, b) => a.localeCompare(b)); +} + +const problems = []; +const report = (rel, line, msg) => { + const where = line ? `${rel}:${line}` : rel; + problems.push(`src/v1/${where}: ${msg}`); +}; + +function checkCode(rel) { + const text = blankComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); + const top = rel.split("/")[0]; + + // S5 applies to test files too: they would load the CSS the same way. + for (const re of IMPORT_RES) { + for (const m of text.matchAll(re)) { + if (CSS_SPEC.test(m[1]) && !CSS_IMPORTERS.has(top)) { + report(rel, lineAt(text, m.index), MSG.cssImport); + } + } + } + if (TEST_FILE.test(rel)) { + return; + } + for (const m of text.matchAll(INLINE_STYLE_RE)) { + report(rel, lineAt(text, m.index), MSG.inlineStyle); + } + if (rel === RAW_VALUES_TS) { + return; + } + for (const literal of literals(text)) { + for (const re of RAW_COLOUR_RES) { + for (const m of literal.text.matchAll(re)) { + report(rel, lineAt(text, literal.index + m.index), MSG.colour); + } + } + for (const m of literal.text.matchAll(ARBITRARY_UNIT_RE)) { + report(rel, lineAt(text, literal.index + m.index), MSG.pixel); + } + for (const { token, index } of tokens(literal)) { + if (isImportantClass(token)) { + report(rel, lineAt(text, index), MSG.important); + } + if (isUnprefixedUtility(token)) { + report(rel, lineAt(text, index), `'${token}': ${MSG.unprefixed}`); + } + } + } +} + +function checkCss(rel) { + const css = blankCssComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); + const at = (re, msg) => { + for (const m of css.matchAll(re)) { + report(rel, lineAt(css, m.index), msg); + } + }; + if (rel !== GENERATED_CSS) { + for (const re of RAW_COLOUR_RES) { + at(re, MSG.colour); + } + at(CSS_UNIT_RE, MSG.pixel); + } + at(CSS_IMPORTANT_RE, MSG.important); + at(COLOUR_SCHEME_RE, MSG.colourScheme); + for (const { prelude, index } of topLevelSelectors(css)) { + for (const selector of splitSelectors(prelude)) { + if (!/^\.v1(?![\w-])/.test(selector)) { + report(rel, lineAt(css, index), `'${selector}': ${MSG.unscoped}`); + } + } + } +} + +if (!fs.existsSync(ROOT)) { + console.error(`check-v1-styles: ${ROOT} does not exist`); + process.exit(2); +} + +for (const rel of walk(ROOT)) { + if (CODE_EXT.test(rel)) { + checkCode(rel); + } else if (CSS_EXT.test(rel)) { + checkCss(rel); + } +} + +if (problems.length > 0) { + console.error(problems.join("\n")); + console.error(`\ncheck-v1-styles: ${problems.length} problem(s).`); + process.exit(1); +} +console.log("check-v1-styles: ok"); diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 61fa459ac3..faf3b077c4 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -16,6 +16,21 @@ file holds only the rules that tooling enforces. class). Global v1 styles live in `styles/` and are imported as `@v1/styles/...`, including from inside `styles/`. +## Styling + +- Utilities carry the `ui:` prefix (`ui:bg-background`): v0's stylesheet also + scans `src/v1`, so an unprefixed class renders with v0's theme. Merge with + `cn` from `@v1/lib/cn`. Tokens live in `styles/tokens.ts`; `tokens.css` is + generated from them by `bun run tokens:v1`. +- `ThemeProvider` sets `data-theme` (`light`, `dark`, `unstract`; preference + key `unstract.v1.theme`). Portals render into `getPortalContainer()`. + `ui:focus-ring` is the one focus style; Geist Mono only for model output, + JSON and token counts. +- `lint:v1` rejects: S1 inline `style`; S2 raw colours; S3 raw pixel values; + S4 `!important`; S5 CSS imports outside `app/` and `styles/`; S6 selectors + not starting with `.v1`; S7 `prefers-color-scheme` in CSS; S8 unprefixed + utilities. + ## Folders and what each may import Every folder may import from itself. Test files (`*.test.ts(x)`, @@ -42,9 +57,10 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | Script | Checks | | --- | --- | | `bun run typecheck:v1` | TypeScript-only files, CSS only in `styles/`, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | -| `bun run lint:v1` | Biome on `src/v1/`, then imports (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks | +| `bun run lint:v1` | Biome on `src/v1/`, then imports (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, then the styling policy (S1 to S8 above) | +| `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.ts` | | `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | The v0 ↔ v1 boundary and the banned imports are Biome rules in `biome.json`, so `biome ci src/` (CI) and editors report them too. The remaining checks are -`src/v1/tsconfig.json` and `scripts/check-v1.mjs`. +`src/v1/tsconfig.json`, `scripts/check-v1.mjs` and `scripts/check-v1-styles.mjs`. diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index c530319d89..e1194b9fbb 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -82,6 +82,19 @@ const CASES = parseTable(` v1-non-test-imports-test | lint:v1 | components/ui/ may not import from test/ v1-test-file-imports-services | lint:v1 | components/ui/ may not import from services/ v1-allowed-imports | typecheck:v1 + lint:v1 | - + # Styling policy (check-v1-styles.mjs) + v1-inline-style | lint:v1 | No inline style under v1 + v1-raw-colour-palette | lint:v1 | No raw colour under v1 + v1-raw-colour-arbitrary | lint:v1 | No raw colour under v1 + v1-raw-pixel-arbitrary | lint:v1 | No raw pixel value under v1 + v1-important-utility | lint:v1 | No !important under v1 + v1-important-css | lint:v1 | No !important under v1 + v1-css-import-from-page | lint:v1 | CSS is imported only from app/ and styles/ + v1-styles-raw-colour | lint:v1 | No raw colour under v1 + v1-styles-unscoped-selector | lint:v1 | Every v1 selector is scoped to .v1 + v1-styles-color-scheme-media | lint:v1 | prefers-color-scheme + v1-unprefixed-utility | lint:v1 | No unprefixed Tailwind utility under v1 + v1-styles-policy-ok | lint:v1 | - `).map(([name, run, fails]) => ({ name: name === "-" ? null : name, run: run.split(" + "), diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/extra.css new file mode 100644 index 0000000000..3a1eeb0120 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/extra.css @@ -0,0 +1,3 @@ +.v1 { + --ui-extra-demo: var(--foreground); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts index 7ab5ff7d43..1e02711d7f 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts @@ -1,3 +1,3 @@ -import "@v1/styles/tokens.css"; +import "@v1/styles/extra.css"; export const themed = true; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css deleted file mode 100644 index ac38c30204..0000000000 --- a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css +++ /dev/null @@ -1,3 +0,0 @@ -:root { - --v1-accent: #000; -} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-css-import-from-page/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-css-import-from-page/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..cf6a87372b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-css-import-from-page/src/v1/pages/demo/index.ts @@ -0,0 +1 @@ +import "@v1/styles/index.css"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-important-css/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-important-css/src/v1/styles/extra.css new file mode 100644 index 0000000000..05a22973e1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-important-css/src/v1/styles/extra.css @@ -0,0 +1,3 @@ +.v1 .x { + color: var(--foreground) !important; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-important-utility/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-important-utility/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..7e5475565d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-important-utility/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-inline-style/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..684e0311af --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-inline-style/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-arbitrary/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-arbitrary/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..03929ff06b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-arbitrary/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-palette/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-palette/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..9708443055 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-palette/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..10ce222d2c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-pixel-arbitrary/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-color-scheme-media/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-color-scheme-media/src/v1/styles/extra.css new file mode 100644 index 0000000000..d2422fe70e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-color-scheme-media/src/v1/styles/extra.css @@ -0,0 +1,5 @@ +@media (prefers-color-scheme: dark) { + .v1 { + color: var(--foreground); + } +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..861e6a6d63 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx @@ -0,0 +1,11 @@ +import { cn } from "@v1/lib/cn"; + +export function Box() { + return ( +
+ ); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/styles/extra.css new file mode 100644 index 0000000000..758e08ee11 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/styles/extra.css @@ -0,0 +1,5 @@ +.v1 .demo, +.v1 .demo::after { + color: var(--foreground); + margin: 0; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour/src/v1/styles/extra.css new file mode 100644 index 0000000000..a54d4a6cd2 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-raw-colour/src/v1/styles/extra.css @@ -0,0 +1,3 @@ +.v1 .x { + color: #fff; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-unscoped-selector/src/v1/styles/extra.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-unscoped-selector/src/v1/styles/extra.css new file mode 100644 index 0000000000..ded6b3af88 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-unscoped-selector/src/v1/styles/extra.css @@ -0,0 +1,3 @@ +button { + color: var(--foreground); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-utility/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-utility/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..e5f587adb1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-unprefixed-utility/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} From c3a703c20039c8934e0e296d7b97f8a42e0d1a8d Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 12:36:08 +0530 Subject: [PATCH 16/36] UN-4299 [FIX] Scope the styling lint to class strings and cover vh/vw arbitrary values Co-Authored-By: Claude Opus 5.5 --- frontend/scripts/check-v1-styles.mjs | 78 +++++++++++++++---- frontend/src/v1/README.md | 10 +-- .../v1-boundary/boundary.fixtures.mjs | 1 + .../src/v1/components/ui/Box.tsx | 10 +++ 4 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index ae7518be11..b6de07ab4f 100644 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -3,10 +3,11 @@ * Styling policy for the v1 frontend (src/v1/). Run by `lint:v1` after * check-v1.mjs; the rules and their reasons are in src/v1/README.md. * - * S1 No inline style (JSX `style=`). + * S1 No inline style (a JSX `style` attribute). * S2 No raw colour: hex, rgb()/hsl()/oklch()/oklab()/color(), or a * Tailwind palette utility (`-red-500`, `-white`). - * S3 No raw pixel value: `[13px]` arbitrary values, or px/rem/em/pt in CSS. + * S3 No raw pixel value: `[13px]` or `[100vh]` arbitrary values, or + * px/rem/em/pt in CSS. * S4 No !important: a `!` class modifier, or `!important` in CSS. * S5 CSS is imported only from app/ and styles/. * S6 Every CSS selector starts with `.v1`, except inside @theme, @utility, @@ -15,9 +16,11 @@ * S8 Every Tailwind utility carries the `ui:` prefix. v0's stylesheet also * scans src/v1, so an unprefixed class would render with v0's theme. * - * In TypeScript, S2 to S4 and S8 read only the contents of string and template - * literals, so a non-null `!` or a hex-looking identifier never matches. Test - * files and styles/tokens.ts (the raw values, by design) are skipped for them. + * In TypeScript, S2 to S4 and S8 read only class strings: string and template + * literals that look like a class list (see isClassString), so a non-null `!`, + * a hex-looking identifier or a value such as "outline" or "pt-BR" never + * matches. Test files and styles/tokens.ts (the raw values, by design) are + * skipped for them. S1 matches only a JSX `style=` attribute. * In CSS, comments are blanked first; the generated styles/tokens.css is * skipped for S2 and S3 only. * @@ -26,7 +29,9 @@ * so a string such as "font" or "top" is not flagged. * * blankComments and its helpers are copied from check-v1.mjs, which runs on - * import and so cannot be imported; keep the two in step. + * import and so cannot be imported; keep the two in step. As there, JSX text + * is parsed as code, so an apostrophe in it ("don't") opens a string to the + * end of the line; the class-string test keeps such text from being reported. * * It always checks the src/v1 beside this script, like * check-v1.mjs; the fixtures in test-fixtures/v1-boundary/ rely on that. @@ -65,13 +70,14 @@ const RAW_COLOUR_RES = [ new RegExp(String.raw`-(?:${PALETTE})-\d{2,3}(?![\w-])`, "g"), /-(?:white|black)(?![\w-])/g, ]; -// A Tailwind arbitrary value in a unit: `w-[13px]`, `p-[1.5rem]`. -const ARBITRARY_UNIT_RE = /\[[^\]\s]*\d(?:px|rem|em|pt)\]/g; +// A Tailwind arbitrary value in a unit: `w-[13px]`, `p-[1.5rem]`, `h-[100vh]`. +const ARBITRARY_UNIT_RE = /\[[^\]\s]*\d(?:px|rem|em|vh|vw|pt)\]/g; // A CSS length in a unit: `3px`, `.5rem`, `-1em`. const CSS_UNIT_RE = /(? DEFINITE_CLASS_RES.some((re) => re.test(w)))) { + return true; + } + return words.filter(isUnprefixedUtility).length >= 2; +} + /* A class token with the `!` important modifier at either end. */ function isImportantClass(token) { return /^![\w-[]|[\w\])]!$/.test(token) && /[-:]/.test(token); @@ -386,7 +429,16 @@ function checkCode(rel) { if (rel === RAW_VALUES_TS) { return; } - for (const literal of literals(text)) { + const pieces = literals(text); + // A template literal is judged as a whole, across its `${}` expressions. + const textById = new Map(); + for (const { id, text: t } of pieces) { + textById.set(id, `${textById.get(id) ?? ""} ${t}`); + } + for (const literal of pieces) { + if (!isClassString(textById.get(literal.id))) { + continue; + } for (const re of RAW_COLOUR_RES) { for (const m of literal.text.matchAll(re)) { report(rel, lineAt(text, literal.index + m.index), MSG.colour); diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index faf3b077c4..2d6efbd873 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -22,14 +22,14 @@ file holds only the rules that tooling enforces. scans `src/v1`, so an unprefixed class renders with v0's theme. Merge with `cn` from `@v1/lib/cn`. Tokens live in `styles/tokens.ts`; `tokens.css` is generated from them by `bun run tokens:v1`. -- `ThemeProvider` sets `data-theme` (`light`, `dark`, `unstract`; preference - key `unstract.v1.theme`). Portals render into `getPortalContainer()`. - `ui:focus-ring` is the one focus style; Geist Mono only for model output, - JSON and token counts. +- `ThemeProvider` sets `data-theme` (`light`, `dark`, `unstract`; key + `unstract.v1.theme`); portals use `getPortalContainer()`. `ui:focus-ring` is + the one focus style; Geist Mono only for model output, JSON and token counts. - `lint:v1` rejects: S1 inline `style`; S2 raw colours; S3 raw pixel values; S4 `!important`; S5 CSS imports outside `app/` and `styles/`; S6 selectors not starting with `.v1`; S7 `prefers-color-scheme` in CSS; S8 unprefixed - utilities. + utilities. In code, S2 to S4 and S8 read class strings only (`className` + values, `cn()`/`cva()` arguments, constants holding classes), not every string. ## Folders and what each may import diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index e1194b9fbb..d847fb22da 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -95,6 +95,7 @@ const CASES = parseTable(` v1-styles-color-scheme-media | lint:v1 | prefers-color-scheme v1-unprefixed-utility | lint:v1 | No unprefixed Tailwind utility under v1 v1-styles-policy-ok | lint:v1 | - + v1-plain-strings-ok | lint:v1 | - `).map(([name, run, fails]) => ({ name: name === "-" ? null : name, run: run.split(" + "), diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..658ea24b5a --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx @@ -0,0 +1,10 @@ +type BoxProps = Readonly<{ variant: "outline" | "ghost" }>; + +export function Box({ variant }: BoxProps) { + return ( +
+ +

You don't have hidden files

+
+ ); +} From a35da5b1f0702b9b5585a6f504aaa89013b006ee Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:23:11 +0530 Subject: [PATCH 17/36] UN-4299 [FIX] Keep v0's Tailwind scan out of src/v1, the lint scripts and the fixtures v0's root auto-detected sources across the whole frontend, so the v1 tree, scripts/check-v1-styles.mjs and test-fixtures/ added 12 unprefixed utilities and 8 theme variables to v0's stylesheet. With these three @source not lines the v0 build is byte-identical to the base again (239/239 files). Co-Authored-By: Claude Opus 5.5 --- frontend/src/index.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/index.css b/frontend/src/index.css index f8a4a554b5..6bfdcd4123 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,6 +3,12 @@ * (UN_SHADCN_IMPL_PLAN P0-10) * ------------------------------------------------------------------ */ @import "tailwindcss"; +/* v1 compiles its own Tailwind root (src/v1/styles/index.css). Keep v0's scan + * out of the v1 tree, the lint scripts and the guardrail fixtures, or class-like + * strings there generate unprefixed utilities in v0's bundle (UN-4299). */ +@source not "./v1"; +@source not "../scripts"; +@source not "../test-fixtures"; @plugin "tailwindcss-animate"; @plugin "@tailwindcss/typography"; From d6047e949f6101c2f9427a383571f01e7dcac921 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:34:47 +0530 Subject: [PATCH 18/36] UN-4299 [FIX] Teach cn the type roles so a role and a text colour no longer cancel each other Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/lib/cn.test.ts | 13 +++++++++++++ frontend/src/v1/lib/cn.ts | 20 +++++++++++++++++++- frontend/src/v1/styles/tokens.test.ts | 10 ++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/frontend/src/v1/lib/cn.test.ts b/frontend/src/v1/lib/cn.test.ts index 5dd843f1ee..d4c41dd3af 100644 --- a/frontend/src/v1/lib/cn.test.ts +++ b/frontend/src/v1/lib/cn.test.ts @@ -15,3 +15,16 @@ it("keeps unrelated and variant classes", () => { it("leaves unprefixed classes alone, so the merge is scoped to the ui prefix", () => { expect(cn("p-2 p-4")).toBe("p-2 p-4"); }); + +it("keeps a type role beside a text colour, and lets the last of two roles win", () => { + expect(cn("ui:text-body ui:text-foreground")).toBe( + "ui:text-body ui:text-foreground", + ); + expect(cn("ui:text-body-small ui:text-muted-foreground")).toBe( + "ui:text-body-small ui:text-muted-foreground", + ); + expect(cn("ui:text-caption ui:text-primary")).toBe( + "ui:text-caption ui:text-primary", + ); + expect(cn("ui:text-body ui:text-caption")).toBe("ui:text-caption"); +}); diff --git a/frontend/src/v1/lib/cn.ts b/frontend/src/v1/lib/cn.ts index 6693302d90..0ba762972b 100644 --- a/frontend/src/v1/lib/cn.ts +++ b/frontend/src/v1/lib/cn.ts @@ -1,7 +1,25 @@ import { type ClassValue, clsx } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; -const twMerge = extendTailwindMerge({ prefix: "ui" }); +/** The type roles from the `type` token family, as `ui:text-` utilities. */ +export const TYPE_ROLES = [ + "display", + "page-title", + "section-title", + "body", + "body-small", + "caption", + "overline", + "mono", +] as const; + +// tailwind-merge knows only the stock text scale; without the roles it reads +// `ui:text-body` as a colour and drops it beside `ui:text-foreground`. +const twMerge = extendTailwindMerge({ + prefix: "ui", + extend: { theme: { text: [...TYPE_ROLES] } }, +}); + export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } diff --git a/frontend/src/v1/styles/tokens.test.ts b/frontend/src/v1/styles/tokens.test.ts index ea9085ef31..fdffce554e 100644 --- a/frontend/src/v1/styles/tokens.test.ts +++ b/frontend/src/v1/styles/tokens.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { TYPE_ROLES } from "@v1/lib/cn"; import { THEME_MODES } from "@v1/lib/theme"; import { renderTokensCss, renderTokenTable } from "@v1/lib/tokens"; import { expect, it } from "vitest"; @@ -75,6 +76,15 @@ it("covers the families the board defines", () => { expect(byFamily("focus")).toEqual(["focus-ring-width", "focus-ring-alpha"]); }); +it("cn's type roles are exactly the roles of the type family, in order", () => { + const roles = TOKENS.filter((t) => t.family === "type").map((t) => + t.name + .replace(/^type-/, "") + .replace(/-(size|line-height|weight|tracking)$/, ""), + ); + expect([...TYPE_ROLES]).toEqual([...new Set(roles)]); +}); + it("Unstract mode is the navy theme from the collection, not a copy of light", () => { const value = (n: string) => TOKENS.find((t) => t.name === n)?.values; expect(value("primary")).toEqual({ From b29503d6a518793971d9f776bfe136b0acd9c44d Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:36:02 +0530 Subject: [PATCH 19/36] UN-4299 [FIX] Reject colour literals outside class strings, named-colour arbitrary values and !important after a variant Co-Authored-By: Claude Opus 5.5 --- frontend/scripts/check-v1-styles.mjs | 77 +++++++++++++++++-- .../v1-boundary/boundary.fixtures.mjs | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 2 +- .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 3 + .../src/v1/components/ui/Box.tsx | 2 +- 7 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-important-variant-utility/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named/src/v1/components/ui/Box.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-prop/src/v1/components/ui/Box.tsx diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index b6de07ab4f..6173a4173f 100644 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -4,8 +4,9 @@ * check-v1.mjs; the rules and their reasons are in src/v1/README.md. * * S1 No inline style (a JSX `style` attribute). - * S2 No raw colour: hex, rgb()/hsl()/oklch()/oklab()/color(), or a - * Tailwind palette utility (`-red-500`, `-white`). + * S2 No raw colour: hex, rgb()/hsl()/oklch()/oklab()/color(), a + * Tailwind palette utility (`-red-500`, `-white`), or a named colour + * as an arbitrary value (`text-[red]`, `[color:tomato]`). * S3 No raw pixel value: `[13px]` or `[100vh]` arbitrary values, or * px/rem/em/pt in CSS. * S4 No !important: a `!` class modifier, or `!important` in CSS. @@ -13,14 +14,17 @@ * S6 Every CSS selector starts with `.v1`, except inside @theme, @utility, * @custom-variant, @font-face and @keyframes. * S7 CSS never reads prefers-color-scheme; data-theme sets the mode. - * S8 Every Tailwind utility carries the `ui:` prefix. v0's stylesheet also - * scans src/v1, so an unprefixed class would render with v0's theme. + * S8 Every Tailwind utility carries the `ui:` prefix. An unprefixed class + * in v1 renders with v0's theme whenever v0 already uses that class + * name, and silently does nothing otherwise. * * In TypeScript, S2 to S4 and S8 read only class strings: string and template * literals that look like a class list (see isClassString), so a non-null `!`, * a hex-looking identifier or a value such as "outline" or "pt-BR" never - * matches. Test files and styles/tokens.ts (the raw values, by design) are - * skipped for them. S1 matches only a JSX `style=` attribute. + * matches. One S2 check reads every literal: a string that is entirely a + * colour (`fill="#ff0000"`, "rgb(1 2 3)") fails wherever it appears, while + * "Run #1234" does not. Test files and styles/tokens.ts (the raw values, by + * design) are skipped for them. S1 matches only a JSX `style=` attribute. * In CSS, comments are blanked first; the generated styles/tokens.css is * skipped for S2 and S3 only. * @@ -59,7 +63,7 @@ const MSG = { colourScheme: "v1 never reads prefers-color-scheme in CSS; mode comes from data-theme.", unprefixed: - "No unprefixed Tailwind utility under v1; write ui:. v0's stylesheet also scans src/v1, so an unprefixed class would render with v0's theme.", + "No unprefixed Tailwind utility under v1; write ui:. An unprefixed class renders with v0's theme whenever v0 already uses that class name, and silently does nothing otherwise.", }; const PALETTE = @@ -70,6 +74,51 @@ const RAW_COLOUR_RES = [ new RegExp(String.raw`-(?:${PALETTE})-\d{2,3}(?![\w-])`, "g"), /-(?:white|black)(?![\w-])/g, ]; +// A literal that is nothing but a colour: `fill="#ff0000"`, "rgb(1 2 3)". +const WHOLE_COLOUR_RES = [ + /^#[0-9a-fA-F]{3,8}$/, + /^(?:rgba?|hsla?|oklch|oklab|color)\(/, +]; +// CSS named colours an arbitrary value might carry. Not all 148: the common +// ones. transparent and currentcolor are listed but allowed (see below). +const NAMED_COLOURS = new Set([ + "black", + "white", + "red", + "green", + "blue", + "yellow", + "orange", + "purple", + "pink", + "gray", + "grey", + "silver", + "tomato", + "navy", + "teal", + "maroon", + "olive", + "lime", + "aqua", + "cyan", + "magenta", + "fuchsia", + "gold", + "indigo", + "violet", + "brown", + "coral", + "crimson", + "salmon", + "transparent", + "currentcolor", +]); +// The focus utility and shadcn patterns use these, so they stay allowed. +const ALLOWED_NAMED_COLOURS = new Set(["transparent", "currentcolor"]); +// An arbitrary value holding one word, after an optional `property:` label: +// `text-[red]`, `[color:tomato]`, `bg-[color:navy]`. +const ARBITRARY_WORD_RE = /(?:-|(? re.test(trimmed))) { + report(rel, lineAt(text, literal.index), MSG.colour); + continue; + } if (!isClassString(textById.get(literal.id))) { continue; } @@ -444,6 +499,12 @@ function checkCode(rel) { report(rel, lineAt(text, literal.index + m.index), MSG.colour); } } + for (const m of literal.text.matchAll(ARBITRARY_WORD_RE)) { + const word = m[1].toLowerCase(); + if (NAMED_COLOURS.has(word) && !ALLOWED_NAMED_COLOURS.has(word)) { + report(rel, lineAt(text, literal.index + m.index), MSG.colour); + } + } for (const m of literal.text.matchAll(ARBITRARY_UNIT_RE)) { report(rel, lineAt(text, literal.index + m.index), MSG.pixel); } diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index d847fb22da..6926d0f073 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -86,8 +86,11 @@ const CASES = parseTable(` v1-inline-style | lint:v1 | No inline style under v1 v1-raw-colour-palette | lint:v1 | No raw colour under v1 v1-raw-colour-arbitrary | lint:v1 | No raw colour under v1 + v1-raw-colour-prop | lint:v1 | No raw colour under v1 + v1-raw-colour-named | lint:v1 | No raw colour under v1 v1-raw-pixel-arbitrary | lint:v1 | No raw pixel value under v1 v1-important-utility | lint:v1 | No !important under v1 + v1-important-variant-utility | lint:v1 | No !important under v1 v1-important-css | lint:v1 | No !important under v1 v1-css-import-from-page | lint:v1 | CSS is imported only from app/ and styles/ v1-styles-raw-colour | lint:v1 | No raw colour under v1 diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-important-variant-utility/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-important-variant-utility/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..e939e1b501 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-important-variant-utility/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx index 658ea24b5a..1e286f3df0 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx +++ b/frontend/test-fixtures/v1-boundary/cases/v1-plain-strings-ok/src/v1/components/ui/Box.tsx @@ -4,7 +4,7 @@ export function Box({ variant }: BoxProps) { return (
-

You don't have hidden files

+

You don't have hidden files

); } diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..dbf62949bc --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-named/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return
; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-prop/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-prop/src/v1/components/ui/Box.tsx new file mode 100644 index 0000000000..6e1319c1ed --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-raw-colour-prop/src/v1/components/ui/Box.tsx @@ -0,0 +1,3 @@ +export function Box() { + return ; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx index 861e6a6d63..11f9727a7a 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-policy-ok/src/v1/components/ui/Box.tsx @@ -4,7 +4,7 @@ export function Box() { return (
); From e064614ec910bb70ca94396027d27e26f7d9e54d Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:37:30 +0530 Subject: [PATCH 20/36] UN-4299 [FIX] Keep the focus ring visible in forced-colors mode and scope variants, shadows and the root fill to v1 Co-Authored-By: Claude Opus 5.5 --- frontend/scripts/check-v1-styles.mjs | 26 ++++++++++++++++++----- frontend/src/v1/styles/base.css | 8 +++++-- frontend/src/v1/styles/index.css | 21 ++++++++++++++----- frontend/src/v1/styles/root.test.ts | 31 +++++++++++++++++++++++----- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/frontend/scripts/check-v1-styles.mjs b/frontend/scripts/check-v1-styles.mjs index 6173a4173f..484950afcf 100644 --- a/frontend/scripts/check-v1-styles.mjs +++ b/frontend/scripts/check-v1-styles.mjs @@ -26,7 +26,10 @@ * "Run #1234" does not. Test files and styles/tokens.ts (the raw values, by * design) are skipped for them. S1 matches only a JSX `style=` attribute. * In CSS, comments are blanked first; the generated styles/tokens.css is - * skipped for S2 and S3 only. + * skipped for S2 and S3 only. One escape hatch, for S3 only: a CSS line that + * ends with the comment `/* v1-styles-allow: forced-colors outline *\/` may + * hold a pixel value. It exists for the focus utility's transparent outline, + * which forced-colors mode paints in place of the box-shadow ring. * * S8 checks a list of utility stems, not a Tailwind parse: a missing stem is a * false negative. A bare stem counts only when it is a whole utility (`flex`), @@ -123,6 +126,9 @@ const ARBITRARY_WORD_RE = /(?:-|(? { + const source = fs.readFileSync(path.join(ROOT, rel), "utf8"); + const css = blankCssComments(source); + // 1-based numbers of the lines that carry the S3 escape-hatch marker. + const pixelAllowed = new Set( + source + .split("\n") + .flatMap((line, i) => (PIXEL_ALLOW_RE.test(line) ? [i + 1] : [])), + ); + const at = (re, msg, skip = new Set()) => { for (const m of css.matchAll(re)) { - report(rel, lineAt(css, m.index), msg); + const line = lineAt(css, m.index); + if (!skip.has(line)) { + report(rel, line, msg); + } } }; if (rel !== GENERATED_CSS) { for (const re of RAW_COLOUR_RES) { at(re, MSG.colour); } - at(CSS_UNIT_RE, MSG.pixel); + at(CSS_UNIT_RE, MSG.pixel, pixelAllowed); } at(CSS_IMPORTANT_RE, MSG.important); at(COLOUR_SCHEME_RE, MSG.colourScheme); diff --git a/frontend/src/v1/styles/base.css b/frontend/src/v1/styles/base.css index 4ae40107cd..d4619d8328 100644 --- a/frontend/src/v1/styles/base.css +++ b/frontend/src/v1/styles/base.css @@ -1,8 +1,12 @@ -/* What preflight would give the v1 tree, scoped to .v1 so v0 is untouched. */ +/* What preflight would give the v1 tree, scoped to .v1 so v0 is untouched. + * The app root (data-ui-version) fills and paints its area; every .v1 element, + * the portal container included, only sets the inherited text defaults. */ @layer base { - .v1 { + .v1[data-ui-version="v1"] { min-height: 100%; background-color: var(--background); + } + .v1 { color: var(--foreground); font-family: var(--font-sans); font-size: var(--type-body-size); diff --git a/frontend/src/v1/styles/index.css b/frontend/src/v1/styles/index.css index c412f0c426..9474272455 100644 --- a/frontend/src/v1/styles/index.css +++ b/frontend/src/v1/styles/index.css @@ -18,10 +18,14 @@ /* Scan only the v1 tree (source(none) above turns off automatic detection). */ @source "../"; -@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); +/* Keyed on the v1 root, so a data-theme elsewhere (v0's ) cannot flip them. */ +@custom-variant dark (&:where( + .v1[data-theme="dark"], + .v1[data-theme="dark"] * + )); @custom-variant unstract (&:where( - [data-theme="unstract"], - [data-theme="unstract"] * + .v1[data-theme="unstract"], + .v1[data-theme="unstract"] * )); /* Every theme key reads a runtime token from tokens.css; the stock namespaces @@ -71,6 +75,9 @@ --shadow-md: var(--shadow-md); --shadow-lg: var(--shadow-lg); --shadow-xl: var(--shadow-xl); + --inset-shadow-*: initial; + --drop-shadow-*: initial; + --text-shadow-*: initial; --text-*: initial; --text-display: var(--type-display-size); @@ -104,9 +111,13 @@ --font-mono: var(--font-mono); } -/* Board section 6: "border → ring, plus 3px ring at 50% outside". */ +/* Board section 6: "border → ring, plus 3px ring at 50% outside". Use it as + * `ui:focus-visible:focus-ring`; a bare `ui:focus-ring` rings the element all + * the time. The transparent outline is what forced-colors mode paints (it + * drops box-shadow), so the ring stays visible in Windows High Contrast. */ @utility focus-ring { - outline: 0; + outline: 2px solid transparent; /* v1-styles-allow: forced-colors outline */ + outline-offset: 2px; /* v1-styles-allow: forced-colors outline */ border-color: var(--ring); box-shadow: 0 0 0 var(--focus-ring-width) color-mix(in srgb, var(--ring) var(--focus-ring-alpha), transparent); diff --git a/frontend/src/v1/styles/root.test.ts b/frontend/src/v1/styles/root.test.ts index 773252256e..c1db537f6a 100644 --- a/frontend/src/v1/styles/root.test.ts +++ b/frontend/src/v1/styles/root.test.ts @@ -25,6 +25,9 @@ const CANDIDATES = [ "ui:text-sm", "ui:shadow-2xl", "ui:rounded-2xl", + "ui:drop-shadow-lg", + "ui:inset-shadow-sm", + "ui:text-shadow-lg", ]; async function build(stylesheet: string): Promise { @@ -91,26 +94,37 @@ it("emits only prefixed utilities bound to runtime variables", () => { // Tailwind wraps the tracking in --tw-tracking so a tracking-* utility can override it. /\.ui\\:text-overline\s*\{[^}]*letter-spacing:\s*var\((--tw-tracking,\s*var\()?--type-overline-tracking\)/, ); - expect(css).toMatch(/\.ui\\:focus-ring\s*\{/); expect(css).not.toMatch(/(^|[^\\:])\.bg-background\b/); expect(css).not.toMatch(/(^|[^\\:])\.flex\b/); }); -it("has no stock palette, text scale, shadow scale or radius scale", () => { +it("has no stock palette, text scale, shadow scales or radius scale", () => { expect(css).not.toContain("bg-red-500"); expect(css).not.toContain("text-sm"); expect(css).not.toContain("shadow-2xl"); expect(css).not.toContain("rounded-2xl"); + expect(css).not.toContain("drop-shadow-lg"); + expect(css).not.toContain("inset-shadow-sm"); + expect(css).not.toContain("text-shadow-lg"); }); -it("dark and unstract variants key on data-theme, never on prefers-color-scheme", () => { +it("the focus ring keeps a transparent outline for forced-colors mode beside the box-shadow ring", () => { + const rule = css.match(/\.ui\\:focus-ring\s*\{([^}]*)\}/)?.[1] ?? ""; + expect(rule).toMatch(/outline(-style)?:\s*(solid|2px solid transparent)/); + expect(rule).toContain("border-color: var(--ring)"); + expect(rule).toContain( + "color-mix(in srgb, var(--ring) var(--focus-ring-alpha), transparent)", + ); +}); + +it("dark and unstract variants key on the v1 root's data-theme, never on prefers-color-scheme", () => { // Name the variant rules and their binding: tokens.css alone also contains // [data-theme=…] selectors, so matching those would pass with no variants. expect(css).toMatch( - /\.ui\\:dark\\:bg-card:where\(\[data-theme="?dark"?\],\s*\[data-theme="?dark"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + /\.ui\\:dark\\:bg-card:where\(\.v1\[data-theme="?dark"?\],\s*\.v1\[data-theme="?dark"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, ); expect(css).toMatch( - /\.ui\\:unstract\\:bg-card:where\(\[data-theme="?unstract"?\],\s*\[data-theme="?unstract"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, + /\.ui\\:unstract\\:bg-card:where\(\.v1\[data-theme="?unstract"?\],\s*\.v1\[data-theme="?unstract"?\]\s*\*\s*\)\s*\{[^}]*background-color:\s*var\(--card\)/, ); expect(css).not.toContain("prefers-color-scheme"); }); @@ -134,3 +148,10 @@ it("declares no !important, and every custom property is a Tailwind ui variable ); expect(strays).toEqual([]); }); + +it("only the app root fills and paints its area, so the portal container is not a painted block", () => { + expect(css).toMatch( + /\.v1\[data-ui-version="?v1"?\]\s*\{\s*min-height:\s*100%;\s*background-color:\s*var\(--background\);?\s*\}/, + ); + expect(css).not.toMatch(/\.v1\s*\{[^}]*(min-height|background-color)/); +}); From 88354e13d1440f44facc007bf6ed5e3f8c545a83 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:37:52 +0530 Subject: [PATCH 21/36] UN-4299 [FIX] Create the portal container before the mode changes so the theme test exercises the mode effect Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/app/theme/ThemeProvider.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/v1/app/theme/ThemeProvider.test.tsx b/frontend/src/v1/app/theme/ThemeProvider.test.tsx index 1b3026fafe..30415c3cc9 100644 --- a/frontend/src/v1/app/theme/ThemeProvider.test.tsx +++ b/frontend/src/v1/app/theme/ThemeProvider.test.tsx @@ -53,15 +53,20 @@ it("follows the system preference while set to system and stops after an explici , ); const root = screen.getByText("hi").parentElement as HTMLElement; + // Created before any change, so only the [mode] effect can update it. + const portal = getPortalContainer(); + expect(portal.dataset.theme).toBe("light"); act(() => { for (const l of listeners) { l({ matches: true }); } }); expect(root.dataset.theme).toBe("dark"); + expect(portal.dataset.theme).toBe("dark"); act(() => { useThemeStore.getState().setPreference("light"); }); + expect(portal.dataset.theme).toBe("light"); act(() => { for (const l of listeners) { l({ matches: false }); @@ -73,7 +78,8 @@ it("follows the system preference while set to system and stops after an explici } }); expect(root.dataset.theme).toBe("light"); - expect(getPortalContainer().dataset.theme).toBe("light"); + expect(portal.dataset.theme).toBe("light"); + expect(getPortalContainer()).toBe(portal); }); it("removes the media listener on unmount", () => { From a41e1baa9b41b95f3fb266b04e8a0ee11b8b04b2 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:38:53 +0530 Subject: [PATCH 22/36] UN-4299 [FIX] Document the v0 rules that reach v1, the focus-ring usage, the pixel escape and the current unprefixed-class rationale Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 2d6efbd873..185302f2e6 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -18,18 +18,19 @@ file holds only the rules that tooling enforces. ## Styling -- Utilities carry the `ui:` prefix (`ui:bg-background`): v0's stylesheet also - scans `src/v1`, so an unprefixed class renders with v0's theme. Merge with - `cn` from `@v1/lib/cn`. Tokens live in `styles/tokens.ts`; `tokens.css` is - generated from them by `bun run tokens:v1`. -- `ThemeProvider` sets `data-theme` (`light`, `dark`, `unstract`; key - `unstract.v1.theme`); portals use `getPortalContainer()`. `ui:focus-ring` is - the one focus style; Geist Mono only for model output, JSON and token counts. -- `lint:v1` rejects: S1 inline `style`; S2 raw colours; S3 raw pixel values; - S4 `!important`; S5 CSS imports outside `app/` and `styles/`; S6 selectors - not starting with `.v1`; S7 `prefers-color-scheme` in CSS; S8 unprefixed - utilities. In code, S2 to S4 and S8 read class strings only (`className` - values, `cn()`/`cva()` arguments, constants holding classes), not every string. +- `ui:` on every utility: an unprefixed class renders with v0's theme whenever + v0 already uses that class name, and silently does nothing otherwise. Merge + with `cn` (`@v1/lib/cn`); tokens live in `styles/tokens.ts`. +- `ThemeProvider` sets `data-theme`; portals use `getPortalContainer()`; focus + is `ui:focus-visible:focus-ring`; Geist Mono only for model output and JSON. +- `lint:v1` S1–S8: no inline style, raw colour or pixel, `!important`, CSS + import outside `app/` or `styles/`, selector outside `.v1`, + `prefers-color-scheme` or unprefixed utility (code: class and colour strings). + Pixel escape, CSS only: `/* v1-styles-allow: forced-colors outline */`. +- Known v0 rules that reach v1 (unlayered, so v1 loses without `!important`): + - `scrollbar-color`, `::-webkit-scrollbar*`: the thumb stays light in dark. + - `[data-radix-popper-content-wrapper] { z-index: 1500 !important }`. + - `[data-sonner-toaster] { pointer-events: auto }`. ## Folders and what each may import From 8231919d016e00945d71dcca6202e218378b107f Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 15:41:16 +0530 Subject: [PATCH 23/36] UN-4299 [FIX] Name the theme storage key in the v1 README Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 185302f2e6..b1fd6afdf6 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -20,9 +20,10 @@ file holds only the rules that tooling enforces. - `ui:` on every utility: an unprefixed class renders with v0's theme whenever v0 already uses that class name, and silently does nothing otherwise. Merge - with `cn` (`@v1/lib/cn`); tokens live in `styles/tokens.ts`. -- `ThemeProvider` sets `data-theme`; portals use `getPortalContainer()`; focus - is `ui:focus-visible:focus-ring`; Geist Mono only for model output and JSON. + with `cn` (`@v1/lib/cn`); `bun run tokens:v1` makes tokens.css from tokens.ts. +- The user chooses the mode, stored per browser under `unstract.v1.theme` and + applied as `data-theme` by `ThemeProvider`. Portals: `getPortalContainer()`; + focus: `ui:focus-visible:focus-ring`; Geist Mono only for model output, JSON. - `lint:v1` S1–S8: no inline style, raw colour or pixel, `!important`, CSS import outside `app/` or `styles/`, selector outside `.v1`, `prefers-color-scheme` or unprefixed utility (code: class and colour strings). From d0c1be0fa8df2692b02c2a90af028fd53c0221de Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Wed, 28 Oct 2026 18:01:04 +0530 Subject: [PATCH 24/36] UN-4299 [MISC] Name the three theme modes in the v1 README Co-Authored-By: Claude Fable 5.1 --- frontend/src/v1/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index b1fd6afdf6..c8fbafcf26 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -21,9 +21,10 @@ file holds only the rules that tooling enforces. - `ui:` on every utility: an unprefixed class renders with v0's theme whenever v0 already uses that class name, and silently does nothing otherwise. Merge with `cn` (`@v1/lib/cn`); `bun run tokens:v1` makes tokens.css from tokens.ts. -- The user chooses the mode, stored per browser under `unstract.v1.theme` and - applied as `data-theme` by `ThemeProvider`. Portals: `getPortalContainer()`; - focus: `ui:focus-visible:focus-ring`; Geist Mono only for model output, JSON. +- Modes `light`, `dark`, `unstract` (or `system`): the user chooses, stored per + browser under `unstract.v1.theme`, applied as `data-theme` by `ThemeProvider`. + Portals: `getPortalContainer()`; focus: `ui:focus-visible:focus-ring`; Geist + Mono only for model output, JSON and token counts. - `lint:v1` S1–S8: no inline style, raw colour or pixel, `!important`, CSS import outside `app/` or `styles/`, selector outside `.v1`, `prefers-color-scheme` or unprefixed utility (code: class and colour strings). From d79157307a940fa1d7290f787099fe6410f057e4 Mon Sep 17 00:00:00 2001 From: Athul Date: Wed, 28 Oct 2026 21:51:09 +0530 Subject: [PATCH 25/36] UN-4290 Move the v1 menus onto the ui: prefix and design tokens Every utility is ui:-prefixed and reads a P0-T04 token: colours, type roles, radius, shadow, scrim. Menus and the switch dialog portal into #v1-portal, where the tokens are defined. Organisation avatars use one token pair until the tokens carry an avatar palette. Co-Authored-By: Claude Opus 5.5 --- frontend/src/v1/shell/AccountMenu.test.tsx | 2 +- frontend/src/v1/shell/AccountMenu.tsx | 36 +++--- .../src/v1/shell/OrganizationSwitcher.tsx | 120 ++++++++---------- 3 files changed, 70 insertions(+), 88 deletions(-) diff --git a/frontend/src/v1/shell/AccountMenu.test.tsx b/frontend/src/v1/shell/AccountMenu.test.tsx index 5058a56617..ab101ced07 100644 --- a/frontend/src/v1/shell/AccountMenu.test.tsx +++ b/frontend/src/v1/shell/AccountMenu.test.tsx @@ -69,7 +69,7 @@ describe("AccountMenu", () => { const lines = screen.getAllByText("athul@zipstack.com"); expect(lines).toHaveLength(1); // The bold title line, not an empty title above the e-mail. - expect(lines[0]).toHaveClass("font-semibold"); + expect(lines[0]).toHaveClass("ui:font-semibold"); }); test("OSS has no Plan & billing item and no version switch (D14)", async () => { diff --git a/frontend/src/v1/shell/AccountMenu.tsx b/frontend/src/v1/shell/AccountMenu.tsx index 4e61fb99b3..8704c6cb0c 100644 --- a/frontend/src/v1/shell/AccountMenu.tsx +++ b/frontend/src/v1/shell/AccountMenu.tsx @@ -1,5 +1,6 @@ import { initial, initials } from "@v1/lib/organizations"; import { Slot } from "@v1/lib/plugins"; +import { getPortalContainer } from "@v1/lib/portal"; import { signOutAndLeave } from "@v1/services/session"; import { ChevronDown, @@ -15,8 +16,8 @@ const HELP_URL = "https://docs.unstract.com"; // Frame NEW09 (1638:44719). Colours are the design's token names. const ITEM = - "flex h-[34px] cursor-pointer items-center gap-2.5 rounded-lg px-[9px] text-[13px] font-medium leading-5 text-[var(--foreground)] outline-none data-[highlighted]:bg-[var(--muted)]"; -const ICON = "size-[18px] shrink-0 text-[var(--muted-foreground)]"; + "ui:flex ui:h-8.5 ui:cursor-pointer ui:items-center ui:gap-2.5 ui:rounded-md ui:px-2.25 ui:text-body-small ui:font-medium ui:text-foreground ui:outline-none ui:data-highlighted:bg-muted"; +const ICON = "ui:size-4.5 ui:shrink-0 ui:text-muted-foreground"; export interface AccountMenuProps { user: { name: string; email: string }; @@ -49,38 +50,38 @@ export function AccountMenu({ user, profileHref }: AccountMenuProps) { - + {initial(label)} - + -
- +
+ {initials(label)} - - + + {user.name || user.email} {user.name && ( - + {user.email} )}
- + Profile & preferences @@ -88,15 +89,16 @@ export function AccountMenu({ user, profileHref }: AccountMenuProps) { - - Help & docs (opens in a new tab) + + Help & docs + (opens in a new tab) - + void signOutAndLeave()} diff --git a/frontend/src/v1/shell/OrganizationSwitcher.tsx b/frontend/src/v1/shell/OrganizationSwitcher.tsx index 3688c33d7e..bd8ed331f0 100644 --- a/frontend/src/v1/shell/OrganizationSwitcher.tsx +++ b/frontend/src/v1/shell/OrganizationSwitcher.tsx @@ -4,6 +4,7 @@ import { landingPath, organizationInitials, } from "@v1/lib/organizations"; +import { getPortalContainer } from "@v1/lib/portal"; import { listOrganizations, type Organization, @@ -21,16 +22,12 @@ import { AlertDialog, Popover } from "radix-ui"; import { useCallback, useRef, useState } from "react"; // Frames NEW10 (1638:44855) and page 19, O01 to O04 (2259:182 to 2259:656). -const AVATAR_COLOURS = [ - "bg-blue-100 text-blue-700", - "bg-teal-100 text-teal-700", - "bg-amber-100 text-amber-700", - "bg-rose-100 text-rose-700", - "bg-violet-100 text-violet-700", -]; +// ponytail: one colour for every organisation avatar; the frames rotate five, +// which have no tokens yet. Rotate again once the tokens include an avatar palette. +const AVATAR_COLOUR = "ui:bg-primary-muted ui:text-primary"; const SIGN_IN_PATH = "/landing"; const BUTTON = - "inline-flex h-8 items-center gap-1.5 rounded-[10px] border border-[var(--border)] bg-[var(--card)] px-3 text-[13px] font-medium text-[var(--foreground)] hover:bg-[var(--muted)] disabled:opacity-50"; + "ui:inline-flex ui:h-8 ui:items-center ui:gap-1.5 ui:rounded-lg ui:border ui:border-border ui:bg-card ui:px-3 ui:text-body-small ui:font-medium ui:text-foreground ui:hover:bg-muted ui:disabled:opacity-50"; export interface CurrentOrganization { id: string; @@ -71,28 +68,15 @@ function useOrganizationList() { return { state, load }; } -function OrgAvatar({ - id, - name, - size, -}: { - id: string; - name: string; - size: "md" | "sm"; -}) { - const colour = - AVATAR_COLOURS[ - [...id].reduce((sum, c) => sum + c.charCodeAt(0), 0) % - AVATAR_COLOURS.length - ]; +function OrgAvatar({ name, size }: { name: string; size: "md" | "sm" }) { const box = size === "md" - ? "size-8 rounded-[10px] text-xs" - : "size-7 rounded-md text-[11px]"; + ? "ui:size-8 ui:rounded-lg ui:text-caption" + : "ui:size-7 ui:rounded-sm ui:text-overline"; return ( {organizationInitials(name)} @@ -101,11 +85,11 @@ function OrgAvatar({ function OrgLine({ org }: { org: Organization }) { return ( - - + + {org.display_name} - + {describeOrganization(org)} @@ -141,56 +125,56 @@ function ConfirmSwitch({ return ( !open && !busy && onClose()}> - - - -
- + + + +
+ -
- +
+ Switch to {to.display_name}? - + Every screen is replaced — projects, workflows, deployments, logs, members and the plan. Nothing is lost; you can switch back from here.
-
- +
+ - +
{failed && (

Could not switch. Try again.

)} -
- +
+ Cancel
@@ -235,40 +219,40 @@ export function OrganizationSwitcher({ > - + {initial(current.displayName)} - + {current.displayName} - + -