Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 185 additions & 3 deletions web/mobile/scripts/generate-shadcn-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
*
* Run (from web/mobile): pnpm generate:tokens
*/
import {readFileSync, writeFileSync} from "fs"
import {readdirSync, readFileSync, writeFileSync} from "fs"
import {dirname, resolve} from "path"
import {fileURLToPath} from "url"

import {controlScale} from "../../oss/src/styles/theme/controlScale"
import {shadcnTokens} from "../../oss/src/styles/theme/shadcnTokens"
import {palette, type ColorValue} from "../../oss/src/styles/theme/palette"

const HERE = dirname(fileURLToPath(import.meta.url)) // web/mobile/scripts
Expand Down Expand Up @@ -67,17 +69,197 @@ const VARS: Record<string, [string, string]> = {
ring: [color(p.accent.primary.light), color(p.accent.primary.dark)],
}

// ── @agenta/ui control tokens ────────────────────────────────────────────────
// The shared components in @agenta/ui speak the palette-derived vocabulary the desktop
// generates (btn-*, error-*, disabled-*). Same palette.ts, same values — only the names
// differ from the shadcn roles above, so both live in one generated file.
const UI_VARS: Record<string, [string, string]> = {
"btn-primary-hover": [color(p.button.primaryHover.light), color(p.button.primaryHover.dark)],
"btn-primary-active": [color(p.button.primaryActive.light), color(p.button.primaryActive.dark)],
"btn-primary-fg": [color(p.button.primaryText.light), color(p.button.primaryText.dark)],
"btn-default-bg": [color(p.button.defaultBg.light), color(p.button.defaultBg.dark)],
"btn-default-hover-bg": [
color(p.button.defaultHoverBg.light),
color(p.button.defaultHoverBg.dark),
],
"btn-default-active-bg": [
color(p.button.defaultActiveBg.light),
color(p.button.defaultActiveBg.dark),
],
"btn-text-hover-bg": [color(p.button.textHoverBg.light), color(p.button.textHoverBg.dark)],
"btn-text-active-bg": [color(p.button.textActiveBg.light), color(p.button.textActiveBg.dark)],
"btn-link": [color(p.button.link.light), color(p.button.link.dark)],
"btn-link-hover": [color(p.button.linkHover.light), color(p.button.linkHover.dark)],
"btn-link-active": [color(p.button.linkActive.light), color(p.button.linkActive.dark)],
error: [color(p.semantic.error.light), color(p.semantic.error.dark)],
"error-hover": [color(p.semantic.errorHover.light), color(p.semantic.errorHover.dark)],
"error-active": [color(p.semantic.errorActive.light), color(p.semantic.errorActive.dark)],
disabled: [color(p.text.disabled.light), color(p.text.disabled.dark)],
"focus-ring": [color(p.semantic.primaryBorder.light), color(p.semantic.primaryBorder.dark)],
"disabled-bg": [
color(p.surface.containerDisabled.light),
color(p.surface.containerDisabled.dark),
],
}

// ── Control geometry ─────────────────────────────────────────────────────────
// Straight from the shared controlScale module (web/oss/src/styles/theme/controlScale.ts) —
// the same object oss spreads into its Tailwind v3 theme. Emitted into Tailwind v4's own
// namespaces so a shared component renders at identical dimensions in both apps, and a retune
// there reaches mobile through this generator instead of a hand-copied pixel list.

const geometry = (): string => {
const out: string[] = []
const push = (ns: string, table: Record<string, unknown>) => {
for (const [name, value] of Object.entries(table)) {
// fontSize entries are [size, {lineHeight}] tuples; the rest are plain strings.
const size = Array.isArray(value) ? (value[0] as string) : (value as string)
out.push(` --${ns}-${name}: ${size};`)
}
}
push("height", controlScale.height)
push("width", controlScale.width)
push("spacing", controlScale.spacing)
push("radius", controlScale.borderRadius)
push("text", controlScale.fontSize)
return out.join("\n")
}

// ── @agenta/ui colour surface ────────────────────────────────────────────────
// The shared components speak the desktop's palette-derived `--ag-*` vocabulary. Rather than
// curating a per-component list, mirror the layer itself: read the generated variables, keep
// the ones @agenta/ui references, and expose each under Tailwind v4's colour namespace.
//
// Source is theme-variables.css, not palette.ts, because many values are antd-algorithm output
// (derived fills, preset tag ramps) that palette.ts cannot reproduce on its own. Both files come
// from the same generator run, so this stays one source of truth.
const AG_CSS = resolve(HERE, "../../oss/src/styles/theme-variables.css")

/**
* Flatten the shared contract into utility-name → variable-name.
*
* Tailwind nests families (`{primary: {DEFAULT, hover}}` → `primary`, `primary-hover`), so the
* same flattening the v3 config gets for free has to happen explicitly here.
*/
const flattenTokens = (
node: unknown,
prefix = "",
out: Record<string, string> = {},
): Record<string, string> => {
if (typeof node === "string") {
const m = node.match(/^var\(--ag-([A-Za-z0-9-]+)\)$/)
if (m && prefix) out[prefix] = m[1]
return out
}
if (node && typeof node === "object") {
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
const name = key === "DEFAULT" ? prefix : prefix ? `${prefix}-${key}` : key
flattenTokens(value, name, out)
}
}
return out
}

const TOKEN_TO_VAR = flattenTokens(shadcnTokens)

/**
* Utility names the shared components actually reference. Scanned rather than enumerated so a
* new component cannot silently render unstyled: whatever it uses, the bridge emits.
*/
const usedNames = (): string[] => {
const dir = resolve(HERE, "../../packages/agenta-ui/src")
const names = new Set<string>()
const walk = (d: string) => {
for (const entry of readdirSync(d, {withFileTypes: true})) {
const full = resolve(d, entry.name)
if (entry.isDirectory()) walk(full)
else if (/\.tsx?$/.test(entry.name)) {
for (const m of readFileSync(full, "utf8").matchAll(
/\b(?:bg|text|border|ring|fill|stroke|outline|from|to|via|decoration|shadow|divide|accent|caret|placeholder)-([a-zA-Z][a-zA-Z0-9-]*)/g,
)) {
names.add(m[1])
}
}
}
}
walk(dir)
return [...names]
}

const parseAgVars = (): {light: Record<string, string>; dark: Record<string, string>} => {
const css = readFileSync(AG_CSS, "utf8")
const grab = (selector: string): Record<string, string> => {
const start = css.indexOf(`${selector} {`)
if (start < 0) throw new Error(`${selector} block missing from theme-variables.css`)
const body = css.slice(start, css.indexOf("\n}", start))
const out: Record<string, string> = {}
for (const line of body.split("\n")) {
const m = line.match(/^\s*--ag-([A-Za-z0-9-]+):\s*(.+);\s*$/)
if (m) out[m[1]] = m[2].trim()
}
return out
}
return {light: grab(":root"), dark: grab(".dark")}
}

/** Which `--ag-*` names the shared components reference, read from their own source. */
const usedAgNames = (): string[] => {
const dir = resolve(HERE, "../../packages/agenta-ui/src")
const names = new Set<string>()
const walk = (d: string) => {
for (const entry of readdirSync(d, {withFileTypes: true})) {
const full = resolve(d, entry.name)
if (entry.isDirectory()) walk(full)
else if (/\.tsx?$/.test(entry.name)) {
const src = readFileSync(full, "utf8")
for (const m of src.matchAll(
/\b(?:bg|text|border|ring|fill|stroke|outline|from|to|via|decoration|shadow)-([a-zA-Z][a-zA-Z0-9-]*)/g,
)) {
names.add(m[1])
}
}
}
}
walk(dir)
return [...names]
}

const block = (selector: string, side: 0 | 1) =>
`${selector} {\n${Object.entries(VARS)
`${selector} {\n${Object.entries({...VARS, ...UI_VARS})
.map(([name, pair]) => ` --${name}: ${pair[side]};`)
.join("\n")}\n}\n`

// Colour surface: `--name` per theme, plus the v4 namespace mapping. Only names that resolve to
// a real variable are emitted — an unmapped utility would silently generate nothing anyway.
const ag = parseAgVars()
// The contract wins where it renames a token; anything else the package references maps to the
// variable of the same name.
const agResolved = [...new Set([...Object.keys(TOKEN_TO_VAR), ...usedNames()])]
.map((name) => ({name, varName: TOKEN_TO_VAR[name] ?? name}))
.filter(({varName}) => ag.light[varName] !== undefined)

const agBlock = (side: "light" | "dark") =>
agResolved.map(({name, varName}) => ` --${name}: ${ag[side][varName]};`).join("\n")

const agTheme = () => agResolved.map(({name}) => ` --color-${name}: var(--${name});`).join("\n")

const css = `/* GENERATED by scripts/generate-shadcn-tokens.ts — DO NOT EDIT.
* Source of truth: web/oss/src/styles/theme/palette.ts
* Regenerate: pnpm --filter @agenta/mobile generate:tokens
*/
:root {
${agBlock("light")}
}
.dark {
${agBlock("dark")}
}
${block(":root", 0)}
${block(".dark", 1)}`
${block(".dark", 1)}
@theme {
${agTheme()}
${geometry()}
}
`

// --check: drift guard — fail (without writing) if the committed file is stale.
if (process.argv.includes("--check")) {
Expand Down
22 changes: 22 additions & 0 deletions web/mobile/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,28 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);

/* ── @agenta/ui control tokens ─────────────────────────────────────────
* Colours come from the generated palette layer; the geometry mirrors
* `controlScale` in web/oss/tailwind.config.ts (a fixed px scale, not palette-derived),
* so a shared component renders at identical dimensions in both apps. */
--color-btn-primary-hover: var(--btn-primary-hover);
--color-btn-primary-active: var(--btn-primary-active);
--color-btn-primary-fg: var(--btn-primary-fg);
--color-btn-default-bg: var(--btn-default-bg);
--color-btn-default-hover-bg: var(--btn-default-hover-bg);
--color-btn-default-active-bg: var(--btn-default-active-bg);
--color-btn-text-hover-bg: var(--btn-text-hover-bg);
--color-btn-text-active-bg: var(--btn-text-active-bg);
--color-btn-link: var(--btn-link);
--color-btn-link-hover: var(--btn-link-hover);
--color-btn-link-active: var(--btn-link-active);
--color-error: var(--error);
--color-error-hover: var(--error-hover);
--color-error-active: var(--error-active);
--color-disabled-bg: var(--disabled-bg);
--color-disabled: var(--disabled);
--color-focus-ring: var(--focus-ring);
}

@layer base {
Expand Down
Loading
Loading