From 9ab067bd5553edad4e2589ce9a71fd06907a8499 Mon Sep 17 00:00:00 2001 From: Natalia Bar Date: Mon, 16 Mar 2026 09:33:05 -0700 Subject: [PATCH 01/16] Configure fluentui-contrib to support nested packages, create core --- package.json | 239 +++++++++--------- packages/cap-foundations/README.md | 26 +- packages/cap-foundations/{ => core}/.swcrc | 0 packages/cap-foundations/core/README.md | 21 ++ .../{ => core}/eslint.config.js | 2 +- .../{ => core}/jest.config.cts | 8 +- packages/cap-foundations/core/package.json | 10 + .../cap-foundations/{ => core}/project.json | 12 +- .../cap-foundations/{ => core}/src/index.ts | 0 .../cap-foundations/{ => core}/tsconfig.json | 2 +- .../{ => core}/tsconfig.lib.json | 2 +- .../{ => core}/tsconfig.spec.json | 2 +- packages/cap-foundations/package.json | 10 - tsconfig.base.json | 4 +- yarn.lock | 4 +- 15 files changed, 190 insertions(+), 152 deletions(-) rename packages/cap-foundations/{ => core}/.swcrc (100%) create mode 100644 packages/cap-foundations/core/README.md rename packages/cap-foundations/{ => core}/eslint.config.js (80%) rename packages/cap-foundations/{ => core}/jest.config.cts (84%) create mode 100644 packages/cap-foundations/core/package.json rename packages/cap-foundations/{ => core}/project.json (61%) rename packages/cap-foundations/{ => core}/src/index.ts (100%) rename packages/cap-foundations/{ => core}/tsconfig.json (82%) rename packages/cap-foundations/{ => core}/tsconfig.lib.json (85%) rename packages/cap-foundations/{ => core}/tsconfig.spec.json (85%) delete mode 100644 packages/cap-foundations/package.json diff --git a/package.json b/package.json index 0b3b1c6d0..ab09f8134 100644 --- a/package.json +++ b/package.json @@ -1,119 +1,120 @@ -{ - "name": "fluentui-contrib", - "private": true, - "version": "0.0.0", - "license": "MIT", - "scripts": { - "build": "nx affected --target=build", - "change": "beachball change", - "check-dependencies": "syncpack list-mismatches", - "lint": "nx affected --target=lint", - "test": "nx affected --target=test", - "type-check": "nx affected --target=type-check", - "preinstall": "node ./tools/scripts/preinstall.mjs", - "postinstall": "patch-package", - "dedupe": "yarn dedupe --strategy highest" - }, - "repository": { - "url": "https://github.com/microsoft/fluentui-contrib", - "type": "git" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0", - "yarn": ">=4.12.0" - }, - "packageManager": "yarn@4.12.0", - "devDependencies": { - "@babel/preset-env": "7.27.1", - "@babel/preset-react": "7.27.1", - "@babel/preset-typescript": "7.27.1", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@fluentui/react": "^8.120.2", - "@fluentui/react-components": "^9.70.0", - "@fluentui/react-icons": "^2.0.249", - "@fluentui/react-migration-v8-v9": "^9.9.4", - "@fluentui/react-shared-contexts": "^9.24.1", - "@fluentui/scheme-utilities": "^8.3.58", - "@fluentui/storybook-llms-extractor": "^0.0.3", - "@griffel/shadow-dom": "~0.2.0", - "@nx/devkit": "22.1.3", - "@nx/eslint": "22.1.3", - "@nx/eslint-plugin": "22.1.3", - "@nx/jest": "22.1.3", - "@nx/js": "22.1.3", - "@nx/playwright": "22.1.3", - "@nx/plugin": "22.1.3", - "@nx/react": "22.1.3", - "@nx/storybook": "22.1.3", - "@nx/web": "22.1.3", - "@nx/webpack": "22.1.3", - "@nx/workspace": "22.1.3", - "@playwright/experimental-ct-react": "1.56.1", - "@playwright/experimental-ct-react17": "1.56.1", - "@rnx-kit/eslint-plugin": "0.8.6", - "@storybook/addon-docs": "9.1.16", - "@storybook/addon-themes": "9.1.16", - "@storybook/addon-webpack5-compiler-babel": "^3.0.6", - "@storybook/react-webpack5": "9.1.16", - "@stylistic/eslint-plugin": "3.1.0", - "@swc-node/register": "1.10.10", - "@swc/cli": "0.7.8", - "@swc/core": "1.13.3", - "@swc/helpers": "~0.5.11", - "@swc/jest": "0.2.39", - "@testing-library/dom": "10.4.0", - "@testing-library/react": "16.3.0", - "@testing-library/user-event": "14.6.1", - "@types/jest": "30.0.0", - "@types/node": "20.14.9", - "@types/react": "18.3.1", - "@types/react-dom": "18.3.0", - "@types/react-virtualized-auto-sizer": "^1.0.8", - "@types/react-window": "^1.8.5", - "beachball": "^2.33.2", - "eslint": "9.26.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-playwright": "2.2.2", - "globals": "15.15.0", - "jest": "30.0.5", - "jest-environment-jsdom": "30.0.5", - "jest-util": "^30.0.2", - "jsonc-eslint-parser": "2.4.0", - "nx": "22.1.3", - "parsel-js": "^1.1.2", - "patch-package": "^7.0.2", - "playwright": "1.56.1", - "prettier": "^2.6.2", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-frame-component": "^5.2.6", - "react-shadow": "^20.6.0", - "react-virtualized-auto-sizer": "^1.0.26", - "react-window": "^1.8.10", - "scheduler": "0.23.0", - "semver": "^7.5.2", - "storybook": "9.1.16", - "stylelint": "^15.10.3", - "syncpack": "^9.8.6", - "ts-node": "10.9.2", - "tslib": "^2.3.0", - "typescript": "5.8.3", - "typescript-eslint": "8.32.1" - }, - "nx": { - "includedScripts": [] - }, - "workspaces": [ - "apps/*", - "packages/*" - ], - "resolutions": { - "@storybook/addon-docs/@jest/transform": "^30", - "tar-fs": "^2.1.4", - "axios": "^1.12.0", - "node-forge": "1.3.2", - "@types/react-window/@types/react": "18.3.1" - } -} +{ + "name": "fluentui-contrib", + "private": true, + "version": "0.0.0", + "license": "MIT", + "scripts": { + "build": "nx affected --target=build", + "change": "beachball change", + "check-dependencies": "syncpack list-mismatches", + "lint": "nx affected --target=lint", + "test": "nx affected --target=test", + "type-check": "nx affected --target=type-check", + "preinstall": "node ./tools/scripts/preinstall.mjs", + "postinstall": "patch-package", + "dedupe": "yarn dedupe --strategy highest" + }, + "repository": { + "url": "https://github.com/microsoft/fluentui-contrib", + "type": "git" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0", + "yarn": ">=4.12.0" + }, + "packageManager": "yarn@4.12.0", + "devDependencies": { + "@babel/preset-env": "7.27.1", + "@babel/preset-react": "7.27.1", + "@babel/preset-typescript": "7.27.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fluentui/react": "^8.120.2", + "@fluentui/react-components": "^9.70.0", + "@fluentui/react-icons": "^2.0.249", + "@fluentui/react-migration-v8-v9": "^9.9.4", + "@fluentui/react-shared-contexts": "^9.24.1", + "@fluentui/scheme-utilities": "^8.3.58", + "@fluentui/storybook-llms-extractor": "^0.0.3", + "@griffel/shadow-dom": "~0.2.0", + "@nx/devkit": "22.1.3", + "@nx/eslint": "22.1.3", + "@nx/eslint-plugin": "22.1.3", + "@nx/jest": "22.1.3", + "@nx/js": "22.1.3", + "@nx/playwright": "22.1.3", + "@nx/plugin": "22.1.3", + "@nx/react": "22.1.3", + "@nx/storybook": "22.1.3", + "@nx/web": "22.1.3", + "@nx/webpack": "22.1.3", + "@nx/workspace": "22.1.3", + "@playwright/experimental-ct-react": "1.56.1", + "@playwright/experimental-ct-react17": "1.56.1", + "@rnx-kit/eslint-plugin": "0.8.6", + "@storybook/addon-docs": "9.1.16", + "@storybook/addon-themes": "9.1.16", + "@storybook/addon-webpack5-compiler-babel": "^3.0.6", + "@storybook/react-webpack5": "9.1.16", + "@stylistic/eslint-plugin": "3.1.0", + "@swc-node/register": "1.10.10", + "@swc/cli": "0.7.8", + "@swc/core": "1.13.3", + "@swc/helpers": "~0.5.11", + "@swc/jest": "0.2.39", + "@testing-library/dom": "10.4.0", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", + "@types/jest": "30.0.0", + "@types/node": "20.14.9", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.0", + "@types/react-virtualized-auto-sizer": "^1.0.8", + "@types/react-window": "^1.8.5", + "beachball": "^2.33.2", + "eslint": "9.26.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-playwright": "2.2.2", + "globals": "15.15.0", + "jest": "30.0.5", + "jest-environment-jsdom": "30.0.5", + "jest-util": "^30.0.2", + "jsonc-eslint-parser": "2.4.0", + "nx": "22.1.3", + "parsel-js": "^1.1.2", + "patch-package": "^7.0.2", + "playwright": "1.56.1", + "prettier": "^2.6.2", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-frame-component": "^5.2.6", + "react-shadow": "^20.6.0", + "react-virtualized-auto-sizer": "^1.0.26", + "react-window": "^1.8.10", + "scheduler": "0.23.0", + "semver": "^7.5.2", + "storybook": "9.1.16", + "stylelint": "^15.10.3", + "syncpack": "^9.8.6", + "ts-node": "10.9.2", + "tslib": "^2.3.0", + "typescript": "5.8.3", + "typescript-eslint": "8.32.1" + }, + "nx": { + "includedScripts": [] + }, + "workspaces": [ + "apps/*", + "packages/*", + "packages/cap-foundations/*" + ], + "resolutions": { + "@storybook/addon-docs/@jest/transform": "^30", + "tar-fs": "^2.1.4", + "axios": "^1.12.0", + "node-forge": "1.3.2", + "@types/react-window/@types/react": "18.3.1" + } +} diff --git a/packages/cap-foundations/README.md b/packages/cap-foundations/README.md index 37065586a..09f448cb1 100644 --- a/packages/cap-foundations/README.md +++ b/packages/cap-foundations/README.md @@ -1,11 +1,27 @@ # cap-foundations -This library was generated with [Nx](https://nx.dev). +Umbrella for the Cap design system packages inside `fluentui-contrib`. -## Building +## Packages -Run `nx build cap-foundations` to build the library. +| Package | Description | +|---------|-------------| +| [`core`](./core) · `@fluentui-contrib/cap-foundations-core` | Static tokens, color utilities, theme generation pipeline, runtime bootstrap | +| `react` *(Phase 6+)* | React components and `ThemeProvider` — depends on `core` | +| `icons` *(Phase 7+)* | SVG icon components — depends on `react` | +| `mock-pages` *(Phase 8+)* | Storybook mock pages — depends on `core` + `react` | -## Running unit tests +## Dependency graph -Run `nx test cap-foundations` to execute the unit tests via [Jest](https://jestjs.io). +``` +mock-pages → react → core + → icons +``` + +## Quick-start (core only) + +```bash +nx run cap-foundations-core:build +nx run cap-foundations-core:test +nx run cap-foundations-core:type-check +``` diff --git a/packages/cap-foundations/.swcrc b/packages/cap-foundations/core/.swcrc similarity index 100% rename from packages/cap-foundations/.swcrc rename to packages/cap-foundations/core/.swcrc diff --git a/packages/cap-foundations/core/README.md b/packages/cap-foundations/core/README.md new file mode 100644 index 000000000..f3e41c923 --- /dev/null +++ b/packages/cap-foundations/core/README.md @@ -0,0 +1,21 @@ +# @fluentui-contrib/cap-foundations-core + +Core foundation layer for the Cap design system: static design tokens, color utilities, theme generation pipeline, and zero-flash runtime bootstrap. + +## Installation + +```bash +yarn add @fluentui-contrib/cap-foundations-core +``` + +## Packages in this workspace + +This package is part of the [cap-foundations](../) umbrella. + +## Building + +```bash +nx run cap-foundations-core:build +nx run cap-foundations-core:test +nx run cap-foundations-core:type-check +``` diff --git a/packages/cap-foundations/eslint.config.js b/packages/cap-foundations/core/eslint.config.js similarity index 80% rename from packages/cap-foundations/eslint.config.js rename to packages/cap-foundations/core/eslint.config.js index 2be3ed1ba..8c80cedce 100644 --- a/packages/cap-foundations/eslint.config.js +++ b/packages/cap-foundations/core/eslint.config.js @@ -1,4 +1,4 @@ -const baseConfig = require('../../eslint.config.js'); +const baseConfig = require('../../../eslint.config.js'); module.exports = [ ...baseConfig, diff --git a/packages/cap-foundations/jest.config.cts b/packages/cap-foundations/core/jest.config.cts similarity index 84% rename from packages/cap-foundations/jest.config.cts rename to packages/cap-foundations/core/jest.config.cts index c04a6d491..f30630f5e 100644 --- a/packages/cap-foundations/jest.config.cts +++ b/packages/cap-foundations/core/jest.config.cts @@ -19,12 +19,12 @@ if (swcJestConfig.swcrc === undefined) { // swcJestConfig.module.noInterop = false; module.exports = { - displayName: 'cap-foundations', - preset: '../../jest.preset.js', + displayName: 'cap-foundations-core', + preset: '../../../jest.preset.js', transform: { '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], }, moduleFileExtensions: ['ts', 'js', 'html'], - testEnvironment: 'jsdom', - coverageDirectory: '../../coverage/packages/cap-foundations', + testEnvironment: 'node', + coverageDirectory: '../../../coverage/packages/cap-foundations/core', }; diff --git a/packages/cap-foundations/core/package.json b/packages/cap-foundations/core/package.json new file mode 100644 index 000000000..bd5484dd3 --- /dev/null +++ b/packages/cap-foundations/core/package.json @@ -0,0 +1,10 @@ +{ + "name": "@fluentui-contrib/cap-foundations-core", + "version": "0.0.1", + "description": "Core tokens, themes and color utilities for Cap design system", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@swc/helpers": "~0.5.11" + } +} diff --git a/packages/cap-foundations/project.json b/packages/cap-foundations/core/project.json similarity index 61% rename from packages/cap-foundations/project.json rename to packages/cap-foundations/core/project.json index ae4bbae8f..c00211d7e 100644 --- a/packages/cap-foundations/project.json +++ b/packages/cap-foundations/core/project.json @@ -1,7 +1,7 @@ { - "name": "cap-foundations", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "packages/cap-foundations/src", + "name": "cap-foundations-core", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/cap-foundations/core/src", "projectType": "library", "tags": [], "targets": { @@ -12,8 +12,8 @@ "executor": "@nx/eslint:lint", "options": { "lintFilePatterns": [ - "packages/cap-foundations/**/*.ts", - "packages/cap-foundations/**/*.tsx" + "packages/cap-foundations/core/**/*.ts", + "packages/cap-foundations/core/**/*.tsx" ] } }, @@ -21,7 +21,7 @@ "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { - "jestConfig": "packages/cap-foundations/jest.config.cts", + "jestConfig": "packages/cap-foundations/core/jest.config.cts", "passWithNoTests": true } }, diff --git a/packages/cap-foundations/src/index.ts b/packages/cap-foundations/core/src/index.ts similarity index 100% rename from packages/cap-foundations/src/index.ts rename to packages/cap-foundations/core/src/index.ts diff --git a/packages/cap-foundations/tsconfig.json b/packages/cap-foundations/core/tsconfig.json similarity index 82% rename from packages/cap-foundations/tsconfig.json rename to packages/cap-foundations/core/tsconfig.json index a5d448b2f..9e1563e3c 100644 --- a/packages/cap-foundations/tsconfig.json +++ b/packages/cap-foundations/core/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "files": [], "compilerOptions": { "jsx": "react" diff --git a/packages/cap-foundations/tsconfig.lib.json b/packages/cap-foundations/core/tsconfig.lib.json similarity index 85% rename from packages/cap-foundations/tsconfig.lib.json rename to packages/cap-foundations/core/tsconfig.lib.json index 884038eac..c26d356ae 100644 --- a/packages/cap-foundations/tsconfig.lib.json +++ b/packages/cap-foundations/core/tsconfig.lib.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../../dist/out-tsc", "declaration": true }, "include": ["src/**/*.ts"], diff --git a/packages/cap-foundations/tsconfig.spec.json b/packages/cap-foundations/core/tsconfig.spec.json similarity index 85% rename from packages/cap-foundations/tsconfig.spec.json rename to packages/cap-foundations/core/tsconfig.spec.json index 361fd5a7e..a95eeaf1d 100644 --- a/packages/cap-foundations/tsconfig.spec.json +++ b/packages/cap-foundations/core/tsconfig.spec.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../dist/out-tsc", + "outDir": "../../../dist/out-tsc", "module": "commonjs", "types": ["jest", "node"] }, diff --git a/packages/cap-foundations/package.json b/packages/cap-foundations/package.json deleted file mode 100644 index 5202d88a6..000000000 --- a/packages/cap-foundations/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@fluentui-contrib/cap-foundations", - "version": "0.0.1", - "main": "./src/index.js", - "types": "./src/index.d.ts", - "dependencies": { - "@swc/helpers": "~0.5.11" - }, - "private": true -} diff --git a/tsconfig.base.json b/tsconfig.base.json index b8d4f31a8..989d9d32b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -78,8 +78,8 @@ "@fluentui-contrib/variant-theme": [ "packages/variant-theme/src/index.ts" ], - "@fluentui-contrib/cap-foundations": [ - "packages/cap-foundations/src/index.ts" + "@fluentui-contrib/cap-foundations-core": [ + "packages/cap-foundations/core/src/index.ts" ] } }, diff --git a/yarn.lock b/yarn.lock index 7b589e93b..cd8c52032 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2000,9 +2000,9 @@ __metadata: languageName: unknown linkType: soft -"@fluentui-contrib/cap-foundations@workspace:packages/cap-foundations": +"@fluentui-contrib/cap-foundations-core@workspace:packages/cap-foundations/core": version: 0.0.0-use.local - resolution: "@fluentui-contrib/cap-foundations@workspace:packages/cap-foundations" + resolution: "@fluentui-contrib/cap-foundations-core@workspace:packages/cap-foundations/core" dependencies: "@swc/helpers": "npm:~0.5.11" languageName: unknown From 250e887cb516fb30b25af6e6edc5fb083e7c1b2f Mon Sep 17 00:00:00 2001 From: Natalia Bar Date: Mon, 16 Mar 2026 10:23:02 -0700 Subject: [PATCH 02/16] Revert pacakge.json --- package.json | 239 +++++++++++++++++++++++++-------------------------- 1 file changed, 119 insertions(+), 120 deletions(-) diff --git a/package.json b/package.json index ab09f8134..0b3b1c6d0 100644 --- a/package.json +++ b/package.json @@ -1,120 +1,119 @@ -{ - "name": "fluentui-contrib", - "private": true, - "version": "0.0.0", - "license": "MIT", - "scripts": { - "build": "nx affected --target=build", - "change": "beachball change", - "check-dependencies": "syncpack list-mismatches", - "lint": "nx affected --target=lint", - "test": "nx affected --target=test", - "type-check": "nx affected --target=type-check", - "preinstall": "node ./tools/scripts/preinstall.mjs", - "postinstall": "patch-package", - "dedupe": "yarn dedupe --strategy highest" - }, - "repository": { - "url": "https://github.com/microsoft/fluentui-contrib", - "type": "git" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0", - "yarn": ">=4.12.0" - }, - "packageManager": "yarn@4.12.0", - "devDependencies": { - "@babel/preset-env": "7.27.1", - "@babel/preset-react": "7.27.1", - "@babel/preset-typescript": "7.27.1", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@fluentui/react": "^8.120.2", - "@fluentui/react-components": "^9.70.0", - "@fluentui/react-icons": "^2.0.249", - "@fluentui/react-migration-v8-v9": "^9.9.4", - "@fluentui/react-shared-contexts": "^9.24.1", - "@fluentui/scheme-utilities": "^8.3.58", - "@fluentui/storybook-llms-extractor": "^0.0.3", - "@griffel/shadow-dom": "~0.2.0", - "@nx/devkit": "22.1.3", - "@nx/eslint": "22.1.3", - "@nx/eslint-plugin": "22.1.3", - "@nx/jest": "22.1.3", - "@nx/js": "22.1.3", - "@nx/playwright": "22.1.3", - "@nx/plugin": "22.1.3", - "@nx/react": "22.1.3", - "@nx/storybook": "22.1.3", - "@nx/web": "22.1.3", - "@nx/webpack": "22.1.3", - "@nx/workspace": "22.1.3", - "@playwright/experimental-ct-react": "1.56.1", - "@playwright/experimental-ct-react17": "1.56.1", - "@rnx-kit/eslint-plugin": "0.8.6", - "@storybook/addon-docs": "9.1.16", - "@storybook/addon-themes": "9.1.16", - "@storybook/addon-webpack5-compiler-babel": "^3.0.6", - "@storybook/react-webpack5": "9.1.16", - "@stylistic/eslint-plugin": "3.1.0", - "@swc-node/register": "1.10.10", - "@swc/cli": "0.7.8", - "@swc/core": "1.13.3", - "@swc/helpers": "~0.5.11", - "@swc/jest": "0.2.39", - "@testing-library/dom": "10.4.0", - "@testing-library/react": "16.3.0", - "@testing-library/user-event": "14.6.1", - "@types/jest": "30.0.0", - "@types/node": "20.14.9", - "@types/react": "18.3.1", - "@types/react-dom": "18.3.0", - "@types/react-virtualized-auto-sizer": "^1.0.8", - "@types/react-window": "^1.8.5", - "beachball": "^2.33.2", - "eslint": "9.26.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-playwright": "2.2.2", - "globals": "15.15.0", - "jest": "30.0.5", - "jest-environment-jsdom": "30.0.5", - "jest-util": "^30.0.2", - "jsonc-eslint-parser": "2.4.0", - "nx": "22.1.3", - "parsel-js": "^1.1.2", - "patch-package": "^7.0.2", - "playwright": "1.56.1", - "prettier": "^2.6.2", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-frame-component": "^5.2.6", - "react-shadow": "^20.6.0", - "react-virtualized-auto-sizer": "^1.0.26", - "react-window": "^1.8.10", - "scheduler": "0.23.0", - "semver": "^7.5.2", - "storybook": "9.1.16", - "stylelint": "^15.10.3", - "syncpack": "^9.8.6", - "ts-node": "10.9.2", - "tslib": "^2.3.0", - "typescript": "5.8.3", - "typescript-eslint": "8.32.1" - }, - "nx": { - "includedScripts": [] - }, - "workspaces": [ - "apps/*", - "packages/*", - "packages/cap-foundations/*" - ], - "resolutions": { - "@storybook/addon-docs/@jest/transform": "^30", - "tar-fs": "^2.1.4", - "axios": "^1.12.0", - "node-forge": "1.3.2", - "@types/react-window/@types/react": "18.3.1" - } -} +{ + "name": "fluentui-contrib", + "private": true, + "version": "0.0.0", + "license": "MIT", + "scripts": { + "build": "nx affected --target=build", + "change": "beachball change", + "check-dependencies": "syncpack list-mismatches", + "lint": "nx affected --target=lint", + "test": "nx affected --target=test", + "type-check": "nx affected --target=type-check", + "preinstall": "node ./tools/scripts/preinstall.mjs", + "postinstall": "patch-package", + "dedupe": "yarn dedupe --strategy highest" + }, + "repository": { + "url": "https://github.com/microsoft/fluentui-contrib", + "type": "git" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0", + "yarn": ">=4.12.0" + }, + "packageManager": "yarn@4.12.0", + "devDependencies": { + "@babel/preset-env": "7.27.1", + "@babel/preset-react": "7.27.1", + "@babel/preset-typescript": "7.27.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fluentui/react": "^8.120.2", + "@fluentui/react-components": "^9.70.0", + "@fluentui/react-icons": "^2.0.249", + "@fluentui/react-migration-v8-v9": "^9.9.4", + "@fluentui/react-shared-contexts": "^9.24.1", + "@fluentui/scheme-utilities": "^8.3.58", + "@fluentui/storybook-llms-extractor": "^0.0.3", + "@griffel/shadow-dom": "~0.2.0", + "@nx/devkit": "22.1.3", + "@nx/eslint": "22.1.3", + "@nx/eslint-plugin": "22.1.3", + "@nx/jest": "22.1.3", + "@nx/js": "22.1.3", + "@nx/playwright": "22.1.3", + "@nx/plugin": "22.1.3", + "@nx/react": "22.1.3", + "@nx/storybook": "22.1.3", + "@nx/web": "22.1.3", + "@nx/webpack": "22.1.3", + "@nx/workspace": "22.1.3", + "@playwright/experimental-ct-react": "1.56.1", + "@playwright/experimental-ct-react17": "1.56.1", + "@rnx-kit/eslint-plugin": "0.8.6", + "@storybook/addon-docs": "9.1.16", + "@storybook/addon-themes": "9.1.16", + "@storybook/addon-webpack5-compiler-babel": "^3.0.6", + "@storybook/react-webpack5": "9.1.16", + "@stylistic/eslint-plugin": "3.1.0", + "@swc-node/register": "1.10.10", + "@swc/cli": "0.7.8", + "@swc/core": "1.13.3", + "@swc/helpers": "~0.5.11", + "@swc/jest": "0.2.39", + "@testing-library/dom": "10.4.0", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", + "@types/jest": "30.0.0", + "@types/node": "20.14.9", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.0", + "@types/react-virtualized-auto-sizer": "^1.0.8", + "@types/react-window": "^1.8.5", + "beachball": "^2.33.2", + "eslint": "9.26.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-playwright": "2.2.2", + "globals": "15.15.0", + "jest": "30.0.5", + "jest-environment-jsdom": "30.0.5", + "jest-util": "^30.0.2", + "jsonc-eslint-parser": "2.4.0", + "nx": "22.1.3", + "parsel-js": "^1.1.2", + "patch-package": "^7.0.2", + "playwright": "1.56.1", + "prettier": "^2.6.2", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-frame-component": "^5.2.6", + "react-shadow": "^20.6.0", + "react-virtualized-auto-sizer": "^1.0.26", + "react-window": "^1.8.10", + "scheduler": "0.23.0", + "semver": "^7.5.2", + "storybook": "9.1.16", + "stylelint": "^15.10.3", + "syncpack": "^9.8.6", + "ts-node": "10.9.2", + "tslib": "^2.3.0", + "typescript": "5.8.3", + "typescript-eslint": "8.32.1" + }, + "nx": { + "includedScripts": [] + }, + "workspaces": [ + "apps/*", + "packages/*" + ], + "resolutions": { + "@storybook/addon-docs/@jest/transform": "^30", + "tar-fs": "^2.1.4", + "axios": "^1.12.0", + "node-forge": "1.3.2", + "@types/react-window/@types/react": "18.3.1" + } +} From f7ff80477bb5ef9cc014c940ce31505a7b7b74da Mon Sep 17 00:00:00 2001 From: Natalia Bar Date: Mon, 16 Mar 2026 11:07:26 -0700 Subject: [PATCH 03/16] Edit manually package.json --- package.json | 3 ++- packages/cap-foundations/README.md | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 0b3b1c6d0..ef728d055 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,8 @@ }, "workspaces": [ "apps/*", - "packages/*" + "packages/*", + "packages/cap-foundations/*" ], "resolutions": { "@storybook/addon-docs/@jest/transform": "^30", diff --git a/packages/cap-foundations/README.md b/packages/cap-foundations/README.md index 09f448cb1..f7f85c4c2 100644 --- a/packages/cap-foundations/README.md +++ b/packages/cap-foundations/README.md @@ -1,15 +1,15 @@ # cap-foundations -Umbrella for the Cap design system packages inside `fluentui-contrib`. +Umbrella for the Cap design system packages. ## Packages -| Package | Description | -|---------|-------------| +| Package | Description | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------- | | [`core`](./core) · `@fluentui-contrib/cap-foundations-core` | Static tokens, color utilities, theme generation pipeline, runtime bootstrap | -| `react` *(Phase 6+)* | React components and `ThemeProvider` — depends on `core` | -| `icons` *(Phase 7+)* | SVG icon components — depends on `react` | -| `mock-pages` *(Phase 8+)* | Storybook mock pages — depends on `core` + `react` | +| `react` _(Phase 6+)_ | React components and `ThemeProvider` — depends on `core` | +| `icons` _(Phase 7+)_ | SVG icon components — depends on `react` | +| `mock-pages` _(Phase 8+)_ | Storybook mock pages — depends on `core` + `react` | ## Dependency graph From fdd756616d9c95028facfc190f74fedd73d0078a Mon Sep 17 00:00:00 2001 From: Natalia Bar Date: Mon, 16 Mar 2026 12:23:12 -0700 Subject: [PATCH 04/16] Add temporary tokens --- packages/cap-foundations/core/src/index.ts | 2 +- .../core/src/tokens/animation.ts | 80 ++++++++++++ .../core/src/tokens/gradients.ts | 27 ++++ .../cap-foundations/core/src/tokens/index.ts | 30 +++++ .../cap-foundations/core/src/tokens/radii.ts | 54 ++++++++ .../core/src/tokens/shadows.ts | 26 ++++ .../core/src/tokens/spacing.ts | 52 ++++++++ .../core/src/tokens/typography.ts | 122 ++++++++++++++++++ 8 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 packages/cap-foundations/core/src/tokens/animation.ts create mode 100644 packages/cap-foundations/core/src/tokens/gradients.ts create mode 100644 packages/cap-foundations/core/src/tokens/index.ts create mode 100644 packages/cap-foundations/core/src/tokens/radii.ts create mode 100644 packages/cap-foundations/core/src/tokens/shadows.ts create mode 100644 packages/cap-foundations/core/src/tokens/spacing.ts create mode 100644 packages/cap-foundations/core/src/tokens/typography.ts diff --git a/packages/cap-foundations/core/src/index.ts b/packages/cap-foundations/core/src/index.ts index cb0ff5c3b..e56053c16 100644 --- a/packages/cap-foundations/core/src/index.ts +++ b/packages/cap-foundations/core/src/index.ts @@ -1 +1 @@ -export {}; +export * from './tokens'; diff --git a/packages/cap-foundations/core/src/tokens/animation.ts b/packages/cap-foundations/core/src/tokens/animation.ts new file mode 100644 index 000000000..b0245c001 --- /dev/null +++ b/packages/cap-foundations/core/src/tokens/animation.ts @@ -0,0 +1,80 @@ +/** + * Animation tokens + */ + +// Duration tokens +export const durationTokens = { + '--duration-fast': '100ms', + '--duration-normal': '200ms', + '--duration-slow': '300ms', +} as const; + +// Easing tokens +export const easingTokens = { + '--ease-default': 'ease-out', + '--ease-in': 'ease-in', + '--ease-out': 'ease-out', + '--ease-in-out': 'ease-in-out', + '--ease-bounce': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', +} as const; + +export const animationTokens = { + ...durationTokens, + ...easingTokens, +} as const; + +export type DurationToken = keyof typeof durationTokens; +export type EasingToken = keyof typeof easingTokens; +export type AnimationToken = keyof typeof animationTokens; + +/** Animation tokens type for custom generation */ +export type AnimationTokenValues = { + '--duration-fast': string; + '--duration-normal': string; + '--duration-slow': string; + '--ease-default': string; + '--ease-in': string; + '--ease-out': string; + '--ease-in-out': string; + '--ease-bounce': string; +}; + +/** + * Generate animation tokens with custom options + */ +export function generateAnimationTokens(options: { + scale?: number; + reduceMotion?: boolean; +} = {}): AnimationTokenValues { + const { scale = 1.0, reduceMotion = false } = options; + + if (reduceMotion) { + return { + '--duration-fast': '0ms', + '--duration-normal': '0ms', + '--duration-slow': '0ms', + '--ease-default': easingTokens['--ease-default'], + '--ease-in': easingTokens['--ease-in'], + '--ease-out': easingTokens['--ease-out'], + '--ease-in-out': easingTokens['--ease-in-out'], + '--ease-bounce': easingTokens['--ease-bounce'], + }; + } + + const baseDurations: Record = { + '--duration-fast': 100, + '--duration-normal': 200, + '--duration-slow': 300, + }; + + return { + '--duration-fast': `${Math.round(baseDurations['--duration-fast'] * scale)}ms`, + '--duration-normal': `${Math.round(baseDurations['--duration-normal'] * scale)}ms`, + '--duration-slow': `${Math.round(baseDurations['--duration-slow'] * scale)}ms`, + '--ease-default': easingTokens['--ease-default'], + '--ease-in': easingTokens['--ease-in'], + '--ease-out': easingTokens['--ease-out'], + '--ease-in-out': easingTokens['--ease-in-out'], + '--ease-bounce': easingTokens['--ease-bounce'], + }; +} diff --git a/packages/cap-foundations/core/src/tokens/gradients.ts b/packages/cap-foundations/core/src/tokens/gradients.ts new file mode 100644 index 000000000..402d74064 --- /dev/null +++ b/packages/cap-foundations/core/src/tokens/gradients.ts @@ -0,0 +1,27 @@ +/** + * Gradient tokens + * + * Brand flair gradients for premium visual elements. + * Based on Copilot brand colors from Koto design system. + */ + +// Brand flair color stops (Copilot gradient) +export const brandFlairTokens = { + '--brand-flair-1': '#464FEB', // Purple/blue + '--brand-flair-2': '#47CFFA', // Cyan + '--brand-flair-3': '#B47CF8', // Violet +} as const; + +// Gradient definitions +export const gradientTokens = { + '--gradient-brand': + 'linear-gradient(90deg, var(--brand-flair-1), var(--brand-flair-2), var(--brand-flair-3))', + '--gradient-brand-vertical': + 'linear-gradient(180deg, var(--brand-flair-1), var(--brand-flair-2), var(--brand-flair-3))', + '--gradient-brand-diagonal': + 'linear-gradient(135deg, var(--brand-flair-1), var(--brand-flair-2), var(--brand-flair-3))', + ...brandFlairTokens, +} as const; + +export type BrandFlairToken = keyof typeof brandFlairTokens; +export type GradientToken = keyof typeof gradientTokens; diff --git a/packages/cap-foundations/core/src/tokens/index.ts b/packages/cap-foundations/core/src/tokens/index.ts new file mode 100644 index 000000000..eac7f9b15 --- /dev/null +++ b/packages/cap-foundations/core/src/tokens/index.ts @@ -0,0 +1,30 @@ +/** + * Token exports + */ +export * from './spacing'; +export * from './typography'; +export * from './radii'; +export * from './shadows'; +export * from './animation'; +export * from './gradients'; + +import { spacingTokens } from './spacing'; +import { typographyTokens } from './typography'; +import { radiiTokens } from './radii'; +import { shadowTokens } from './shadows'; +import { animationTokens } from './animation'; +import { gradientTokens } from './gradients'; + +/** + * All static tokens (non-color) + */ +export const staticTokens = { + ...spacingTokens, + ...typographyTokens, + ...radiiTokens, + ...shadowTokens, + ...animationTokens, + ...gradientTokens, +} as const; + +export type StaticToken = keyof typeof staticTokens; diff --git a/packages/cap-foundations/core/src/tokens/radii.ts b/packages/cap-foundations/core/src/tokens/radii.ts new file mode 100644 index 000000000..8ae19f5f0 --- /dev/null +++ b/packages/cap-foundations/core/src/tokens/radii.ts @@ -0,0 +1,54 @@ +/** + * Border radius tokens + */ +export const radiiTokens = { + '--radius-sm': '2px', + '--radius-md': '4px', + '--radius-lg': '8px', + '--radius-xl': '12px', + '--radius-2xl': '16px', + '--radius-full': '9999px', +} as const; + +export type RadiiToken = keyof typeof radiiTokens; +export type RadiiValue = (typeof radiiTokens)[RadiiToken]; + +export type RadiiStyle = 'sharp' | 'subtle' | 'rounded' | 'pill'; + +const styleBaseValues: Record = { + sharp: 0, + subtle: 2, + rounded: 4, + pill: 8, +}; + +/** + * Generate radii tokens with custom options + */ +export function generateRadiiTokens(options: { + style?: RadiiStyle; + scale?: number; +} = {}): Record { + const { style = 'rounded', scale = 1.0 } = options; + + const baseValue = styleBaseValues[style]; + + const multipliers: Record = { + '--radius-sm': 0.5, + '--radius-md': 1, + '--radius-lg': 2, + '--radius-xl': 3, + '--radius-2xl': 4, + '--radius-full': 9999, + }; + + return Object.fromEntries( + Object.entries(multipliers).map(([token, multiplier]) => { + if (token === '--radius-full') { + return [token, '9999px']; + } + const value = Math.round(baseValue * multiplier * scale); + return [token, `${value}px`]; + }) + ) as Record; +} diff --git a/packages/cap-foundations/core/src/tokens/shadows.ts b/packages/cap-foundations/core/src/tokens/shadows.ts new file mode 100644 index 000000000..6eab5e06c --- /dev/null +++ b/packages/cap-foundations/core/src/tokens/shadows.ts @@ -0,0 +1,26 @@ +/** + * Shadow tokens + */ +export const shadowTokens = { + '--shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + '--shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)', + '--shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)', + '--shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)', + '--shadow-inner': 'inset 0 1px 0 0 rgba(0, 0, 0, 0.06)', +} as const; + +export type ShadowToken = keyof typeof shadowTokens; +export type ShadowValue = (typeof shadowTokens)[ShadowToken]; + +/** + * Generate shadow tokens for dark mode (reduced opacity) + */ +export function generateDarkModeShadows(): Record { + return { + '--shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.3)', + '--shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3)', + '--shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3)', + '--shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.4)', + '--shadow-inner': 'inset 0 1px 0 0 rgba(0, 0, 0, 0.15)', + }; +} diff --git a/packages/cap-foundations/core/src/tokens/spacing.ts b/packages/cap-foundations/core/src/tokens/spacing.ts new file mode 100644 index 000000000..2ba31ec36 --- /dev/null +++ b/packages/cap-foundations/core/src/tokens/spacing.ts @@ -0,0 +1,52 @@ +/** + * Spacing tokens based on a 4px grid + */ +export const spacingTokens = { + '--space-1': '4px', + '--space-2': '8px', + '--space-3': '12px', + '--space-4': '16px', + '--space-5': '20px', + '--space-6': '24px', + '--space-8': '32px', + '--space-10': '40px', + '--space-12': '48px', + '--space-16': '64px', + '--space-20': '80px', + '--space-24': '96px', +} as const; + +export type SpacingToken = keyof typeof spacingTokens; +export type SpacingValue = (typeof spacingTokens)[SpacingToken]; + +/** + * Generate spacing tokens with a custom scale + */ +export function generateSpacingTokens(options: { + baseUnit?: number; + scale?: number; +} = {}): Record { + const { baseUnit = 4, scale = 1.0 } = options; + + const multipliers: Record = { + '--space-1': 1, + '--space-2': 2, + '--space-3': 3, + '--space-4': 4, + '--space-5': 5, + '--space-6': 6, + '--space-8': 8, + '--space-10': 10, + '--space-12': 12, + '--space-16': 16, + '--space-20': 20, + '--space-24': 24, + }; + + return Object.fromEntries( + Object.entries(multipliers).map(([token, multiplier]) => [ + token, + `${Math.round(baseUnit * multiplier * scale)}px`, + ]) + ) as Record; +} diff --git a/packages/cap-foundations/core/src/tokens/typography.ts b/packages/cap-foundations/core/src/tokens/typography.ts new file mode 100644 index 000000000..c78c4f5ba --- /dev/null +++ b/packages/cap-foundations/core/src/tokens/typography.ts @@ -0,0 +1,122 @@ +/** + * Typography tokens + */ + +// Font families +export const fontFamilyTokens = { + '--font-sans': "'Segoe UI Web', 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + '--font-mono': "'JetBrains Mono', 'Fira Code', 'Consolas', monospace", + '--font-serif': "'Merriweather', Georgia, 'Times New Roman', serif", +} as const; + +// Font sizes +export const fontSizeTokens = { + '--text-xs': '11px', + '--text-sm': '13px', + '--text-base': '15px', + '--text-lg': '17px', + '--text-xl': '20px', + '--text-2xl': '24px', + '--text-3xl': '30px', + '--text-4xl': '36px', + '--text-5xl': '40px', + '--text-display': '68px', + '--text-title-1': '28px', + '--text-subtitle-2': '16px', + '--text-body-2': '14px', + '--text-body-3': '12px', + '--text-caption-1': '12px', + '--text-caption-2': '10px', +} as const; + +// Font weights +export const fontWeightTokens = { + '--weight-normal': '400', + '--weight-medium': '500', + '--weight-semibold': '600', + '--weight-bold': '700', +} as const; + +// Line heights +export const lineHeightTokens = { + '--leading-tight': '1.25', + '--leading-normal': '1.5', + '--leading-loose': '1.75', +} as const; + +export const typographyTokens = { + ...fontFamilyTokens, + ...fontSizeTokens, + ...fontWeightTokens, + ...lineHeightTokens, +} as const; + +export type FontFamilyToken = keyof typeof fontFamilyTokens; +export type FontSizeToken = keyof typeof fontSizeTokens; +export type FontWeightToken = keyof typeof fontWeightTokens; +export type LineHeightToken = keyof typeof lineHeightTokens; +export type TypographyToken = keyof typeof typographyTokens; + +/** Typography tokens type for custom generation */ +export type TypographyTokenValues = Record; + +/** + * Generate typography tokens with custom options + */ +export function generateTypographyTokens(options: { + fontSans?: string; + fontMono?: string; + fontSerif?: string; + baseSize?: number; + scale?: number; +} = {}): TypographyTokenValues { + const { + fontSans = fontFamilyTokens['--font-sans'], + fontMono = fontFamilyTokens['--font-mono'], + fontSerif = fontFamilyTokens['--font-serif'], + baseSize = 15, + scale = 1.0, + } = options; + + const baseSizes: Record = { + '--text-xs': 11, + '--text-sm': 13, + '--text-base': 15, + '--text-lg': 17, + '--text-xl': 20, + '--text-2xl': 24, + '--text-3xl': 30, + '--text-4xl': 36, + '--text-5xl': 40, + '--text-display': 68, + '--text-title-1': 28, + '--text-subtitle-2': 16, + '--text-body-2': 14, + '--text-body-3': 12, + '--text-caption-1': 12, + '--text-caption-2': 10, + }; + + const ratio = baseSize / 15; + + const scaledSizes = Object.fromEntries( + Object.entries(baseSizes).map(([token, size]) => [ + token, + `${Math.round(size * ratio * scale)}px`, + ]) + ) as Record; + + return { + '--font-sans': fontSans, + '--font-mono': fontMono, + '--font-serif': fontSerif, + ...scaledSizes, + '--weight-normal': fontWeightTokens['--weight-normal'], + '--weight-medium': fontWeightTokens['--weight-medium'], + '--weight-semibold': fontWeightTokens['--weight-semibold'], + '--weight-bold': fontWeightTokens['--weight-bold'], + '--leading-tight': lineHeightTokens['--leading-tight'], + '--leading-normal': lineHeightTokens['--leading-normal'], + '--leading-loose': lineHeightTokens['--leading-loose'], + }; +} From b4a78bac03c1f09a24eeb53c77f0aaa013a88a9f Mon Sep 17 00:00:00 2001 From: Natalia Bar Date: Mon, 16 Mar 2026 12:34:23 -0700 Subject: [PATCH 05/16] Add temp colors and utils --- .../core/src/colors/dynamicSurface.test.ts | 297 ++++++++++++++++++ .../core/src/colors/dynamicSurface.ts | 290 +++++++++++++++++ .../cap-foundations/core/src/colors/index.ts | 5 + .../cap-foundations/core/src/colors/utils.ts | 258 +++++++++++++++ packages/cap-foundations/core/src/index.ts | 3 + .../core/src/surfaces/definitions.ts | 90 ++++++ .../core/src/surfaces/index.ts | 5 + .../core/src/surfaces/types.ts | 143 +++++++++ .../cap-foundations/core/src/themes/types.ts | 110 +++++++ 9 files changed, 1201 insertions(+) create mode 100644 packages/cap-foundations/core/src/colors/dynamicSurface.test.ts create mode 100644 packages/cap-foundations/core/src/colors/dynamicSurface.ts create mode 100644 packages/cap-foundations/core/src/colors/index.ts create mode 100644 packages/cap-foundations/core/src/colors/utils.ts create mode 100644 packages/cap-foundations/core/src/surfaces/definitions.ts create mode 100644 packages/cap-foundations/core/src/surfaces/index.ts create mode 100644 packages/cap-foundations/core/src/surfaces/types.ts create mode 100644 packages/cap-foundations/core/src/themes/types.ts diff --git a/packages/cap-foundations/core/src/colors/dynamicSurface.test.ts b/packages/cap-foundations/core/src/colors/dynamicSurface.test.ts new file mode 100644 index 000000000..c51e546a4 --- /dev/null +++ b/packages/cap-foundations/core/src/colors/dynamicSurface.test.ts @@ -0,0 +1,297 @@ +import { + generateSurfaceColors, + generateSurfaceFromPreset, + generateSurfaceCSS, + generateAllSurfaceCSS, + surfaceColorsToCSSVariables, + injectSurfaceStyles, + getRandomSurfaceName, + getSurfaceClassName, + SURFACE_PRESETS, + SURFACE_PRESET_NAMES, + type SurfaceColors, + type ColorMode, +} from './dynamicSurface'; + +const hasDom = typeof document !== 'undefined'; +const itDom = hasDom ? it : it.skip; + +describe('dynamicSurface', () => { + describe('generateSurfaceColors', () => { + it('generates light mode colors with correct structure', () => { + const colors = generateSurfaceColors(16, 90, 'light'); + + expect(colors).toHaveProperty('bg'); + expect(colors).toHaveProperty('text'); + expect(colors).toHaveProperty('textSoft'); + expect(colors).toHaveProperty('border'); + expect(colors).toHaveProperty('icon'); + }); + + it('generates dark mode colors with correct structure', () => { + const colors = generateSurfaceColors(16, 90, 'dark'); + + expect(colors).toHaveProperty('bg'); + expect(colors).toHaveProperty('text'); + expect(colors).toHaveProperty('textSoft'); + expect(colors).toHaveProperty('border'); + expect(colors).toHaveProperty('icon'); + }); + + it('returns valid HSL strings', () => { + const colors = generateSurfaceColors(200, 80, 'light'); + const hslPattern = /^hsl\(\d+,\s*\d+(\.\d+)?%,\s*\d+(\.\d+)?%\)$/; + + expect(colors.bg).toMatch(hslPattern); + expect(colors.text).toMatch(hslPattern); + expect(colors.textSoft).toMatch(hslPattern); + expect(colors.border).toMatch(hslPattern); + expect(colors.icon).toMatch(hslPattern); + }); + + it('light mode has high lightness background', () => { + const colors = generateSurfaceColors(180, 70, 'light'); + const bgMatch = colors.bg.match(/hsl\(\d+,\s*[\d.]+%,\s*([\d.]+)%\)/); + const lightness = bgMatch ? parseFloat(bgMatch[1]) : 0; + + expect(lightness).toBeGreaterThan(80); + }); + + it('dark mode has low lightness background', () => { + const colors = generateSurfaceColors(180, 70, 'dark'); + const bgMatch = colors.bg.match(/hsl\(\d+,\s*[\d.]+%,\s*([\d.]+)%\)/); + const lightness = bgMatch ? parseFloat(bgMatch[1]) : 100; + + expect(lightness).toBeLessThan(35); + }); + + it('light mode text has low lightness (dark text)', () => { + const colors = generateSurfaceColors(180, 70, 'light'); + const textMatch = colors.text.match(/hsl\(\d+,\s*[\d.]+%,\s*([\d.]+)%\)/); + const lightness = textMatch ? parseFloat(textMatch[1]) : 100; + + expect(lightness).toBeLessThan(30); + }); + + it('dark mode text has high lightness (light text)', () => { + const colors = generateSurfaceColors(180, 70, 'dark'); + const textMatch = colors.text.match(/hsl\(\d+,\s*[\d.]+%,\s*([\d.]+)%\)/); + const lightness = textMatch ? parseFloat(textMatch[1]) : 0; + + expect(lightness).toBeGreaterThan(90); + }); + + it('preserves hue across all color properties', () => { + const hue = 250; + const colors = generateSurfaceColors(hue, 70, 'light'); + + const huePattern = /hsl\((\d+),/; + expect(colors.bg.match(huePattern)?.[1]).toBe(String(hue)); + expect(colors.text.match(huePattern)?.[1]).toBe(String(hue)); + expect(colors.border.match(huePattern)?.[1]).toBe(String(hue)); + }); + + it('caps saturation at maximum values', () => { + const colors = generateSurfaceColors(180, 100, 'light'); + const satMatch = colors.bg.match(/hsl\(\d+,\s*([\d.]+)%/); + const saturation = satMatch ? parseFloat(satMatch[1]) : 100; + + expect(saturation).toBeLessThanOrEqual(70); + }); + }); + + describe('generateSurfaceFromPreset', () => { + it('generates colors for valid preset', () => { + const colors = generateSurfaceFromPreset('coral', 'light'); + + expect(colors).not.toBeNull(); + expect(colors?.bg).toBeDefined(); + }); + + it('returns null for invalid preset', () => { + const colors = generateSurfaceFromPreset('nonexistent', 'light'); + + expect(colors).toBeNull(); + }); + + it('generates correct hue for coral preset', () => { + const colors = generateSurfaceFromPreset('coral', 'light'); + const hueMatch = colors?.bg.match(/hsl\((\d+),/); + const hue = hueMatch ? parseInt(hueMatch[1]) : -1; + + expect(hue).toBe(16); + }); + }); + + describe('SURFACE_PRESETS', () => { + it('contains 32 presets', () => { + expect(Object.keys(SURFACE_PRESETS)).toHaveLength(32); + }); + + it('all presets have valid hue values (0-360)', () => { + Object.entries(SURFACE_PRESETS).forEach(([name, [hue]]) => { + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThanOrEqual(360); + }); + }); + + it('all presets have valid saturation values (0-100)', () => { + Object.entries(SURFACE_PRESETS).forEach(([name, [, saturation]]) => { + expect(saturation).toBeGreaterThanOrEqual(0); + expect(saturation).toBeLessThanOrEqual(100); + }); + }); + + it('SURFACE_PRESET_NAMES matches SURFACE_PRESETS keys', () => { + expect(SURFACE_PRESET_NAMES).toEqual(Object.keys(SURFACE_PRESETS)); + }); + }); + + describe('surfaceColorsToCSSVariables', () => { + it('converts colors to standard CSS token variables', () => { + const colors: SurfaceColors = { + bg: 'hsl(16, 67%, 85%)', + text: 'hsl(16, 40%, 20%)', + textSoft: 'hsl(16, 30%, 35%)', + border: 'hsl(16, 33%, 72%)', + icon: 'hsl(16, 42%, 30%)', + }; + + const vars = surfaceColorsToCSSVariables(colors); + + expect(vars['--base-bg']).toBe(colors.bg); + expect(vars['--base-fg']).toBe(colors.text); + expect(vars['--base-fg-soft']).toBe(colors.textSoft); + expect(vars['--base-border']).toBe(colors.border); + expect(vars['--base-fg-primary']).toBe(colors.icon); + }); + }); + + describe('generateSurfaceCSS', () => { + it('generates valid CSS class with standard tokens', () => { + const css = generateSurfaceCSS('coral', 16, 90, 'light'); + + expect(css).toContain('.dynamicSurface-coral'); + expect(css).toContain('--base-bg:'); + expect(css).toContain('--base-fg:'); + expect(css).toContain('background: var(--base-bg)'); + }); + + it('uses custom class prefix', () => { + const css = generateSurfaceCSS('coral', 16, 90, 'light', 'custom'); + + expect(css).toContain('.custom-coral'); + expect(css).not.toContain('.dynamicSurface-coral'); + }); + }); + + describe('generateAllSurfaceCSS', () => { + it('includes all preset surfaces', () => { + const css = generateAllSurfaceCSS(); + + SURFACE_PRESET_NAMES.forEach((name) => { + expect(css).toContain(`.dynamicSurface-${name}`); + }); + }); + + it('includes light mode comment', () => { + const css = generateAllSurfaceCSS(); + + expect(css).toContain('Light Mode'); + }); + + it('includes dark mode with selector', () => { + const css = generateAllSurfaceCSS('dynamicSurface', '[data-mode="dark"]'); + + expect(css).toContain('[data-mode="dark"]'); + expect(css).toContain('Dark Mode'); + }); + + it('uses custom dark mode selector', () => { + const css = generateAllSurfaceCSS('dynamicSurface', '.dark-theme'); + + expect(css).toContain('.dark-theme .dynamicSurface-'); + }); + }); + + describe('injectSurfaceStyles', () => { + beforeEach(() => { + if (!hasDom) return; + const existing = document.getElementById('dynamic-surface-styles'); + if (existing) existing.remove(); + }); + + afterEach(() => { + if (!hasDom) return; + const existing = document.getElementById('dynamic-surface-styles'); + if (existing) existing.remove(); + }); + + it('does nothing when document is undefined', () => { + expect(() => injectSurfaceStyles()).not.toThrow(); + }); + + itDom('injects style element into document head', () => { + injectSurfaceStyles(); + + const style = document.getElementById('dynamic-surface-styles'); + expect(style).not.toBeNull(); + expect(style?.tagName).toBe('STYLE'); + }); + + itDom('replaces existing style element', () => { + injectSurfaceStyles(); + injectSurfaceStyles(); + + const styles = document.querySelectorAll('#dynamic-surface-styles'); + expect(styles).toHaveLength(1); + }); + + itDom('uses custom style ID', () => { + injectSurfaceStyles({ styleId: 'custom-styles' }); + + const style = document.getElementById('custom-styles'); + expect(style).not.toBeNull(); + + style?.remove(); + }); + + itDom('includes all preset classes in injected CSS', () => { + injectSurfaceStyles(); + + const style = document.getElementById('dynamic-surface-styles'); + const css = style?.textContent || ''; + + expect(css).toContain('.dynamicSurface-coral'); + expect(css).toContain('.dynamicSurface-sky'); + }); + }); + + describe('getRandomSurfaceName', () => { + it('returns a valid preset name', () => { + const name = getRandomSurfaceName(); + + expect(SURFACE_PRESET_NAMES).toContain(name); + }); + + it('returns different values over multiple calls (probabilistic)', () => { + const names = new Set(); + + for (let i = 0; i < 100; i++) { + names.add(getRandomSurfaceName()); + } + + expect(names.size).toBeGreaterThan(1); + }); + }); + + describe('getSurfaceClassName', () => { + it('returns correct class name with default prefix', () => { + expect(getSurfaceClassName('coral')).toBe('dynamicSurface-coral'); + }); + + it('uses custom prefix', () => { + expect(getSurfaceClassName('coral', 'custom')).toBe('custom-coral'); + }); + }); +}); diff --git a/packages/cap-foundations/core/src/colors/dynamicSurface.ts b/packages/cap-foundations/core/src/colors/dynamicSurface.ts new file mode 100644 index 000000000..2013aeccc --- /dev/null +++ b/packages/cap-foundations/core/src/colors/dynamicSurface.ts @@ -0,0 +1,290 @@ +/** + * Dynamic Surface Color Generation + * + * Generates accessible, theme-aware surface color schemes from HSL hue/saturation values. + * Creates cohesive palettes that work in both light and dark modes with proper contrast. + * + * @example + * ```ts + * import { generateSurfaceColors, SURFACE_PRESETS, injectSurfaceStyles } from '@fluentui-contrib/cap-foundations-core'; + * + * // Generate colors for a custom hue + * const colors = generateSurfaceColors(16, 90, 'light'); + * // Returns { bg, text, textSoft, border, icon } as HSL strings + * + * // Use preset surfaces + * const roseColors = generateSurfaceColors('rose', ...SURFACE_PRESETS.rose, 'dark'); + * + * // Inject all preset surfaces as CSS classes + * injectSurfaceStyles(); // Creates .dynamicSurface-rose, .dynamicSurface-coral, etc. + * ``` + */ + +/** + * Surface color definition - hue and base saturation + */ +export interface SurfaceDefinition { + /** Unique name for the surface (used in CSS class names) */ + name: string; + /** HSL hue (0-360) and saturation (0-100) */ + hsl: [hue: number, saturation: number]; +} + +/** + * Generated surface colors as HSL strings + */ +export interface SurfaceColors { + /** Background color */ + bg: string; + /** Primary text color */ + text: string; + /** Secondary/soft text color */ + textSoft: string; + /** Border color */ + border: string; + /** Icon color */ + icon: string; +} + +/** + * CSS custom properties for a surface + * Uses standard token names so components automatically pick up the colors + */ +export interface SurfaceCSSVariables { + '--base-bg': string; + '--base-fg': string; + '--base-fg-soft': string; + '--base-border': string; + '--base-fg-primary': string; +} + +/** + * Color mode for surface generation + */ +export type ColorMode = 'light' | 'dark'; + +/** + * 32 preset surface definitions covering the full color spectrum + */ +export const SURFACE_PRESETS: Record = { + // Reds & Pinks (340-30) + crimson: [0, 85], + rose: [350, 85], + ruby: [340, 80], + coral: [16, 90], + // Oranges & Yellows (30-60) + tangerine: [25, 92], + amber: [38, 95], + honey: [45, 90], + gold: [50, 88], + // Yellow-Greens (60-100) + lime: [80, 70], + olive: [75, 50], + moss: [95, 55], + chartreuse: [90, 75], + // Greens (100-160) + sage: [140, 60], + emerald: [130, 70], + forest: [150, 65], + mint: [160, 75], + // Cyans & Teals (160-200) + teal: [175, 80], + aqua: [185, 85], + cyan: [190, 88], + cerulean: [195, 82], + // Blues (200-240) + sky: [200, 90], + ocean: [210, 85], + steel: [215, 45], + cobalt: [230, 80], + // Purples & Indigos (240-280) + indigo: [250, 75], + grape: [260, 70], + lavender: [270, 70], + violet: [280, 75], + // Magentas & Pinks (280-340) + plum: [290, 65], + fuchsia: [300, 80], + mauve: [320, 65], + pink: [330, 75], +} as const; + +/** + * All available preset surface names + */ +export const SURFACE_PRESET_NAMES = Object.keys(SURFACE_PRESETS) as ReadonlyArray; + +/** + * Generate surface colors for a given hue, saturation, and color mode + */ +export function generateSurfaceColors( + hue: number, + saturation: number, + mode: ColorMode +): SurfaceColors { + const h = hue; + const s = saturation; + + if (mode === 'light') { + const bgL = 85; + const bgS = Math.min(s * 0.75, 70); + + const textL = 20; + const softTextL = 35; + const iconL = 30; + const textS = Math.min(s * 0.8, 50); + const softTextS = Math.min(s * 0.6, 40); + const iconS = Math.min(s * 0.7, 60); + + const borderL = 58; + const borderS = Math.min(s * 0.7, 60); + + return { + bg: `hsl(${h}, ${bgS}%, ${bgL}%)`, + text: `hsl(${h}, ${textS}%, ${textL}%)`, + textSoft: `hsl(${h}, ${softTextS}%, ${softTextL}%)`, + border: `hsl(${h}, ${borderS}%, ${borderL}%)`, + icon: `hsl(${h}, ${iconS}%, ${iconL}%)`, + }; + } else { + const bgL = 28; + const bgS = Math.min(s + 10, 95); + + const textL = 95; + const softTextL = 80; + const iconL = 88; + const textS = Math.min(s * 0.3, 25); + const softTextS = Math.min(s * 0.25, 20); + const iconS = Math.min(s * 0.5, 50); + + const borderL = 38; + const borderS = Math.min(s + 5, 90); + + return { + bg: `hsl(${h}, ${bgS}%, ${bgL}%)`, + text: `hsl(${h}, ${textS}%, ${textL}%)`, + textSoft: `hsl(${h}, ${softTextS}%, ${softTextL}%)`, + border: `hsl(${h}, ${borderS}%, ${borderL}%)`, + icon: `hsl(${h}, ${iconS}%, ${iconL}%)`, + }; + } +} + +/** + * Generate surface colors from a preset name + */ +export function generateSurfaceFromPreset( + presetName: string, + mode: ColorMode +): SurfaceColors | null { + const preset = SURFACE_PRESETS[presetName]; + if (!preset) return null; + return generateSurfaceColors(preset[0], preset[1], mode); +} + +/** + * Convert surface colors to CSS custom properties using standard token names + */ +export function surfaceColorsToCSSVariables(colors: SurfaceColors): SurfaceCSSVariables { + return { + '--base-bg': colors.bg, + '--base-fg': colors.text, + '--base-fg-soft': colors.textSoft, + '--base-border': colors.border, + '--base-fg-primary': colors.icon, + }; +} + +/** + * Generate CSS class definition for a dynamic surface + */ +export function generateSurfaceCSS( + name: string, + hue: number, + saturation: number, + mode: ColorMode, + classPrefix = 'dynamicSurface' +): string { + const colors = generateSurfaceColors(hue, saturation, mode); + + return ` +.${classPrefix}-${name} { + --base-bg: ${colors.bg}; + --base-fg: ${colors.text}; + --base-fg-soft: ${colors.textSoft}; + --base-border: ${colors.border}; + --base-fg-primary: ${colors.icon}; + background: var(--base-bg); + color: var(--base-fg); + border-color: var(--base-border); +}`; +} + +/** + * Generate complete CSS for all preset surfaces in both modes + */ +export function generateAllSurfaceCSS( + classPrefix = 'dynamicSurface', + darkModeSelector = '[data-mode="dark"]' +): string { + const lightCSS = Object.entries(SURFACE_PRESETS) + .map(([name, [hue, sat]]) => generateSurfaceCSS(name, hue, sat, 'light', classPrefix)) + .join('\n'); + + const darkCSS = Object.entries(SURFACE_PRESETS) + .map(([name, [hue, sat]]) => { + const css = generateSurfaceCSS(name, hue, sat, 'dark', classPrefix); + return css.replace( + new RegExp(`\\.${classPrefix}-${name}`, 'g'), + `${darkModeSelector} .${classPrefix}-${name}` + ); + }) + .join('\n'); + + return `/* Dynamic Surface Colors - Light Mode (default) */ +${lightCSS} + +/* Dynamic Surface Colors - Dark Mode */ +${darkCSS}`; +} + +/** + * Inject dynamic surface styles into the document head (SSR-safe) + */ +export function injectSurfaceStyles(options: { + classPrefix?: string; + darkModeSelector?: string; + styleId?: string; +} = {}): void { + if (typeof document === 'undefined') return; + + const { + classPrefix = 'dynamicSurface', + darkModeSelector = '[data-mode="dark"]', + styleId = 'dynamic-surface-styles', + } = options; + + const existingStyle = document.getElementById(styleId); + if (existingStyle) existingStyle.remove(); + + const css = generateAllSurfaceCSS(classPrefix, darkModeSelector); + const style = document.createElement('style'); + style.id = styleId; + style.textContent = css; + document.head.appendChild(style); +} + +/** + * Get a random preset surface name + */ +export function getRandomSurfaceName(): string { + const names = SURFACE_PRESET_NAMES; + return names[Math.floor(Math.random() * names.length)]; +} + +/** + * Get the CSS class name for a surface + */ +export function getSurfaceClassName(name: string, classPrefix = 'dynamicSurface'): string { + return `${classPrefix}-${name}`; +} diff --git a/packages/cap-foundations/core/src/colors/index.ts b/packages/cap-foundations/core/src/colors/index.ts new file mode 100644 index 000000000..f569cf7f4 --- /dev/null +++ b/packages/cap-foundations/core/src/colors/index.ts @@ -0,0 +1,5 @@ +/** + * Color exports + */ +export * from './utils'; +export * from './dynamicSurface'; diff --git a/packages/cap-foundations/core/src/colors/utils.ts b/packages/cap-foundations/core/src/colors/utils.ts new file mode 100644 index 000000000..e1fc87365 --- /dev/null +++ b/packages/cap-foundations/core/src/colors/utils.ts @@ -0,0 +1,258 @@ +/** + * Color utility functions + */ + +/** + * Parse a hex color to RGB components + */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) return null; + + return { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + }; +} + +/** + * Convert RGB to hex + */ +export function rgbToHex(r: number, g: number, b: number): string { + const toHex = (n: number) => { + const clamped = Math.max(0, Math.min(255, Math.round(n))); + return clamped.toString(16).padStart(2, '0'); + }; + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +/** + * Convert RGB to HSL + */ +export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + + let h = 0; + let s = 0; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + break; + case g: + h = ((b - r) / d + 2) / 6; + break; + case b: + h = ((r - g) / d + 4) / 6; + break; + } + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; +} + +/** + * Convert HSL to RGB + */ +export function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { + h /= 360; + s /= 100; + l /= 100; + + let r: number, g: number, b: number; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255), + }; +} + +/** + * Calculate relative luminance for WCAG contrast calculations + */ +export function relativeLuminance(r: number, g: number, b: number): number { + const [rs, gs, bs] = [r, g, b].map((c) => { + c /= 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; +} + +/** + * Calculate contrast ratio between two colors (WCAG formula) + */ +export function contrastRatio(color1: string, color2: string): number { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + + if (!rgb1 || !rgb2) return 0; + + const l1 = relativeLuminance(rgb1.r, rgb1.g, rgb1.b); + const l2 = relativeLuminance(rgb2.r, rgb2.g, rgb2.b); + + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Check if contrast meets WCAG AA (4.5:1 for normal text) + */ +export function meetsContrastAA(color1: string, color2: string): boolean { + return contrastRatio(color1, color2) >= 4.5; +} + +/** + * Check if contrast meets WCAG AAA (7:1 for normal text) + */ +export function meetsContrastAAA(color1: string, color2: string): boolean { + return contrastRatio(color1, color2) >= 7; +} + +/** + * Lighten a color by a percentage + */ +export function lighten(hex: string, amount: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + hsl.l = Math.min(100, hsl.l + amount); + + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} + +/** + * Darken a color by a percentage + */ +export function darken(hex: string, amount: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + hsl.l = Math.max(0, hsl.l - amount); + + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} + +/** + * Adjust saturation of a color + */ +export function saturate(hex: string, amount: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + hsl.s = Math.max(0, Math.min(100, hsl.s + amount)); + + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} + +/** + * Mix two colors + */ +export function mix(color1: string, color2: string, weight = 0.5): string { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + + if (!rgb1 || !rgb2) return color1; + + const w = Math.max(0, Math.min(1, weight)); + const r = Math.round(rgb1.r * (1 - w) + rgb2.r * w); + const g = Math.round(rgb1.g * (1 - w) + rgb2.g * w); + const b = Math.round(rgb1.b * (1 - w) + rgb2.b * w); + + return rgbToHex(r, g, b); +} + +/** + * Get the best text color (black or white) for a background + * Uses actual contrast ratio calculation, but prefers white for saturated colors + * where it provides sufficient contrast (4.5:1 for accessibility) + */ +export function getContrastingTextColor(bgColor: string): '#000000' | '#ffffff' { + const rgb = hexToRgb(bgColor); + if (!rgb) return '#000000'; + + const bgLuminance = relativeLuminance(rgb.r, rgb.g, rgb.b); + + const contrastWithWhite = (1 + 0.05) / (bgLuminance + 0.05); + const contrastWithBlack = (bgLuminance + 0.05) / (0 + 0.05); + + const maxChannel = Math.max(rgb.r, rgb.g, rgb.b); + const minChannel = Math.min(rgb.r, rgb.g, rgb.b); + const saturation = maxChannel > 0 ? (maxChannel - minChannel) / maxChannel : 0; + + if (saturation > 0.4 && contrastWithWhite >= 3.0) { + return '#ffffff'; + } + + return contrastWithWhite >= contrastWithBlack ? '#ffffff' : '#000000'; +} + +/** + * Ensure a foreground color meets contrast requirements against a background + */ +export function ensureContrast( + foreground: string, + background: string, + targetRatio = 4.5 +): string { + const currentRatio = contrastRatio(foreground, background); + if (currentRatio >= targetRatio) return foreground; + + const bgRgb = hexToRgb(background); + if (!bgRgb) return foreground; + + const bgLuminance = relativeLuminance(bgRgb.r, bgRgb.g, bgRgb.b); + const isLightBackground = bgLuminance > 0.179; + + let adjusted = foreground; + for (let i = 0; i < 100; i += 5) { + adjusted = isLightBackground ? darken(foreground, i) : lighten(foreground, i); + if (contrastRatio(adjusted, background) >= targetRatio) { + return adjusted; + } + } + + return isLightBackground ? '#000000' : '#ffffff'; +} diff --git a/packages/cap-foundations/core/src/index.ts b/packages/cap-foundations/core/src/index.ts index e56053c16..f0afc298e 100644 --- a/packages/cap-foundations/core/src/index.ts +++ b/packages/cap-foundations/core/src/index.ts @@ -1 +1,4 @@ export * from './tokens'; +export * from './colors'; +export * from './surfaces'; +export * from './themes/types'; diff --git a/packages/cap-foundations/core/src/surfaces/definitions.ts b/packages/cap-foundations/core/src/surfaces/definitions.ts new file mode 100644 index 000000000..8f83e4841 --- /dev/null +++ b/packages/cap-foundations/core/src/surfaces/definitions.ts @@ -0,0 +1,90 @@ +/** + * Surface definitions for the tonal surface system + */ + +import type { + SurfaceType, + TonalSurface, + FeedbackSurface, +} from './types'; + +// ============================================================================ +// TONAL SURFACES +// ============================================================================ + +/** + * All tonal surface types + */ +export const tonalSurfaces: readonly TonalSurface[] = [ + 'base', + 'raised', + 'sunken', + 'soft', + 'softer', + 'strong', + 'stronger', + 'inverted', + 'primary', +] as const; + +/** + * All feedback surface types + */ +export const feedbackSurfaces: readonly FeedbackSurface[] = [ + 'success', + 'warning', + 'danger', + 'info', +] as const; + +/** + * All surface types (tonal + feedback) + */ +export const surfaceTypes: readonly SurfaceType[] = [ + ...tonalSurfaces, + ...feedbackSurfaces, +] as const; + +/** + * Check if a value is a valid surface type + */ +export function isSurfaceType(value: string): value is SurfaceType { + return surfaceTypes.includes(value as SurfaceType); +} + +/** + * Check if a value is a tonal surface + */ +export function isTonalSurface(value: string): value is TonalSurface { + return tonalSurfaces.includes(value as TonalSurface); +} + +/** + * Check if a value is a feedback surface + */ +export function isFeedbackSurface(value: string): value is FeedbackSurface { + return feedbackSurfaces.includes(value as FeedbackSurface); +} + +/** + * CSS class name for a surface + * + * Returns "surface {modifier}" for use with the .surface base class + * Example: surfaceClassName('raised') => 'surface raised' + */ +export function surfaceClassName(name: SurfaceType): string { + return `surface ${name}`; +} + +/** + * Get CSS classes for a surface + * Returns an object for use with classnames libraries or template strings + * + * Example: getSurfaceClasses('raised') => { surface: true, raised: true } + */ +export function getSurfaceClasses(surface: SurfaceType): Record { + return { + surface: true, + [surface]: true, + }; +} diff --git a/packages/cap-foundations/core/src/surfaces/index.ts b/packages/cap-foundations/core/src/surfaces/index.ts new file mode 100644 index 000000000..e181c877f --- /dev/null +++ b/packages/cap-foundations/core/src/surfaces/index.ts @@ -0,0 +1,5 @@ +/** + * Surface exports + */ +export * from './types'; +export * from './definitions'; diff --git a/packages/cap-foundations/core/src/surfaces/types.ts b/packages/cap-foundations/core/src/surfaces/types.ts new file mode 100644 index 000000000..d55011dd2 --- /dev/null +++ b/packages/cap-foundations/core/src/surfaces/types.ts @@ -0,0 +1,143 @@ +/** + * Surface type definitions + * + * Surfaces are CSS classes that create distinct visual contexts by resetting + * and overriding design tokens. They solve the accessibility problem of ensuring + * components remain readable when placed on different backgrounds. + * + * The surface system uses a reset/override pattern: + * 1. Every .surface element resets ALL tokens to page defaults + * 2. Tonal modifiers (.raised, .sunken, etc.) apply specific overrides + * 3. Nested surfaces automatically reset - no compounding + * + * Usage:
...
+ */ + +/** + * Tonal surfaces - background variations relative to page + */ +export type TonalSurface = + | 'base' // Explicit reset to page defaults + | 'raised' // Elevated content (cards, panels) - lighter/elevated from page + | 'sunken' // Recessed areas (input wells, sidebars) - darker/recessed from page + | 'soft' // Subtle backgrounds - slightly muted from page + | 'softer' // Very subtle backgrounds - more muted from page + | 'strong' // Emphasized sections - higher contrast from page + | 'stronger' // Very emphasized sections - highest contrast from page + | 'inverted' // Opposite color scheme (tooltips, callouts) + | 'primary'; // Primary color background (teaching bubbles, branded sections) + +/** + * Container surfaces - static backgrounds for content regions + * @deprecated Use TonalSurface instead. These will be removed in the next major version. + */ +export type ContainerSurface = + | 'page' // Main application background + | 'card' // Elevated content containers -> use 'raised' + | 'overlay' // Modals, dialogs, sheets -> use 'raised' + | 'popout' // Dropdowns, menus, tooltips -> use 'raised' or 'inverted' + | 'inset'; // Recessed areas -> use 'sunken' + +/** + * Control roles - token prefixes for interactive elements + * Note: These are token ROLES, not surface classes. Use tokens like --control-bg directly. + */ +export type ControlRole = + | 'control' // Default interactive (buttons, list items) + | 'controlPrimary' // Primary actions (CTA buttons, selected states) + | 'controlDanger' // Destructive actions + | 'controlSubtle' // Ghost/minimal buttons, tabs + | 'controlDisabled'; // Non-interactive state + +/** + * @deprecated Use ControlRole instead - controls are token roles, not surfaces + */ +export type ControlSurface = ControlRole; + +/** + * Feedback surfaces - status communication + */ +export type FeedbackSurface = + | 'success' // Positive outcomes, confirmations + | 'warning' // Caution, attention needed + | 'danger' // Errors, destructive states + | 'info'; // Informational, neutral status + +/** + * All surface types (tonal + feedback) + * Note: ControlRole is not included as controls use tokens directly, not surface classes + */ +export type SurfaceType = TonalSurface | FeedbackSurface; + +/** + * All standard surfaces + * @deprecated Use SurfaceType instead + */ +export type Surface = ContainerSurface | ControlSurface | FeedbackSurface; + +/** + * Surface token properties + */ +export interface SurfaceTokens { + // Background + bg: string; + 'bg-hover'?: string; + 'bg-pressed'?: string; + 'bg-focus'?: string; + + // Text + text: string; + 'text-soft'?: string; + 'text-softer'?: string; + 'text-strong'?: string; + 'text-stronger'?: string; + 'text-hover'?: string; + 'text-pressed'?: string; + + // Border + border: string; + 'border-soft'?: string; + 'border-strong'?: string; + 'border-stronger'?: string; + 'border-hover'?: string; + 'border-pressed'?: string; + 'border-focus'?: string; + + // Shadow + shadow?: string; + + // Icon (for feedback surfaces) + icon?: string; +} + +/** + * Surface state modifiers + */ +export type SurfaceState = 'hover' | 'pressed' | 'focus' | 'selected' | 'disabled'; + +/** + * Component shortcut tokens + */ +export interface ComponentTokens { + '--button-padding-x': string; + '--button-padding-y': string; + '--button-radius': string; + '--input-height': string; + '--input-padding-x': string; + '--card-padding': string; + '--modal-padding': string; + '--avatar-size-sm': string; + '--avatar-size-md': string; + '--avatar-size-lg': string; +} + +/** + * Special tokens (focus, selection, links, scrollbar) + */ +export interface SpecialTokens { + '--focus-ring': string; + '--focus-ring-offset': string; + '--focus-ring-width': string; + '--selection-bg': string; + '--selection-text': string; +} diff --git a/packages/cap-foundations/core/src/themes/types.ts b/packages/cap-foundations/core/src/themes/types.ts new file mode 100644 index 000000000..2343bdce0 --- /dev/null +++ b/packages/cap-foundations/core/src/themes/types.ts @@ -0,0 +1,110 @@ +/** + * Theme type definitions + */ + +import type { RadiiStyle } from '../tokens/radii'; + +/** + * Accessibility level + */ +export type AccessibilityLevel = 'AA' | 'AAA'; + +/** + * Theme color definition + */ +export interface ThemeColors { + /** Primary brand color */ + primary: string; + /** Secondary color (optional, computed if omitted) */ + secondary?: string; + /** Accent color (optional, computed if omitted) */ + accent?: string; + /** Neutral/gray base (optional, derived from primary) */ + neutral?: string; +} + +/** + * Typography configuration + */ +export interface ThemeTypography { + fontSans?: string; + fontMono?: string; + fontSerif?: string; + scale?: number; + baseSize?: number; +} + +/** + * Spacing configuration + */ +export interface ThemeSpacing { + scale?: number; + baseUnit?: number; +} + +/** + * Border radius configuration + */ +export interface ThemeRadii { + scale?: number; + style?: RadiiStyle; +} + +/** + * Animation configuration + */ +export interface ThemeAnimation { + scale?: number; + reduceMotion?: boolean; +} + +/** + * Accessibility configuration + */ +export interface ThemeAccessibility { + level?: AccessibilityLevel; +} + +/** + * Color adjustment configuration + */ +export interface ThemeConfig { + saturation?: number; + temperature?: number; + contrastBoost?: number; +} + +/** + * Custom surface definition + */ +export interface CustomSurface { + background: string; + text?: string; + border?: string; +} + +/** + * Token overrides for specific modes + */ +export interface ThemeOverrides { + light?: Record; + dark?: Record; +} + +/** + * Complete theme definition + */ +export interface ThemeDefinition { + id: string; + name: string; + description?: string; + colors: ThemeColors; + typography?: ThemeTypography; + spacing?: ThemeSpacing; + radii?: ThemeRadii; + animation?: ThemeAnimation; + accessibility?: ThemeAccessibility; + config?: ThemeConfig; + surfaces?: Record; + overrides?: ThemeOverrides; +} From 4b40fc8ce6173c939afe2bee1e043b0a83578262 Mon Sep 17 00:00:00 2001 From: Natalia Bar Date: Mon, 16 Mar 2026 14:47:20 -0700 Subject: [PATCH 06/16] Add theme schema and generator --- packages/cap-foundations/core/project.json | 7 + .../core/scripts/build-themes.ts | 119 ++ packages/cap-foundations/core/src/index.ts | 2 + .../core/src/themes/definitions.ts | 6 + .../core/src/themes/definitions/default.json | 14 + .../core/src/themes/generator.ts | 1439 +++++++++++++++++ .../cap-foundations/core/src/themes/index.ts | 4 + .../src/themes/schema/schema-definition.md | 531 ++++++ .../core/src/themes/schema/theme-rules.json | 734 +++++++++ .../core/src/themes/theme-definition.md | 682 ++++++++ packages/cap-foundations/core/tsconfig.json | 4 +- .../core/tsconfig.scripts.json | 9 + 12 files changed, 3550 insertions(+), 1 deletion(-) create mode 100644 packages/cap-foundations/core/scripts/build-themes.ts create mode 100644 packages/cap-foundations/core/src/themes/definitions.ts create mode 100644 packages/cap-foundations/core/src/themes/definitions/default.json create mode 100644 packages/cap-foundations/core/src/themes/generator.ts create mode 100644 packages/cap-foundations/core/src/themes/index.ts create mode 100644 packages/cap-foundations/core/src/themes/schema/schema-definition.md create mode 100644 packages/cap-foundations/core/src/themes/schema/theme-rules.json create mode 100644 packages/cap-foundations/core/src/themes/theme-definition.md create mode 100644 packages/cap-foundations/core/tsconfig.scripts.json diff --git a/packages/cap-foundations/core/project.json b/packages/cap-foundations/core/project.json index c00211d7e..e0fcdb2cf 100644 --- a/packages/cap-foundations/core/project.json +++ b/packages/cap-foundations/core/project.json @@ -27,6 +27,13 @@ }, "type-check": { "executor": "@fluentui-contrib/nx-plugin:type-check" + }, + "build-themes": { + "executor": "nx:run-commands", + "options": { + "command": "npx ts-node --project tsconfig.scripts.json scripts/build-themes.ts", + "cwd": "{projectRoot}" + } } } } diff --git a/packages/cap-foundations/core/scripts/build-themes.ts b/packages/cap-foundations/core/scripts/build-themes.ts new file mode 100644 index 000000000..79ced509a --- /dev/null +++ b/packages/cap-foundations/core/scripts/build-themes.ts @@ -0,0 +1,119 @@ +/** + * Build theme CSS files from JSON definitions + * + * This script reads theme definitions from JSON files in src/themes/definitions/ + * and generates CSS files using the data-driven generator. + * + * Run via: yarn ts-node --project tsconfig.scripts.json scripts/build-themes.ts + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { generateThemeCSS, validateTheme } from '../src/themes/generator'; +import type { ThemeDefinition } from '../src/themes/types'; + +const srcDir = path.resolve(__dirname, '..', 'src'); +const distDir = path.resolve(__dirname, '..', 'dist'); +const themesDir = path.resolve(distDir, 'themes'); +const definitionsDir = path.resolve(srcDir, 'themes', 'definitions'); + +// Ensure themes directory exists +if (!fs.existsSync(themesDir)) { + fs.mkdirSync(themesDir, { recursive: true }); +} + +console.log('Generating theme CSS files from JSON definitions...'); +console.log(`Reading from: ${definitionsDir}`); +console.log(`Writing to: ${themesDir}`); +console.log(''); + +// Load all theme JSON files +const themeFiles = fs.readdirSync(definitionsDir).filter(f => f.endsWith('.json')); + +if (themeFiles.length === 0) { + console.error('No theme JSON files found in', definitionsDir); + process.exit(1); +} + +const themes: ThemeDefinition[] = []; +const errors: string[] = []; + +for (const file of themeFiles) { + const filePath = path.join(definitionsDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const theme = JSON.parse(content); + + if (validateTheme(theme)) { + themes.push(theme); + console.log(` ✓ Loaded ${file} (${theme.name})`); + } else { + errors.push(` ✗ Invalid theme schema in ${file}`); + } + } catch (err) { + errors.push(` ✗ Failed to parse ${file}: ${err instanceof Error ? err.message : err}`); + } +} + +if (errors.length > 0) { + console.log('\nErrors:'); + errors.forEach(e => console.error(e)); +} + +if (themes.length === 0) { + console.error('\nNo valid themes found!'); + process.exit(1); +} + +console.log(`\nGenerating CSS for ${themes.length} themes...`); + +const manifest: Array<{ + id: string; + name: string; + description?: string; + accessibility?: string; + files: { light: string; dark: string }; +}> = []; + +for (const theme of themes) { + console.log(` Generating ${theme.name}...`); + + // Generate light mode + const lightCSS = generateThemeCSS(theme, 'light'); + const lightPath = path.resolve(themesDir, `${theme.id}-light.css`); + fs.writeFileSync(lightPath, lightCSS, 'utf-8'); + + // Generate dark mode + const darkCSS = generateThemeCSS(theme, 'dark'); + const darkPath = path.resolve(themesDir, `${theme.id}-dark.css`); + fs.writeFileSync(darkPath, darkCSS, 'utf-8'); + + manifest.push({ + id: theme.id, + name: theme.name, + description: theme.description, + accessibility: theme.accessibility?.level, + files: { + light: `${theme.id}-light.css`, + dark: `${theme.id}-dark.css`, + }, + }); +} + +// Sort manifest alphabetically by name, but keep 'default' first +manifest.sort((a, b) => { + if (a.id === 'default') return -1; + if (b.id === 'default') return 1; + return a.name.localeCompare(b.name); +}); + +// Write manifest +const manifestPath = path.resolve(themesDir, 'manifest.json'); +fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); + +console.log(''); +console.log(`✓ Generated ${themes.length * 2} theme CSS files`); +console.log(`✓ Generated manifest.json with ${manifest.length} themes`); +console.log(''); +console.log('Available themes:'); +manifest.forEach(t => console.log(` - ${t.name} (${t.id})`)); diff --git a/packages/cap-foundations/core/src/index.ts b/packages/cap-foundations/core/src/index.ts index f0afc298e..e694ef34e 100644 --- a/packages/cap-foundations/core/src/index.ts +++ b/packages/cap-foundations/core/src/index.ts @@ -2,3 +2,5 @@ export * from './tokens'; export * from './colors'; export * from './surfaces'; export * from './themes/types'; +export * from './themes/definitions'; +export * from './themes/generator'; diff --git a/packages/cap-foundations/core/src/themes/definitions.ts b/packages/cap-foundations/core/src/themes/definitions.ts new file mode 100644 index 000000000..43ee6ce16 --- /dev/null +++ b/packages/cap-foundations/core/src/themes/definitions.ts @@ -0,0 +1,6 @@ +import type { ThemeDefinition } from './types'; +import defaultThemeJson from './definitions/default.json'; + +export const defaultTheme = defaultThemeJson as ThemeDefinition; + +export const themes: readonly ThemeDefinition[] = [defaultTheme]; diff --git a/packages/cap-foundations/core/src/themes/definitions/default.json b/packages/cap-foundations/core/src/themes/definitions/default.json new file mode 100644 index 000000000..c90900659 --- /dev/null +++ b/packages/cap-foundations/core/src/themes/definitions/default.json @@ -0,0 +1,14 @@ +{ + "id": "default", + "name": "Default", + "description": "Clean, professional design with balanced blues", + "colors": { + "primary": "#2563eb", + "secondary": "#3b82f6", + "accent": "#8b5cf6", + "neutral": "#64748b" + }, + "accessibility": { + "level": "AA" + } +} diff --git a/packages/cap-foundations/core/src/themes/generator.ts b/packages/cap-foundations/core/src/themes/generator.ts new file mode 100644 index 000000000..7919a765f --- /dev/null +++ b/packages/cap-foundations/core/src/themes/generator.ts @@ -0,0 +1,1439 @@ +/** + * Theme generator - Data-driven from theme-rules.json + * + * Compiles theme definitions into complete CSS with all surface tokens. + * All color relationships and derivation rules are read from the rules configuration. + */ + +import type { ThemeDefinition } from './types'; +import { tonalSurfaces } from '../surfaces/definitions'; +import type { FeedbackSurface } from '../surfaces/types'; +import { + hexToRgb, + rgbToHsl, + hslToRgb, + rgbToHex, + lighten, + darken, + mix, + getContrastingTextColor, + ensureContrast, + contrastRatio, +} from '../colors/utils'; +import { generateSpacingTokens } from '../tokens/spacing'; +import { generateTypographyTokens } from '../tokens/typography'; +import { generateRadiiTokens } from '../tokens/radii'; +import { shadowTokens, generateDarkModeShadows } from '../tokens/shadows'; +import { generateAnimationTokens } from '../tokens/animation'; +import { gradientTokens } from '../tokens/gradients'; + +// Import theme rules - this is the single source of truth +import themeRules from './schema/theme-rules.json'; + +// Types for theme rules +interface SpecialTokenConfig { + derivation?: string | { light: string; dark: string }; + default?: string | { light: string; dark: string }; +} + +interface ColorGroupConfig { + description?: string; + defaults?: { + light?: Record; + dark?: Record; + }; + derivation?: Record; +} + +interface ProcessedColors { + primary: string; + secondary: string; + accent: string; + neutral: string; + success: string; + warning: string; + danger: string; + info: string; +} + +interface GeneratorContext { + colors: ProcessedColors; + isDark: boolean; + contrastLevel: number; + tokens: Record; +} + +/** + * Generate all tokens for a theme mode using rules from theme-rules.json + */ +export function generateThemeTokens( + theme: ThemeDefinition, + mode: 'light' | 'dark' +): Record { + const tokens: Record = {}; + const isDark = mode === 'dark'; + + // Get colors with adjustments + const colors = applyColorAdjustments(theme.colors, theme.config, isDark); + const contrastLevel = theme.accessibility?.level === 'AAA' ? 7 : 4.5; + + // Pre-populate page tokens from overrides if provided + // This ensures feedback surfaces can derive from the correct background + const modeOverrides = isDark ? theme.overrides?.dark : theme.overrides?.light; + if (modeOverrides?.['--page-bg']) { + tokens['--page-bg'] = modeOverrides['--page-bg']; + } + if (modeOverrides?.['--page-text']) { + tokens['--page-text'] = modeOverrides['--page-text']; + } + if (modeOverrides?.['--page-border']) { + tokens['--page-border'] = modeOverrides['--page-border']; + } + + // Create generator context + const ctx: GeneratorContext = { + colors, + isDark, + contrastLevel, + tokens, + }; + + // Generate static tokens + const spacingTokens = generateSpacingTokens(theme.spacing); + const typographyTokens = generateTypographyTokens(theme.typography); + const radiiTokens = generateRadiiTokens(theme.radii); + const animTokens = generateAnimationTokens(theme.animation); + const shadows = isDark ? generateDarkModeShadows() : shadowTokens; + + Object.assign(tokens, spacingTokens, typographyTokens, radiiTokens, animTokens, shadows, gradientTokens); + + // Generate color group tokens from rules + // Each group contains all fg tokens (fg, fg-soft, fg-softer, fg-strong, fg-stronger, + // fg-primary, fg-danger, fg-success, fg-warning, fg-info) ensuring accessibility + generateColorGroupTokens(ctx); + + // Generate feedback surface tokens (soft backgrounds for alerts) + generateFeedbackSurfaceTokens(ctx); + + // Generate special tokens from rules + generateSpecialTokensFromRules(ctx); + + // Generate component shortcut tokens from rules + generateComponentTokensFromRules(tokens); + + // Apply overrides from theme definition + const overrides = isDark ? theme.overrides?.dark : theme.overrides?.light; + if (overrides) { + Object.assign(tokens, overrides); + } + + return tokens; +} + +/** + * Apply color adjustments from theme config + */ +function applyColorAdjustments( + colors: ThemeDefinition['colors'], + config: ThemeDefinition['config'] = {}, + isDark: boolean +): ProcessedColors { + const { saturation = 0, temperature = 0 } = config; + + // Derive missing colors using rules from theme-rules.json + const rules = themeRules.colorDerivation.rules; + + let primary = colors.primary; + let secondary = colors.secondary || applyFormula(rules['secondary-from-primary'].formula, primary); + let accent = colors.accent || applyFormula(rules['accent-from-primary'].formula, primary); + let neutral = colors.neutral || applyFormula(rules['neutral-from-primary'].formula, primary); + + // Apply saturation adjustment + if (saturation !== 0) { + primary = adjustSaturation(primary, saturation); + secondary = adjustSaturation(secondary, saturation); + accent = adjustSaturation(accent, saturation); + } + + // Apply temperature adjustment + if (temperature !== 0) { + primary = adjustTemperature(primary, temperature); + secondary = adjustTemperature(secondary, temperature); + accent = adjustTemperature(accent, temperature); + neutral = adjustTemperature(neutral, temperature); + } + + // Get semantic colors from rules (fixed across all themes for UX clarity) + const semanticColors = themeRules.semanticColors; + + // Info derives from primary, other semantic colors are fixed + const infoBase = semanticColors.info.base; + const infoColor = infoBase === 'theme:primary' ? primary : infoBase; + + return { + primary, + secondary, + accent, + neutral, + success: semanticColors.success.base, + warning: semanticColors.warning.base, + danger: semanticColors.danger.base, + info: infoColor, + }; +} + +/** + * Apply a derivation formula to a color + */ +function applyFormula(formula: string | { light: string; dark: string }, baseColor: string): string { + if (typeof formula !== 'string') { + // This shouldn't happen for color derivation, but handle it + return baseColor; + } + + // Parse formula like "shiftHue(primary, 15)" or "desaturate(primary, 80)" + const shiftHueMatch = formula.match(/shiftHue\((\w+),\s*(-?\d+)\)/); + if (shiftHueMatch) { + return shiftHue(baseColor, parseInt(shiftHueMatch[2], 10)); + } + + const desaturateMatch = formula.match(/desaturate\((\w+),\s*(\d+)\)/); + if (desaturateMatch) { + return desaturate(baseColor, parseInt(desaturateMatch[2], 10)); + } + + return baseColor; +} + +/** + * Generate color group tokens from theme-rules.json + * Each group has 18 tokens: + * - bg, bg-hover, bg-pressed, bg-disabled (4) + * - border, border-hover, border-pressed, border-disabled (4) + * - fg, fg-soft, fg-softer, fg-strong, fg-stronger (5) + * - fg-primary, fg-danger, fg-success, fg-warning, fg-info (5) + */ +function generateColorGroupTokens(ctx: GeneratorContext): void { + const { colors, isDark, tokens } = ctx; + const colorGroups = (themeRules as any).colorGroups; + + if (!colorGroups?.groups) return; + + const tokenStructure = colorGroups.tokenStructure?.tokens || [ + 'bg', 'bg-hover', 'bg-pressed', 'bg-disabled', + 'border', 'border-hover', 'border-pressed', 'border-disabled', + 'fg', 'fg-soft', 'fg-softer', 'fg-strong', 'fg-stronger', + 'fg-primary', 'fg-danger', 'fg-success', 'fg-warning', 'fg-info' + ]; + const mode = isDark ? 'dark' : 'light'; + + for (const [groupName, config] of Object.entries(colorGroups.groups as Record)) { + const defaults = config.defaults?.[mode] || {}; + const derivation = config.derivation || {}; + + // Process each token for this color group + for (const tokenSuffix of tokenStructure) { + const cssVar = `--${groupName}-${tokenSuffix}`; + + // Check for explicit default first + if (defaults[tokenSuffix] !== undefined) { + tokens[cssVar] = defaults[tokenSuffix]; + continue; + } + + // Check for derivation rule + const rule = derivation[tokenSuffix]; + if (rule !== undefined) { + tokens[cssVar] = evaluateDerivation(rule, groupName, tokenSuffix, ctx); + continue; + } + + // Apply automatic derivation based on token type + tokens[cssVar] = deriveColorGroupTokenValue(groupName, tokenSuffix, ctx); + } + } +} + +/** + * Derive a color group token value automatically based on token type + */ +function deriveColorGroupTokenValue( + groupName: string, + tokenSuffix: string, + ctx: GeneratorContext +): string { + const { colors, isDark, tokens } = ctx; + + // Get the background color for this group + const bgToken = `--${groupName}-bg`; + const bg = tokens[bgToken]; + + // Get page colors for reference + const pageBg = tokens['--page-bg'] || (isDark ? '#0f0f0f' : '#fafafa'); + const pageText = tokens['--page-text'] || (isDark ? '#e5e5e5' : '#171717'); + + switch (tokenSuffix) { + // Foreground tokens + case 'fg': { + if (bg) { + return getContrastingTextColor(bg); + } + return pageText; + } + + case 'fg-soft': { + const fg = tokens[`--${groupName}-fg`] || deriveColorGroupTokenValue(groupName, 'fg', ctx); + const background = bg || pageBg; + return mix(fg, background, 0.3); + } + + case 'fg-softer': { + const fg = tokens[`--${groupName}-fg`] || deriveColorGroupTokenValue(groupName, 'fg', ctx); + const background = bg || pageBg; + return mix(fg, background, 0.5); + } + + case 'fg-strong': { + const fg = tokens[`--${groupName}-fg`] || deriveColorGroupTokenValue(groupName, 'fg', ctx); + const maxContrast = isDark ? '#ffffff' : '#000000'; + return mix(fg, maxContrast, 0.3); + } + + case 'fg-stronger': + return isDark ? '#ffffff' : '#000000'; + + // Semantic foreground colors (guaranteed accessible on this group's bg) + case 'fg-primary': { + // Primary link/accent color accessible on this background + // For dark mode, we use a higher contrast target (5.5:1) and minimum lightening + // because blues can appear perceptually dim even at 4.5:1 + const primary = colors.primary; + if (bg) { + const targetRatio = isDark ? 5.5 : 4.5; + const adjusted = ensureContrast(primary, bg, targetRatio); + // In dark mode, ensure minimum 15% lightening for better visibility + if (isDark) { + const minLightened = lighten(primary, 15); + const adjustedRgb = hexToRgb(adjusted); + const minRgb = hexToRgb(minLightened); + if (adjustedRgb && minRgb) { + // Use whichever is lighter (higher luminance) + const adjustedHsl = rgbToHsl(adjustedRgb.r, adjustedRgb.g, adjustedRgb.b); + const minHsl = rgbToHsl(minRgb.r, minRgb.g, minRgb.b); + return adjustedHsl.l >= minHsl.l ? adjusted : minLightened; + } + } + return adjusted; + } + return isDark ? lighten(primary, 20) : darken(primary, 10); + } + + case 'fg-danger': { + const danger = colors.danger; + if (bg) { + // Use higher contrast target in dark mode for better visibility + return ensureContrast(danger, bg, isDark ? 5.5 : 4.5); + } + return isDark ? lighten(danger, 25) : darken(danger, 10); + } + + case 'fg-success': { + const success = colors.success; + if (bg) { + // Use higher contrast target in dark mode for better visibility + return ensureContrast(success, bg, isDark ? 5.5 : 4.5); + } + return isDark ? lighten(success, 25) : darken(success, 10); + } + + case 'fg-warning': { + const warning = colors.warning; + if (bg) { + // Warning already has good luminance, lower target is fine + return ensureContrast(warning, bg, isDark ? 5.0 : 4.5); + } + return isDark ? lighten(warning, 15) : darken(warning, 15); + } + + case 'fg-info': { + const info = colors.info; + if (bg) { + // Info uses primary color, apply same treatment as fg-primary + return ensureContrast(info, bg, isDark ? 5.5 : 4.5); + } + return isDark ? lighten(info, 25) : darken(info, 10); + } + + // Border tokens + case 'border': { + const border = tokens['--page-border'] || (isDark ? '#333333' : '#e5e5e5'); + return border; + } + + case 'border-hover': { + const border = tokens[`--${groupName}-border`] || tokens['--page-border'] || (isDark ? '#333333' : '#e5e5e5'); + return isDark ? lighten(border, 10) : darken(border, 10); + } + + case 'border-pressed': { + const border = tokens[`--${groupName}-border`] || tokens['--page-border'] || (isDark ? '#333333' : '#e5e5e5'); + return isDark ? lighten(border, 15) : darken(border, 15); + } + + case 'border-disabled': { + const border = tokens[`--${groupName}-border`] || tokens['--page-border'] || (isDark ? '#333333' : '#e5e5e5'); + const background = bg || pageBg; + return mix(border, background, 0.5); + } + + default: + return ''; + } +} + +/** + * Generate feedback surface tokens from feedbackSurfaces rules + * These are soft/tinted backgrounds for alert components + */ +function generateFeedbackSurfaceTokens(ctx: GeneratorContext): void { + const feedbackSurfaces = (themeRules as any).feedbackSurfaces; + if (!feedbackSurfaces?.surfaces) return; + + for (const [surfaceName, config] of Object.entries(feedbackSurfaces.surfaces as Record)) { + const derivation = config.derivation || {}; + + // Process each token for this feedback surface + for (const [tokenSuffix, rule] of Object.entries(derivation)) { + const cssVar = `--${surfaceName}-${tokenSuffix}`; + if (rule !== undefined) { + ctx.tokens[cssVar] = evaluateDerivation(rule as any, surfaceName, tokenSuffix, ctx); + } + } + } +} + +/** + * Evaluate a derivation rule + */ +function evaluateDerivation( + rule: string | { light: string; dark: string }, + surfaceName: string, + tokenName: string, + ctx: GeneratorContext +): string { + const { colors, isDark, tokens } = ctx; + + // Default page colors (used as fallback when page.* tokens are referenced) + const pageDefaults = { + bg: isDark ? '#0f0f0f' : '#fafafa', + text: isDark ? '#e5e5e5' : '#171717', + border: isDark ? '#333333' : '#e5e5e5', + }; + + // Helper to resolve token with page fallback + const resolveToken = (surface: string, token: string): string => { + const tokenValue = tokens[`--${surface}-${token}`]; + if (tokenValue) return tokenValue; + if (surface === 'page' && token in pageDefaults) { + return pageDefaults[token as keyof typeof pageDefaults]; + } + return `${surface}.${token}`; + }; + + // Get the rule string for current mode + const ruleStr = typeof rule === 'string' ? rule : (isDark ? rule.dark : rule.light); + + // Parse different derivation patterns + + // "theme:primary" - reference theme color + if (ruleStr.startsWith('theme:')) { + const colorName = ruleStr.slice(6) as keyof ProcessedColors; + return colors[colorName] || ruleStr; + } + + // "semantic:danger" - reference semantic color + if (ruleStr.startsWith('semantic:')) { + const colorName = ruleStr.slice(9) as keyof ProcessedColors; + return colors[colorName] || ruleStr; + } + + // "inherit:surface.token" - inherit from another surface + if (ruleStr.startsWith('inherit:')) { + const [surface, token] = ruleStr.slice(8).split('.'); + return resolveToken(surface, token); + } + + // "transparent" - literal value + if (ruleStr === 'transparent') { + return 'transparent'; + } + + // "contrast(bg)" - get contrasting text color (black or white) + if (ruleStr.startsWith('contrast(')) { + const refToken = ruleStr.slice(9, -1); + const bgColor = tokens[`--${surfaceName}-${refToken}`] || tokens[`--${surfaceName}-bg`]; + return bgColor ? getContrastingTextColor(bgColor) : '#000000'; + } + + // "contrastOpacity(bg, opacity)" - contrast color with specified opacity (0-100) + // Returns rgba() with the contrasting color (black or white) at the given opacity + const contrastOpacityMatch = ruleStr.match(/contrastOpacity\(([^,]+),\s*([\d.]+)\)/); + if (contrastOpacityMatch) { + const bgRef = contrastOpacityMatch[1].trim(); + const opacity = parseFloat(contrastOpacityMatch[2]); + // Get the background color + let bgColor: string; + if (bgRef === 'bg') { + bgColor = tokens[`--${surfaceName}-bg`] || (isDark ? '#0f0f0f' : '#fafafa'); + } else if (bgRef.includes('.')) { + const [surface, token] = bgRef.split('.'); + bgColor = tokens[`--${surface}-${token}`] || (isDark ? '#0f0f0f' : '#fafafa'); + } else { + bgColor = tokens[`--${surfaceName}-${bgRef}`] || (isDark ? '#0f0f0f' : '#fafafa'); + } + // Get the contrast color and apply opacity + const contrastColor = getContrastingTextColor(bgColor); + if (contrastColor === '#ffffff') { + return `rgba(255, 255, 255, ${opacity / 100})`; + } else { + return `rgba(0, 0, 0, ${opacity / 100})`; + } + } + + // "accessibleColor(color, bgRef)" - ensure color is accessible on background + // Example: accessibleColor(semantic:success, bg) - returns success color adjusted for contrast + const accessibleMatch = ruleStr.match(/accessibleColor\(([^,]+),\s*([^)]+)\)/); + if (accessibleMatch) { + const baseColor = resolveColorRef(accessibleMatch[1], ctx); + const bgRef = accessibleMatch[2].trim(); + // Get the background color - could be "bg" (same surface) or "surface.token" + let bgColor: string; + if (bgRef === 'bg') { + bgColor = tokens[`--${surfaceName}-bg`] || (isDark ? '#0f0f0f' : '#fafafa'); + } else if (bgRef.includes('.')) { + const [surface, token] = bgRef.split('.'); + bgColor = tokens[`--${surface}-${token}`] || (isDark ? '#0f0f0f' : '#fafafa'); + } else { + bgColor = tokens[`--${surfaceName}-${bgRef}`] || (isDark ? '#0f0f0f' : '#fafafa'); + } + // Ensure the color meets AA contrast (4.5:1) on the background + return ensureContrast(baseColor, bgColor, 4.5); + } + + // "darken(color, amount)" or "lighten(color, amount)" + const darkenMatch = ruleStr.match(/darken\(([^,]+),\s*(\d+)\)/); + if (darkenMatch) { + const baseColor = resolveColorRef(darkenMatch[1], ctx); + return darken(baseColor, parseInt(darkenMatch[2], 10)); + } + + const lightenMatch = ruleStr.match(/lighten\(([^,]+),\s*(\d+)\)/); + if (lightenMatch) { + const baseColor = resolveColorRef(lightenMatch[1], ctx); + return lighten(baseColor, parseInt(lightenMatch[2], 10)); + } + + // "mix(color1, color2, weight)" + const mixMatch = ruleStr.match(/mix\(([^,]+),\s*([^,]+),\s*([\d.]+)\)/); + if (mixMatch) { + const color1 = resolveColorRef(mixMatch[1], ctx); + const color2 = resolveColorRef(mixMatch[2], ctx); + const weight = parseFloat(mixMatch[3]); + return mix(color1, color2, weight); + } + + // "surface.token" - reference another token + if (ruleStr.includes('.')) { + const [surface, token] = ruleStr.split('.'); + return resolveToken(surface, token); + } + + // Literal value (hex color, rgba, etc.) + return ruleStr; +} + +/** + * Resolve a color reference in a derivation formula + */ +function resolveColorRef(ref: string, ctx: GeneratorContext): string { + const { colors, tokens, isDark } = ctx; + const trimmed = ref.trim(); + + // Default page colors (used as fallback when page.* tokens are referenced) + const pageDefaults = { + bg: isDark ? '#0f0f0f' : '#fafafa', + text: isDark ? '#e5e5e5' : '#171717', + border: isDark ? '#333333' : '#e5e5e5', + }; + + // "theme:primary" style + if (trimmed.startsWith('theme:')) { + const colorName = trimmed.slice(6) as keyof ProcessedColors; + return colors[colorName] || trimmed; + } + + // "semantic:danger" style + if (trimmed.startsWith('semantic:')) { + const colorName = trimmed.slice(9) as keyof ProcessedColors; + return colors[colorName] || trimmed; + } + + // Token reference "surface.token" + if (trimmed.includes('.')) { + const [surface, token] = trimmed.split('.'); + const tokenValue = tokens[`--${surface}-${token}`]; + if (tokenValue) { + return tokenValue; + } + // Fallback for page.* references + if (surface === 'page' && token in pageDefaults) { + return pageDefaults[token as keyof typeof pageDefaults]; + } + return trimmed; + } + + // Simple color name from ProcessedColors + if (trimmed in colors) { + return colors[trimmed as keyof ProcessedColors]; + } + + // Literal hex color + if (trimmed.startsWith('#')) { + return trimmed; + } + + return trimmed; +} + +/** + * Generate special tokens from theme-rules.json + */ +function generateSpecialTokensFromRules(ctx: GeneratorContext): void { + const { colors, isDark, tokens } = ctx; + const specialTokens = themeRules.specialTokens; + + // Process each special token category + for (const [category, config] of Object.entries(specialTokens)) { + const categoryConfig = config as { description?: string; tokens: Record }; + + for (const [tokenName, tokenConfig] of Object.entries(categoryConfig.tokens)) { + const cssVar = `--${category === 'link' ? '' : category + '-'}${tokenName}`.replace('--focus-ring', '--focus-ring'); + + // Handle the naming for different categories + let finalCssVar: string; + if (category === 'focus') { + finalCssVar = `--focus-${tokenName}`; + } else if (category === 'selection') { + finalCssVar = `--selection-${tokenName}`; + } else if (category === 'link') { + finalCssVar = tokenName === 'default' ? '--link' : `--link-${tokenName}`; + } else if (category === 'scrollbar') { + finalCssVar = `--scrollbar-${tokenName}`; + } else if (category === 'skeleton') { + finalCssVar = `--skeleton-${tokenName}`; + } else if (category === 'highlight') { + finalCssVar = `--highlight-${tokenName}`; + } else { + finalCssVar = `--${category}-${tokenName}`; + } + + // Evaluate the token value + if (tokenConfig.derivation !== undefined) { + tokens[finalCssVar] = evaluateDerivation(tokenConfig.derivation, '', tokenName, ctx); + } else if (tokenConfig.default !== undefined) { + const defaultVal = tokenConfig.default; + if (typeof defaultVal === 'string') { + tokens[finalCssVar] = defaultVal; + } else { + tokens[finalCssVar] = isDark ? defaultVal.dark : defaultVal.light; + } + } + } + } +} + +/** + * Generate component shortcut tokens from theme-rules.json + */ +function generateComponentTokensFromRules(tokens: Record): void { + const componentTokens = themeRules.componentTokens.tokens; + + for (const [tokenName, config] of Object.entries(componentTokens)) { + const tokenConfig = config as { default: string }; + tokens[`--${tokenName}`] = tokenConfig.default; + } +} + +/** + * Composite an rgba color over a solid hex background + */ +function compositeRgbaOverHex(rgba: string, hexBg: string): string { + // Parse rgba(r, g, b, a) + const rgbaMatch = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),?\s*([\d.]+)?\)/); + if (!rgbaMatch) return hexBg; // fallback to bg if can't parse + + const fgR = parseInt(rgbaMatch[1], 10); + const fgG = parseInt(rgbaMatch[2], 10); + const fgB = parseInt(rgbaMatch[3], 10); + const alpha = parseFloat(rgbaMatch[4] || '1'); + + const bgRgb = hexToRgb(hexBg); + if (!bgRgb) return hexBg; + + // Alpha compositing: result = fg * alpha + bg * (1 - alpha) + const r = Math.round(fgR * alpha + bgRgb.r * (1 - alpha)); + const g = Math.round(fgG * alpha + bgRgb.g * (1 - alpha)); + const b = Math.round(fgB * alpha + bgRgb.b * (1 - alpha)); + + return rgbToHex(r, g, b); +} + +/** + * Evaluate a derivation rule for surface overrides + * Supports: contrast(token), contrastOpacity(token, opacity) + */ +function evaluateSurfaceDerivation( + rule: string, + resolvedTokens: Record, + isDark: boolean +): string { + // "contrast(primary-bg)" - get contrasting text color (black or white) + const contrastMatch = rule.match(/^contrast\(([^)]+)\)$/); + if (contrastMatch) { + const tokenRef = contrastMatch[1].trim(); + let bgColor = resolvedTokens[tokenRef]; + + if (bgColor) { + // If bg is rgba, composite it over base-bg to get effective color + if (bgColor.startsWith('rgba')) { + const baseBg = resolvedTokens['base-bg']; + if (baseBg && baseBg.startsWith('#')) { + bgColor = compositeRgbaOverHex(bgColor, baseBg); + } + } + + // Only process if we have a valid hex color + if (bgColor.startsWith('#')) { + return getContrastingTextColor(bgColor); + } + } + return isDark ? '#000000' : '#ffffff'; + } + + // "contrastOpacity(primary-bg, 85)" - contrast color with opacity + const contrastOpacityMatch = rule.match(/^contrastOpacity\(([^,]+),\s*([\d.]+)\)$/); + if (contrastOpacityMatch) { + const tokenRef = contrastOpacityMatch[1].trim(); + const opacity = parseFloat(contrastOpacityMatch[2]); + let bgColor = resolvedTokens[tokenRef]; + + if (bgColor) { + // If bg is rgba, composite it over base-bg to get effective color + if (bgColor.startsWith('rgba')) { + const baseBg = resolvedTokens['base-bg']; + if (baseBg && baseBg.startsWith('#')) { + bgColor = compositeRgbaOverHex(bgColor, baseBg); + } + } + + // Only process if we have a valid hex color + if (bgColor.startsWith('#')) { + const contrastColor = getContrastingTextColor(bgColor); + if (contrastColor === '#ffffff') { + return `rgba(255, 255, 255, ${opacity / 100})`; + } else { + return `rgba(0, 0, 0, ${opacity / 100})`; + } + } + } + return isDark ? `rgba(0, 0, 0, ${opacity / 100})` : `rgba(255, 255, 255, ${opacity / 100})`; + } + + // Return rule as-is if not recognized (might be a literal value) + return rule; +} + +// Color utility helpers +function shiftHue(hex: string, degrees: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + hsl.h = (hsl.h + degrees) % 360; + if (hsl.h < 0) hsl.h += 360; + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} + +function desaturate(hex: string, amount: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + hsl.s = Math.max(0, hsl.s - amount); + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} + +function adjustSaturation(hex: string, amount: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); + hsl.s = Math.max(0, Math.min(100, hsl.s + amount)); + const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); + return rgbToHex(newRgb.r, newRgb.g, newRgb.b); +} + +function adjustTemperature(hex: string, amount: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + // Positive = warmer (more red/yellow), negative = cooler (more blue) + const factor = amount / 100; + const r = Math.min(255, Math.max(0, rgb.r + factor * 20)); + const b = Math.min(255, Math.max(0, rgb.b - factor * 20)); + return rgbToHex(r, rgb.g, b); +} + +// Color groups that need theme-level preservation for surface resets +const COLOR_GROUPS = ['softer', 'soft', 'base', 'strong', 'stronger', 'primary', 'inverted', 'success', 'warning', 'danger', 'info'] as const; +const COLOR_GROUP_SUFFIXES = ['bg', 'bg-hover', 'bg-pressed', 'bg-disabled', 'border', 'border-hover', 'border-pressed', 'border-disabled', 'fg', 'fg-soft', 'fg-softer', 'fg-strong', 'fg-stronger', 'fg-primary', 'fg-danger', 'fg-success', 'fg-warning', 'fg-info'] as const; + +// Semantic fg token suffixes that need contrast adjustment on colored surfaces +const SEMANTIC_FG_SUFFIXES = ['fg-primary', 'fg-danger', 'fg-success', 'fg-warning', 'fg-info'] as const; + +// Minimum contrast ratio for UI components (WCAG AA for UI = 3:1) +const MIN_UI_CONTRAST = 3.0; + +// Semantic color groups that need accessibility checking +const SEMANTIC_COLOR_GROUPS = ['primary', 'success', 'warning', 'danger', 'info'] as const; + +// Surface types that are semantically colored (their base-bg is a semantic color) +const SEMANTIC_SURFACE_TYPES = ['primary', 'success', 'warning', 'danger', 'info'] as const; + +/** + * Default semantic colors used when theme colors aren't available + * These are approximate values for checking color similarity + */ +const DEFAULT_SEMANTIC_COLORS: Record = { + primary: '#2563eb', // Blue + success: '#22c55e', // Green + warning: '#eab308', // Amber/Yellow + danger: '#ef4444', // Red + info: '#2563eb', // Blue (same as primary) +}; + +/** + * Check if two colors are in the same hue family (within 30 degrees) + */ +function isSameColorFamily(color1: string, color2: string): boolean { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + if (!rgb1 || !rgb2) return false; + + const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b); + const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b); + + // Calculate hue difference (accounting for wraparound at 360) + let hueDiff = Math.abs(hsl1.h - hsl2.h); + if (hueDiff > 180) hueDiff = 360 - hueDiff; + + // Colors are in the same family if hue difference is less than 30 degrees + // and both have some saturation (not grayscale) + return hueDiff < 30 && hsl1.s > 20 && hsl2.s > 20; +} + +/** + * Generate an accessible bg color that contrasts with the surface base-bg + * Uses the gravitation approach: soft → toward page bg, strong → toward inverted + */ +function deriveAccessibleBg( + originalBg: string, + surfaceBaseBg: string, + isDark: boolean, + direction: 'neutral' | 'lighter' | 'darker' = 'neutral' +): string { + const pageBg = isDark ? '#0f0f0f' : '#fafafa'; + const invertedBg = isDark ? '#fafafa' : '#0f0f0f'; + + // Determine target based on direction + let targetBg: string; + switch (direction) { + case 'lighter': + targetBg = pageBg; + break; + case 'darker': + targetBg = invertedBg; + break; + default: + // Neutral: pick whichever provides better contrast + const pageBgContrast = contrastRatio(pageBg, surfaceBaseBg); + const invertedBgContrast = contrastRatio(invertedBg, surfaceBaseBg); + targetBg = pageBgContrast > invertedBgContrast ? pageBg : invertedBg; + } + + // Start with original color and mix toward target until we have sufficient contrast + let result = originalBg; + for (let mixAmount = 0.1; mixAmount <= 1.0; mixAmount += 0.1) { + result = mix(originalBg, targetBg, mixAmount); + if (contrastRatio(result, surfaceBaseBg) >= MIN_UI_CONTRAST) { + return result; + } + } + + // If mixing didn't work, return the target directly + return targetBg; +} + +/** + * Generate accessible color group overrides for a surface + * + * This function ensures ALL color group tokens are accessible on a given surface: + * 1. Checks each color group's bg against the surface's base-bg + * 2. If contrast < 3:1, derives a new accessible bg + * 3. Derives fg/border tokens that are accessible on the new bg + * + * @param surfaceName - The surface type being generated + * @param baseBg - The surface's base-bg color (resolved hex value) + * @param themeColors - The theme's color definitions + * @param isDark - Whether in dark mode + * @returns Record of token overrides for accessibility + */ +function generateAccessibleSurfaceOverrides( + surfaceName: string, + baseBg: string, + themeColors: Record, + isDark: boolean +): Record { + const overrides: Record = {}; + + // Get contrasting text color for this surface + const textColor = getContrastingTextColor(baseBg); + const needsLightText = textColor === '#ffffff'; + + // 1. Handle semantic color groups (primary, success, warning, danger, info) + // These are the button/accent colors that might conflict with the surface + for (const colorGroup of SEMANTIC_COLOR_GROUPS) { + // Get the original bg color for this group + const originalBg = themeColors[colorGroup] || DEFAULT_SEMANTIC_COLORS[colorGroup]; + + // Check if this color group conflicts with the surface + const currentContrast = contrastRatio(originalBg, baseBg); + const isSameFamily = isSameColorFamily(originalBg, baseBg); + + // Need to flip if contrast is too low OR if they're in the same color family + // (same family = would look like the same color even if contrast is technically ok) + if (currentContrast < MIN_UI_CONTRAST || isSameFamily) { + // Determine if this is the surface's own color group (e.g., primary on primary surface) + const isSurfaceOwnGroup = colorGroup === surfaceName; + + // Generate flipped token values + const flippedBg = needsLightText ? '#ffffff' : (isDark ? '#1a1a1a' : '#171717'); + const flippedBgHover = needsLightText + ? (isDark ? '#f0f0f0' : '#f5f5f5') + : (isDark ? '#262626' : '#252525'); + const flippedBgPressed = needsLightText + ? (isDark ? '#e5e5e5' : '#ebebeb') + : (isDark ? '#333333' : '#333333'); + const flippedFg = needsLightText ? '#000000' : '#ffffff'; + const flippedFgOpacity = needsLightText ? '0, 0, 0' : '255, 255, 255'; + const flippedBorderOpacity = needsLightText ? '0, 0, 0' : '255, 255, 255'; + + // Override all tokens for this color group + overrides[`${colorGroup}-bg`] = flippedBg; + overrides[`${colorGroup}-bg-hover`] = flippedBgHover; + overrides[`${colorGroup}-bg-pressed`] = flippedBgPressed; + overrides[`${colorGroup}-bg-disabled`] = `rgba(${flippedFgOpacity}, 0.3)`; + overrides[`${colorGroup}-fg`] = flippedFg; + overrides[`${colorGroup}-fg-soft`] = `rgba(${flippedFgOpacity}, 0.85)`; + overrides[`${colorGroup}-fg-softer`] = `rgba(${flippedFgOpacity}, 0.7)`; + overrides[`${colorGroup}-fg-strong`] = flippedFg; + overrides[`${colorGroup}-fg-stronger`] = flippedFg; + overrides[`${colorGroup}-border`] = `rgba(${flippedBorderOpacity}, 0.15)`; + overrides[`${colorGroup}-border-hover`] = `rgba(${flippedBorderOpacity}, 0.2)`; + overrides[`${colorGroup}-border-pressed`] = `rgba(${flippedBorderOpacity}, 0.25)`; + } + } + + // 2. Handle semantic fg tokens on all color groups + // These are the colored text/links that appear on various backgrounds + // e.g., --base-fg-primary (primary-colored text on base background) + for (const group of COLOR_GROUPS) { + // Skip the inverted group (has its own complete token set) + if (group === 'inverted') continue; + + // Skip the same-named group as the surface (e.g., primary-fg-primary on primary surface + // is already handled by the color group override above) + if (group === surfaceName) continue; + + // For each semantic fg token suffix + for (const fgSuffix of ['fg-primary', 'fg-success', 'fg-warning', 'fg-danger', 'fg-info']) { + const semanticColor = fgSuffix.replace('fg-', ''); + const originalFgColor = themeColors[semanticColor] || DEFAULT_SEMANTIC_COLORS[semanticColor]; + + // Check if this semantic fg color conflicts with the surface + if (isSameColorFamily(originalFgColor, baseBg)) { + const token = `${group}-${fgSuffix}`; + + // Use appropriate contrast color + if (needsLightText) { + if (group === 'softer' || group === 'soft') { + overrides[token] = `rgba(255, 255, 255, ${isDark ? 0.9 : 0.95})`; + } else { + overrides[token] = '#ffffff'; + } + } else { + if (group === 'softer' || group === 'soft') { + overrides[token] = `rgba(0, 0, 0, ${isDark ? 0.9 : 0.85})`; + } else { + overrides[token] = '#000000'; + } + } + } + } + } + + return overrides; +} + +// Generate short internal token names (--_a0, --_a1, ..., --_z9, --_aa, etc.) +// These are implementation details for surface resets - not meant for direct use +function generateTokenMap(): Map { + const map = new Map(); + let index = 0; + + const toCode = (n: number): string => { + // Use base36 (0-9, a-z) for compact codes + return n.toString(36); + }; + + // Map color group tokens + for (const group of COLOR_GROUPS) { + for (const suffix of COLOR_GROUP_SUFFIXES) { + const fullName = `${group}-${suffix}`; + map.set(fullName, `--_${toCode(index++)}`); + } + } + + // Map special tokens + const specialTokens = ['focus-ring', 'selection-bg', 'selection-text']; + for (const name of specialTokens) { + map.set(name, `--_${toCode(index++)}`); + } + + return map; +} + +const INTERNAL_TOKEN_MAP = generateTokenMap(); + +/** + * Generate CSS from tokens + */ +export function generateThemeCSS( + theme: ThemeDefinition, + mode: 'light' | 'dark' +): string { + const tokens = generateThemeTokens(theme, mode); + const themeId = theme.id || theme.name.toLowerCase().replace(/\s+/g, '-'); + + const lines = [ + `/* ${theme.name} - ${mode} mode */`, + `/* Generated by @ui-kit/core from theme-rules.json */`, + '', + ]; + + // Build the theme qualifier selector + // Uses CSS nesting - all rules scoped to this theme+mode combination + const qualifier = `[data-theme='${themeId}'][data-mode='${mode}']`; + lines.push(`${qualifier} {`); + + // First pass: output internal tokens (preserved originals for surface resets) + // Uses short codes like --_0, --_1, etc. to minimize file size + for (const group of COLOR_GROUPS) { + for (const suffix of COLOR_GROUP_SUFFIXES) { + const fullName = `${group}-${suffix}`; + const shortName = INTERNAL_TOKEN_MAP.get(fullName); + const value = tokens[`--${fullName}`]; + if (value && shortName) { + lines.push(` ${shortName}: ${value};`); + } + } + } + // Also preserve special tokens that surfaces might override + const specialTokensToPreserve = ['focus-ring', 'selection-bg', 'selection-text']; + for (const name of specialTokensToPreserve) { + const shortName = INTERNAL_TOKEN_MAP.get(name); + const value = tokens[`--${name}`]; + if (value && shortName) { + lines.push(` ${shortName}: ${value};`); + } + } + lines.push(''); + + // Second pass: output all tokens normally + for (const [name, value] of Object.entries(tokens)) { + if (value) { + lines.push(` ${name}: ${value};`); + } + } + lines.push(''); + + // Add body typography and color rule (nested) + lines.push(' /* Base typography */'); + lines.push(' & body {'); + lines.push(' font-family: var(--font-sans);'); + lines.push(' font-size: var(--text-base);'); + lines.push(' line-height: var(--leading-normal);'); + lines.push(' color: var(--base-fg);'); + lines.push(' background: var(--base-bg);'); + lines.push(' }'); + lines.push(''); + + // Generate nested surface classes + lines.push(' /* Surface classes */'); + // Extract theme colors for accessibility checking in surfaces + const themeColors: Record = { + primary: tokens['--primary-bg'] || '#2563eb', + success: tokens['--success-bg'] || '#22c55e', + warning: tokens['--warning-bg'] || '#eab308', + danger: tokens['--danger-bg'] || '#ef4444', + info: tokens['--info-bg'] || '#2563eb', + }; + const surfaceLines = generateSurfaceClasses(mode, themeColors); + // Indent surface class lines for nesting + for (const line of surfaceLines) { + if (line.trim()) { + lines.push(` ${line}`); + } else { + lines.push(''); + } + } + + lines.push('}'); + + return lines.join('\n'); +} + +/** + * Generate surface class CSS for a specific mode + * + * Surfaces reset ALL color group tokens to their theme-level values (--_theme-*), + * then apply modifier-specific overrides. This ensures nested surfaces properly + * reset and don't inherit overrides from parent surfaces. + * + * Uses CSS nesting with & prefix since these are nested inside the theme qualifier. + */ +function generateSurfaceClasses(mode: 'light' | 'dark', themeColors: Record): string[] { + const lines: string[] = []; + + lines.push('/* ================================================================'); + lines.push(' TONAL SURFACE SYSTEM'); + lines.push(' Usage:
...
'); + lines.push(' Every .surface resets ALL color group tokens to theme defaults,'); + lines.push(' ensuring nested surfaces are isolated from parent overrides.'); + lines.push(' ================================================================ */'); + lines.push(''); + + // Base .surface class - resets ALL color group tokens to theme values + lines.push('& .surface {'); + + // Reset all color group tokens using short internal references + for (const group of COLOR_GROUPS) { + for (const suffix of COLOR_GROUP_SUFFIXES) { + const fullName = `${group}-${suffix}`; + const shortName = INTERNAL_TOKEN_MAP.get(fullName); + if (shortName) { + lines.push(` --${fullName}: var(${shortName});`); + } + } + } + + // Also reset special tokens that surfaces might override + const focusRingShort = INTERNAL_TOKEN_MAP.get('focus-ring'); + const selectionBgShort = INTERNAL_TOKEN_MAP.get('selection-bg'); + const selectionTextShort = INTERNAL_TOKEN_MAP.get('selection-text'); + lines.push(` --focus-ring: var(${focusRingShort}, var(--primary-bg));`); + lines.push(` --selection-bg: var(${selectionBgShort});`); + lines.push(` --selection-text: var(${selectionTextShort});`); + lines.push(''); + + lines.push(' /* Apply base surface styles */'); + lines.push(' background: var(--base-bg);'); + lines.push(' color: var(--base-fg);'); + lines.push(''); + + // Selection styles nested inside .surface + lines.push(' /* Selection styles */'); + lines.push(' & ::selection {'); + lines.push(' background: var(--selection-bg);'); + lines.push(' color: var(--selection-text);'); + lines.push(' }'); + lines.push(''); + + // Generate tonal surface modifiers (nested) + const surfaceDefinitions = themeRules.surfaces?.types as Record> }> | undefined; + + if (surfaceDefinitions) { + for (const surfaceName of tonalSurfaces) { + const config = surfaceDefinitions[surfaceName]; + if (!config) continue; + + const overrides = config.overrides?.[mode] || {}; + + // Skip if no overrides for this mode + if (Object.keys(overrides).length === 0) { + lines.push(` /* &.${surfaceName} - ${config.description} */`); + lines.push(` /* No overrides for ${mode} mode */`); + lines.push(''); + continue; + } + + // First pass: collect resolved values so derivations can reference them + const resolvedOverrides: Record = {}; + + // Process bg tokens first (they don't have derivations typically) + for (const [token, value] of Object.entries(overrides)) { + if (typeof value === 'string' && !value.startsWith('derive:')) { + resolvedOverrides[token] = value; + } + } + + // Second pass: process derivations + for (const [token, value] of Object.entries(overrides)) { + if (typeof value === 'string' && value.startsWith('derive:')) { + const derivationRule = value.slice(7); // Remove 'derive:' prefix + resolvedOverrides[token] = evaluateSurfaceDerivation(derivationRule, resolvedOverrides, mode === 'dark'); + } + } + + // For all surfaces with a defined base-bg, add accessibility overrides to ensure + // color group tokens have sufficient contrast against the surface's base-bg + if (resolvedOverrides['base-bg']) { + const accessibilityOverrides = generateAccessibleSurfaceOverrides( + surfaceName, + resolvedOverrides['base-bg'], + themeColors, + mode === 'dark' + ); + Object.assign(resolvedOverrides, accessibilityOverrides); + } + + lines.push(` /* &.${surfaceName} - ${config.description} */`); + lines.push(` &.${surfaceName} {`); + for (const [token, value] of Object.entries(resolvedOverrides)) { + lines.push(` --${token}: ${value};`); + } + lines.push(' }'); + lines.push(''); + } + } + + // Generate feedback surface modifiers (nested) + // These use the soft tinted feedback tokens (--feedback-*), NOT the solid button tokens (--warning-bg etc.) + lines.push(' /* Feedback surfaces - soft tinted backgrounds for alerts/cards */'); + const feedbackSurfacesList: FeedbackSurface[] = ['success', 'warning', 'danger', 'info']; + for (const feedback of feedbackSurfacesList) { + lines.push(` &.${feedback} {`); + lines.push(` --base-bg: var(--feedback-${feedback}-bg);`); + lines.push(` --base-fg: var(--feedback-${feedback}-fg);`); + lines.push(` --base-border: var(--feedback-${feedback}-border);`); + lines.push(` background: var(--base-bg);`); + lines.push(` color: var(--base-fg);`); + lines.push(' }'); + lines.push(''); + } + + // Close the .surface block + lines.push('}'); + + return lines; +} + +/** + * Load and validate a theme from JSON + */ +export function validateTheme(theme: unknown): theme is ThemeDefinition { + if (!theme || typeof theme !== 'object') return false; + + const t = theme as Record; + const schema = themeRules.themeInputSchema; + + // Check required fields + for (const field of schema.required) { + if (!(field in t)) return false; + } + + // Check id pattern + if (typeof t.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(t.id)) { + return false; + } + + // Check name + if (typeof t.name !== 'string') return false; + + // Check colors + if (!t.colors || typeof t.colors !== 'object') return false; + const colors = t.colors as Record; + if (typeof colors.primary !== 'string') return false; + + return true; +} + +// ============================================================================ +// RUNTIME THEME GENERATION +// ============================================================================ + +/** + * Simplified config for runtime theme generation (Theme Designer) + */ +export interface RuntimeThemeConfig { + /** Primary brand color */ + primary: string; + /** Secondary color (optional) */ + secondary?: string; + /** Accent color (optional) */ + accent?: string; + /** Neutral color (optional) */ + neutral?: string; + + /** Light mode background color override */ + lightBg?: string; + /** Dark mode background color override */ + darkBg?: string; + + /** Saturation adjustment (-100 to 100) */ + saturation?: number; + /** Temperature adjustment (-100 to 100) */ + temperature?: number; + + /** Border radius scale (default 1.0) */ + radiusScale?: number; + /** Border radius style */ + radiusStyle?: 'sharp' | 'subtle' | 'rounded' | 'pill'; + + /** Control size scale (0.8 to 1.3) */ + sizeScale?: number; + + /** Glow intensity (0 to 1) */ + glowIntensity?: number; + + /** Accessibility level */ + accessibilityLevel?: 'AA' | 'AAA'; + + // Typography settings + /** Sans-serif font family */ + fontSans?: string; + /** Monospace font family */ + fontMono?: string; + /** Serif font family */ + fontSerif?: string; + /** Base font size in pixels (default 15) */ + fontBaseSize?: number; + /** Font size scale multiplier (default 1.0) */ + fontScale?: number; +} + +/** + * Convert a font value from JSON array format to CSS font-family string. + * The Theme Designer stores fonts as JSON arrays (e.g., '["Inter", "system-ui"]') + * but CSS needs comma-separated strings (e.g., 'Inter, system-ui'). + */ +function fontValueToCss(value: string | undefined): string | undefined { + if (!value) return undefined; + + // Check if it's a JSON array (starts with '[') + if (value.startsWith('[')) { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.join(', '); + } + } catch { + // Not valid JSON, return as-is + } + } + + // Return as-is (already a CSS string or legacy format) + return value; +} + +/** + * Convert RuntimeThemeConfig to ThemeDefinition + */ +function runtimeConfigToDefinition(config: RuntimeThemeConfig): ThemeDefinition { + const overrides: ThemeDefinition['overrides'] = {}; + + // Apply background overrides + if (config.lightBg) { + overrides.light = { '--page-bg': config.lightBg }; + } + if (config.darkBg) { + overrides.dark = { '--page-bg': config.darkBg }; + } + + return { + id: 'runtime', + name: 'Runtime Theme', + colors: { + primary: config.primary, + secondary: config.secondary, + accent: config.accent, + neutral: config.neutral, + }, + config: { + saturation: config.saturation ?? 0, + temperature: config.temperature ?? 0, + }, + typography: { + // Convert font values from JSON array format to CSS strings + fontSans: fontValueToCss(config.fontSans), + fontMono: fontValueToCss(config.fontMono), + fontSerif: fontValueToCss(config.fontSerif), + baseSize: config.fontBaseSize, + scale: config.fontScale, + }, + radii: { + scale: config.radiusScale ?? 1, + style: config.radiusStyle ?? 'rounded', + }, + accessibility: { + level: config.accessibilityLevel ?? 'AA', + }, + overrides, + }; +} + +/** + * Generate theme tokens for runtime use (Theme Designer) + * + * This is the main function for browser-side theme generation. + * It accepts a simplified config and returns tokens for a specific mode. + */ +export function generateRuntimeThemeTokens( + config: RuntimeThemeConfig, + mode: 'light' | 'dark' +): Record { + const definition = runtimeConfigToDefinition(config); + const tokens = generateThemeTokens(definition, mode); + + // Add size scale tokens (not in core generator yet) + const sizeScale = config.sizeScale ?? 1; + const baseSizes = { + '--control-height-sm': 28, + '--control-height-md': 36, + '--control-height-lg': 44, + '--control-height-xl': 52, + }; + for (const [token, baseValue] of Object.entries(baseSizes)) { + tokens[token] = `${Math.round(baseValue * sizeScale)}px`; + } + + // Add glow tokens (not in core generator yet) + const glowIntensity = config.glowIntensity ?? 0.5; + const glowOpacity = Math.round(glowIntensity * 40); + tokens['--glow-intensity'] = String(glowIntensity); + tokens['--glow-spread-sm'] = `${Math.round(8 * glowIntensity)}px`; + tokens['--glow-spread-md'] = `${Math.round(20 * glowIntensity)}px`; + tokens['--glow-spread-lg'] = `${Math.round(40 * glowIntensity)}px`; + tokens['--glow-opacity'] = `${glowOpacity}%`; + tokens['--glow-color'] = config.primary; + + return tokens; +} diff --git a/packages/cap-foundations/core/src/themes/index.ts b/packages/cap-foundations/core/src/themes/index.ts new file mode 100644 index 000000000..5bee4495c --- /dev/null +++ b/packages/cap-foundations/core/src/themes/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './definitions'; +export * from './generator'; +// Note: storage (runtime theme storage) is excluded — not needed in the core package diff --git a/packages/cap-foundations/core/src/themes/schema/schema-definition.md b/packages/cap-foundations/core/src/themes/schema/schema-definition.md new file mode 100644 index 000000000..ae652afa4 --- /dev/null +++ b/packages/cap-foundations/core/src/themes/schema/schema-definition.md @@ -0,0 +1,531 @@ +# Theme Rules Schema Definition + +This document describes the `theme-rules.json` schema — the single source of truth for all `@fluentui-contrib/cap-foundations-core` theming rules, token structure, color group logic, and derivation rules. + +## Overview + +The theme rules schema defines: + +- **Semantic colors** — Fixed colors for consistent UX meaning across all themes +- **Color groups** — 11 named groups, each with 18 tokens (bg × 4, border × 4, fg × 10) +- **Feedback surfaces** — Soft tinted backgrounds for alerts and notifications +- **Special tokens** — Focus ring, text selection, links, scrollbar, skeleton, highlight +- **Component tokens** — Shortcut tokens for common component size/spacing patterns +- **Color derivation rules** — How secondary/accent/neutral are computed from the theme's primary color +- **Tonal surface overrides** — CSS class system that redefines tokens in a scoped context +- **Accessibility requirements** — WCAG contrast standards enforced by the generator +- **Theme input schema** — Structure that theme JSON files must follow + +--- + +## Semantic Colors + +Fixed colors that remain consistent across **all themes** for UX clarity. Users learn that green = success, red = danger, etc., regardless of the active theme. + +```json +"semanticColors": { + "success": { "base": "#16a34a" }, + "warning": { "base": "#f59e0b" }, + "danger": { "base": "#dc2626" }, + "info": { "base": "theme:primary" } +} +``` + +| Color | Value | Purpose | +| --------- | --------------- | ------------------------------------------------ | +| `success` | `#16a34a` | Positive outcomes, confirmations, completion | +| `warning` | `#f59e0b` | Caution states, attention needed, pending | +| `danger` | `#dc2626` | Errors, destructive actions, critical states | +| `info` | `theme:primary` | Informational — tracks the theme's primary color | + +> **Important:** `success`, `warning`, and `danger` are fixed and not customizable. `info` always matches the active theme's primary color. + +--- + +## Color Groups + +The color group system is the core of the theming engine. There are **11 named groups**, each generating **18 CSS custom properties**. When you pick a group as your component's background, you use only that group's foreground tokens — contrast is guaranteed. + +### Group Names + +| Group | Description | +| ---------- | ------------------------------------------------------ | +| `softer` | Most subtle — closest to white (light) or black (dark) | +| `soft` | Subtle — slight step toward white/black | +| `base` | Default page content, main surface | +| `strong` | Emphasized — 10% darker (light) or lighter (dark) | +| `stronger` | Maximum tonal emphasis | +| `primary` | Selection, active states, branded elements | +| `inverted` | Opposite color scheme — for tooltips and callouts | +| `success` | Solid green — for success buttons and actions | +| `warning` | Solid amber — for warning buttons and actions | +| `danger` | Solid red — for destructive actions | +| `info` | Solid primary — for informational actions | + +### Token Structure (18 per group) + +Each group generates CSS variables prefixed with `--{group}-`: + +``` +Background tokens (4): + --{group}-bg Normal background + --{group}-bg-hover Hovered background + --{group}-bg-pressed Active / pressed background + --{group}-bg-disabled Disabled state background + +Border tokens (4): + --{group}-border Normal border + --{group}-border-hover Hovered border + --{group}-border-pressed Pressed border + --{group}-border-disabled Disabled border + +Foreground tokens (10): + --{group}-fg Primary text + --{group}-fg-soft Secondary text (~70% opacity) + --{group}-fg-softer Tertiary text (~55% opacity) + --{group}-fg-strong Higher-contrast text + --{group}-fg-stronger Maximum contrast text + --{group}-fg-primary Primary-colored text / links + --{group}-fg-danger Danger-colored text + --{group}-fg-success Success-colored text + --{group}-fg-warning Warning-colored text + --{group}-fg-info Info-colored text +``` + +### Usage Pattern + +```css +/* Pick one group for your surface background, use that group's tokens */ +.my-card { + background: var(--base-bg); + color: var(--base-fg); + border: 1px solid var(--base-border); +} + +.my-card:hover { + background: var(--base-bg-hover); + border-color: var(--base-border-hover); +} + +/* On a primary-colored button, fg tokens guarantee contrast */ +.btn-primary { + background: var(--primary-bg); + color: var(--primary-fg); + border-color: var(--primary-border); +} +``` + +### Group Derivation + +Tonal groups (`softer` → `stronger`) are derived from the page background using lightness shifts. Semantic groups (`primary`, `success`, `warning`, `danger`, `info`) are derived from their respective semantic or theme color. + +| Group | Source color | `bg` derivation (light / dark) | +| ---------- | ------------------ | ---------------------------------------------- | +| `softer` | Page background | `lighten(page.bg, 10)` / `darken(page.bg, 10)` | +| `soft` | Page background | `lighten(page.bg, 5)` / `darken(page.bg, 5)` | +| `base` | Page background | `page.bg` (identity) | +| `strong` | Page background | `darken(page.bg, 10)` / `lighten(page.bg, 10)` | +| `stronger` | Page background | `darken(page.bg, 20)` / `lighten(page.bg, 20)` | +| `primary` | `theme:primary` | `theme:primary` | +| `inverted` | Fixed defaults | `#1a1a1a` (light) / `#fafafa` (dark) | +| `success` | `semantic:success` | `semantic:success` | +| `warning` | `semantic:warning` | `semantic:warning` | +| `danger` | `semantic:danger` | `semantic:danger` | +| `info` | `theme:primary` | `theme:primary` | + +--- + +## Feedback Surfaces + +Feedback surfaces produce **soft tinted backgrounds** for alert components. They are distinct from color groups — the tint is subtle enough that the page background shows through. + +| Token prefix | Source color | `bg` (light) | `bg` (dark) | +| --------------------- | ------------------ | ----------------------------- | ----------------------------- | +| `--feedback-success-` | `semantic:success` | `mix(page.bg, success, 0.12)` | `mix(page.bg, success, 0.18)` | +| `--feedback-warning-` | `semantic:warning` | `mix(page.bg, warning, 0.12)` | `mix(page.bg, warning, 0.18)` | +| `--feedback-danger-` | `semantic:danger` | `mix(page.bg, danger, 0.12)` | `mix(page.bg, danger, 0.18)` | +| `--feedback-info-` | `theme:primary` | `mix(page.bg, primary, 0.12)` | `mix(page.bg, primary, 0.18)` | + +Each feedback prefix provides: + +``` +--feedback-{role}-bg Tinted background +--feedback-{role}-bg-hover Slightly more saturated hover state +--feedback-{role}-fg Text color (WCAG AA 4.5:1 guaranteed against bg) +--feedback-{role}-border Tinted border +``` + +--- + +## Derivation Syntax + +Rules in `theme-rules.json` use a mini-language to describe how token values are computed. + +### Reference Patterns + +| Syntax | Description | Example | +| ------------------ | ------------------------------------------- | -------------------------- | +| `theme:primary` | The active theme's primary color | `"bg": "theme:primary"` | +| `semantic:success` | A fixed semantic color | `"bg": "semantic:success"` | +| `page.bg` | The resolved page background token | `"bg": "page.bg"` | +| `surface.{token}` | A token resolved within the current surface | `"fg": "surface.base-bg"` | +| `transparent` | CSS `transparent` | `"border": "transparent"` | + +### Color Function Patterns + +| Syntax | Description | +| ---------------------------------- | --------------------------------------------------- | +| `darken(color, amount)` | Darken by `amount` lightness points (0–100) | +| `lighten(color, amount)` | Lighten by `amount` lightness points (0–100) | +| `mix(color1, color2, weight)` | Blend two colors; `weight` is fraction of `color2` | +| `contrast(token)` | `#ffffff` or `#000000` for best contrast with token | +| `contrastOpacity(token, opacity)` | Same as `contrast()` but as `rgba(…, opacity/100)` | +| `accessibleColor(color, bg)` | Adjust `color` until it meets AA contrast on `bg` | +| `shiftHue(color, degrees)` | Rotate HSL hue by `degrees` | +| `desaturate(color, amount)` | Reduce HSL saturation by `amount` | +| `adjustSaturation(color, amount)` | Add/subtract HSL saturation (negative = desaturate) | +| `adjustTemperature(color, amount)` | Positive = warmer, negative = cooler | + +### Mode-Specific Rules + +A derivation can specify different values for light and dark mode: + +```json +"bg-hover": { + "light": "darken(theme:primary, 8)", + "dark": "lighten(theme:primary, 8)" +} +``` + +When the rule is a plain string it applies to both modes: + +```json +"bg": "theme:primary" +``` + +### Surface Override Derivations (CSS generation only) + +Inside `theme-rules.json`'s `surfaces.types` entries, the `derive:` prefix triggers +run-time resolution during CSS generation — not at the generator's token pass: + +```json +"base-fg": "derive:contrast(base-bg)" +``` + +This lets surface overrides reference tokens defined earlier in the same override block. + +--- + +## Special Tokens + +These tokens are generated at the theme level (not per-group) and are available everywhere. + +### Focus Ring + +``` +--focus-ring Color of focus ring (derived from theme:primary) +--focus-ring-offset Space between element and ring (default: 2px) +--focus-ring-width Thickness of ring (default: 2px) +``` + +### Text Selection + +``` +--selection-bg Background of selected text (mix of primary + white) +--selection-text Text color of selected text (always #000000) +``` + +### Links + +``` +--link Default link color (theme:primary in light, lightened in dark) +--link-hover Hover state +--link-pressed Active/pressed state +--link-visited Visited link color (blend of primary + purple) +``` + +### Scrollbar + +``` +--scrollbar-track Track background +--scrollbar-thumb Thumb color +--scrollbar-thumb-hover Thumb hover color +``` + +### Skeleton Loading + +``` +--skeleton-bg Base color for skeleton placeholders +--skeleton-shimmer Shimmer highlight overlay +``` + +### Text Highlight + +``` +--highlight-bg Search / code highlight background (mix of primary + white) +--highlight-text Text color on highlighted span (always #000000) +``` + +--- + +## Component Tokens + +Fixed-size shortcut tokens for common component patterns. + +``` +--control-height-sm 28px +--control-height-md 36px +--control-height-lg 44px +--button-padding-x var(--space-4) +--button-padding-y var(--space-2) +--button-radius var(--radius-md) +--input-padding-x var(--space-3) +--card-padding var(--space-4) +--modal-padding var(--space-6) +--avatar-size-sm 24px +--avatar-size-md 32px +--avatar-size-lg 48px +``` + +These reference spacing tokens (`--space-*`) and radius tokens (`--radius-*`) defined in the static token layer. + +--- + +## Color Derivation Rules + +Auto-derived colors when optional theme fields are omitted: + +| Rule | Formula | Description | +| ------------------------ | ------------------------- | ---------------------------------------------- | +| `secondary-from-primary` | `shiftHue(primary, 15)` | Secondary: primary hue rotated 15° | +| `accent-from-primary` | `shiftHue(primary, 180)` | Accent: complementary (opposite) hue | +| `neutral-from-primary` | `desaturate(primary, 80)` | Neutral/gray: primary desaturated by 80 points | + +--- + +## Tonal Surface System + +Surfaces are CSS classes (`
`) that **reset and selectively override** color group tokens for a scoped context. This ensures nested components pick up the right colors without explicit prop drilling. + +### How It Works + +The theme selector emits internal CSS variables (`--_0`, `--_1`, …) that store the original theme-level values for every color group token. Every `.surface` class resets all group tokens back to these internal variables, then a modifier class (`.raised`, `.primary`, etc.) applies its specific overrides. This means any depth of surface nesting stays isolated. + +``` +[data-theme='default'][data-mode='light'] { + --_0: #fafafa; /* base-bg original */ + --_1: #f0f0f0; /* base-bg-hover original */ + /* ... ~240 internal tokens ... */ + + --base-bg: var(--_0); + /* ... all public tokens ... */ + + & .surface { + --base-bg: var(--_0); /* always reset to theme level */ + & .raised { + --base-bg: #ffffff; /* override specific token */ + } + } +} +``` + +### Surface Modifiers + +| Modifier | Description | +| ---------- | ------------------------------------------------------------- | +| `base` | Explicit reset to page defaults (same as `.surface` alone) | +| `raised` | Elevated content — cards, panels — brighter in light mode | +| `sunken` | Recessed areas — input wells, sidebars — dimmer in light mode | +| `soft` | Subtle step toward white (light) / black (dark) | +| `softer` | Most subtle step toward white (light) / black (dark) | +| `strong` | Emphasized — darker (light) or lighter (dark) than page | +| `stronger` | Maximum tonal emphasis | +| `inverted` | Opposite color scheme — for tooltips, callouts | +| `primary` | Primary brand color background — for teaching bubbles, CTAs | + +Feedback modifiers map the feedback surface tokens onto `--base-*`: + +| Modifier | Maps to | +| --------- | ---------------------- | +| `success` | `--feedback-success-*` | +| `warning` | `--feedback-warning-*` | +| `danger` | `--feedback-danger-*` | +| `info` | `--feedback-info-*` | + +### Usage + +```html + +
+

Card title

+ + + +
+ + + + + + +``` + +### Accessibility Enforcement + +When the generator emits a surface class, it also computes per-surface overrides for all semantic color groups (`primary`, `success`, `warning`, `danger`, `info`). If the group's `bg` color has insufficient contrast (< 3:1) against the surface's `base-bg`, or if both colors share the same hue family, the generator flips the group's tokens to either white or near-black to restore legibility. + +--- + +## Accessibility Requirements + +WCAG contrast ratios enforced automatically by `generateThemeTokens` and `generateThemeCSS`. + +### AA Level (default) + +| Content type | Minimum ratio | +| ------------------- | ------------- | +| Normal text | 4.5 : 1 | +| Large text (18 px+) | 3.0 : 1 | +| UI components | 3.0 : 1 | + +### AAA Level (opt-in via `"accessibility": { "level": "AAA" }`) + +| Content type | Minimum ratio | +| ------------------- | ------------- | +| Normal text | 7.0 : 1 | +| Large text (18 px+) | 4.5 : 1 | +| UI components | 4.5 : 1 | + +> **Note:** The generator uses a slightly elevated target (5.5:1) for dark-mode foreground tokens because blues and greens can appear perceptually dim even at 4.5:1. + +--- + +## Theme Input Schema + +Defines what a theme JSON file must contain. The generator validates this at load time via `validateTheme()`. + +### Required Fields + +| Field | Type | Constraints | Description | +| ---------------- | ------ | --------------------- | ------------------------------ | +| `id` | string | `/^[a-z][a-z0-9-]*$/` | Unique slug, e.g. `"my-theme"` | +| `name` | string | — | Display name | +| `colors.primary` | string | hex color | Primary brand color | + +### Optional Fields + +| Field | Type | Description | +| ---------------------- | ------ | ------------------------------------------------------------------ | +| `description` | string | Human-readable description | +| `colors.secondary` | string | Secondary color (auto-derived: `shiftHue(primary, 15)` if omitted) | +| `colors.accent` | string | Accent color (auto-derived: `shiftHue(primary, 180)` if omitted) | +| `colors.neutral` | string | Neutral/gray (auto-derived: `desaturate(primary, 80)` if omitted) | +| `config.saturation` | number | Adjust all color saturations (−100 to +100) | +| `config.temperature` | number | Shift color warmth (positive = warmer, negative = cooler) | +| `typography.fontSans` | string | CSS font-family string for sans-serif stack | +| `typography.fontMono` | string | CSS font-family string for monospace stack | +| `typography.fontSerif` | string | CSS font-family string for serif stack | +| `typography.baseSize` | number | Base font size in pixels (default: 15) | +| `typography.scale` | number | Font size scale multiplier (default: 1.0) | +| `radii.scale` | number | Border radius multiplier (default: 1.0) | +| `radii.style` | enum | `"sharp"` \| `"subtle"` \| `"rounded"` \| `"pill"` | +| `accessibility.level` | enum | `"AA"` (default) \| `"AAA"` | +| `overrides.light` | object | Direct `--token: value` overrides for light mode | +| `overrides.dark` | object | Direct `--token: value` overrides for dark mode | + +### Minimal Example + +```json +{ + "id": "ocean", + "name": "Ocean", + "description": "Cool blue tones inspired by deep water", + "colors": { + "primary": "#0284c7" + }, + "accessibility": { "level": "AA" } +} +``` + +### Full Example + +```json +{ + "id": "ocean", + "name": "Ocean", + "description": "Cool blue tones inspired by deep water", + "colors": { + "primary": "#0284c7", + "secondary": "#0ea5e9", + "accent": "#6366f1", + "neutral": "#475569" + }, + "config": { + "saturation": 10, + "temperature": -15 + }, + "typography": { + "fontSans": "Inter, system-ui, sans-serif", + "fontMono": "JetBrains Mono, monospace", + "baseSize": 15 + }, + "radii": { + "style": "rounded", + "scale": 1.0 + }, + "accessibility": { "level": "AA" }, + "overrides": { + "light": { "--page-bg": "#f0f9ff" }, + "dark": { "--page-bg": "#082f49" } + } +} +``` + +--- + +## CSS Output Structure + +The generator (`generateThemeCSS`) wraps all tokens inside a scoped selector: + +```css +[data-theme='ocean'][data-mode='light'] { + /* Internal preservation tokens (short names) */ + --_0: ; + --_1: ; + /* ... ~240 internal tokens ... */ + + /* All public tokens */ + --base-bg: ; + --base-fg: ; + /* ... ~300+ tokens ... */ + + /* Base typography */ + & body { + font-family: var(--font-sans); + font-size: var(--text-base); + color: var(--base-fg); + background: var(--base-bg); + } + + /* Tonal surface classes */ + & .surface { … } + & .surface.raised { … } + & .surface.inverted { … } + & .surface.primary { … } + /* … etc. */ +} +``` + +Apply the theme by setting `data-theme` and `data-mode` on a root element: + +```html + + … + +``` + +Switch modes at runtime: + +```js +document.documentElement.setAttribute('data-mode', 'dark'); +``` diff --git a/packages/cap-foundations/core/src/themes/schema/theme-rules.json b/packages/cap-foundations/core/src/themes/schema/theme-rules.json new file mode 100644 index 000000000..93b756b3c --- /dev/null +++ b/packages/cap-foundations/core/src/themes/schema/theme-rules.json @@ -0,0 +1,734 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "version": "1.0.0", + "description": "UI Kit Theme Generation Rules - Defines all token roles, derivation rules, and color logic", + + "semanticColors": { + "description": "Fixed semantic colors that remain consistent across all themes for UX clarity", + "success": { + "base": "#16a34a", + "description": "Positive outcomes, confirmations, completion states" + }, + "warning": { + "base": "#f59e0b", + "description": "Caution states, attention needed, pending actions" + }, + "danger": { + "base": "#dc2626", + "description": "Errors, destructive actions, critical states" + }, + "info": { + "base": "theme:primary", + "description": "Informational - derived from theme primary for consistent branding" + } + }, + + "colorGroups": { + "description": "Color group system: 18 tokens per group. Pick a group for your background, use ONLY that group's fg tokens. Contrast is guaranteed.", + "tokenStructure": { + "description": "Each color group contains 18 tokens: 4 bg states, 4 border states, 10 fg variants", + "tokens": [ + "bg", "bg-hover", "bg-pressed", "bg-disabled", + "border", "border-hover", "border-pressed", "border-disabled", + "fg", "fg-soft", "fg-softer", "fg-strong", "fg-stronger", + "fg-primary", "fg-danger", "fg-success", "fg-warning", "fg-info" + ] + }, + "groups": { + "softer": { + "description": "Most subtle - closest to white (light) or black (dark), capped at 10%", + "derivation": { + "bg": { "light": "lighten(page.bg, 10)", "dark": "darken(page.bg, 10)" }, + "bg-hover": { "light": "lighten(page.bg, 6)", "dark": "darken(page.bg, 6)" }, + "bg-pressed": { "light": "lighten(page.bg, 3)", "dark": "darken(page.bg, 3)" }, + "bg-disabled": { "light": "lighten(page.bg, 8)", "dark": "darken(page.bg, 8)" }, + "border": { "light": "darken(page.bg, 10)", "dark": "lighten(page.bg, 16)" }, + "border-hover": { "light": "darken(page.bg, 14)", "dark": "lighten(page.bg, 20)" }, + "border-pressed": { "light": "darken(page.bg, 18)", "dark": "lighten(page.bg, 24)" }, + "border-disabled": { "light": "darken(page.bg, 6)", "dark": "lighten(page.bg, 10)" } + } + }, + "soft": { + "description": "Subtle - halfway toward white (light) or black (dark), capped at 5%", + "derivation": { + "bg": { "light": "lighten(page.bg, 5)", "dark": "darken(page.bg, 5)" }, + "bg-hover": { "light": "lighten(page.bg, 8)", "dark": "darken(page.bg, 2)" }, + "bg-pressed": { "light": "lighten(page.bg, 2)", "dark": "darken(page.bg, 8)" }, + "bg-disabled": { "light": "lighten(page.bg, 4)", "dark": "darken(page.bg, 4)" }, + "border": { "light": "darken(page.bg, 10)", "dark": "lighten(page.bg, 16)" }, + "border-hover": { "light": "darken(page.bg, 14)", "dark": "lighten(page.bg, 20)" }, + "border-pressed": { "light": "darken(page.bg, 18)", "dark": "lighten(page.bg, 24)" }, + "border-disabled": { "light": "darken(page.bg, 6)", "dark": "lighten(page.bg, 12)" } + } + }, + "base": { + "description": "Default page content, main surface", + "derivation": { + "bg": "page.bg", + "bg-hover": { "light": "darken(page.bg, 6)", "dark": "lighten(page.bg, 6)" }, + "bg-pressed": { "light": "darken(page.bg, 10)", "dark": "lighten(page.bg, 10)" }, + "bg-disabled": "page.bg", + "border": { "light": "darken(page.bg, 14)", "dark": "lighten(page.bg, 20)" }, + "border-hover": { "light": "darken(page.bg, 18)", "dark": "lighten(page.bg, 24)" }, + "border-pressed": { "light": "darken(page.bg, 22)", "dark": "lighten(page.bg, 28)" }, + "border-disabled": { "light": "darken(page.bg, 8)", "dark": "lighten(page.bg, 12)" } + } + }, + "strong": { + "description": "Emphasized - 10% darker (light) or lighter (dark) than base", + "derivation": { + "bg": { "light": "darken(page.bg, 10)", "dark": "lighten(page.bg, 10)" }, + "bg-hover": { "light": "darken(page.bg, 14)", "dark": "lighten(page.bg, 14)" }, + "bg-pressed": { "light": "darken(page.bg, 18)", "dark": "lighten(page.bg, 18)" }, + "bg-disabled": { "light": "darken(page.bg, 6)", "dark": "lighten(page.bg, 6)" }, + "border": { "light": "darken(page.bg, 16)", "dark": "lighten(page.bg, 20)" }, + "border-hover": { "light": "darken(page.bg, 20)", "dark": "lighten(page.bg, 24)" }, + "border-pressed": { "light": "darken(page.bg, 24)", "dark": "lighten(page.bg, 28)" }, + "border-disabled": { "light": "darken(page.bg, 10)", "dark": "lighten(page.bg, 14)" } + } + }, + "stronger": { + "description": "Maximum emphasis - 20% darker (light) or lighter (dark) than base", + "derivation": { + "bg": { "light": "darken(page.bg, 20)", "dark": "lighten(page.bg, 20)" }, + "bg-hover": { "light": "darken(page.bg, 24)", "dark": "lighten(page.bg, 24)" }, + "bg-pressed": { "light": "darken(page.bg, 28)", "dark": "lighten(page.bg, 28)" }, + "bg-disabled": { "light": "darken(page.bg, 12)", "dark": "lighten(page.bg, 12)" }, + "border": { "light": "darken(page.bg, 24)", "dark": "lighten(page.bg, 28)" }, + "border-hover": { "light": "darken(page.bg, 28)", "dark": "lighten(page.bg, 32)" }, + "border-pressed": { "light": "darken(page.bg, 32)", "dark": "lighten(page.bg, 36)" }, + "border-disabled": { "light": "darken(page.bg, 16)", "dark": "lighten(page.bg, 18)" } + } + }, + "primary": { + "description": "Selection, active states, branded elements", + "derivation": { + "bg": "theme:primary", + "bg-hover": { "light": "darken(theme:primary, 8)", "dark": "lighten(theme:primary, 8)" }, + "bg-pressed": { "light": "darken(theme:primary, 12)", "dark": "lighten(theme:primary, 12)" }, + "bg-disabled": { "light": "mix(theme:primary, page.bg, 0.5)", "dark": "mix(theme:primary, page.bg, 0.5)" }, + "border": { "light": "rgba(0, 0, 0, 0.1)", "dark": "rgba(0, 0, 0, 0.15)" }, + "border-hover": { "light": "rgba(0, 0, 0, 0.15)", "dark": "rgba(0, 0, 0, 0.2)" }, + "border-pressed": { "light": "rgba(0, 0, 0, 0.2)", "dark": "rgba(0, 0, 0, 0.25)" }, + "border-disabled": { "light": "rgba(0, 0, 0, 0.05)", "dark": "rgba(0, 0, 0, 0.08)" }, + "fg": "contrastOpacity(bg, 92)", + "fg-soft": "contrastOpacity(bg, 80)", + "fg-softer": "contrastOpacity(bg, 65)", + "fg-strong": "contrastOpacity(bg, 97)", + "fg-stronger": "contrast(bg)", + "fg-primary": "contrast(bg)", + "fg-danger": { "light": "#fca5a5", "dark": "#7f1d1d" }, + "fg-success": { "light": "#86efac", "dark": "#14532d" }, + "fg-warning": { "light": "#fcd34d", "dark": "#78350f" }, + "fg-info": { "light": "#7dd3fc", "dark": "#0c4a6e" } + } + }, + "inverted": { + "description": "Opposite color scheme (tooltips)", + "defaults": { + "light": { + "bg": "#1a1a1a", + "bg-hover": "#262626", + "bg-pressed": "#333333", + "bg-disabled": "#1f1f1f", + "border": "#333333", + "border-hover": "#404040", + "border-pressed": "#4d4d4d", + "border-disabled": "#262626", + "fg": "#e5e5e5", + "fg-soft": "#a3a3a3", + "fg-softer": "#737373", + "fg-strong": "#f0f0f0", + "fg-stronger": "#ffffff", + "fg-primary": "#60a5fa", + "fg-danger": "#f87171", + "fg-success": "#4ade80", + "fg-warning": "#fbbf24", + "fg-info": "#38bdf8" + }, + "dark": { + "bg": "#fafafa", + "bg-hover": "#f0f0f0", + "bg-pressed": "#e5e5e5", + "bg-disabled": "#f5f5f5", + "border": "#e5e5e5", + "border-hover": "#d4d4d4", + "border-pressed": "#c4c4c4", + "border-disabled": "#f0f0f0", + "fg": "#171717", + "fg-soft": "#5b5b5b", + "fg-softer": "#898989", + "fg-strong": "#0f0f0f", + "fg-stronger": "#000000", + "fg-primary": "#2563eb", + "fg-danger": "#dc2626", + "fg-success": "#16a34a", + "fg-warning": "#d97706", + "fg-info": "#0284c7" + } + } + }, + "success": { + "description": "Success color group - SOLID green for buttons and actions", + "derivation": { + "bg": "semantic:success", + "bg-hover": { "light": "darken(semantic:success, 8)", "dark": "lighten(semantic:success, 8)" }, + "bg-pressed": { "light": "darken(semantic:success, 12)", "dark": "lighten(semantic:success, 12)" }, + "bg-disabled": { "light": "mix(semantic:success, page.bg, 0.5)", "dark": "mix(semantic:success, page.bg, 0.5)" }, + "border": { "light": "rgba(0, 0, 0, 0.1)", "dark": "rgba(0, 0, 0, 0.15)" }, + "border-hover": { "light": "rgba(0, 0, 0, 0.15)", "dark": "rgba(0, 0, 0, 0.2)" }, + "border-pressed": { "light": "rgba(0, 0, 0, 0.2)", "dark": "rgba(0, 0, 0, 0.25)" }, + "border-disabled": { "light": "rgba(0, 0, 0, 0.05)", "dark": "rgba(0, 0, 0, 0.08)" }, + "fg": "contrastOpacity(bg, 92)", + "fg-soft": "contrastOpacity(bg, 80)", + "fg-softer": "contrastOpacity(bg, 65)", + "fg-strong": "contrastOpacity(bg, 97)", + "fg-stronger": "contrast(bg)" + } + }, + "warning": { + "description": "Warning color group - SOLID amber for buttons and actions", + "derivation": { + "bg": "semantic:warning", + "bg-hover": { "light": "darken(semantic:warning, 8)", "dark": "lighten(semantic:warning, 8)" }, + "bg-pressed": { "light": "darken(semantic:warning, 12)", "dark": "lighten(semantic:warning, 12)" }, + "bg-disabled": { "light": "mix(semantic:warning, page.bg, 0.5)", "dark": "mix(semantic:warning, page.bg, 0.5)" }, + "border": { "light": "rgba(0, 0, 0, 0.1)", "dark": "rgba(0, 0, 0, 0.15)" }, + "border-hover": { "light": "rgba(0, 0, 0, 0.15)", "dark": "rgba(0, 0, 0, 0.2)" }, + "border-pressed": { "light": "rgba(0, 0, 0, 0.2)", "dark": "rgba(0, 0, 0, 0.25)" }, + "border-disabled": { "light": "rgba(0, 0, 0, 0.05)", "dark": "rgba(0, 0, 0, 0.08)" }, + "fg": "contrastOpacity(bg, 92)", + "fg-soft": "contrastOpacity(bg, 80)", + "fg-softer": "contrastOpacity(bg, 65)", + "fg-strong": "contrastOpacity(bg, 97)", + "fg-stronger": "contrast(bg)" + } + }, + "danger": { + "description": "Danger color group - SOLID red for buttons and destructive actions", + "derivation": { + "bg": "semantic:danger", + "bg-hover": { "light": "darken(semantic:danger, 8)", "dark": "lighten(semantic:danger, 8)" }, + "bg-pressed": { "light": "darken(semantic:danger, 12)", "dark": "lighten(semantic:danger, 12)" }, + "bg-disabled": { "light": "mix(semantic:danger, page.bg, 0.5)", "dark": "mix(semantic:danger, page.bg, 0.5)" }, + "border": { "light": "rgba(0, 0, 0, 0.1)", "dark": "rgba(0, 0, 0, 0.15)" }, + "border-hover": { "light": "rgba(0, 0, 0, 0.15)", "dark": "rgba(0, 0, 0, 0.2)" }, + "border-pressed": { "light": "rgba(0, 0, 0, 0.2)", "dark": "rgba(0, 0, 0, 0.25)" }, + "border-disabled": { "light": "rgba(0, 0, 0, 0.05)", "dark": "rgba(0, 0, 0, 0.08)" }, + "fg": "contrastOpacity(bg, 92)", + "fg-soft": "contrastOpacity(bg, 80)", + "fg-softer": "contrastOpacity(bg, 65)", + "fg-strong": "contrastOpacity(bg, 97)", + "fg-stronger": "contrast(bg)" + } + }, + "info": { + "description": "Info color group - SOLID primary for buttons and informational actions", + "derivation": { + "bg": "theme:primary", + "bg-hover": { "light": "darken(theme:primary, 8)", "dark": "lighten(theme:primary, 8)" }, + "bg-pressed": { "light": "darken(theme:primary, 12)", "dark": "lighten(theme:primary, 12)" }, + "bg-disabled": { "light": "mix(theme:primary, page.bg, 0.5)", "dark": "mix(theme:primary, page.bg, 0.5)" }, + "border": { "light": "rgba(0, 0, 0, 0.1)", "dark": "rgba(0, 0, 0, 0.15)" }, + "border-hover": { "light": "rgba(0, 0, 0, 0.15)", "dark": "rgba(0, 0, 0, 0.2)" }, + "border-pressed": { "light": "rgba(0, 0, 0, 0.2)", "dark": "rgba(0, 0, 0, 0.25)" }, + "border-disabled": { "light": "rgba(0, 0, 0, 0.05)", "dark": "rgba(0, 0, 0, 0.08)" }, + "fg": "contrastOpacity(bg, 92)", + "fg-soft": "contrastOpacity(bg, 80)", + "fg-softer": "contrastOpacity(bg, 65)", + "fg-strong": "contrastOpacity(bg, 97)", + "fg-stronger": "contrast(bg)" + } + } + } + }, + + "feedbackSurfaces": { + "description": "Feedback surface system - light tinted backgrounds for alerts/notifications. Foreground colors are computed to ensure AA contrast (4.5:1) on the computed background.", + "surfaces": { + "feedback-success": { + "description": "Success feedback surface - subtle green tint for alerts", + "derivation": { + "bg": { "light": "mix(page.bg, semantic:success, 0.12)", "dark": "mix(page.bg, semantic:success, 0.18)" }, + "bg-hover": { "light": "mix(page.bg, semantic:success, 0.18)", "dark": "mix(page.bg, semantic:success, 0.25)" }, + "fg": "accessibleColor(semantic:success, bg)", + "border": { "light": "mix(page.bg, semantic:success, 0.35)", "dark": "mix(page.bg, semantic:success, 0.45)" } + } + }, + "feedback-warning": { + "description": "Warning feedback surface - subtle amber tint for alerts", + "derivation": { + "bg": { "light": "mix(page.bg, semantic:warning, 0.12)", "dark": "mix(page.bg, semantic:warning, 0.18)" }, + "bg-hover": { "light": "mix(page.bg, semantic:warning, 0.18)", "dark": "mix(page.bg, semantic:warning, 0.25)" }, + "fg": "accessibleColor(semantic:warning, bg)", + "border": { "light": "mix(page.bg, semantic:warning, 0.35)", "dark": "mix(page.bg, semantic:warning, 0.45)" } + } + }, + "feedback-danger": { + "description": "Danger feedback surface - subtle red tint for alerts", + "derivation": { + "bg": { "light": "mix(page.bg, semantic:danger, 0.12)", "dark": "mix(page.bg, semantic:danger, 0.18)" }, + "bg-hover": { "light": "mix(page.bg, semantic:danger, 0.18)", "dark": "mix(page.bg, semantic:danger, 0.25)" }, + "fg": "accessibleColor(semantic:danger, bg)", + "border": { "light": "mix(page.bg, semantic:danger, 0.35)", "dark": "mix(page.bg, semantic:danger, 0.45)" } + } + }, + "feedback-info": { + "description": "Info feedback surface - subtle primary tint for alerts", + "derivation": { + "bg": { "light": "mix(page.bg, theme:primary, 0.12)", "dark": "mix(page.bg, theme:primary, 0.18)" }, + "bg-hover": { "light": "mix(page.bg, theme:primary, 0.18)", "dark": "mix(page.bg, theme:primary, 0.25)" }, + "fg": "accessibleColor(theme:primary, bg)", + "border": { "light": "mix(page.bg, theme:primary, 0.35)", "dark": "mix(page.bg, theme:primary, 0.45)" } + } + } + } + }, + + "specialTokens": { + "focus": { + "description": "Focus ring for accessibility", + "tokens": { + "ring": { "derivation": "theme:primary" }, + "ring-offset": { "default": "2px" }, + "ring-width": { "default": "2px" } + } + }, + "selection": { + "description": "Text selection highlight - uses primary-based colors visible on all surfaces", + "tokens": { + "bg": { "derivation": { "light": "mix(theme:primary, #ffffff, 0.7)", "dark": "mix(theme:primary, #ffffff, 0.5)" } }, + "text": { "default": { "light": "#000000", "dark": "#000000" } } + } + }, + "link": { + "description": "Hyperlink colors", + "tokens": { + "default": { "derivation": { "light": "theme:primary", "dark": "lighten(theme:primary, 20)" } }, + "hover": { "derivation": { "light": "darken(theme:primary, 10)", "dark": "lighten(theme:primary, 28)" } }, + "pressed": { "derivation": { "light": "darken(theme:primary, 15)", "dark": "lighten(theme:primary, 35)" } }, + "visited": { "derivation": { "light": "mix(theme:primary, #8b5cf6, 0.5)", "dark": "lighten(mix(theme:primary, #8b5cf6, 0.5), 15)" } } + } + }, + "scrollbar": { + "description": "Custom scrollbar styling", + "tokens": { + "track": { "default": { "light": "#f5f5f5", "dark": "#1a1a1a" } }, + "thumb": { "default": { "light": "#c4c4c4", "dark": "#404040" } }, + "thumb-hover": { "default": { "light": "#a3a3a3", "dark": "#525252" } } + } + }, + "skeleton": { + "description": "Loading skeleton states", + "tokens": { + "bg": { "default": { "light": "#e5e5e5", "dark": "#333333" } }, + "shimmer": { "default": { "light": "rgba(255, 255, 255, 0.5)", "dark": "rgba(255, 255, 255, 0.1)" } } + } + }, + "highlight": { + "description": "Search/text highlight - uses primary-based colors visible on all surfaces", + "tokens": { + "bg": { "derivation": { "light": "mix(theme:primary, #ffffff, 0.8)", "dark": "mix(theme:primary, #ffffff, 0.6)" } }, + "text": { "default": { "light": "#000000", "dark": "#000000" } } + } + } + }, + + "componentTokens": { + "description": "Shortcut tokens for common component patterns", + "tokens": { + "control-height-sm": { "default": "28px" }, + "control-height-md": { "default": "36px" }, + "control-height-lg": { "default": "44px" }, + "button-padding-x": { "default": "var(--space-4)" }, + "button-padding-y": { "default": "var(--space-2)" }, + "button-radius": { "default": "var(--radius-md)" }, + "input-padding-x": { "default": "var(--space-3)" }, + "card-padding": { "default": "var(--space-4)" }, + "modal-padding": { "default": "var(--space-6)" }, + "avatar-size-sm": { "default": "24px" }, + "avatar-size-md": { "default": "32px" }, + "avatar-size-lg": { "default": "48px" } + } + }, + + "colorDerivation": { + "description": "Rules for deriving colors from theme inputs", + "rules": { + "text-soft": { + "formula": "mix(text, bg, 0.3)", + "description": "30% less contrast than base text" + }, + "text-softer": { + "formula": "mix(text, bg, 0.5)", + "description": "50% less contrast than base text" + }, + "text-strong": { + "formula": "mix(text, contrast(bg), 0.3)", + "description": "Higher contrast text (30% toward maximum)" + }, + "text-stronger": { + "formula": { "light": "#000000", "dark": "#ffffff" }, + "description": "Maximum contrast text" + }, + "border-soft": { + "formula": { "light": "mix(page.border, page.bg, 0.4)", "dark": "mix(page.border, page.bg, 0.4)" }, + "description": "Subtle border - less contrast than default" + }, + "border-strong": { + "formula": { "light": "darken(page.border, 20)", "dark": "lighten(page.border, 20)" }, + "description": "Higher contrast border" + }, + "border-stronger": { + "formula": { "light": "mix(page.border, #000000, 0.5)", "dark": "mix(page.border, #ffffff, 0.5)" }, + "description": "Maximum contrast border" + }, + "border-default": { + "formula": { "light": "#e5e5e5", "dark": "#333333" }, + "description": "Subtle border color" + }, + "secondary-from-primary": { + "formula": "shiftHue(primary, 15)", + "description": "Secondary derived by shifting primary hue 15 degrees" + }, + "accent-from-primary": { + "formula": "shiftHue(primary, 180)", + "description": "Accent as complementary color to primary" + }, + "neutral-from-primary": { + "formula": "desaturate(primary, 80)", + "description": "Neutral derived by desaturating primary" + } + } + }, + + "surfaces": { + "description": "Tonal surface definitions - CSS classes that scope/override color group tokens for different visual contexts", + "pattern": { + "description": "Surfaces override color group tokens (--base-*, --soft-*, etc.) so components inside get contextual colors automatically.", + "baseClass": ".surface", + "modifierClasses": ".surface.{variant}" + }, + "types": { + "base": { + "description": "Explicit reset to page defaults", + "overrides": {} + }, + "raised": { + "description": "Elevated content (cards, panels) - BRIGHTER, closer to light source (user)", + "overrides": { + "light": { + "base-bg": "#ffffff", + "base-border": "#e5e5e5", + "shadow-md": "0 4px 6px -1px rgba(0, 0, 0, 0.1)" + }, + "dark": { + "base-bg": "#1a1a1a", + "base-border": "#333333", + "shadow-md": "0 4px 6px -1px rgba(0, 0, 0, 0.4)" + } + } + }, + "sunken": { + "description": "Recessed areas (input wells, sidebars) - DIMMER, farther from light source", + "overrides": { + "light": { + "base-bg": "#f0f0f0", + "base-border": "#e5e5e5", + "strong-bg": "#e5e5e5", + "strong-bg-hover": "#d9d9d9", + "strong-bg-pressed": "#cccccc" + }, + "dark": { + "base-bg": "#080808", + "base-border": "#1a1a1a", + "strong-bg": "#141414", + "strong-bg-hover": "#1a1a1a", + "strong-bg-pressed": "#222222" + } + } + }, + "soft": { + "description": "Subtle - closer to target (white in light, black in dark)", + "overrides": { + "light": { + "base-bg": "#fcfcfc" + }, + "dark": { + "base-bg": "#0a0a0a" + } + } + }, + "softer": { + "description": "Most subtle - even closer to target (white in light, black in dark)", + "overrides": { + "light": { + "base-bg": "#fefefe" + }, + "dark": { + "base-bg": "#050505" + } + } + }, + "strong": { + "description": "Emphasized - darker (light) or lighter (dark) than page", + "overrides": { + "light": { + "base-bg": "#ebebeb", + "stronger-bg": "#e0e0e0", + "stronger-bg-hover": "#d5d5d5", + "stronger-bg-pressed": "#cacaca" + }, + "dark": { + "base-bg": "#1f1f1f", + "stronger-bg": "#2a2a2a", + "stronger-bg-hover": "#333333", + "stronger-bg-pressed": "#3d3d3d" + } + } + }, + "stronger": { + "description": "Maximum emphasis - even more contrast from page", + "overrides": { + "light": { + "base-bg": "#dedede" + }, + "dark": { + "base-bg": "#2a2a2a" + } + } + }, + "inverted": { + "description": "Opposite color scheme (tooltips, callouts)", + "overrides": { + "light": { + "base-bg": "#1a1a1a", + "base-bg-hover": "#262626", + "base-bg-pressed": "#333333", + "base-fg": "#e5e5e5", + "base-fg-soft": "#a3a3a3", + "base-fg-softer": "#737373", + "base-fg-strong": "#f0f0f0", + "base-fg-stronger": "#ffffff", + "base-border": "#333333", + "base-border-hover": "#4d4d4d", + "base-border-pressed": "#666666", + "strong-bg": "#333333", + "strong-bg-hover": "#404040", + "strong-bg-pressed": "#4d4d4d", + "strong-fg": "#e5e5e5", + "strong-border": "#4d4d4d", + "primary-bg": "#2563eb", + "primary-bg-hover": "#1d4ed8", + "primary-bg-pressed": "#1e40af", + "primary-bg-disabled": "#6b8dd6", + "primary-fg": "derive:contrast(primary-bg)", + "primary-fg-soft": "derive:contrastOpacity(primary-bg, 85)", + "primary-fg-softer": "derive:contrastOpacity(primary-bg, 70)", + "primary-border": "transparent", + "primary-border-hover": "derive:contrastOpacity(primary-bg, 20)", + "primary-border-pressed": "derive:contrastOpacity(primary-bg, 30)", + "focus-ring": "#60a5fa", + "selection-bg": "#4b5563", + "selection-text": "#ffffff" + }, + "dark": { + "base-bg": "#fafafa", + "base-bg-hover": "#f5f5f5", + "base-bg-pressed": "#f0f0f0", + "base-fg": "#171717", + "base-fg-soft": "#5b5b5b", + "base-fg-softer": "#898989", + "base-fg-strong": "#0f0f0f", + "base-fg-stronger": "#000000", + "base-border": "#e5e5e5", + "base-border-hover": "#d9d9d9", + "base-border-pressed": "#c4c4c4", + "primary-bg": "#2563eb", + "primary-bg-hover": "#1450d2", + "primary-bg-pressed": "#1249bf", + "primary-bg-disabled": "#90aff3", + "primary-fg": "derive:contrast(primary-bg)", + "primary-fg-soft": "derive:contrastOpacity(primary-bg, 85)", + "primary-fg-softer": "derive:contrastOpacity(primary-bg, 70)", + "primary-border": "transparent", + "primary-border-hover": "derive:contrastOpacity(primary-bg, 20)", + "primary-border-pressed": "derive:contrastOpacity(primary-bg, 30)", + "strong-bg": "#e0e0e0", + "strong-bg-hover": "#d6d6d6", + "strong-bg-pressed": "#cccccc", + "strong-bg-disabled": "#ebebeb", + "strong-fg": "#000000", + "strong-fg-soft": "#434343", + "strong-border": "#d1d1d1", + "strong-border-hover": "#c7c7c7", + "strong-border-pressed": "#bdbdbd", + "softer-bg": "#f0f0f0", + "softer-bg-hover": "#ebebeb", + "softer-bg-pressed": "#e6e6e6", + "softer-bg-disabled": "#f5f5f5", + "softer-fg": "#000000", + "softer-fg-soft": "#484848", + "softer-fg-softer": "#787878", + "softer-border": "#dbdbdb", + "softer-border-hover": "#d1d1d1", + "softer-border-pressed": "#c7c7c7", + "soft-bg": "#f5f5f5", + "soft-bg-hover": "#f0f0f0", + "soft-bg-pressed": "#ebebeb", + "soft-fg": "#171717", + "soft-border": "#e5e5e5", + "focus-ring": "#2563eb", + "selection-bg": "#bed0f9", + "selection-text": "#000000" + } + } + }, + "primary": { + "description": "Primary color background (teaching bubbles, branded sections)", + "note": "On primary surfaces, buttons use inverted colors for contrast", + "overrides": { + "light": { + "base-bg": "#2563eb", + "base-bg-hover": "#1d4ed8", + "base-bg-pressed": "#1e40af", + "base-fg": "derive:contrast(base-bg)", + "base-fg-soft": "derive:contrastOpacity(base-bg, 85)", + "base-fg-softer": "derive:contrastOpacity(base-bg, 70)", + "base-border": "derive:contrastOpacity(base-bg, 20)", + "primary-bg": "#ffffff", + "primary-bg-hover": "rgba(255, 255, 255, 0.9)", + "primary-bg-pressed": "rgba(255, 255, 255, 0.8)", + "primary-fg": "derive:contrast(primary-bg)", + "primary-fg-soft": "derive:contrastOpacity(primary-bg, 85)", + "primary-fg-softer": "derive:contrastOpacity(primary-bg, 70)", + "primary-border": "derive:contrastOpacity(primary-bg, 30)", + "softer-bg": "rgba(0, 0, 0, 0.15)", + "softer-bg-hover": "rgba(0, 0, 0, 0.2)", + "softer-bg-pressed": "rgba(0, 0, 0, 0.25)", + "softer-border": "derive:contrastOpacity(softer-bg, 20)", + "softer-fg": "derive:contrast(softer-bg)", + "softer-fg-soft": "derive:contrastOpacity(softer-bg, 70)", + "shadow-inner": "inset 0 1px 2px rgba(0, 0, 0, 0.2)", + "soft-bg": "rgba(255, 255, 255, 0.15)", + "soft-bg-hover": "rgba(255, 255, 255, 0.25)", + "soft-fg": "derive:contrast(soft-bg)", + "soft-border": "derive:contrastOpacity(soft-bg, 20)", + "strong-bg": "rgba(255, 255, 255, 0.2)", + "strong-bg-hover": "rgba(255, 255, 255, 0.3)", + "strong-bg-pressed": "rgba(255, 255, 255, 0.4)", + "strong-fg": "derive:contrast(strong-bg)", + "strong-border": "derive:contrastOpacity(strong-bg, 30)", + "base-bg-disabled": "rgba(255, 255, 255, 0.1)", + "base-border-soft": "derive:contrastOpacity(base-bg, 20)", + "focus-ring": "#ffffff", + "selection-bg": "rgba(255, 255, 255, 0.4)", + "selection-text": "#000000" + }, + "dark": { + "base-bg": "#2563eb", + "base-bg-hover": "#1d4ed8", + "base-bg-pressed": "#1e40af", + "base-fg": "derive:contrast(base-bg)", + "base-fg-soft": "derive:contrastOpacity(base-bg, 85)", + "base-fg-softer": "derive:contrastOpacity(base-bg, 70)", + "base-border": "derive:contrastOpacity(base-bg, 20)", + "primary-bg": "#ffffff", + "primary-bg-hover": "rgba(255, 255, 255, 0.9)", + "primary-bg-pressed": "rgba(255, 255, 255, 0.8)", + "primary-fg": "derive:contrast(primary-bg)", + "primary-fg-soft": "derive:contrastOpacity(primary-bg, 85)", + "primary-fg-softer": "derive:contrastOpacity(primary-bg, 70)", + "primary-border": "derive:contrastOpacity(primary-bg, 30)", + "softer-bg": "rgba(0, 0, 0, 0.2)", + "softer-bg-hover": "rgba(0, 0, 0, 0.25)", + "softer-bg-pressed": "rgba(0, 0, 0, 0.3)", + "softer-border": "derive:contrastOpacity(softer-bg, 15)", + "softer-fg": "derive:contrast(softer-bg)", + "softer-fg-soft": "derive:contrastOpacity(softer-bg, 70)", + "shadow-inner": "inset 0 1px 2px rgba(0, 0, 0, 0.25)", + "soft-bg": "rgba(255, 255, 255, 0.1)", + "soft-bg-hover": "rgba(255, 255, 255, 0.2)", + "soft-fg": "derive:contrast(soft-bg)", + "soft-border": "derive:contrastOpacity(soft-bg, 20)", + "strong-bg": "rgba(255, 255, 255, 0.1)", + "strong-bg-hover": "rgba(255, 255, 255, 0.25)", + "strong-bg-pressed": "rgba(255, 255, 255, 0.35)", + "strong-fg": "derive:contrast(strong-bg)", + "strong-border": "derive:contrastOpacity(strong-bg, 25)", + "base-bg-disabled": "rgba(255, 255, 255, 0.1)", + "focus-ring": "#ffffff", + "selection-bg": "rgba(255, 255, 255, 0.4)", + "selection-text": "#000000" + } + } + } + }, + "feedbackSurfaces": { + "description": "Feedback surfaces use the existing feedback role tokens", + "note": "success, warning, danger, info surfaces apply their role tokens as surface overrides" + } + }, + + "accessibility": { + "contrastRequirements": { + "AA": { + "normalText": 4.5, + "largeText": 3, + "uiComponents": 3 + }, + "AAA": { + "normalText": 7, + "largeText": 4.5, + "uiComponents": 4.5 + } + } + }, + + "themeInputSchema": { + "description": "Schema for theme JSON files", + "required": ["id", "name", "colors"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "colors": { + "required": ["primary"], + "properties": { + "primary": { "type": "string", "format": "color" }, + "secondary": { "type": "string", "format": "color", "description": "Optional, derived from primary if omitted" }, + "accent": { "type": "string", "format": "color", "description": "Optional, derived from primary if omitted" }, + "neutral": { "type": "string", "format": "color", "description": "Optional, derived from primary if omitted" } + } + }, + "config": { + "properties": { + "saturation": { "type": "number", "minimum": -100, "maximum": 100 }, + "temperature": { "type": "number", "minimum": -100, "maximum": 100 }, + "contrastBoost": { "type": "number", "minimum": 0, "maximum": 100 } + } + }, + "accessibility": { + "properties": { + "level": { "enum": ["AA", "AAA"] } + } + }, + "typography": { + "properties": { + "fontSans": { "type": "string" }, + "fontMono": { "type": "string" }, + "fontSerif": { "type": "string" }, + "scale": { "type": "number" }, + "baseSize": { "type": "number" } + } + }, + "overrides": { + "description": "Direct token overrides for specific modes", + "properties": { + "light": { "type": "object", "additionalProperties": { "type": "string" } }, + "dark": { "type": "object", "additionalProperties": { "type": "string" } } + } + } + } + } +} diff --git a/packages/cap-foundations/core/src/themes/theme-definition.md b/packages/cap-foundations/core/src/themes/theme-definition.md new file mode 100644 index 000000000..d180ce07b --- /dev/null +++ b/packages/cap-foundations/core/src/themes/theme-definition.md @@ -0,0 +1,682 @@ +# Theme Definition Guide + +This document describes how to create theme JSON files for `@fluentui-contrib/cap-foundations-core`. Themes are defined as JSON files in `src/themes/definitions/` and processed by the generator to produce CSS. + +## Quick Start + +Minimal theme definition: + +```json +{ + "id": "my-theme", + "name": "My Theme", + "colors": { + "primary": "#3b82f6" + } +} +``` + +That's it! The generator derives all other colors and tokens automatically. + +--- + +## Complete Theme Structure + +```json +{ + "id": "my-theme", + "name": "My Theme", + "description": "A custom theme with blue primary color", + + "colors": { + "primary": "#3b82f6", + "secondary": "#6366f1", + "accent": "#f59e0b", + "neutral": "#64748b" + }, + + "config": { + "saturation": 0, + "temperature": 0, + "contrastBoost": 0 + }, + + "typography": { + "fontSans": "'Inter', sans-serif", + "fontMono": "'JetBrains Mono', monospace", + "fontSerif": "'Merriweather', serif", + "scale": 1.0, + "baseSize": 15 + }, + + "spacing": { + "scale": 1.0, + "baseUnit": 4 + }, + + "radii": { + "scale": 1.0, + "style": "rounded" + }, + + "animation": { + "scale": 1.0, + "reduceMotion": false + }, + + "accessibility": { + "level": "AA" + }, + + "overrides": { + "light": { + "--base-bg": "#fafafa" + }, + "dark": { + "--base-bg": "#0a0a0a" + } + } +} +``` + +--- + +## Property Reference + +### `id` (required) + +Unique identifier for the theme. Used in CSS selectors and generated file names. + +- **Type:** `string` +- **Pattern:** `^[a-z][a-z0-9-]*$` (lowercase letters and hyphens only) +- **Examples:** `"default"`, `"high-contrast"`, `"ocean-breeze"` + +```json +"id": "ocean-breeze" +``` + +### `name` (required) + +Human-readable display name shown in UI and the manifest. + +- **Type:** `string` +- **Examples:** `"Ocean Breeze"`, `"High Contrast"`, `"GitHub Dark"` + +```json +"name": "Ocean Breeze" +``` + +### `description` (optional) + +Brief description of the theme's visual style. Included in `manifest.json`. + +- **Type:** `string` + +```json +"description": "Cool blues and aquatic tones inspired by the ocean" +``` + +--- + +## Colors + +### `colors.primary` (required) + +The main brand color. This is the **only required color** — all others can be derived automatically. + +- **Type:** `string` (hex, e.g. `"#2563eb"`) +- **Used for:** Primary buttons (`--primary-bg`), links (`--link`), focus rings (`--focus-ring`), selected states + +```json +"colors": { + "primary": "#2563eb" +} +``` + +### `colors.secondary` (optional) + +Secondary brand color for complementary UI elements. + +- **Type:** `string` (hex) +- **Default:** Derived by shifting the primary hue 15°: `shiftHue(primary, 15)` +- **Used for:** Complementary accents, secondary highlights + +```json +"colors": { + "primary": "#2563eb", + "secondary": "#7c3aed" +} +``` + +### `colors.accent` (optional) + +Accent/pop color for special callouts and badges. + +- **Type:** `string` (hex) +- **Default:** Derived as the complementary color: `shiftHue(primary, 180)` +- **Used for:** Highlights, badges, Copilot flair gradients + +```json +"colors": { + "primary": "#2563eb", + "accent": "#f59e0b" +} +``` + +### `colors.neutral` (optional) + +Base neutral/gray for UI chrome. + +- **Type:** `string` (hex) +- **Default:** Derived by heavily desaturating primary: `desaturate(primary, 80)` +- **Used for:** Borders, disabled states, muted text + +```json +"colors": { + "primary": "#2563eb", + "neutral": "#64748b" +} +``` + +--- + +## Config (Color Adjustments) + +Fine-tune how the primary color palette is processed before tokens are generated. + +### `config.saturation` + +Adjust the saturation of all derived colors. + +- **Type:** `number` +- **Range:** `−100` to `100` +- **Default:** `0` +- **Effect:** Positive → more vibrant. Negative → more muted. + +```json +"config": { "saturation": 20 } +``` + +### `config.temperature` + +Shift color temperature across the palette. + +- **Type:** `number` +- **Range:** `−100` to `100` +- **Default:** `0` +- **Effect:** Positive → warmer (red/yellow). Negative → cooler (blue). + +```json +"config": { "temperature": -15 } +``` + +### `config.contrastBoost` + +Increase contrast between foreground and background tokens. + +- **Type:** `number` +- **Range:** `0` to `100` +- **Default:** `0` +- **Effect:** Higher values push foreground colors harder toward black (light) or white (dark). + +```json +"config": { "contrastBoost": 20 } +``` + +--- + +## Typography + +Customize the font stack and type scale. + +### `typography.fontSans` + +Primary sans-serif font stack. + +- **Type:** `string` +- **Default:** `'Segoe UI Web', 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif` + +```json +"typography": { "fontSans": "'Inter', -apple-system, sans-serif" } +``` + +### `typography.fontMono` + +Monospace font stack for code and pre-formatted text. + +- **Type:** `string` +- **Default:** `'JetBrains Mono', 'Fira Code', 'Consolas', monospace` + +```json +"typography": { "fontMono": "'Fira Code', monospace" } +``` + +### `typography.fontSerif` + +Serif font stack for editorial/display content. + +- **Type:** `string` +- **Default:** `'Merriweather', Georgia, 'Times New Roman', serif` + +```json +"typography": { "fontSerif": "'Playfair Display', Georgia, serif" } +``` + +### `typography.scale` + +Multiplier applied to every generated font-size token. + +- **Type:** `number` +- **Default:** `1.0` +- **Effect:** `0.9` = 90% of defaults, `1.1` = 110%. + +```json +"typography": { "scale": 1.1 } +``` + +### `typography.baseSize` + +Root font size in pixels from which the type scale is calculated. + +- **Type:** `number` +- **Default:** `15` + +```json +"typography": { "baseSize": 16 } +``` + +--- + +## Spacing + +Customize the spacing scale. + +### `spacing.scale` + +Multiplier applied to all `--space-*` tokens. + +- **Type:** `number` +- **Default:** `1.0` +- **Effect:** `0.8` = compact, `1.2` = spacious. + +```json +"spacing": { "scale": 0.9 } +``` + +### `spacing.baseUnit` + +Base grid unit in pixels (all spacing tokens are multiples of this). + +- **Type:** `number` +- **Default:** `4` + +```json +"spacing": { "baseUnit": 4 } +``` + +--- + +## Border Radius + +### `radii.scale` + +Multiplier applied to all `--radius-*` tokens. + +- **Type:** `number` +- **Default:** `1.0` + +```json +"radii": { "scale": 1.5 } +``` + +### `radii.style` + +Preset border-radius personality. + +- **Type:** `"sharp"` | `"subtle"` | `"rounded"` | `"pill"` +- **Default:** `"rounded"` + +| Style | Effect | +| --------- | ----------------------------- | +| `sharp` | No rounding (0 px) | +| `subtle` | Minimal rounding (2 px base) | +| `rounded` | Standard rounding (4 px base) | +| `pill` | Heavy rounding (8 px base) | + +```json +"radii": { "style": "pill" } +``` + +--- + +## Animation + +### `animation.scale` + +Multiplier applied to all `--duration-*` tokens. + +- **Type:** `number` +- **Default:** `1.0` +- **Effect:** `0.5` = faster animations, `2.0` = slower. + +```json +"animation": { "scale": 0.8 } +``` + +### `animation.reduceMotion` + +Set to `true` to collapse all durations to `0ms` (respects `prefers-reduced-motion` users). + +- **Type:** `boolean` +- **Default:** `false` + +```json +"animation": { "reduceMotion": true } +``` + +--- + +## Accessibility + +### `accessibility.level` + +Target WCAG contrast standard. The generator enforces the selected ratios across all text and UI component tokens. + +- **Type:** `"AA"` | `"AAA"` +- **Default:** `"AA"` + +| Level | Normal text | Large text | UI components | +| ----- | ----------- | ---------- | ------------- | +| AA | 4.5 : 1 | 3.0 : 1 | 3.0 : 1 | +| AAA | 7.0 : 1 | 4.5 : 1 | 4.5 : 1 | + +```json +"accessibility": { "level": "AAA" } +``` + +--- + +## Overrides + +Directly set any generated CSS variable for a specific mode, bypassing derivation. Useful for fine-tuning edge cases or setting exact brand values. + +### `overrides.light` + +```json +"overrides": { + "light": { + "--base-bg": "#ffffff", + "--base-fg": "#1a1a1a", + "--focus-ring-width": "3px" + } +} +``` + +### `overrides.dark` + +```json +"overrides": { + "dark": { + "--base-bg": "#0a0a0a", + "--base-fg": "#e5e5e5", + "--focus-ring-width": "3px" + } +} +``` + +### Commonly Overridden Tokens + +**Color groups (tonal):** + +- `--base-bg`, `--base-fg`, `--base-border` +- `--raised-bg`, `--sunken-bg` +- `--soft-bg`, `--strong-bg`, `--stronger-bg` + +**Color groups (semantic):** + +- `--primary-bg`, `--primary-fg` +- `--success-bg`, `--warning-bg`, `--danger-bg` + +**Special tokens:** + +- `--focus-ring`, `--focus-ring-width` +- `--link`, `--link-hover` +- `--selection-bg`, `--selection-text` + +**Sizing and shape:** + +- `--radius-sm`, `--radius-md`, `--radius-lg` +- `--shadow-sm`, `--shadow-md`, `--shadow-lg` + +--- + +## Example Themes + +### Minimal Theme + +```json +{ + "id": "minimal", + "name": "Minimal", + "description": "Clean and understated", + "colors": { + "primary": "#374151" + }, + "config": { + "saturation": -20 + } +} +``` + +### High Contrast Theme + +```json +{ + "id": "high-contrast", + "name": "High Contrast", + "description": "AAA-compliant for maximum accessibility", + "colors": { + "primary": "#0052cc" + }, + "accessibility": { + "level": "AAA" + }, + "overrides": { + "light": { + "--base-fg": "#000000", + "--base-bg": "#ffffff", + "--focus-ring-width": "3px" + }, + "dark": { + "--base-fg": "#ffffff", + "--base-bg": "#000000", + "--focus-ring-width": "3px" + } + } +} +``` + +### Terminal Theme + +```json +{ + "id": "terminal", + "name": "Terminal", + "description": "Green-on-black hacker aesthetic", + "colors": { + "primary": "#22c55e", + "secondary": "#10b981", + "accent": "#06b6d4" + }, + "typography": { + "fontSans": "'JetBrains Mono', monospace" + }, + "overrides": { + "dark": { + "--base-bg": "#0a0a0a", + "--base-fg": "#22c55e" + } + } +} +``` + +### Ocean Theme + +```json +{ + "id": "ocean", + "name": "Ocean", + "description": "Cool blue tones inspired by deep water", + "colors": { + "primary": "#0284c7", + "secondary": "#0ea5e9", + "accent": "#6366f1", + "neutral": "#475569" + }, + "config": { + "saturation": 10, + "temperature": -15 + }, + "typography": { + "fontSans": "Inter, system-ui, sans-serif", + "baseSize": 15 + }, + "radii": { + "style": "rounded", + "scale": 1.0 + }, + "accessibility": { "level": "AA" }, + "overrides": { + "light": { "--base-bg": "#f0f9ff" }, + "dark": { "--base-bg": "#082f49" } + } +} +``` + +--- + +## Adding a New Theme + +1. **Create a JSON file** in `src/themes/definitions/`: + + ``` + packages/cap-foundations/core/src/themes/definitions/my-theme.json + ``` + +2. **Add the required fields:** + + ```json + { + "id": "my-theme", + "name": "My Theme", + "colors": { + "primary": "#hexcolor" + } + } + ``` + +3. **Generate CSS:** + + ```bash + yarn nx run cap-foundations-core:build-themes + ``` + +4. **Inspect the output** in `dist/themes/`: + + - `my-theme-light.css` + - `my-theme-dark.css` + - `manifest.json` (updated) + +5. **Use in your application:** + + ```html + + ``` + + Switch mode at runtime: + + ```js + document.documentElement.setAttribute('data-mode', 'dark'); + ``` + +--- + +## CSS Output + +Each theme definition generates two CSS files and an updated `manifest.json`: + +| File | Contents | +| ---------------- | ------------------------------- | +| `{id}-light.css` | All tokens scoped to light mode | +| `{id}-dark.css` | All tokens scoped to dark mode | +| `manifest.json` | Theme registry with metadata | + +The generated CSS uses attribute selectors for zero-specificity scoping: + +```css +[data-theme='my-theme'][data-mode='light'] { + /* ~300+ CSS custom properties */ + --base-bg: #fafafa; + --base-fg: #171717; + /* … */ + + & .surface { + /* Tonal surface resets + modifiers */ + } +} +``` + +--- + +## Color Groups vs Surfaces + +### Color Groups + +Color groups are the token naming system. There are **11 groups** (`softer`, `soft`, `base`, `strong`, `stronger`, `primary`, `inverted`, `success`, `warning`, `danger`, `info`), each with **18 tokens** per group: + +``` +--{group}-bg background +--{group}-fg primary foreground text +--{group}-border border +--{group}-fg-primary primary-colored link/accent text +… +``` + +Pick **one group** for your component's background, then use **only that group's** foreground tokens — contrast is guaranteed. + +```css +/* Use base group */ +.my-card { + background: var(--base-bg); + color: var(--base-fg); + border: 1px solid var(--base-border); +} + +/* Use primary group for a CTA button */ +.btn-primary { + background: var(--primary-bg); + color: var(--primary-fg); +} +``` + +### Surfaces + +Surfaces are CSS classes (`
`) that **reset** all color group tokens to their theme-level originals and then apply modifier-specific overrides. They create isolated scoped areas where nested components automatically get contextually correct colors. + +```html + +
+

Text automatically uses raised surface colors.

+ + + +
+ + + + + + +``` + +See [schema-definition.md](./schema/schema-definition.md) for the complete color group and surface system documentation. diff --git a/packages/cap-foundations/core/tsconfig.json b/packages/cap-foundations/core/tsconfig.json index 9e1563e3c..ea7ab593b 100644 --- a/packages/cap-foundations/core/tsconfig.json +++ b/packages/cap-foundations/core/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../../tsconfig.base.json", "files": [], "compilerOptions": { - "jsx": "react" + "jsx": "react", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true }, "include": [], "references": [ diff --git a/packages/cap-foundations/core/tsconfig.scripts.json b/packages/cap-foundations/core/tsconfig.scripts.json new file mode 100644 index 000000000..4d1ef2d75 --- /dev/null +++ b/packages/cap-foundations/core/tsconfig.scripts.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["scripts/**/*.ts", "src/**/*.ts"] +} From de308d91556bef19d52a0a5ea5cad38f069b2b51 Mon Sep 17 00:00:00 2001 From: Natalia Bar Date: Tue, 17 Mar 2026 12:38:44 -0700 Subject: [PATCH 07/16] Add runtime bootstrap --- packages/cap-foundations/core/project.json | 7 + .../core/scripts/build-bootstrap.ts | 66 ++++ .../core/src/build/inline-bootstrap.ts | 194 ++++++++++ packages/cap-foundations/core/src/index.ts | 1 + .../core/src/runtime/bootstrap.ts | 333 ++++++++++++++++++ .../cap-foundations/core/src/runtime/index.ts | 16 + 6 files changed, 617 insertions(+) create mode 100644 packages/cap-foundations/core/scripts/build-bootstrap.ts create mode 100644 packages/cap-foundations/core/src/build/inline-bootstrap.ts create mode 100644 packages/cap-foundations/core/src/runtime/bootstrap.ts create mode 100644 packages/cap-foundations/core/src/runtime/index.ts diff --git a/packages/cap-foundations/core/project.json b/packages/cap-foundations/core/project.json index e0fcdb2cf..3175da317 100644 --- a/packages/cap-foundations/core/project.json +++ b/packages/cap-foundations/core/project.json @@ -34,6 +34,13 @@ "command": "npx ts-node --project tsconfig.scripts.json scripts/build-themes.ts", "cwd": "{projectRoot}" } + }, + "build-bootstrap": { + "executor": "nx:run-commands", + "options": { + "command": "npx ts-node --project tsconfig.scripts.json scripts/build-bootstrap.ts", + "cwd": "{projectRoot}" + } } } } diff --git a/packages/cap-foundations/core/scripts/build-bootstrap.ts b/packages/cap-foundations/core/scripts/build-bootstrap.ts new file mode 100644 index 000000000..7b780d18b --- /dev/null +++ b/packages/cap-foundations/core/scripts/build-bootstrap.ts @@ -0,0 +1,66 @@ +/** + * Build standalone bootstrap.js for browser usage + * + * Uses esbuild to bundle src/runtime/bootstrap.ts into two output files: + * dist/bootstrap.js — readable IIFE for debugging + * dist/bootstrap.min.js — minified IIFE for production + * + * Run via: npx ts-node --project tsconfig.scripts.json scripts/build-bootstrap.ts + */ + +import * as esbuild from 'esbuild'; +import * as fs from 'fs'; +import * as path from 'path'; + +const rootDir = path.resolve(__dirname, '..'); +const distDir = path.resolve(rootDir, 'dist'); + +// Ensure dist directory exists +if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); +} + +console.log('Building bootstrap.js...'); + +const entryPoint = path.resolve(rootDir, 'src', 'runtime', 'bootstrap.ts'); + +async function run(): Promise { + // Readable IIFE for debugging + await esbuild.build({ + entryPoints: [entryPoint], + outfile: path.resolve(distDir, 'bootstrap.js'), + bundle: true, + minify: false, + format: 'iife', + globalName: 'CapFoundationsBootstrap', + target: ['es2015'], + platform: 'browser', + banner: { + js: '/* @fluentui-contrib/cap-foundations-core bootstrap */\n', + }, + }); + + // Minified version for production + await esbuild.build({ + entryPoints: [entryPoint], + outfile: path.resolve(distDir, 'bootstrap.min.js'), + bundle: true, + minify: true, + format: 'iife', + globalName: 'CapFoundationsBootstrap', + target: ['es2015'], + platform: 'browser', + banner: { + js: '/* @fluentui-contrib/cap-foundations-core bootstrap */\n', + }, + }); + + console.log('Bootstrap built successfully:'); + console.log(' dist/bootstrap.js (readable)'); + console.log(' dist/bootstrap.min.js (minified)'); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/cap-foundations/core/src/build/inline-bootstrap.ts b/packages/cap-foundations/core/src/build/inline-bootstrap.ts new file mode 100644 index 000000000..9ac0fd0ad --- /dev/null +++ b/packages/cap-foundations/core/src/build/inline-bootstrap.ts @@ -0,0 +1,194 @@ +/** + * Cap-Foundations Inline Bootstrap Generator + * + * Generates inline HTML (CSS + JS) for zero-flash theme loading. + * This module is Node-safe — no browser APIs used anywhere. + * + * Embed the result in your HTML to ensure the correct theme + * background color is applied before any CSS loads. + */ + +export interface InlineBootstrapOptions { + /** Base path to theme CSS files (default: '/themes') */ + basePath?: string; + /** Default theme name (default: 'default') */ + defaultTheme?: string; + /** Default colors for flash prevention (shown before theme CSS loads) */ + defaultColors?: { + light: { baseBg: string; baseFg: string }; + dark: { baseBg: string; baseFg: string }; + }; +} + +const DEFAULT_COLORS = { + light: { baseBg: '#fafafa', baseFg: '#171717' }, + dark: { baseBg: '#0f0f0f', baseFg: '#e5e5e5' }, +}; + +/** + * Generate fallback CSS that defines critical CSS variables before + * the theme stylesheet loads. Prevents flashes of unstyled content. + */ +function generateFallbackCSS(colors = DEFAULT_COLORS): string { + return ( + `:root{--base-bg:${colors.light.baseBg};--base-fg:${colors.light.baseFg}}` + + `@media(prefers-color-scheme:dark){:root{--base-bg:${colors.dark.baseBg};--base-fg:${colors.dark.baseFg}}}` + ); +} + +/** + * Generate the inline bootstrap script source. Handles: + * - Reading persisted theme/mode from localStorage + * - Setting data-theme / data-mode on immediately + * - Loading the appropriate theme CSS on demand + * - Exposing the window.CapFoundations runtime API + */ +function generateBootstrapScript(options: InlineBootstrapOptions = {}): string { + const { + basePath = '/themes', + defaultTheme = 'default', + defaultColors = DEFAULT_COLORS, + } = options; + + return `(function(){ +var STORAGE='cap-foundations-theme',CACHE='cap-foundations-css-cache'; +var d=document.documentElement,s,c,m,r,k; +try{s=JSON.parse(localStorage.getItem(STORAGE))}catch(e){} +try{c=JSON.parse(localStorage.getItem(CACHE))}catch(e){} +m=(s&&s.mode)||'auto'; +r=m==='auto'?(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'):m; +var th=(s&&s.theme)||'${defaultTheme}'; +if(!s&&matchMedia('(prefers-contrast:more)').matches)th='high-contrast'; +k=th+'-'+r; +d.dataset.theme=th; +d.dataset.mode=r; + +// Inject cached CSS immediately if available (zero flash on repeat visits) +var fb=document.getElementById('cap-foundations-fallback'); +if(c&&c[k]){ + var style=document.createElement('style'); + style.id='cap-foundations-cached-'+k; + style.textContent=c[k]; + document.head.appendChild(style); + if(fb)fb.remove(); +} + +// Track state +var theme=th,mode=m,resolvedMode=r; +var loadedCSS=new Map(); +var subscribers=new Set(); + +// Load CSS file and cache it in localStorage for future visits +function loadCSS(th,md,cb){ + var key=th+'-'+md; + if(loadedCSS.has(key)){if(cb)cb();return} + var link=document.createElement('link'); + link.rel='stylesheet'; + link.href='${basePath}/'+key+'.css'; + link.id='cap-foundations-theme-'+key; + link.onload=function(){ + loadedCSS.set(key,link); + var fb=document.getElementById('cap-foundations-fallback'); + if(fb)fb.remove(); + fetch(link.href).then(function(r){return r.text()}).then(function(css){ + try{ + var cache=JSON.parse(localStorage.getItem(CACHE)||'{}'); + cache[key]=css; + localStorage.setItem(CACHE,JSON.stringify(cache)); + }catch(e){} + }); + if(cb)cb(); + }; + document.head.appendChild(link); +} + +// Load initial theme CSS +loadCSS(theme,resolvedMode); + +// Runtime API +window.CapFoundations={ + setTheme:function(newTheme,newMode,callback){ + newMode=newMode||mode; + var newResolved=newMode==='auto'?(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'):newMode; + loadCSS(newTheme,newResolved,function(){ + theme=newTheme;mode=newMode;resolvedMode=newResolved; + d.dataset.theme=theme;d.dataset.mode=resolvedMode; + try{ + var obj=JSON.parse(localStorage.getItem(STORAGE)||'{}'); + obj.theme=theme;obj.mode=mode; + localStorage.setItem(STORAGE,JSON.stringify(obj)); + }catch(e){} + var state={theme:theme,mode:mode,resolvedMode:resolvedMode}; + subscribers.forEach(function(cb){cb(state)}); + if(callback)callback(state); + }); + }, + getTheme:function(){return{theme:theme,mode:mode,resolvedMode:resolvedMode}}, + subscribe:function(cb){subscribers.add(cb);return function(){subscribers.delete(cb)}}, + configure:function(cfg){if(cfg.basePath)'${basePath}'=cfg.basePath} +}; + +// Mirror OS-level dark/light preference when mode is 'auto' +matchMedia('(prefers-color-scheme:dark)').addEventListener('change',function(){ + if(mode==='auto')window.CapFoundations.setTheme(theme,'auto'); +}); +var bg=(s&&s.bg&&s.bg[r])||(r==='dark'?'${defaultColors.dark.baseBg}':'${defaultColors.light.baseBg}'); +d.style.backgroundColor=bg; +})();`; +} + +/** + * Simple minification: strips comments and collapses whitespace. + */ +function minify(code: string): string { + return code + .replace(/\/\/.*$/gm, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\s+/g, ' ') + .replace(/\s*([{}();,:])\s*/g, '$1') + .replace(/\s*=\s*(?!=)/g, '=') + .replace(/\s*(===?|!==?|[<>]=?|\|\||&&)\s*/g, '$1') + .trim(); +} + +/** + * Generate the complete inline bootstrap HTML (CSS + JS) for embedding + * at the top of your HTML . Output is minified. + * + * @example + * ```html + * + * + * + * + * + * ``` + */ +export function getInlineBootstrap(options: InlineBootstrapOptions = {}): string { + const css = generateFallbackCSS(options.defaultColors); + const js = minify(generateBootstrapScript(options)); + return ``; +} + +/** + * Generate formatted (non-minified) bootstrap HTML for debugging. + */ +export function getInlineBootstrapPretty(options: InlineBootstrapOptions = {}): string { + const css = generateFallbackCSS(options.defaultColors); + const js = generateBootstrapScript(options); + return `\n`; +} + +/** + * Get just the fallback CSS string (for static HTML templates). + */ +export function getFallbackCSS(options?: { colors?: typeof DEFAULT_COLORS }): string { + return generateFallbackCSS(options?.colors); +} + +/** + * Get just the minified bootstrap script string (without the wrapping + * + * + * Or import in JS (may cause brief flash on first load): + * import '@fluentui-contrib/cap-foundations-core/bootstrap'; + * + * API: + * - CapFoundations.setTheme(theme, mode, callback) — change active theme + * - CapFoundations.getTheme() — returns { theme, mode, resolvedMode } + * - CapFoundations.subscribe(callback) — subscribe to theme changes, returns unsubscribe fn + * - CapFoundations.configure(config) — reconfigure base path and defaults + * + * SSR note: all browser API calls are guarded with typeof checks so this + * module is safe to import in Node.js / server-side environments. + */ + +// Types +export interface CapFoundationsConfig { + /** Base path to theme CSS files (e.g., '/themes' or 'https://cdn.example.com/themes') */ + basePath?: string; + /** Default theme name */ + defaultTheme?: string; + /** Default mode: 'light', 'dark', or 'auto' */ + defaultMode?: 'light' | 'dark' | 'auto'; + /** Default background colors for immediate flash prevention */ + defaultBg?: { light: string; dark: string }; +} + +export interface CapFoundationsThemeState { + theme: string; + mode: 'light' | 'dark' | 'auto'; + resolvedMode: 'light' | 'dark'; +} + +export type ThemeCallback = (state: CapFoundationsThemeState) => void; + +export interface CapFoundationsAPI { + /** Set theme and/or mode. Loads CSS on demand and calls back when ready. */ + setTheme: (theme: string, mode?: 'light' | 'dark' | 'auto', callback?: ThemeCallback) => void; + /** Get current theme state */ + getTheme: () => CapFoundationsThemeState; + /** Subscribe to theme changes. Returns unsubscribe function. */ + subscribe: (callback: ThemeCallback) => () => void; + /** Reconfigure CapFoundations (e.g., change basePath) */ + configure: (config: CapFoundationsConfig) => void; +} + +// Storage key +const STORAGE_KEY = 'cap-foundations-theme'; + +// State +let config: Required = { + basePath: '/themes', + defaultTheme: 'default', + defaultMode: 'auto', + defaultBg: { light: '#fafafa', dark: '#0f0f0f' }, +}; + +let currentState: CapFoundationsThemeState = { + theme: 'default', + mode: 'auto', + resolvedMode: 'light', +}; + +const loadedCSS = new Map(); +const subscribers = new Set(); + +/** + * Resolve 'auto' mode to actual light/dark based on system preference. + * SSR-safe: returns 'light' when window is unavailable. + */ +function resolveMode(mode: 'light' | 'dark' | 'auto'): 'light' | 'dark' { + if (mode !== 'auto') return mode; + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +/** + * Get stored theme settings from localStorage. + * SSR-safe: returns null when localStorage is unavailable. + */ +function getStoredSettings(): { theme?: string; mode?: 'light' | 'dark' | 'auto'; bg?: { light?: string; dark?: string } } | null { + if (typeof localStorage === 'undefined') return null; + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } +} + +/** + * Save theme settings to localStorage. + * SSR-safe: no-op when localStorage is unavailable. + */ +function saveSettings(state: CapFoundationsThemeState, bgColor?: string): void { + if (typeof localStorage === 'undefined') return; + try { + const existing = getStoredSettings() || {}; + const bg = existing.bg || {}; + if (bgColor) { + bg[state.resolvedMode] = bgColor; + } + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + theme: state.theme, + mode: state.mode, + bg, + })); + } catch { + // Storage quota exceeded or restricted — silently skip + } +} + +/** + * Load a theme CSS file by creating a element. + * SSR-safe: no-op when document is unavailable. + */ +function loadThemeCSS(theme: string, mode: 'light' | 'dark', callback?: () => void): void { + if (typeof document === 'undefined') { + callback?.(); + return; + } + + const key = `${theme}-${mode}`; + + // Already loaded + if (loadedCSS.has(key)) { + callback?.(); + return; + } + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `${config.basePath}/${key}.css`; + link.id = `cap-foundations-theme-${key}`; + + link.onload = () => { + loadedCSS.set(key, link); + callback?.(); + }; + + link.onerror = () => { + console.error(`[CapFoundations] Failed to load theme: ${link.href}`); + callback?.(); + }; + + document.head.appendChild(link); +} + +/** + * Apply theme state to DOM by setting data attributes on . + * SSR-safe: no-op when document is unavailable. + */ +function applyToDOM(state: CapFoundationsThemeState): void { + if (typeof document === 'undefined') return; + document.documentElement.dataset.theme = state.theme; + document.documentElement.dataset.mode = state.resolvedMode; +} + +/** + * Notify all subscribers of a state change. + */ +function notifySubscribers(): void { + const stateCopy = { ...currentState }; + subscribers.forEach(cb => cb(stateCopy)); +} + +/** + * Set theme with CSS loading and optional callback. + */ +function setTheme(theme: string, mode?: 'light' | 'dark' | 'auto', callback?: ThemeCallback): void { + const newMode = mode ?? currentState.mode; + const resolvedMode = resolveMode(newMode); + + const newState: CapFoundationsThemeState = { + theme, + mode: newMode, + resolvedMode, + }; + + loadThemeCSS(theme, resolvedMode, () => { + currentState = newState; + applyToDOM(currentState); + + // Save background color for flash prevention on next page load + if (typeof document !== 'undefined' && typeof getComputedStyle !== 'undefined') { + const computedBg = getComputedStyle(document.documentElement).getPropertyValue('--base-bg').trim(); + saveSettings(currentState, computedBg || undefined); + } else { + saveSettings(currentState); + } + + notifySubscribers(); + callback?.(currentState); + }); +} + +/** + * Get current theme state. + */ +function getTheme(): CapFoundationsThemeState { + return { ...currentState }; +} + +/** + * Subscribe to theme changes. Returns an unsubscribe function. + */ +function subscribe(callback: ThemeCallback): () => void { + subscribers.add(callback); + return () => subscribers.delete(callback); +} + +/** + * Reconfigure bootstrap options. + */ +function configure(newConfig: CapFoundationsConfig): void { + config = { ...config, ...newConfig }; +} + +/** + * Initialize bootstrap — called automatically when this module is imported + * in a browser environment. Safe no-op in Node.js or SSR. + */ +function init(): void { + if (typeof window === 'undefined') return; + + // Apply user config if provided via global variable + const userConfig = (window as unknown as { CapFoundationsConfig?: CapFoundationsConfig }).CapFoundationsConfig; + if (userConfig) { + configure(userConfig); + } + + // Auto-detect basePath from script src if not explicitly configured + if (!userConfig?.basePath && typeof document !== 'undefined') { + const scripts = document.getElementsByTagName('script'); + for (let i = 0; i < scripts.length; i++) { + const src = scripts[i].src; + if (src && src.includes('bootstrap')) { + const match = src.match(/(.*)\/bootstrap/); + if (match) { + config.basePath = `${match[1]}/themes`; + } + break; + } + } + } + + // Restore persisted settings + const stored = getStoredSettings(); + + let theme = stored?.theme ?? config.defaultTheme; + let mode: 'light' | 'dark' | 'auto' = stored?.mode ?? config.defaultMode; + + // Respect high-contrast OS preference when no stored setting + if (!stored && window.matchMedia('(prefers-contrast: more)').matches) { + theme = 'high-contrast'; + } + + const resolvedMode = resolveMode(mode); + + // Set background color IMMEDIATELY (before CSS loads) to prevent flash + if (typeof document !== 'undefined') { + const bgColor = stored?.bg?.[resolvedMode] ?? config.defaultBg[resolvedMode]; + document.documentElement.style.backgroundColor = bgColor; + } + + // Apply data attributes immediately so CSS selectors activate without waiting for onload + currentState = { theme, mode, resolvedMode }; + applyToDOM(currentState); + + // Load the active theme CSS file + loadThemeCSS(theme, resolvedMode); + + // Mirror OS-level dark/light changes when mode is 'auto' + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (currentState.mode === 'auto') { + const newResolvedMode = resolveMode('auto'); + if (newResolvedMode !== currentState.resolvedMode) { + setTheme(currentState.theme, 'auto'); + } + } + }); +} + +// Check if an inline bootstrap already ran (embedded in HTML) +const existingAPI = typeof window !== 'undefined' + ? (window as unknown as { CapFoundations?: CapFoundationsAPI }).CapFoundations + : undefined; + +if (existingAPI) { + // Sync state from the inline bootstrap rather than re-initializing + const existingState = existingAPI.getTheme(); + currentState = { ...existingState }; + + existingAPI.subscribe((state) => { + currentState = { ...state }; + notifySubscribers(); + }); +} else { + init(); +} + +// Build the public API object +const CapFoundations: CapFoundationsAPI = existingAPI ?? { + setTheme, + getTheme, + subscribe, + configure, +}; + +// Expose globally — safe no-op in Node.js +if (typeof window !== 'undefined' && !existingAPI) { + (window as unknown as { CapFoundations: CapFoundationsAPI }).CapFoundations = CapFoundations; +} + +// Named exports for module usage +export { CapFoundations, setTheme, getTheme, subscribe, configure }; +export default CapFoundations; + +// Re-export inline bootstrap generators (Node-safe, no browser APIs) +export { getInlineBootstrap, getInlineBootstrapPretty, getFallbackCSS, getBootstrapScript } from '../build/inline-bootstrap'; diff --git a/packages/cap-foundations/core/src/runtime/index.ts b/packages/cap-foundations/core/src/runtime/index.ts new file mode 100644 index 000000000..a6bbfedfd --- /dev/null +++ b/packages/cap-foundations/core/src/runtime/index.ts @@ -0,0 +1,16 @@ +/** + * Runtime exports + * + * The bootstrap module provides all runtime theme functionality: + * - CapFoundations.setTheme(theme, mode, callback) — change active theme with on-demand CSS loading + * - CapFoundations.getTheme() — get current theme state + * - CapFoundations.subscribe(callback) — subscribe to theme changes + * - CapFoundations.configure(config) — configure base path and defaults + * + * Also exports inline bootstrap generators (Node-safe) for embedding in HTML: + * - getInlineBootstrap(options) — returns