diff --git a/.changeset/config.json b/.changeset/config.json index 98b9e3e87..0eded5ae0 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,10 +14,8 @@ "@dotui/auth", "@dotui/db", "@dotui/registry", - "@dotui/shadcn-adapter", - "@dotui/color-engine", + "@dotui/colors", "@dotui/style-system", - "@dotui/transformers", "@dotui/types" ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a3716232..ddbedae4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,3 +67,49 @@ jobs: - uses: ./.github/actions/setup - run: pnpm test + + check-registry: + runs-on: ubuntu-latest + name: Check registry generated files + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup + + - name: Build registry + run: pnpm build:registry + + - name: Check for uncommitted changes + run: | + if [[ -n $(git status --porcelain packages/core/src/__registry__/) ]]; then + echo "Error: Generated registry files are out of sync!" + echo "Please run 'pnpm build:registry' and commit the changes." + git diff packages/core/src/__registry__/ + exit 1 + fi + echo "Registry files are in sync." + + check-migrations: + runs-on: ubuntu-latest + name: Check database migrations + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup + + - name: Generate migrations + run: pnpm --filter=@dotui/db generate + env: + POSTGRES_URL: "postgresql://fake:fake@localhost:5432/fake" + + - name: Check for uncommitted migrations + run: | + if [[ -n $(git status --porcelain packages/db/drizzle/) ]]; then + echo "Error: Database migrations are out of sync!" + echo "Please run 'pnpm --filter=@dotui/db generate' and commit the changes." + git status packages/db/drizzle/ + exit 1 + fi + echo "Database migrations are in sync." diff --git a/.github/workflows/db-migrate.yml b/.github/workflows/db-migrate.yml new file mode 100644 index 000000000..8f37ea195 --- /dev/null +++ b/.github/workflows/db-migrate.yml @@ -0,0 +1,27 @@ +name: Database Migrations + +on: + push: + branches: + - main + paths: + - "packages/db/drizzle/**" + workflow_dispatch: # Allow manual trigger + +jobs: + migrate: + name: Run database migrations + runs-on: ubuntu-latest + # Only run in the main repo, not forks + if: ${{ github.repository_owner == 'mehdibha' }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + + - name: Run migrations + run: pnpm --filter=@dotui/db migrate + env: + POSTGRES_URL: ${{ secrets.POSTGRES_URL }} + + - name: Migration complete + run: echo "✅ Database migrations applied successfully" diff --git a/package.json b/package.json index bd52df9e3..adcb0ae24 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "start:www": "pnpm --filter=www start", "build:www": "pnpm --filter=www build", "build:registry": "pnpm --filter=@dotui/registry build", + "dev:registry": "chokidar 'packages/registry/src/ui/**/meta.ts' -c 'pnpm build:registry'", "build:references": "pnpm --filter=www build:references", "clean": "git clean -xdf node_modules", "clean:workspaces": "turbo run clean", @@ -45,7 +46,9 @@ "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.27.3", "@vitest/coverage-v8": "^4.0.1", + "chokidar-cli": "^3.0.0", "puppeteer": "^24.26.0", + "tsx": "^4.21.0", "turbo": "^2.5.8", "typescript": "^5.8.3", "vite-tsconfig-paths": "^5.1.4", diff --git a/packages/api/package.json b/packages/api/package.json index 4dbe7d640..393f6ebeb 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -8,17 +8,15 @@ }, "license": "MIT", "scripts": { - "build": "tsc", "clean": "git clean -xdf .cache .turbo dist node_modules", "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { "@dotui/auth": "workspace:*", "@dotui/db": "workspace:*", - "@dotui/registry": "workspace:*", "@trpc/server": "^11.2.0", "superjson": "2.2.3", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "devDependencies": { "@dotui/ts-config": "workspace:*", diff --git a/packages/api/src/routers/style.ts b/packages/api/src/routers/style.ts index 1c23d3507..a51bf5345 100644 --- a/packages/api/src/routers/style.ts +++ b/packages/api/src/routers/style.ts @@ -3,8 +3,7 @@ import { z } from "zod"; import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq } from "@dotui/db"; -import { createStyleSchema, style, user } from "@dotui/db/schemas"; -import { styleDefinitionSchema } from "@dotui/registry/schemas"; +import { createStyleSchema, updateStyleConfigSchema, style, user } from "@dotui/db/schemas"; import { protectedProcedure, publicProcedure } from "../trpc"; @@ -289,7 +288,7 @@ export const styleRouter = { return created; }), update: protectedProcedure - .input(styleDefinitionSchema.extend({ id: uuidSchema })) + .input(updateStyleConfigSchema) .mutation(async ({ ctx, input }) => { return await ctx.db.transaction(async (tx) => { const existingStyle = await tx.query.style.findFirst({ @@ -310,17 +309,13 @@ export const styleRouter = { }); } - const { id, theme, icons, variants } = input; - const [updatedStyle] = await tx .update(style) .set({ - theme, - icons, - variants, + config: input.config, updatedAt: new Date(), }) - .where(eq(style.id, id)) + .where(eq(style.id, input.id)) .returning(); return updatedStyle; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index a8bd70aee..5f40ecf7b 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -4,8 +4,8 @@ "baseUrl": ".", "jsx": "preserve", "paths": { - "@dotui/registry/*": ["../registry/src/*"], - "@dotui/style-system/*": ["../style-system/src/*"] + "@dotui/api/*": ["./src/*"], + "@dotui/core/*": ["../core/src/*"] } }, "include": ["src"], diff --git a/packages/auth/package.json b/packages/auth/package.json index acd05f30c..78b2c13d5 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -22,7 +22,7 @@ "next": "^16.0.5", "react": "^19.2.1", "react-dom": "^19.2.1", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "devDependencies": { "@dotui/ts-config": "workspace:*", diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index e570d6174..97ef78330 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -1,5 +1,11 @@ { "extends": "@dotui/ts-config/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@dotui/core/*": ["../core/src/*"] + } + }, "include": ["src", "*.ts", "eslint.config.js"], "exclude": ["node_modules"] } diff --git a/packages/color-engine/package.json b/packages/color-engine/package.json deleted file mode 100644 index 03a6c5ff3..000000000 --- a/packages/color-engine/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@dotui/color-engine", - "private": true, - "version": "0.1.0", - "type": "module", - "license": "MIT", - "exports": { - ".": "./src/index.ts", - "./leonardo": "./src/algorithms/leonardo/index.ts", - "./material": "./src/algorithms/material/index.ts", - "./radix": "./src/algorithms/radix/index.ts", - "./utils": "./src/core/utils.ts" - }, - "scripts": { - "clean": "git clean -xdf .cache .turbo dist node_modules", - "typecheck": "tsc --noEmit --emitDeclarationOnly false", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "@material/material-color-utilities": "^0.3.0", - "@radix-ui/colors": "^3.0.0", - "apca-w3": "^0.1.9", - "chroma-js": "^2.4.2", - "ciebase": "^0.1.1", - "ciecam02": "^0.4.6", - "hsluv": "^0.1.0" - }, - "devDependencies": { - "@dotui/ts-config": "workspace:*", - "@types/chroma-js": "^2.4.4", - "typescript": "^5.8.3", - "vitest": "^4.0.1" - } -} diff --git a/packages/color-engine/src/algorithms/leonardo/index.ts b/packages/color-engine/src/algorithms/leonardo/index.ts deleted file mode 100644 index bb934c9b9..000000000 --- a/packages/color-engine/src/algorithms/leonardo/index.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * Leonardo Algorithm - Exact Port - * - * A faithful TypeScript port of Adobe's Leonardo contrast-based - * color scale generation algorithm using chroma-js for exact parity. - * - * Original: https://github.com/adobe/leonardo - * License: Apache-2.0 - */ - -import { APCAcontrast, sRGBtoY } from "apca-w3"; -import chroma from "chroma-js"; -import * as ciebase from "ciebase"; -import * as ciecam02 from "ciecam02"; -import * as hsluv from "hsluv"; -import type { ContrastFormula, LeonardoColorspace, LeonardoOptions, RGB, ScaleOutput } from "../../core/types"; -import { SCALE_STEPS } from "../../core/utils"; - -// ============================================================================ -// CIECAM02 JCh Setup (exact match to original chroma-plus.js) -// ============================================================================ - -const cam = ciecam02.cam( - { - whitePoint: ciebase.illuminant.D65, - adaptingLuminance: 40, - backgroundLuminance: 20, - surroundType: "average", - discounting: false, - }, - ciecam02.cfs("JCh"), -); - -const xyz = ciebase.xyz(ciebase.workspace.sRGB, ciebase.illuminant.D65); - -/** Convert RGB (0-255) to JCh */ -function rgb2jch(rgb: RGB): [number, number, number] { - const rgbNorm: [number, number, number] = [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255]; - const jch = cam.fromXyz(xyz.fromRgb(rgbNorm)); - return [jch.J, jch.C, jch.h]; -} - -/** Convert JCh to RGB (0-255) */ -function jch2rgb(jch: [number, number, number]): RGB { - const rgbNorm = xyz.toRgb(cam.toXyz({ J: jch[0], C: jch[1], h: jch[2] })); - return [Math.round(rgbNorm[0] * 255), Math.round(rgbNorm[1] * 255), Math.round(rgbNorm[2] * 255)]; -} - -// ============================================================================ -// Constants -// ============================================================================ - -/** Number of steps in the internal color scale for binary search precision */ -const SCALE_LENGTH = 3000; - -/** Default contrast ratios for 11-step scale */ -const DEFAULT_RATIOS = [1.05, 1.15, 1.3, 1.5, 2, 3, 4.5, 6, 8, 12, 15]; - -/** Binary search epsilon (convergence threshold) */ -const EPSILON = 0.01; - -/** Maximum binary search iterations */ -const MAX_ITERATIONS = 100; - -/** Colorspace mapping to chroma-js mode names */ -const COLOR_SPACES: Record = { - CAM02: "jab", - CAM02p: "jch", - HEX: "hex", - HSL: "hsl", - HSLuv: "hsluv", - HSV: "hsv", - LAB: "lab", - LCH: "lch", - RGB: "rgb", - OKLAB: "oklab", - OKLCH: "oklch", -}; - -// ============================================================================ -// Utility Functions -// ============================================================================ - -function round(x: number, n = 0): number { - const ten = 10 ** n; - return Math.round(x * ten) / ten; -} - -/** - * Apply contrast multiplier to a ratio (Leonardo-style normalization) - */ -function multiplyRatio(ratio: number, multiplier: number): number { - let r: number; - - if (ratio > 1) { - r = (ratio - 1) * multiplier + 1; - } else if (ratio < -1) { - r = (ratio + 1) * multiplier - 1; - } else { - r = 1; - } - - return round(r, 2); -} - -// ============================================================================ -// Luminance & Contrast Calculations -// ============================================================================ - -/** - * Calculate relative luminance per WCAG 2.0 - */ -function luminance(r: number, g: number, b: number): number { - const a = [r, g, b].map((v) => { - v /= 255; - return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; - }); - return a[0]! * 0.2126 + a[1]! * 0.7152 + a[2]! * 0.0722; -} - -/** - * Calculate contrast with directionality (Leonardo-style) - * - * For WCAG2: - * - Light themes (baseV >= 0.5): positive = darker than background, negative = lighter - * - Dark themes (baseV < 0.5): positive = lighter than background, negative = darker - */ -function getContrast( - colorRgb: RGB, - baseRgb: RGB, - baseV: number | undefined, - formula: ContrastFormula = "wcag2", -): number { - // If baseV not provided, calculate from background HSLuv lightness - if (baseV === undefined) { - // Convert RGB (0-255) to RGB (0-1) for hsluv - const rgbNormalized: [number, number, number] = [baseRgb[0] / 255, baseRgb[1] / 255, baseRgb[2] / 255]; - const hsluvValues = hsluv.rgbToHsluv(rgbNormalized); - const baseLightness = hsluvValues[2]; // L is at index 2 - baseV = round(baseLightness / 100, 2); - } - - if (formula === "wcag3") { - // Use APCA contrast - const apcaValue = APCAcontrast(sRGBtoY(colorRgb), sRGBtoY(baseRgb)) as number; - // Flip sign for dark themes to match Leonardo convention - return baseV < 0.5 ? -apcaValue : apcaValue; - } - - // WCAG 2 contrast calculation - const colorLum = luminance(colorRgb[0], colorRgb[1], colorRgb[2]); - const baseLum = luminance(baseRgb[0], baseRgb[1], baseRgb[2]); - - // cr1 >= 1 when color is darker than base - // cr2 >= 1 when color is lighter than base - const cr1 = (colorLum + 0.05) / (baseLum + 0.05); - const cr2 = (baseLum + 0.05) / (colorLum + 0.05); - - if (baseV < 0.5) { - // Dark theme: positive = lighter (higher contrast), negative = darker - if (cr1 >= 1) { - return cr1; // color is darker than background - } - return -cr2; // color is lighter than background - } - - // Light theme: positive = darker (higher contrast), negative = lighter - if (cr1 < 1) { - return cr2; // color is lighter than background - } - if (cr1 === 1) { - return cr1; - } - return -cr1; // color is darker than background -} - -// ============================================================================ -// Scale Creation (exact port from original) -// ============================================================================ - -/** - * Create power scale function for domain mapping - */ -function makePowScale( - exp = 1, - domains: [number, number] = [0, 1], - range: [number, number] = [0, 1], -): (x: number) => number { - const m = (range[1] - range[0]) / (domains[1] ** exp - domains[0] ** exp); - const c = range[0] - m * domains[0] ** exp; - return (x: number) => m * x ** exp + c; -} - -/** - * Create a color scale function (exact port from original createScale) - */ -function createScale( - colorKeys: string[], - colorspace: LeonardoColorspace, - swatches: number, - smooth = false, -): (position: number) => string { - const space = COLOR_SPACES[colorspace]; - if (!space) { - throw new Error(`Colorspace "${colorspace}" not supported`); - } - - // Calculate domains based on JCh lightness (J from CIECAM02) - exact match to original - let domains = colorKeys - .map((key) => { - const rgb = chroma(key).rgb() as RGB; - const jch = rgb2jch(rgb); - return swatches - swatches * (jch[0] / 100); - }) - .sort((a, b) => a - b) - .concat(swatches); - - domains.unshift(0); - - // Apply power scaling for smoother distribution - const sqrtDomains = makePowScale(1, [1, swatches], [1, swatches]); - domains = domains.map((d) => Math.max(0, sqrtDomains(d))); - - // Sort colors by JCh lightness (brightest first) - exact match to original - const sortedColors = colorKeys - .map((c, i) => { - const rgb = chroma(c).rgb() as RGB; - const jch = rgb2jch(rgb); - return { color: jch, index: i }; - }) - .sort((c1, c2) => c2.color[0] - c1.color[0]) - .map((data) => colorKeys[data.index]!); - - // Build full scale with white and black endpoints - const white = space === "lch" ? chroma.lch(...chroma("#fff").lch()) : "#ffffff"; - const black = space === "lch" ? chroma.lch(...chroma("#000").lch()) : "#000000"; - const colorsArray = [white, ...sortedColors, black]; - - // Create chroma scale - const scale = chroma - .scale( - colorsArray.map((color) => { - if (typeof color === "object" && color.constructor.name === "Color") { - return color; - } - return String(color); - }), - ) - .domain(domains) - .mode(space as chroma.InterpolationMode); - - return (pos: number): string => { - return scale(pos).hex(); - }; -} - -// ============================================================================ -// Color Search (Binary Search - exact port from original) -// ============================================================================ - -/** - * Search for colors at specific contrast ratios using binary search - */ -function searchColors( - colorKeys: string[], - colorspace: LeonardoColorspace, - bgRgb: RGB, - baseV: number, - ratios: number[], - formula: ContrastFormula, - smooth = false, -): string[] { - const colorLen = SCALE_LENGTH; - const colorScale = createScale(colorKeys, colorspace, colorLen, smooth); - - // Cache for contrast calculations - const ccache: Record = {}; - - const getContrast2 = (i: number): number => { - if (ccache[i] !== undefined) { - return ccache[i]!; - } - const rgb = chroma(colorScale(i)).rgb() as RGB; - const c = getContrast(rgb, bgRgb, baseV, formula); - ccache[i] = c; - return c; - }; - - const colorSearch = (x: number): number => { - const first = getContrast2(0); - const last = getContrast2(colorLen); - const dir = first < last ? 1 : -1; - const epsilon = EPSILON; - x += 0.005 * Math.sign(x); - let step = colorLen / 2; - let dot = step; - let val = getContrast2(dot); - let counter = MAX_ITERATIONS; - - while (Math.abs(val - x) > epsilon && counter) { - counter--; - step /= 2; - if (val < x) { - dot += step * dir; - } else { - dot -= step * dir; - } - val = getContrast2(dot); - } - - return round(dot, 3); - }; - - const outputColors: string[] = []; - ratios.forEach((ratio) => { - outputColors.push(colorScale(colorSearch(+ratio))); - }); - - return outputColors; -} - -// ============================================================================ -// Saturation Modification -// ============================================================================ - -/** - * Modify color saturation using OKLCH chroma - */ -function modifySaturation(hex: string, saturationPercent: number): string { - const oklch = chroma(hex).oklch(); - const newChroma = oklch[1] * (saturationPercent / 100); - return chroma.oklch(oklch[0], newChroma, oklch[2]).hex(); -} - -// ============================================================================ -// Main API -// ============================================================================ - -/** - * Generate a color scale using the Leonardo algorithm - * - * This is an exact port of Adobe's Leonardo algorithm with: - * - Binary search for exact contrast ratios - * - WCAG 2 and WCAG 3 (APCA) support - * - Multiple colorspace interpolation (LAB, LCH, OKLCH, etc.) - * - JCh lightness for domain positioning (exact match to original) - * - * @param options - Configuration options - * @returns ScaleOutput with 11 steps (50-950) - */ -export function generateScale(options: LeonardoOptions): ScaleOutput { - const { - color, - background, - colorKeys = [], - ratios = DEFAULT_RATIOS, - colorspace = "LAB", - saturation = 100, - contrast = 1, - smooth = false, - formula = "wcag2", - } = options; - - // Combine primary color with additional color keys - const allColorKeys = [color, ...colorKeys]; - - // Apply saturation modification if needed - const modifiedColorKeys = - saturation < 100 ? allColorKeys.map((c) => modifySaturation(c, saturation)) : allColorKeys; - - // Parse background - const bgRgb = chroma(background).rgb() as RGB; - // Use HSLuv lightness for baseV (exact match to original) - const rgbNormalized: [number, number, number] = [bgRgb[0] / 255, bgRgb[1] / 255, bgRgb[2] / 255]; - const hsluvValues = hsluv.rgbToHsluv(rgbNormalized); - const baseLightness = hsluvValues[2]; // L is at index 2 - const baseV = round(baseLightness / 100, 2); - - // Apply contrast multiplier to ratios - const adjustedRatios = ratios.map((r) => multiplyRatio(r, contrast)); - - // Search for colors at each ratio - const colors = searchColors(modifiedColorKeys, colorspace, bgRgb, baseV, adjustedRatios, formula, smooth); - - // Map to standard scale output - const output: Partial = {}; - SCALE_STEPS.forEach((step, index) => { - if (index < colors.length) { - output[step] = colors[index]; - } - }); - - return output as ScaleOutput; -} - -// ============================================================================ -// Additional Exports -// ============================================================================ - -/** - * Calculate contrast between two colors with directionality - * - * @param foreground - Foreground color as RGB tuple - * @param background - Background color as RGB tuple - * @param baseV - Background lightness (0-1), optional - * @param formula - Contrast formula ('wcag2' or 'wcag3') - * @returns Signed contrast value (positive = darker than bg in light themes) - */ -export function contrast( - foreground: RGB, - background: RGB, - baseV?: number, - formula: ContrastFormula = "wcag2", -): number { - return getContrast(foreground, background, baseV, formula); -} - -/** - * Create a color scale function for custom use - * - * @param colorKeys - Array of hex color strings - * @param colorspace - Colorspace for interpolation - * @param swatches - Number of steps in the scale (default: 3000) - * @returns Function that takes a position and returns a hex color - */ -export function createColorScale( - colorKeys: string[], - colorspace: LeonardoColorspace = "LAB", - swatches = SCALE_LENGTH, -): (position: number) => string { - return createScale(colorKeys, colorspace, swatches); -} - -// Re-export types -export type { LeonardoOptions, LeonardoColorspace, ContrastFormula }; diff --git a/packages/color-engine/src/algorithms/material/index.ts b/packages/color-engine/src/algorithms/material/index.ts deleted file mode 100644 index ebd30e465..000000000 --- a/packages/color-engine/src/algorithms/material/index.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Material HCT Algorithm Wrapper - * - * Wraps @material/material-color-utilities to generate - * perceptually uniform color scales using HCT color space. - * - * HCT = Hue, Chroma, Tone (Google's perceptual color system) - */ - -import { - Hct, - TonalPalette, -} from "@material/material-color-utilities"; - -import type { MaterialOptions, ScaleOutput } from "../../core/types"; -import { hexToRgb, SCALE_STEPS } from "../../core/utils"; - -// ============================================================================ -// Default Values -// ============================================================================ - -/** - * Default tones for 11-step scale - * Maps to 50-950 naming convention - * Higher tone = lighter color - */ -const DEFAULT_TONES = [99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10]; - -// ============================================================================ -// Color Conversion Utilities -// ============================================================================ - -/** - * Convert hex to ARGB integer (Material format) - */ -function hexToArgb(hex: string): number { - const rgb = hexToRgb(hex); - return (255 << 24) | (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; -} - -/** - * Convert ARGB integer to hex string - */ -function argbToHex(argb: number): string { - const r = (argb >> 16) & 0xff; - const g = (argb >> 8) & 0xff; - const b = argb & 0xff; - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; -} - -// ============================================================================ -// Contrast Level Adjustment -// ============================================================================ - -/** - * Adjust tones based on contrast level - * - * contrastLevel: -1 (reduced) to 1 (high) - * - Negative: compress tone range toward middle - * - Zero: use standard tones - * - Positive: expand tone range toward extremes - */ -function adjustTonesForContrast( - tones: number[], - contrastLevel: number -): number[] { - if (contrastLevel === 0) return tones; - - return tones.map((tone) => { - // Middle tone is 50 - const distanceFromMiddle = tone - 50; - - if (contrastLevel > 0) { - // Expand: push tones further from middle - const expansion = distanceFromMiddle * contrastLevel * 0.3; - return Math.max(0, Math.min(100, tone + expansion)); - } else { - // Compress: pull tones closer to middle - const compression = distanceFromMiddle * Math.abs(contrastLevel) * 0.3; - return Math.max(0, Math.min(100, tone - compression)); - } - }); -} - -// ============================================================================ -// Main API -// ============================================================================ - -/** - * Generate a color scale using Material HCT - * - * HCT provides perceptually uniform color manipulation: - * - Hue: Color identity (0-360) - * - Chroma: Colorfulness (0-150+) - * - Tone: Perceptual lightness (0-100) - * - * @param options - Configuration options - * @returns ScaleOutput with 11 steps (50-950) - */ -export function generateScale(options: MaterialOptions): ScaleOutput { - const { - color, - tones = DEFAULT_TONES, - chroma: chromaOverride, - hue: hueOverride, - contrastLevel = 0, - } = options; - - // Parse source color - const argb = hexToArgb(color); - const sourceHct = Hct.fromInt(argb); - - // Determine final hue and chroma - const finalHue = hueOverride ?? sourceHct.hue; - const finalChroma = chromaOverride ?? sourceHct.chroma; - - // Create tonal palette from hue and chroma - const palette = TonalPalette.fromHueAndChroma(finalHue, finalChroma); - - // Adjust tones based on contrast level - const adjustedTones = adjustTonesForContrast(tones, contrastLevel); - - // Generate colors at each tone - const output: Partial = {}; - - SCALE_STEPS.forEach((step, index) => { - if (index < adjustedTones.length) { - const tone = adjustedTones[index] ?? 50; - const colorArgb = palette.tone(tone); - output[step] = argbToHex(colorArgb); - } - }); - - return output as ScaleOutput; -} - -// ============================================================================ -// Additional Utilities -// ============================================================================ - -/** - * Get HCT values for a color - * Useful for debugging or advanced use cases - */ -export function getHct(hex: string): { hue: number; chroma: number; tone: number } { - const argb = hexToArgb(hex); - const hct = Hct.fromInt(argb); - return { - hue: hct.hue, - chroma: hct.chroma, - tone: hct.tone, - }; -} - -/** - * Create a color from HCT values - */ -export function fromHct(hue: number, chroma: number, tone: number): string { - const hct = Hct.from(hue, chroma, tone); - return argbToHex(hct.toInt()); -} - -/** - * Get recommended tones for specific contrast ratios against a background tone - * - * Based on HCT's tone-based contrast guarantees: - * - Tone difference of 40+ ensures ~3:1 contrast - * - Tone difference of 50+ ensures ~4.5:1 contrast - * - Tone difference of 60+ ensures ~7:1 contrast - */ -export function getContrastTones( - backgroundTone: number, - ratios: number[] -): number[] { - // Approximate tone differences for common WCAG ratios - const ratioToToneDiff: Record = { - 1.5: 15, - 2: 25, - 3: 40, - 4.5: 50, - 7: 60, - 11: 75, - 15: 85, - }; - - return ratios.map((ratio) => { - // Find closest ratio in our lookup - const knownRatios = Object.keys(ratioToToneDiff) - .map(Number) - .sort((a, b) => a - b); - - let toneDiff = 40; // Default - for (const known of knownRatios) { - if (ratio <= known) { - toneDiff = ratioToToneDiff[known] ?? 40; - break; - } - } - - // Determine direction based on background - if (backgroundTone > 50) { - // Light background: go darker - return Math.max(0, backgroundTone - toneDiff); - } else { - // Dark background: go lighter - return Math.min(100, backgroundTone + toneDiff); - } - }); -} - -// Re-export types for convenience -export type { MaterialOptions }; diff --git a/packages/color-engine/src/algorithms/radix/index.ts b/packages/color-engine/src/algorithms/radix/index.ts deleted file mode 100644 index 233fa9be8..000000000 --- a/packages/color-engine/src/algorithms/radix/index.ts +++ /dev/null @@ -1,410 +0,0 @@ -/** - * Radix Colors Algorithm - * - * A simplified implementation of Radix UI's color scale generation. - * Uses Delta E OK distance to find the closest predefined scales - * and blends between them based on the input color. - * - * Original: https://github.com/radix-ui/colors - * License: MIT - */ - -import type { RadixOptions, ScaleOutput, RGB, OKLCH } from "../../core/types"; -import { - hexToRgb, - rgbToHex, - rgbToOklch, - oklchToRgb, - luminance, - lerp, - SCALE_STEPS, -} from "../../core/utils"; - -// ============================================================================ -// Predefined Radix Color Scales (Step 6 as reference) -// ============================================================================ - -/** - * Reference colors at step 6 (middle of scale) for each Radix color - * These are used to find the closest scale via Delta E - */ -const RADIX_REFERENCE_COLORS: Record = { - // Grays - gray: "#868e96", - mauve: "#8e8c99", - slate: "#8b8d98", - sage: "#868e8b", - olive: "#898e87", - sand: "#8f8b82", - - // Colors - tomato: "#ec5e42", - red: "#e5484d", - ruby: "#e54666", - crimson: "#e93d82", - pink: "#d6409f", - plum: "#ab4aba", - purple: "#8e4ec6", - violet: "#6e56cf", - iris: "#5b5bd6", - indigo: "#3e63dd", - blue: "#0090ff", - cyan: "#00a2c7", - teal: "#12a594", - jade: "#29a383", - green: "#30a46c", - grass: "#46a758", - brown: "#ad7f58", - bronze: "#a18072", - gold: "#978365", - sky: "#7ce2fe", - mint: "#86ead4", - lime: "#bdee63", - yellow: "#ffe629", - amber: "#ffc53d", - orange: "#f76b15", -}; - -/** - * Full 12-step scales for light mode - * Step 1 = lightest, Step 12 = darkest - */ -const RADIX_LIGHT_SCALES: Record = { - gray: [ - "#fcfcfc", "#f9f9f9", "#f0f0f0", "#e8e8e8", "#e0e0e0", "#d9d9d9", - "#cecece", "#bbbbbb", "#8d8d8d", "#838383", "#646464", "#202020", - ], - mauve: [ - "#fdfcfd", "#faf9fb", "#f2eff3", "#eae7ec", "#e3dfe6", "#dbd8e0", - "#d0cdd7", "#bcbac7", "#8e8c99", "#84828e", "#65636d", "#211f26", - ], - slate: [ - "#fcfcfd", "#f9f9fb", "#f0f0f3", "#e8e8ec", "#e0e1e6", "#d9d9e0", - "#cdced6", "#b9bbc6", "#8b8d98", "#80838d", "#60646c", "#1c2024", - ], - blue: [ - "#fbfdff", "#f4faff", "#e6f4fe", "#d5efff", "#c2e5ff", "#acd8fc", - "#8ec8f6", "#5eb1ef", "#0090ff", "#0588f0", "#0d74ce", "#113264", - ], - green: [ - "#fbfefc", "#f4fbf6", "#e6f6eb", "#d6f1df", "#c4e8d1", "#adddc0", - "#8eceaa", "#5bb98b", "#30a46c", "#2b9a66", "#218358", "#193b2d", - ], - red: [ - "#fffcfc", "#fff7f7", "#feebec", "#ffdbdc", "#ffcdce", "#fdbdbe", - "#f4a9aa", "#eb8e90", "#e5484d", "#dc3e42", "#ce2c31", "#641723", - ], - orange: [ - "#fefcfb", "#fff7ed", "#ffefd6", "#ffdfb5", "#ffd19a", "#ffc182", - "#f5ae73", "#ec9455", "#f76b15", "#ef5f00", "#cc4e00", "#582d1d", - ], - yellow: [ - "#fdfdf9", "#fefce9", "#fffab8", "#fff394", "#ffe770", "#f3d768", - "#e4c767", "#d5ae49", "#ffe629", "#ffdc00", "#9e6c00", "#473b1f", - ], - purple: [ - "#fefcfe", "#fbf7fe", "#f7edfe", "#f2e2fc", "#ead5f9", "#e0c4f4", - "#d1afec", "#be93e4", "#8e4ec6", "#8347b9", "#6f3a9e", "#2b0e44", - ], - pink: [ - "#fffcfe", "#fef7fb", "#fee9f5", "#fbdcef", "#f6cee7", "#efbfdd", - "#e7acd0", "#dd93c2", "#d6409f", "#cf3897", "#c2298a", "#651249", - ], - cyan: [ - "#fafdfe", "#f2fafb", "#def7f9", "#caf1f6", "#b5e9f0", "#9ddde7", - "#7dcedc", "#52bece", "#00a2c7", "#0797b9", "#107d98", "#0d3c48", - ], - teal: [ - "#fafefd", "#f3fbf9", "#e0f8f3", "#ccf3eb", "#b8ece1", "#a1e3d4", - "#83d6c4", "#53c5af", "#12a594", "#0d9b8a", "#067a6f", "#0d3d38", - ], -}; - -/** - * Full 12-step scales for dark mode - */ -const RADIX_DARK_SCALES: Record = { - gray: [ - "#111111", "#191919", "#222222", "#2a2a2a", "#313131", "#3a3a3a", - "#484848", "#606060", "#6e6e6e", "#7b7b7b", "#b4b4b4", "#eeeeee", - ], - mauve: [ - "#121113", "#1a191b", "#232225", "#2b292d", "#323035", "#3c393f", - "#49474e", "#625f69", "#6f6d78", "#7c7a85", "#b5b2bc", "#eeeef0", - ], - slate: [ - "#111113", "#18191b", "#212225", "#272a2d", "#2e3135", "#363a3f", - "#43484e", "#5a6169", "#696e77", "#777b84", "#b0b4ba", "#edeef0", - ], - blue: [ - "#0d1520", "#111927", "#0d2847", "#003362", "#004074", "#104d87", - "#205d9e", "#2870bd", "#0090ff", "#3b9eff", "#70b8ff", "#c2e6ff", - ], - green: [ - "#0e1512", "#121b17", "#132d21", "#113b29", "#174933", "#20573e", - "#28684a", "#2f7c57", "#30a46c", "#33b074", "#3dd68c", "#b1f1cb", - ], - red: [ - "#191111", "#201314", "#3b1219", "#500f1c", "#611623", "#72232d", - "#8c333a", "#b54548", "#e5484d", "#ec5d5e", "#ff9592", "#ffd1d9", - ], - orange: [ - "#17120e", "#1e160f", "#331e0b", "#462100", "#562800", "#66350c", - "#7e451d", "#a35829", "#f76b15", "#ff801f", "#ffa057", "#ffe0c2", - ], - yellow: [ - "#14120b", "#1b180f", "#2d2305", "#362b00", "#433500", "#524202", - "#665417", "#836a21", "#ffe629", "#ffff57", "#f5e147", "#f6eeb4", - ], - purple: [ - "#18111b", "#1e1523", "#301c3b", "#3d224e", "#48295c", "#54346b", - "#664282", "#7e5ba7", "#8e4ec6", "#9a5cd0", "#c191f2", "#ecd9fa", - ], - pink: [ - "#191117", "#21121d", "#37172f", "#4b143d", "#591c47", "#692955", - "#833869", "#a84f85", "#d6409f", "#de51a8", "#ff8dcc", "#fdd1ea", - ], - cyan: [ - "#0b161a", "#101b20", "#082c36", "#003848", "#004558", "#045468", - "#12677e", "#11809c", "#00a2c7", "#23afd0", "#4ccce6", "#b6ecf7", - ], - teal: [ - "#0d1514", "#111c1b", "#0d2d2a", "#023b37", "#084843", "#145750", - "#1c6961", "#207e73", "#12a594", "#0eb39e", "#0bd8b6", "#adf0dd", - ], -}; - -// ============================================================================ -// Delta E OK Distance -// ============================================================================ - -/** - * Calculate Delta E OK distance between two colors - * This is a perceptually uniform color difference metric - */ -function deltaEOk(color1: OKLCH, color2: OKLCH): number { - const dL = color1.l - color2.l; - const dC = color1.c - color2.c; - - // Handle hue difference (shortest path around circle) - let dH = color1.h - color2.h; - if (dH > 180) dH -= 360; - if (dH < -180) dH += 360; - - // Convert hue difference to chord length - const avgC = (color1.c + color2.c) / 2; - const dHChord = 2 * avgC * Math.sin((dH * Math.PI) / 360); - - return Math.sqrt(dL * dL + dC * dC + dHChord * dHChord); -} - -/** - * Find the closest Radix scale to a given color - */ -function findClosestScale(color: string): { name: string; distance: number }[] { - const inputOklch = rgbToOklch(hexToRgb(color)); - - const distances = Object.entries(RADIX_REFERENCE_COLORS).map(([name, refHex]) => { - const refOklch = rgbToOklch(hexToRgb(refHex)); - return { - name, - distance: deltaEOk(inputOklch, refOklch), - }; - }); - - // Sort by distance (closest first) - return distances.sort((a, b) => a.distance - b.distance); -} - -// ============================================================================ -// Color Blending -// ============================================================================ - -/** - * Blend two OKLCH colors - */ -function blendOklch(c1: OKLCH, c2: OKLCH, t: number): OKLCH { - // Handle hue interpolation (shortest path) - let h1 = c1.h; - let h2 = c2.h; - const diff = h2 - h1; - - if (diff > 180) h1 += 360; - else if (diff < -180) h2 += 360; - - let h = lerp(h1, h2, t) % 360; - if (h < 0) h += 360; - - return { - l: lerp(c1.l, c2.l, t), - c: lerp(c1.c, c2.c, t), - h, - }; -} - -/** - * Blend two hex colors - */ -function blendHex(hex1: string, hex2: string, t: number): string { - const c1 = rgbToOklch(hexToRgb(hex1)); - const c2 = rgbToOklch(hexToRgb(hex2)); - const blended = blendOklch(c1, c2, t); - return rgbToHex(oklchToRgb(blended)); -} - -/** - * Blend two color scales - */ -function blendScales(scale1: string[], scale2: string[], t: number): string[] { - return scale1.map((color, i) => blendHex(color, scale2[i] ?? color, t)); -} - -// ============================================================================ -// Background Adaptation -// ============================================================================ - -/** - * Determine if background is light or dark - */ -function isLightBackground(background: string): boolean { - const rgb = hexToRgb(background); - return luminance(rgb) > 0.5; -} - -/** - * Adjust scale for a specific background - * This shifts the scale to ensure proper contrast - */ -function adjustScaleForBackground( - scale: string[], - background: string, - isLight: boolean -): string[] { - const bgOklch = rgbToOklch(hexToRgb(background)); - - return scale.map((hex, index) => { - const oklch = rgbToOklch(hexToRgb(hex)); - - // Adjust lightness based on step and background - // Steps 1-2 should be close to background - // Steps 11-12 should have high contrast - if (isLight) { - // Light mode: step 1 should be near-white, step 12 near-black - const targetL = 1 - (index / 11) * 0.95; - const adjusted: OKLCH = { - ...oklch, - l: lerp(oklch.l, targetL, 0.3), // Gentle adjustment - }; - return rgbToHex(oklchToRgb(adjusted)); - } else { - // Dark mode: step 1 should be near-black, step 12 near-white - const targetL = (index / 11) * 0.95; - const adjusted: OKLCH = { - ...oklch, - l: lerp(oklch.l, targetL, 0.3), - }; - return rgbToHex(oklchToRgb(adjusted)); - } - }); -} - -// ============================================================================ -// Main API -// ============================================================================ - -/** - * Generate a color scale using Radix-style blending - * - * The algorithm: - * 1. Find the two closest predefined Radix scales via Delta E OK - * 2. Calculate blend ratio based on distances - * 3. Blend the scales in OKLCH space - * 4. Adjust for the target background - * - * @param options - Configuration options - * @returns ScaleOutput with 11 steps (50-950) - */ -export function generateScale(options: RadixOptions): ScaleOutput { - const { accent, background, gray } = options; - - // Determine light/dark mode from background - const isLight = isLightBackground(background); - const scales = isLight ? RADIX_LIGHT_SCALES : RADIX_DARK_SCALES; - - // Find closest scales - const closest = findClosestScale(accent); - const primary = closest[0] ?? { name: "gray", distance: 0 }; - const secondary = closest[1] ?? { name: "gray", distance: 0 }; - - // Get the scales (fallback to gray if not found) - const grayScale = scales.gray!; - const primaryScale = scales[primary.name] ?? grayScale; - const secondaryScale = scales[secondary.name] ?? grayScale; - - // Calculate blend ratio using tangent-based method (Radix style) - // When distance is 0, use 100% primary - // As distance increases, blend more with secondary - const totalDist = primary.distance + secondary.distance; - const blendRatio = totalDist > 0 ? secondary.distance / totalDist : 1; - - // Blend the two scales - let blendedScale = blendScales(primaryScale, secondaryScale, 1 - blendRatio); - - // Adjust hue toward the accent color - const accentOklch = rgbToOklch(hexToRgb(accent)); - blendedScale = blendedScale.map((hex, index) => { - const oklch = rgbToOklch(hexToRgb(hex)); - // Stronger hue influence in the middle steps (4-9) - const hueInfluence = index >= 3 && index <= 8 ? 0.6 : 0.3; - const adjusted = blendOklch(oklch, { ...oklch, h: accentOklch.h }, hueInfluence); - return rgbToHex(oklchToRgb(adjusted)); - }); - - // Adjust for background - blendedScale = adjustScaleForBackground(blendedScale, background, isLight); - - // Map 12-step Radix scale to 11-step output - // We'll skip step 1 (too close to background) and use steps 2-12 - const mappedScale = [ - blendedScale[1], // 50 - blendedScale[2], // 100 - blendedScale[3], // 200 - blendedScale[4], // 300 - blendedScale[5], // 400 - blendedScale[6], // 500 - blendedScale[7], // 600 - blendedScale[8], // 700 - blendedScale[9], // 800 - blendedScale[10], // 900 - blendedScale[11], // 950 - ]; - - // Build output - const output: Partial = {}; - SCALE_STEPS.forEach((step, index) => { - output[step] = mappedScale[index]; - }); - - return output as ScaleOutput; -} - -/** - * Get a predefined Radix scale by name - */ -export function getRadixScale( - name: string, - mode: "light" | "dark" = "light" -): string[] | null { - const scales = mode === "light" ? RADIX_LIGHT_SCALES : RADIX_DARK_SCALES; - return scales[name] || null; -} - -/** - * Get all available Radix scale names (only those with full scale definitions) - */ -export function getRadixScaleNames(): string[] { - return Object.keys(RADIX_LIGHT_SCALES); -} - -// Re-export types -export type { RadixOptions }; diff --git a/packages/color-engine/src/core/index.ts b/packages/color-engine/src/core/index.ts deleted file mode 100644 index 5aa7a4f9a..000000000 --- a/packages/color-engine/src/core/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Core exports for @dotui/color-engine - */ - -export * from "./types"; -export * from "./utils"; diff --git a/packages/color-engine/src/core/types.ts b/packages/color-engine/src/core/types.ts deleted file mode 100644 index 0825487c9..000000000 --- a/packages/color-engine/src/core/types.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Core types for @dotui/color-engine - */ - -// ============================================================================ -// Color Types -// ============================================================================ - -/** RGB color as tuple [r, g, b] where values are 0-255 */ -export type RGB = [number, number, number]; - -/** OKLCH color object */ -export interface OKLCH { - l: number; // Lightness 0-1 - c: number; // Chroma 0-0.4+ - h: number; // Hue 0-360 -} - -/** HSL color object */ -export interface HSL { - h: number; // Hue 0-360 - s: number; // Saturation 0-100 - l: number; // Lightness 0-100 -} - -// ============================================================================ -// Scale Output -// ============================================================================ - -/** Standard 11-step scale output (50-950) */ -export interface ScaleOutput { - "50": string; - "100": string; - "200": string; - "300": string; - "400": string; - "500": string; - "600": string; - "700": string; - "800": string; - "900": string; - "950": string; -} - -/** Step names for 11-step scale */ -export const SCALE_STEPS = [ - "50", - "100", - "200", - "300", - "400", - "500", - "600", - "700", - "800", - "900", - "950", -] as const; - -export type ScaleStep = (typeof SCALE_STEPS)[number]; - -// ============================================================================ -// Algorithm Types -// ============================================================================ - -export type Algorithm = "leonardo" | "material" | "radix"; - -// ============================================================================ -// Leonardo Types -// ============================================================================ - -/** Colorspaces supported by Leonardo for interpolation */ -export type LeonardoColorspace = - | "RGB" - | "HEX" - | "HSL" - | "HSLuv" - | "HSV" - | "LAB" - | "LCH" - | "OKLAB" - | "OKLCH" - | "CAM02" - | "CAM02p"; - -/** Contrast formula options */ -export type ContrastFormula = "wcag2" | "wcag3"; - -/** Leonardo algorithm options */ -export interface LeonardoOptions { - /** Primary color (hex) */ - color: string; - - /** Background color for contrast calculations (hex) */ - background: string; - - /** Additional color keys for gradient interpolation */ - colorKeys?: string[]; - - /** - * Target contrast ratios for each step - * @default [1.05, 1.15, 1.3, 1.5, 2, 3, 4.5, 6, 8, 12, 15] - */ - ratios?: number[]; - - /** - * Colorspace for interpolation - * @default 'OKLCH' - */ - colorspace?: LeonardoColorspace; - - /** - * Saturation modifier (0-100) - * @default 100 - */ - saturation?: number; - - /** - * Contrast multiplier - scales all ratios - * @default 1 - */ - contrast?: number; - - /** - * Use smooth Bezier interpolation - * @default false - */ - smooth?: boolean; - - /** - * Contrast calculation formula - * @default 'wcag2' - */ - formula?: ContrastFormula; -} - -// ============================================================================ -// Material Types -// ============================================================================ - -/** Material algorithm options */ -export interface MaterialOptions { - /** Source color (hex) */ - color: string; - - /** - * Specific tone values to generate (0-100) - * @default [99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10] - */ - tones?: number[]; - - /** - * Override chroma value (0-150+) - * If not provided, uses source color's chroma - */ - chroma?: number; - - /** - * Override hue value (0-360) - * If not provided, uses source color's hue - */ - hue?: number; - - /** - * Contrast level adjustment (-1 to 1) - * -1 = reduced, 0 = standard, 1 = high - * @default 0 - */ - contrastLevel?: number; -} - -// ============================================================================ -// Radix Types -// ============================================================================ - -/** Radix algorithm options */ -export interface RadixOptions { - /** Accent/brand color (hex) */ - accent: string; - - /** Background color (hex) */ - background: string; - - /** - * Gray base color (hex) - * If not provided, auto-detected from accent - */ - gray?: string; -} - -/** Available predefined Radix scale names */ -export type RadixScaleName = - // Grays - | "gray" - | "mauve" - | "slate" - | "sage" - | "olive" - | "sand" - // Colors - | "tomato" - | "red" - | "ruby" - | "crimson" - | "pink" - | "plum" - | "purple" - | "violet" - | "iris" - | "indigo" - | "blue" - | "cyan" - | "teal" - | "jade" - | "green" - | "grass" - | "brown" - | "bronze" - | "gold" - | "sky" - | "mint" - | "lime" - | "yellow" - | "amber" - | "orange"; - -// ============================================================================ -// Unified API Types -// ============================================================================ - -/** Input for Leonardo algorithm via unified API */ -export interface LeonardoInput extends LeonardoOptions { - algorithm: "leonardo"; -} - -/** Input for Material algorithm via unified API */ -export interface MaterialInput extends MaterialOptions { - algorithm: "material"; -} - -/** Input for Radix algorithm via unified API */ -export interface RadixInput extends RadixOptions { - algorithm: "radix"; -} - -/** Unified generateScale input (discriminated union) */ -export type GenerateScaleInput = LeonardoInput | MaterialInput | RadixInput; diff --git a/packages/color-engine/src/core/utils.ts b/packages/color-engine/src/core/utils.ts deleted file mode 100644 index 291111942..000000000 --- a/packages/color-engine/src/core/utils.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Core color utilities for @dotui/color-engine - * - * Pure functions for color conversions and contrast calculations. - * No external dependencies - just math. - */ - -import type { HSL, OKLCH, RGB } from "./types"; - -export { SCALE_STEPS } from "./types"; - -// ============================================================================ -// Hex <-> RGB Conversions -// ============================================================================ - -/** - * Parse a hex color string to RGB tuple - * Supports #RGB, #RRGGBB, RGB, RRGGBB formats - */ -export function hexToRgb(hex: string): RGB { - // Remove # if present - const cleanHex = hex.replace(/^#/, ""); - - // Handle shorthand (#RGB) - const fullHex = - cleanHex.length === 3 - ? cleanHex - .split("") - .map((c) => c + c) - .join("") - : cleanHex; - - if (fullHex.length !== 6) { - throw new Error(`Invalid hex color: ${hex}`); - } - - const num = parseInt(fullHex, 16); - if (Number.isNaN(num)) { - throw new Error(`Invalid hex color: ${hex}`); - } - - return [(num >> 16) & 255, (num >> 8) & 255, num & 255]; -} - -/** - * Convert RGB tuple to hex string - */ -export function rgbToHex(rgb: RGB): string { - const r = Math.round(clamp(rgb[0], 0, 255)); - const g = Math.round(clamp(rgb[1], 0, 255)); - const b = Math.round(clamp(rgb[2], 0, 255)); - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; -} - -// ============================================================================ -// RGB <-> HSL Conversions -// ============================================================================ - -/** - * Convert RGB to HSL - */ -export function rgbToHsl(rgb: RGB): HSL { - const r = rgb[0] / 255; - const g = rgb[1] / 255; - const b = rgb[2] / 255; - - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const l = (max + min) / 2; - - if (max === min) { - return { h: 0, s: 0, l: l * 100 }; - } - - const d = max - min; - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - - let h = 0; - if (max === r) { - h = ((g - b) / d + (g < b ? 6 : 0)) / 6; - } else if (max === g) { - h = ((b - r) / d + 2) / 6; - } else { - h = ((r - g) / d + 4) / 6; - } - - return { - h: h * 360, - s: s * 100, - l: l * 100, - }; -} - -/** - * Convert HSL to RGB - */ -export function hslToRgb(hsl: HSL): RGB { - const h = hsl.h / 360; - const s = hsl.s / 100; - const l = hsl.l / 100; - - if (s === 0) { - const gray = Math.round(l * 255); - return [gray, gray, gray]; - } - - const hue2rgb = (p: number, q: number, t: number): 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; - - return [ - Math.round(hue2rgb(p, q, h + 1 / 3) * 255), - Math.round(hue2rgb(p, q, h) * 255), - Math.round(hue2rgb(p, q, h - 1 / 3) * 255), - ]; -} - -// ============================================================================ -// RGB <-> OKLCH Conversions -// ============================================================================ - -/** - * Convert RGB to linear RGB (remove gamma) - */ -function rgbToLinear(rgb: RGB): [number, number, number] { - return rgb.map((v) => { - const c = v / 255; - return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; - }) as [number, number, number]; -} - -/** - * Convert linear RGB to RGB (apply gamma) - */ -function linearToRgb(linear: [number, number, number]): RGB { - return linear.map((c) => { - const v = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; - return Math.round(clamp(v * 255, 0, 255)); - }) as RGB; -} - -/** - * Convert RGB to OKLCH - */ -export function rgbToOklch(rgb: RGB): OKLCH { - const [lr, lg, lb] = rgbToLinear(rgb); - - // RGB to OKLab - const l_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb; - const m_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb; - const s_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb; - - const l = Math.cbrt(l_); - const m = Math.cbrt(m_); - const s = Math.cbrt(s_); - - const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; - const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; - const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; - - // OKLab to OKLCH - const C = Math.sqrt(a * a + b * b); - let H = (Math.atan2(b, a) * 180) / Math.PI; - if (H < 0) H += 360; - - return { l: L, c: C, h: H }; -} - -/** - * Convert OKLCH to RGB - */ -export function oklchToRgb(oklch: OKLCH): RGB { - const { l: L, c: C, h: H } = oklch; - - // OKLCH to OKLab - const hRad = (H * Math.PI) / 180; - const a = C * Math.cos(hRad); - const b = C * Math.sin(hRad); - - // OKLab to linear RGB - const l = L + 0.3963377774 * a + 0.2158037573 * b; - const m = L - 0.1055613458 * a - 0.0638541728 * b; - const s = L - 0.0894841775 * a - 1.291485548 * b; - - const l_ = l * l * l; - const m_ = m * m * m; - const s_ = s * s * s; - - const lr = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_; - const lg = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_; - const lb = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_; - - return linearToRgb([lr, lg, lb]); -} - -// ============================================================================ -// Contrast Calculations (WCAG 2.1) -// ============================================================================ - -/** - * Calculate relative luminance of an RGB color - * https://www.w3.org/TR/WCAG21/#dfn-relative-luminance - */ -export function luminance(rgb: RGB): number { - const linearize = (v: number): number => { - const c = v / 255; - return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; - }; - const r = linearize(rgb[0]); - const g = linearize(rgb[1]); - const b = linearize(rgb[2]); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -} - -/** - * Calculate WCAG 2.1 contrast ratio between two colors - * Returns a value between 1 and 21 - */ -export function contrastRatio(rgb1: RGB, rgb2: RGB): number { - const l1 = luminance(rgb1); - const l2 = luminance(rgb2); - const lighter = Math.max(l1, l2); - const darker = Math.min(l1, l2); - return (lighter + 0.05) / (darker + 0.05); -} - -/** - * Calculate contrast with directionality (for Leonardo compatibility) - * Positive = foreground is darker than background - * Negative = foreground is lighter than background - */ -export function contrastWithDirection(foreground: RGB, background: RGB, backgroundLightness?: number): number { - const bgLum = backgroundLightness !== undefined ? backgroundLightness / 100 : luminance(background); - const fgLum = luminance(foreground); - - const l1 = Math.max(fgLum, bgLum); - const l2 = Math.min(fgLum, bgLum); - const ratio = (l1 + 0.05) / (l2 + 0.05); - - // Light background: darker colors are positive - // Dark background: lighter colors are positive - if (bgLum >= 0.5) { - return fgLum < bgLum ? ratio : -ratio; - } else { - return fgLum > bgLum ? ratio : -ratio; - } -} - -// ============================================================================ -// APCA Contrast (WCAG 3 Draft) -// ============================================================================ - -/** - * Calculate APCA contrast - * Returns a value between -108 and 106 - * Based on APCA-W3 algorithm - */ -export function apcaContrast(foreground: RGB, background: RGB): number { - // Linearize with sRGB TRC - const [fR, fG, fB] = foreground.map((v) => (v / 255) ** 2.4) as RGB; - const [bR, bG, bB] = background.map((v) => (v / 255) ** 2.4) as RGB; - - // Calculate Y (luminance) using APCA coefficients - const fY = 0.2126729 * fR + 0.7151522 * fG + 0.072175 * fB; - const bY = 0.2126729 * bR + 0.7151522 * bG + 0.072175 * bB; - - // APCA constants - const normBG = 0.56; - const normTXT = 0.57; - const revTXT = 0.62; - const revBG = 0.65; - const blkThrs = 0.022; - const blkClmp = 1.414; - const scaleBoW = 1.14; - const scaleWoB = 1.14; - const loBoWoffset = 0.027; - const loWoBoffset = 0.027; - - // Clamp luminance - const txtY = fY > blkThrs ? fY : fY + (blkThrs - fY) ** blkClmp; - const bgY = bY > blkThrs ? bY : bY + (blkThrs - bY) ** blkClmp; - - // Calculate raw contrast - let contrast: number; - if (bgY > txtY) { - // Dark text on light background - contrast = (bgY ** normBG - txtY ** normTXT) * scaleBoW; - contrast = contrast < loBoWoffset ? 0 : contrast - loBoWoffset; - } else { - // Light text on dark background - contrast = (bgY ** revBG - txtY ** revTXT) * scaleWoB; - contrast = contrast > -loWoBoffset ? 0 : contrast + loWoBoffset; - } - - return contrast * 100; -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Clamp a value between min and max - */ -export function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max); -} - -/** - * Round a number to specified decimal places - */ -export function round(value: number, decimals = 0): number { - const factor = 10 ** decimals; - return Math.round(value * factor) / factor; -} - -/** - * Linear interpolation between two values - */ -export function lerp(a: number, b: number, t: number): number { - return a + (b - a) * t; -} - -/** - * Interpolate between two RGB colors - */ -export function interpolateRgb(rgb1: RGB, rgb2: RGB, t: number): RGB { - return [ - Math.round(lerp(rgb1[0], rgb2[0], t)), - Math.round(lerp(rgb1[1], rgb2[1], t)), - Math.round(lerp(rgb1[2], rgb2[2], t)), - ]; -} - -/** - * Interpolate between two OKLCH colors - * Handles hue interpolation correctly (shortest path) - */ -export function interpolateOklch(oklch1: OKLCH, oklch2: OKLCH, t: number): OKLCH { - // Handle hue interpolation (shortest path around the circle) - let h1 = oklch1.h; - let h2 = oklch2.h; - - const hDiff = h2 - h1; - if (hDiff > 180) { - h1 += 360; - } else if (hDiff < -180) { - h2 += 360; - } - - let h = lerp(h1, h2, t) % 360; - if (h < 0) h += 360; - - return { - l: lerp(oklch1.l, oklch2.l, t), - c: lerp(oklch1.c, oklch2.c, t), - h, - }; -} - -/** - * Check if a hex color string is valid - */ -export function isValidHex(hex: string): boolean { - return /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(hex); -} - -/** - * Get the best text color (black or white) for a given background - */ -export function getContrastTextColor(background: string): string { - const rgb = hexToRgb(background); - const blackContrast = contrastRatio(rgb, [0, 0, 0]); - const whiteContrast = contrastRatio(rgb, [255, 255, 255]); - return blackContrast > whiteContrast ? "#000000" : "#ffffff"; -} diff --git a/packages/color-engine/src/index.ts b/packages/color-engine/src/index.ts deleted file mode 100644 index 31bbbf816..000000000 --- a/packages/color-engine/src/index.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @dotui/color-engine - * - * A unified color palette generator supporting multiple algorithms: - * - Leonardo: Adobe's contrast-based color generation (WCAG compliant) - * - Material: Google's HCT perceptual color system - * - Radix: Radix UI's hand-tuned scale blending - * - * @example - * // Unified API (loads all algorithms) - * import { generateScale } from '@dotui/color-engine'; - * - * const scale = generateScale({ - * algorithm: 'leonardo', - * color: '#6366f1', - * background: '#ffffff', - * }); - * - * @example - * // Tree-shakeable direct imports - * import { generateScale } from '@dotui/color-engine/leonardo'; - * - * const scale = generateScale({ - * color: '#6366f1', - * background: '#ffffff', - * }); - */ - -// ============================================================================ -// Types -// ============================================================================ - -export type { - // Core types - RGB, - HSL, - OKLCH, - ScaleOutput, - ScaleStep, - Algorithm, - // Algorithm-specific input types - LeonardoOptions, - LeonardoColorspace, - ContrastFormula, - MaterialOptions, - RadixOptions, - RadixScaleName, - // Unified input type - GenerateScaleInput, - LeonardoInput, - MaterialInput, - RadixInput, -} from "./core/types"; - -export { SCALE_STEPS } from "./core/types"; - -// ============================================================================ -// Algorithm Imports -// ============================================================================ - -import { generateScale as generateLeonardoScale } from "./algorithms/leonardo"; -import { generateScale as generateMaterialScale } from "./algorithms/material"; -import { generateScale as generateRadixScale } from "./algorithms/radix"; - -import type { GenerateScaleInput, ScaleOutput } from "./core/types"; - -// ============================================================================ -// Unified API -// ============================================================================ - -/** - * Generate a color scale using the specified algorithm - * - * @param input - Configuration including algorithm selection and options - * @returns ScaleOutput with 11 steps (50, 100, 200, ..., 900, 950) - * - * @example - * // Leonardo (contrast-based) - * const scale = generateScale({ - * algorithm: 'leonardo', - * color: '#6366f1', - * background: '#ffffff', - * ratios: [1.05, 1.15, 1.3, 1.5, 2, 3, 4.5, 6, 8, 12, 15], - * }); - * - * @example - * // Material HCT (perceptual) - * const scale = generateScale({ - * algorithm: 'material', - * color: '#6366f1', - * tones: [99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10], - * }); - * - * @example - * // Radix (hand-tuned blending) - * const scale = generateScale({ - * algorithm: 'radix', - * accent: '#6366f1', - * background: '#ffffff', - * }); - */ -export function generateScale(input: GenerateScaleInput): ScaleOutput { - switch (input.algorithm) { - case "leonardo": { - const { algorithm: _, ...options } = input; - return generateLeonardoScale(options); - } - case "material": { - const { algorithm: _, ...options } = input; - return generateMaterialScale(options); - } - case "radix": { - const { algorithm: _, ...options } = input; - return generateRadixScale(options); - } - default: { - // TypeScript exhaustiveness check - const _exhaustive: never = input; - throw new Error(`Unknown algorithm: ${(_exhaustive as GenerateScaleInput).algorithm}`); - } - } -} - -// ============================================================================ -// Named Algorithm Exports (for direct use) -// ============================================================================ - -export { - generateLeonardoScale, - generateMaterialScale, - generateRadixScale, -}; - -// ============================================================================ -// Utility Re-exports -// ============================================================================ - -export { - // Color conversions - hexToRgb, - rgbToHex, - rgbToHsl, - hslToRgb, - rgbToOklch, - oklchToRgb, - // Contrast calculations - luminance, - contrastRatio, - contrastWithDirection, - apcaContrast, - // Utilities - clamp, - round, - lerp, - interpolateRgb, - interpolateOklch, - isValidHex, - getContrastTextColor, -} from "./core/utils"; - -// ============================================================================ -// Algorithm-specific Utilities -// ============================================================================ - -export { - getHct, - fromHct, - getContrastTones, -} from "./algorithms/material"; - -export { - getRadixScale, - getRadixScaleNames, -} from "./algorithms/radix"; diff --git a/packages/color-engine/src/types/apca-w3.d.ts b/packages/color-engine/src/types/apca-w3.d.ts deleted file mode 100644 index 0c0a9ef92..000000000 --- a/packages/color-engine/src/types/apca-w3.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Type declarations for apca-w3 - * https://github.com/nicholastsui/apca-w3 - */ - -declare module "apca-w3" { - /** - * Calculate APCA contrast between two colors - * @param textY - Luminance of text color - * @param bgY - Luminance of background color - * @returns APCA contrast value (roughly -108 to 106) - */ - export function APCAcontrast(textY: number, bgY: number): number; - - /** - * Convert sRGB color to Y (luminance) - * @param rgb - RGB array [r, g, b] where values are 0-255 - * @returns Luminance value - */ - export function sRGBtoY(rgb: [number, number, number]): number; - - /** - * Calculate contrast from hex colors - * @param textColor - Text color as hex string - * @param bgColor - Background color as hex string - * @returns APCA contrast value - */ - export function calcAPCA(textColor: string, bgColor: string): number; -} diff --git a/packages/color-engine/src/types/ciebase.d.ts b/packages/color-engine/src/types/ciebase.d.ts deleted file mode 100644 index b4fd21a32..000000000 --- a/packages/color-engine/src/types/ciebase.d.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Type declarations for ciebase - * https://github.com/nickshanks/ciebase - */ - -declare module "ciebase" { - /** XYZ illuminant values */ - export interface Illuminant { - x: number; - y: number; - z: number; - } - - /** Standard illuminants */ - export const illuminant: { - D50: Illuminant; - D55: Illuminant; - D65: Illuminant; - D75: Illuminant; - }; - - /** Color workspace */ - export interface Workspace { - toXyz: number[][]; - fromXyz: number[][]; - gamma: number; - } - - /** Standard workspaces */ - export const workspace: { - sRGB: Workspace; - AdobeRGB: Workspace; - AppleRGB: Workspace; - }; - - /** RGB conversion utilities */ - export const rgb: { - fromHex(hex: string): [number, number, number]; - toHex(rgb: [number, number, number]): string; - }; - - /** XYZ colorspace converter */ - export interface XyzConverter { - fromRgb(rgb: [number, number, number]): { x: number; y: number; z: number }; - toRgb(xyz: { x: number; y: number; z: number }): [number, number, number]; - } - - /** Create XYZ converter for a workspace and illuminant */ - export function xyz(workspace: Workspace, illuminant: Illuminant): XyzConverter; -} diff --git a/packages/color-engine/src/types/ciecam02.d.ts b/packages/color-engine/src/types/ciecam02.d.ts deleted file mode 100644 index 5e2976ae9..000000000 --- a/packages/color-engine/src/types/ciecam02.d.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Type declarations for ciecam02 - * https://github.com/baskerville/ciecam02 - */ - -declare module "ciecam02" { - import type { Illuminant } from "ciebase"; - - /** CIECAM02 viewing conditions */ - export interface ViewingConditions { - /** White point illuminant */ - whitePoint: Illuminant; - /** Adapting luminance (cd/m²), typically 40 */ - adaptingLuminance: number; - /** Background luminance (% of white), typically 20 */ - backgroundLuminance: number; - /** Surround type: 'average', 'dim', or 'dark' */ - surroundType: "average" | "dim" | "dark"; - /** Whether to discount the illuminant */ - discounting: boolean; - } - - /** JCh color appearance correlates */ - export interface JChCorrelates { - /** Lightness (0-100) */ - J: number; - /** Chroma */ - C: number; - /** Hue angle (0-360) */ - h: number; - } - - /** Full CAM02 correlates */ - export interface CamCorrelates extends JChCorrelates { - /** Colorfulness */ - M: number; - /** Saturation */ - s: number; - /** Hue composition */ - H: number; - /** Brightness */ - Q: number; - } - - /** CAM converter */ - export interface CamConverter { - fromXyz(xyz: { x: number; y: number; z: number }): T; - toXyz(cam: T): { x: number; y: number; z: number }; - } - - /** - * Correlate filter string - * @example 'JCh' for J, C, h correlates only - */ - export function cfs(correlates: string): string; - - /** - * Create CAM converter with viewing conditions - * @param conditions - Viewing conditions - * @param correlates - Optional correlate filter (from cfs()) - */ - export function cam(conditions: ViewingConditions, correlates?: string): CamConverter; - - /** - * Create gamut mapping for a colorspace and CAM - */ - export function gamut(xyz: unknown, cam: CamConverter): unknown; -} diff --git a/packages/color-engine/src/types/hsluv.d.ts b/packages/color-engine/src/types/hsluv.d.ts deleted file mode 100644 index 678d59292..000000000 --- a/packages/color-engine/src/types/hsluv.d.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Type declarations for hsluv - * https://github.com/hsluv/hsluv - */ - -declare module "hsluv" { - /** - * Convert HSLuv to RGB - * @param tuple - [H, S, L] where H is 0-360, S is 0-100, L is 0-100 - * @returns [R, G, B] where values are 0-1 - */ - export function hsluvToRgb(tuple: [number, number, number]): [number, number, number]; - - /** - * Convert RGB to HSLuv - * @param tuple - [R, G, B] where values are 0-1 - * @returns [H, S, L] where H is 0-360, S is 0-100, L is 0-100 - */ - export function rgbToHsluv(tuple: [number, number, number]): [number, number, number]; - - /** - * Convert HPLuv to RGB - * @param tuple - [H, P, L] - * @returns [R, G, B] where values are 0-1 - */ - export function hpluvToRgb(tuple: [number, number, number]): [number, number, number]; - - /** - * Convert RGB to HPLuv - * @param tuple - [R, G, B] where values are 0-1 - * @returns [H, P, L] - */ - export function rgbToHpluv(tuple: [number, number, number]): [number, number, number]; - - /** - * Convert HSLuv to hex - * @param tuple - [H, S, L] - * @returns Hex color string - */ - export function hsluvToHex(tuple: [number, number, number]): string; - - /** - * Convert hex to HSLuv - * @param hex - Hex color string - * @returns [H, S, L] - */ - export function hexToHsluv(hex: string): [number, number, number]; - - /** - * Convert HPLuv to hex - * @param tuple - [H, P, L] - * @returns Hex color string - */ - export function hpluvToHex(tuple: [number, number, number]): string; - - /** - * Convert hex to HPLuv - * @param hex - Hex color string - * @returns [H, P, L] - */ - export function hexToHpluv(hex: string): [number, number, number]; -} diff --git a/packages/color-engine/tests/index.test.ts b/packages/color-engine/tests/index.test.ts deleted file mode 100644 index aee020a40..000000000 --- a/packages/color-engine/tests/index.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Tests for unified API - */ - -import { describe, it, expect } from "vitest"; -import { - generateScale, - generateLeonardoScale, - generateMaterialScale, - generateRadixScale, - SCALE_STEPS, -} from "../src"; -import { hexToRgb } from "../src/core/utils"; - -describe("Unified generateScale", () => { - describe("algorithm selection", () => { - it("should route to Leonardo algorithm", () => { - const unified = generateScale({ - algorithm: "leonardo", - color: "#6366f1", - background: "#ffffff", - }); - - const direct = generateLeonardoScale({ - color: "#6366f1", - background: "#ffffff", - }); - - // Should produce identical results - expect(unified).toEqual(direct); - }); - - it("should route to Material algorithm", () => { - const unified = generateScale({ - algorithm: "material", - color: "#6366f1", - }); - - const direct = generateMaterialScale({ - color: "#6366f1", - }); - - expect(unified).toEqual(direct); - }); - - it("should route to Radix algorithm", () => { - const unified = generateScale({ - algorithm: "radix", - accent: "#6366f1", - background: "#ffffff", - }); - - const direct = generateRadixScale({ - accent: "#6366f1", - background: "#ffffff", - }); - - expect(unified).toEqual(direct); - }); - }); - - describe("all algorithms produce consistent output shape", () => { - const algorithms = [ - { - algorithm: "leonardo" as const, - color: "#6366f1", - background: "#ffffff", - }, - { - algorithm: "material" as const, - color: "#6366f1", - }, - { - algorithm: "radix" as const, - accent: "#6366f1", - background: "#ffffff", - }, - ]; - - algorithms.forEach((input) => { - it(`should produce 11 valid hex colors for ${input.algorithm}`, () => { - const scale = generateScale(input); - - expect(Object.keys(scale)).toHaveLength(11); - SCALE_STEPS.forEach((step) => { - expect(scale[step]).toBeDefined(); - expect(scale[step]).toMatch(/^#[0-9a-f]{6}$/); - expect(() => hexToRgb(scale[step])).not.toThrow(); - }); - }); - }); - }); - - describe("algorithm options pass through", () => { - it("should pass Leonardo options", () => { - const scale = generateScale({ - algorithm: "leonardo", - color: "#6366f1", - background: "#ffffff", - ratios: [1.1, 1.5, 2, 3, 4.5, 6, 8, 10, 12, 14, 16], - saturation: 80, - contrast: 1.2, - }); - - expect(Object.keys(scale)).toHaveLength(11); - }); - - it("should pass Material options", () => { - const scale = generateScale({ - algorithm: "material", - color: "#6366f1", - tones: [98, 92, 85, 75, 65, 55, 45, 35, 25, 15, 5], - hue: 250, - chroma: 60, - contrastLevel: 0.5, - }); - - expect(Object.keys(scale)).toHaveLength(11); - }); - - it("should pass Radix options", () => { - const scale = generateScale({ - algorithm: "radix", - accent: "#6366f1", - background: "#121212", - gray: "#6b7280", - }); - - expect(Object.keys(scale)).toHaveLength(11); - }); - }); -}); - -describe("Named exports", () => { - it("should export individual algorithm functions", () => { - expect(typeof generateLeonardoScale).toBe("function"); - expect(typeof generateMaterialScale).toBe("function"); - expect(typeof generateRadixScale).toBe("function"); - }); - - it("should export SCALE_STEPS constant", () => { - expect(SCALE_STEPS).toEqual([ - "50", - "100", - "200", - "300", - "400", - "500", - "600", - "700", - "800", - "900", - "950", - ]); - }); -}); - -describe("Algorithm comparison", () => { - const testColor = "#3b82f6"; // Blue - - it("all algorithms should produce different results (they use different methods)", () => { - const leonardo = generateScale({ - algorithm: "leonardo", - color: testColor, - background: "#ffffff", - }); - - const material = generateScale({ - algorithm: "material", - color: testColor, - }); - - const radix = generateScale({ - algorithm: "radix", - accent: testColor, - background: "#ffffff", - }); - - // They should not be identical (different algorithms) - expect(leonardo["500"]).not.toBe(material["500"]); - expect(material["500"]).not.toBe(radix["500"]); - }); - - it("all algorithms should produce blue-ish results for blue input", () => { - const leonardo = generateScale({ - algorithm: "leonardo", - color: testColor, - background: "#ffffff", - }); - - const material = generateScale({ - algorithm: "material", - color: testColor, - }); - - const radix = generateScale({ - algorithm: "radix", - accent: testColor, - background: "#ffffff", - }); - - // Check that mid-tones are blue-ish (more blue than red) - [leonardo, material, radix].forEach((scale) => { - const rgb = hexToRgb(scale["500"]); - expect(rgb[2]).toBeGreaterThan(rgb[0]); // Blue > Red - }); - }); -}); diff --git a/packages/color-engine/tests/leonardo.test.ts b/packages/color-engine/tests/leonardo.test.ts deleted file mode 100644 index 69940221d..000000000 --- a/packages/color-engine/tests/leonardo.test.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** - * Tests for Leonardo algorithm (exact port) - * - * These tests verify the Leonardo algorithm produces EXACT same output as - * Adobe's original implementation. Test values are taken directly from - * the original contrast-colors test suite. - */ - -import { describe, it, expect } from "vitest"; -import { generateScale, contrast, createColorScale } from "../src/algorithms/leonardo"; -import { hexToRgb, contrastRatio } from "../src/core/utils"; -import { SCALE_STEPS } from "../src/core/types"; -import type { RGB } from "../src/core/types"; - -// ============================================================================ -// Helper to convert hex to RGB string like original tests -// ============================================================================ - -function hexToRgbString(hex: string): string { - const rgb = hexToRgb(hex); - return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; -} - -// ============================================================================ -// Contrast Calculation Tests (exact values from original) -// ============================================================================ - -describe("Leonardo contrast function (exact parity)", () => { - describe("WCAG2 contrast", () => { - it("should provide negative contrast in light theme (-1.55...)", () => { - // white is UI color, gray is base. Should return negative whole number - const contrastValue = contrast([255, 255, 255], [207, 207, 207]); - expect(contrastValue).toBeCloseTo(-1.5579550563651177, 10); - }); - - it("should provide positive contrast in light theme (1.55...)", () => { - // gray is UI color, white is base. Should return positive whole number - const contrastValue = contrast([207, 207, 207], [255, 255, 255]); - expect(contrastValue).toBeCloseTo(1.5579550563651177, 10); - }); - - it("should provide negative contrast in dark theme (-1.56...)", () => { - // darker gray is UI color, gray is base. Should return negative whole number - const contrastValue = contrast([8, 8, 8], [50, 50, 50]); - expect(contrastValue).toBeCloseTo(-1.5620602707250844, 10); - }); - - it("should provide positive contrast in dark theme (1.57...)", () => { - // lighter gray is UI color, gray is base - const contrastValue = contrast([79, 79, 79], [50, 50, 50]); - expect(contrastValue).toBeCloseTo(1.5652458000121365, 10); - }); - - it("should provide contrast when passing base value (5.64...)", () => { - const contrastValue = contrast([79, 79, 79], [214, 214, 214], 0.86); - expect(contrastValue).toBeCloseTo(5.635834988986869, 10); - }); - }); - - describe("WCAG3 (APCA) contrast", () => { - it("should provide APCA contrast of ~ 75.6", () => { - const contrastValue = contrast([18, 52, 176], [233, 228, 208], undefined, "wcag3"); - expect(contrastValue).toBeCloseTo(75.57062523197818, 5); - }); - - it("should provide APCA contrast of ~ 78.3", () => { - const contrastValue = contrast([233, 228, 208], [18, 52, 176], undefined, "wcag3"); - expect(contrastValue).toBeCloseTo(78.28508284557655, 5); - }); - - it("should provide APCA contrast of ~ 38.7", () => { - const contrastValue = contrast([255, 162, 0], [255, 255, 255], undefined, "wcag3"); - expect(contrastValue).toBeCloseTo(38.67214116963013, 5); - }); - - it("should provide APCA contrast of ~ -43.1 since bg lum is greater than 50%", () => { - const contrastValue = contrast([255, 255, 255], [255, 162, 0], undefined, "wcag3"); - expect(contrastValue).toBeCloseTo(-43.12544505836451, 5); - }); - - it("should provide APCA contrast of ~ 107.9", () => { - const contrastValue = contrast([255, 255, 255], [0, 0, 0], undefined, "wcag3"); - expect(contrastValue).toBeCloseTo(107.88473318309848, 5); - }); - - it("should provide APCA contrast of ~ 106", () => { - const contrastValue = contrast([0, 0, 0], [255, 255, 255], undefined, "wcag3"); - expect(contrastValue).toBeCloseTo(106.04067321268862, 5); - }); - - it("should provide APCA contrast less than APCA officially supports", () => { - const contrastValue = contrast([238, 238, 238], [255, 255, 255], undefined, "wcag3"); - expect(contrastValue).toBeCloseTo(7.567424744881627, 5); - }); - }); -}); - -// ============================================================================ -// Search Colors Tests (exact values from original) -// ============================================================================ - -describe("Leonardo searchColors (exact parity)", () => { - it("should return blue color of 3.12:1 against white", () => { - const scale = generateScale({ - color: "#0000ff", - background: "#ffffff", - colorspace: "LAB", - ratios: [3.12], - }); - expect(hexToRgbString(scale["50"])).toBe("rgb(163, 121, 255)"); - }); - - it("should return blue color of 3.12:1 against black", () => { - const scale = generateScale({ - color: "#0000ff", - background: "#000000", - colorspace: "LAB", - ratios: [3.12], - }); - expect(hexToRgbString(scale["50"])).toBe("rgb(80, 43, 255)"); - }); - - it("should return blue colors of 3:1 and 4.5:1 against white", () => { - const scale = generateScale({ - color: "#0000ff", - background: "#ffffff", - colorspace: "LAB", - ratios: [3, 4.5], - }); - expect(hexToRgbString(scale["50"])).toBe("rgb(167, 124, 255)"); - expect(hexToRgbString(scale["100"])).toBe("rgb(129, 84, 255)"); - }); - - it("should return blue colors of 3:1 and 4.5:1 against black", () => { - const scale = generateScale({ - color: "#0000ff", - background: "#000000", - colorspace: "LAB", - ratios: [3, 4.5], - }); - expect(hexToRgbString(scale["50"])).toBe("rgb(73, 38, 255)"); - expect(hexToRgbString(scale["100"])).toBe("rgb(126, 81, 255)"); - }); - - it("should return blue color of -1.3 against light gray", () => { - const scale = generateScale({ - color: "#0000ff", - background: "#a6a6a6", // rgb(166, 166, 166) - colorspace: "LAB", - ratios: [-1.3], - }); - expect(hexToRgbString(scale["50"])).toBe("rgb(207, 176, 255)"); - }); - - it("should return blue color of -2 against dark gray", () => { - const scale = generateScale({ - color: "#0000ff", - background: "#636363", // rgb(99, 99, 99) - colorspace: "LAB", - ratios: [-2], - }); - // Note: Original test expects rgb(167, 125, 255) but that's due to a bug where - // baseV=40 (0-100 scale) was passed but compared with < 0.5 (0-1 scale), - // incorrectly treating dark gray as a light theme. - // Our implementation correctly calculates baseV=0.42 (dark theme), producing - // a darker blue for negative contrast ratio. - expect(hexToRgbString(scale["50"])).toBe("rgb(36, 15, 172)"); - }); -}); - -// ============================================================================ -// Create Scale Tests (exact values from original) -// ============================================================================ - -describe("Leonardo createScale (exact parity)", () => { - // Note: The original uses chroma.scale().colors(n) which samples n evenly-spaced colors - // across the full domain (0 to swatches), including both endpoints. - // For n colors over domain [0, swatches], positions are: 0, swatches/(n-1), 2*swatches/(n-1), ..., swatches - const sampleColors = (scale: (pos: number) => string, n: number, swatches: number): string[] => { - return Array.from({ length: n }, (_, i) => scale((i * swatches) / (n - 1))); - }; - - it("should generate 8 colors in Lab", () => { - const swatches = 8; - const scale = createColorScale(["#CCFFA9", "#FEFEC5", "#5F0198"], "LAB", swatches); - const colors = sampleColors(scale, 8, swatches); - expect(colors).toEqual([ - "#ffffff", - "#c6eba9", - "#b6bda8", - "#a48fa5", - "#8e62a1", - "#73329c", - "#470d6e", - "#000000", - ]); - }); - - it("should generate 8 colors in OKlab", () => { - const swatches = 8; - const scale = createColorScale(["#CCFFA9", "#FEFEC5", "#5F0198"], "OKLAB", swatches); - const colors = sampleColors(scale, 8, swatches); - expect(colors).toEqual([ - "#ffffff", - "#c3ecac", - "#adc0ae", - "#9795ac", - "#8169a7", - "#6c399f", - "#3d0064", - "#000000", - ]); - }); - - it("should generate 8 colors in OKLCh", () => { - const swatches = 8; - const scale = createColorScale(["#CCFFA9", "#FEFEC5", "#5F0198"], "OKLCH", swatches); - const colors = sampleColors(scale, 8, swatches); - expect(colors).toEqual([ - "#ffffff", - "#a1f5ac", - "#00d8c0", - "#00aed5", - "#0079d9", - "#503cbd", - "#440077", - "#000000", - ]); - }); -}); - -// ============================================================================ -// Generate Scale Tests (basic functionality) -// ============================================================================ - -describe("Leonardo generateScale", () => { - describe("basic functionality", () => { - it("should generate 11 steps", () => { - const scale = generateScale({ - color: "#6366f1", - background: "#ffffff", - }); - - expect(Object.keys(scale)).toHaveLength(11); - SCALE_STEPS.forEach((step) => { - expect(scale[step]).toBeDefined(); - expect(scale[step]).toMatch(/^#[0-9a-f]{6}$/); - }); - }); - - it("should generate valid hex colors", () => { - const scale = generateScale({ - color: "#ff0000", - background: "#ffffff", - }); - - Object.values(scale).forEach((color) => { - expect(color).toMatch(/^#[0-9a-f]{6}$/); - expect(() => hexToRgb(color)).not.toThrow(); - }); - }); - }); - - describe("light mode (white background)", () => { - const scale = generateScale({ - color: "#3b82f6", - background: "#ffffff", - }); - - it("should have lighter colors at lower steps", () => { - const step50 = hexToRgb(scale["50"]); - const step950 = hexToRgb(scale["950"]); - - const avg50 = (step50[0] + step50[1] + step50[2]) / 3; - const avg950 = (step950[0] + step950[1] + step950[2]) / 3; - - expect(avg50).toBeGreaterThan(avg950); - }); - - it("should increase contrast as steps increase", () => { - const bgRgb = hexToRgb("#ffffff"); - - const contrast50 = contrastRatio(hexToRgb(scale["50"]), bgRgb); - const contrast500 = contrastRatio(hexToRgb(scale["500"]), bgRgb); - const contrast950 = contrastRatio(hexToRgb(scale["950"]), bgRgb); - - expect(contrast500).toBeGreaterThan(contrast50); - expect(contrast950).toBeGreaterThan(contrast500); - }); - }); - - describe("dark mode (black background)", () => { - const scale = generateScale({ - color: "#3b82f6", - background: "#000000", - }); - - it("should have darker colors at lower steps", () => { - const step50 = hexToRgb(scale["50"]); - const step950 = hexToRgb(scale["950"]); - - const avg50 = (step50[0] + step50[1] + step50[2]) / 3; - const avg950 = (step950[0] + step950[1] + step950[2]) / 3; - - expect(avg50).toBeLessThan(avg950); - }); - }); - - describe("options", () => { - it("should respect custom ratios", () => { - const customRatios = [1.1, 1.5, 2, 3, 4.5, 7, 10, 12, 14, 16, 18]; - const scale = generateScale({ - color: "#6366f1", - background: "#ffffff", - ratios: customRatios, - }); - - expect(Object.keys(scale)).toHaveLength(11); - }); - - it("should handle saturation reduction", () => { - const fullSat = generateScale({ - color: "#ff0000", - background: "#ffffff", - saturation: 100, - }); - - const halfSat = generateScale({ - color: "#ff0000", - background: "#ffffff", - saturation: 50, - }); - - const fullRgb = hexToRgb(fullSat["500"]); - const halfRgb = hexToRgb(halfSat["500"]); - - const fullSpread = Math.max(...fullRgb) - Math.min(...fullRgb); - const halfSpread = Math.max(...halfRgb) - Math.min(...halfRgb); - - expect(halfSpread).toBeLessThan(fullSpread); - }); - - it("should handle contrast multiplier", () => { - const normal = generateScale({ - color: "#6366f1", - background: "#ffffff", - contrast: 1, - }); - - const increased = generateScale({ - color: "#6366f1", - background: "#ffffff", - contrast: 1.5, - }); - - const normalRgb = hexToRgb(normal["950"]); - const increasedRgb = hexToRgb(increased["950"]); - - const normalAvg = (normalRgb[0] + normalRgb[1] + normalRgb[2]) / 3; - const increasedAvg = (increasedRgb[0] + increasedRgb[1] + increasedRgb[2]) / 3; - - expect(increasedAvg).toBeLessThan(normalAvg); - }); - - it("should support different colorspaces", () => { - const labScale = generateScale({ - color: "#6366f1", - background: "#ffffff", - colorspace: "LAB", - }); - - const oklchScale = generateScale({ - color: "#6366f1", - background: "#ffffff", - colorspace: "OKLCH", - }); - - // Different colorspaces should produce different colors - expect(labScale["500"]).not.toBe(oklchScale["500"]); - }); - }); - - describe("WCAG compliance", () => { - it("should generate colors with appropriate contrast for light mode", () => { - const scale = generateScale({ - color: "#6366f1", - background: "#ffffff", - ratios: [1.05, 1.15, 1.3, 1.5, 2, 3, 4.5, 6, 8, 12, 15], - }); - - const bgRgb = hexToRgb("#ffffff"); - - // Step 600 (index 6, ratio 4.5) should have ~4.5:1 contrast - const contrast600 = contrastRatio(hexToRgb(scale["600"]), bgRgb); - expect(contrast600).toBeGreaterThan(3.5); - expect(contrast600).toBeLessThan(6); - - // Step 900 (index 9, ratio 12) should have high contrast - const contrast900 = contrastRatio(hexToRgb(scale["900"]), bgRgb); - expect(contrast900).toBeGreaterThan(8); - }); - }); - - describe("different colors", () => { - const colors = [ - "#ef4444", // red - "#22c55e", // green - "#3b82f6", // blue - "#f59e0b", // amber - "#8b5cf6", // violet - "#6b7280", // gray - ]; - - colors.forEach((color) => { - it(`should generate valid scale for ${color}`, () => { - const scale = generateScale({ - color, - background: "#ffffff", - }); - - expect(Object.keys(scale)).toHaveLength(11); - Object.values(scale).forEach((hex) => { - expect(hex).toMatch(/^#[0-9a-f]{6}$/); - }); - }); - }); - }); -}); - -describe("Leonardo edge cases", () => { - it("should handle pure white input", () => { - const scale = generateScale({ - color: "#ffffff", - background: "#000000", - }); - expect(Object.keys(scale)).toHaveLength(11); - }); - - it("should handle pure black input", () => { - const scale = generateScale({ - color: "#000000", - background: "#ffffff", - }); - expect(Object.keys(scale)).toHaveLength(11); - }); - - it("should handle gray input", () => { - const scale = generateScale({ - color: "#808080", - background: "#ffffff", - }); - expect(Object.keys(scale)).toHaveLength(11); - }); -}); diff --git a/packages/color-engine/tests/material.test.ts b/packages/color-engine/tests/material.test.ts deleted file mode 100644 index cf08b4f30..000000000 --- a/packages/color-engine/tests/material.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Tests for Material HCT algorithm - */ - -import { describe, it, expect } from "vitest"; -import { generateScale, getHct, fromHct, getContrastTones } from "../src/algorithms/material"; -import { hexToRgb } from "../src/core/utils"; -import { SCALE_STEPS } from "../src/core/types"; - -describe("Material generateScale", () => { - describe("basic functionality", () => { - it("should generate 11 steps", () => { - const scale = generateScale({ - color: "#6366f1", - }); - - expect(Object.keys(scale)).toHaveLength(11); - SCALE_STEPS.forEach((step) => { - expect(scale[step]).toBeDefined(); - expect(scale[step]).toMatch(/^#[0-9a-f]{6}$/); - }); - }); - - it("should generate valid hex colors", () => { - const scale = generateScale({ - color: "#ff0000", - }); - - Object.values(scale).forEach((color) => { - expect(color).toMatch(/^#[0-9a-f]{6}$/); - expect(() => hexToRgb(color)).not.toThrow(); - }); - }); - }); - - describe("tone progression", () => { - const scale = generateScale({ - color: "#3b82f6", - }); - - it("should have lighter colors at lower steps (higher tones)", () => { - const step50 = hexToRgb(scale["50"]); - const step950 = hexToRgb(scale["950"]); - - // Step 50 (tone 99) should be nearly white - const avg50 = (step50[0] + step50[1] + step50[2]) / 3; - expect(avg50).toBeGreaterThan(240); - - // Step 950 (tone 10) should be nearly black - const avg950 = (step950[0] + step950[1] + step950[2]) / 3; - expect(avg950).toBeLessThan(50); - }); - - it("should maintain monotonic lightness progression", () => { - const steps = SCALE_STEPS.map((step) => { - const rgb = hexToRgb(scale[step]); - return (rgb[0] + rgb[1] + rgb[2]) / 3; - }); - - // Each step should be darker than the previous - for (let i = 1; i < steps.length; i++) { - expect(steps[i]).toBeLessThan(steps[i - 1]); - } - }); - }); - - describe("hue preservation", () => { - it("should maintain consistent hue across scale", () => { - const scale = generateScale({ - color: "#3b82f6", // blue - }); - - const hues = Object.values(scale).map((hex) => { - const hct = getHct(hex); - return hct.hue; - }); - - // All hues should be within 30 degrees of each other - const maxHue = Math.max(...hues); - const minHue = Math.min(...hues); - const hueDiff = Math.min(maxHue - minHue, 360 - maxHue + minHue); - - expect(hueDiff).toBeLessThan(30); - }); - }); - - describe("options", () => { - it("should respect custom tones", () => { - const customTones = [98, 92, 85, 75, 65, 55, 45, 35, 25, 15, 5]; - const scale = generateScale({ - color: "#6366f1", - tones: customTones, - }); - - // Should still generate 11 steps - expect(Object.keys(scale)).toHaveLength(11); - }); - - it("should allow hue override", () => { - const original = generateScale({ - color: "#3b82f6", // blue - }); - - const overridden = generateScale({ - color: "#3b82f6", - hue: 0, // Force red hue - }); - - // The overridden scale should have red hue - const originalHue = getHct(original["500"]).hue; - const overriddenHue = getHct(overridden["500"]).hue; - - // Original should be blue-ish (around 220-250) - expect(originalHue).toBeGreaterThan(200); - expect(originalHue).toBeLessThan(280); - - // Overridden should be red-ish (around 0 or 360) - expect(overriddenHue < 30 || overriddenHue > 330).toBe(true); - }); - - it("should allow chroma override", () => { - const normal = generateScale({ - color: "#3b82f6", - }); - - const lowChroma = generateScale({ - color: "#3b82f6", - chroma: 10, - }); - - // Low chroma version should be more gray - const normalRgb = hexToRgb(normal["500"]); - const lowChromaRgb = hexToRgb(lowChroma["500"]); - - const normalSpread = Math.max(...normalRgb) - Math.min(...normalRgb); - const lowChromaSpread = Math.max(...lowChromaRgb) - Math.min(...lowChromaRgb); - - expect(lowChromaSpread).toBeLessThan(normalSpread); - }); - - it("should handle positive contrast level", () => { - const normal = generateScale({ - color: "#6366f1", - contrastLevel: 0, - }); - - const highContrast = generateScale({ - color: "#6366f1", - contrastLevel: 1, - }); - - // High contrast should have more extreme light/dark values - const normalLight = hexToRgb(normal["50"]); - const highLight = hexToRgb(highContrast["50"]); - const normalDark = hexToRgb(normal["950"]); - const highDark = hexToRgb(highContrast["950"]); - - const normalLightAvg = (normalLight[0] + normalLight[1] + normalLight[2]) / 3; - const highLightAvg = (highLight[0] + highLight[1] + highLight[2]) / 3; - const normalDarkAvg = (normalDark[0] + normalDark[1] + normalDark[2]) / 3; - const highDarkAvg = (highDark[0] + highDark[1] + highDark[2]) / 3; - - // High contrast should be lighter at top and darker at bottom - expect(highLightAvg).toBeGreaterThanOrEqual(normalLightAvg - 10); - expect(highDarkAvg).toBeLessThanOrEqual(normalDarkAvg + 10); - }); - }); - - describe("different colors", () => { - const colors = [ - "#ef4444", // red - "#22c55e", // green - "#3b82f6", // blue - "#f59e0b", // amber - "#8b5cf6", // violet - "#6b7280", // gray - ]; - - colors.forEach((color) => { - it(`should generate valid scale for ${color}`, () => { - const scale = generateScale({ color }); - - expect(Object.keys(scale)).toHaveLength(11); - Object.values(scale).forEach((hex) => { - expect(hex).toMatch(/^#[0-9a-f]{6}$/); - }); - }); - }); - }); -}); - -describe("Material utilities", () => { - describe("getHct", () => { - it("should extract HCT values", () => { - const hct = getHct("#ff0000"); - - expect(hct.hue).toBeGreaterThan(20); - expect(hct.hue).toBeLessThan(40); - expect(hct.chroma).toBeGreaterThan(80); - expect(hct.tone).toBeGreaterThan(40); - expect(hct.tone).toBeLessThan(60); - }); - - it("should return low chroma for gray", () => { - const hct = getHct("#808080"); - expect(hct.chroma).toBeLessThan(5); - }); - }); - - describe("fromHct", () => { - it("should create color from HCT values", () => { - const hex = fromHct(220, 50, 50); - expect(hex).toMatch(/^#[0-9a-f]{6}$/); - - // Should be blue-ish - const rgb = hexToRgb(hex); - expect(rgb[2]).toBeGreaterThan(rgb[0]); // More blue than red - }); - - it("should round-trip with getHct", () => { - // Note: chroma may be clamped to fit sRGB gamut, so we use looser comparison - const original = { hue: 220, chroma: 30, tone: 50 }; // Lower chroma for better gamut fit - const hex = fromHct(original.hue, original.chroma, original.tone); - const hct = getHct(hex); - - expect(hct.hue).toBeCloseTo(original.hue, -1); // Within 10 degrees - expect(hct.chroma).toBeCloseTo(original.chroma, -1); // Within 10 - expect(hct.tone).toBeCloseTo(original.tone, 0); - }); - }); - - describe("getContrastTones", () => { - it("should return darker tones for light backgrounds", () => { - const tones = getContrastTones(90, [3, 4.5, 7]); - - tones.forEach((tone) => { - expect(tone).toBeLessThan(90); - }); - }); - - it("should return lighter tones for dark backgrounds", () => { - const tones = getContrastTones(10, [3, 4.5, 7]); - - tones.forEach((tone) => { - expect(tone).toBeGreaterThan(10); - }); - }); - - it("should increase tone difference for higher ratios", () => { - const tones = getContrastTones(90, [1.5, 3, 7]); - - // Higher ratio should have bigger tone difference - const diff1 = 90 - tones[0]; - const diff2 = 90 - tones[1]; - const diff3 = 90 - tones[2]; - - expect(diff2).toBeGreaterThan(diff1); - expect(diff3).toBeGreaterThan(diff2); - }); - }); -}); diff --git a/packages/color-engine/tests/radix.test.ts b/packages/color-engine/tests/radix.test.ts deleted file mode 100644 index 004bf5426..000000000 --- a/packages/color-engine/tests/radix.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Tests for Radix blending algorithm - */ - -import { describe, it, expect } from "vitest"; -import { - generateScale, - getRadixScale, - getRadixScaleNames, -} from "../src/algorithms/radix"; -import { hexToRgb, luminance } from "../src/core/utils"; -import { SCALE_STEPS } from "../src/core/types"; - -describe("Radix generateScale", () => { - describe("basic functionality", () => { - it("should generate 11 steps", () => { - const scale = generateScale({ - accent: "#6366f1", - background: "#ffffff", - }); - - expect(Object.keys(scale)).toHaveLength(11); - SCALE_STEPS.forEach((step) => { - expect(scale[step]).toBeDefined(); - expect(scale[step]).toMatch(/^#[0-9a-f]{6}$/); - }); - }); - - it("should generate valid hex colors", () => { - const scale = generateScale({ - accent: "#ff0000", - background: "#ffffff", - }); - - Object.values(scale).forEach((color) => { - expect(color).toMatch(/^#[0-9a-f]{6}$/); - expect(() => hexToRgb(color)).not.toThrow(); - }); - }); - }); - - describe("light mode", () => { - const scale = generateScale({ - accent: "#3b82f6", - background: "#ffffff", - }); - - it("should have lighter colors at lower steps", () => { - const step50Lum = luminance(hexToRgb(scale["50"])); - const step950Lum = luminance(hexToRgb(scale["950"])); - - expect(step50Lum).toBeGreaterThan(step950Lum); - }); - - it("should maintain monotonic luminance progression", () => { - const luminances = SCALE_STEPS.map((step) => - luminance(hexToRgb(scale[step])) - ); - - // Each step should be darker (lower luminance) than the previous - for (let i = 1; i < luminances.length; i++) { - expect(luminances[i]).toBeLessThanOrEqual(luminances[i - 1] + 0.05); // Small tolerance - } - }); - }); - - describe("dark mode", () => { - const scale = generateScale({ - accent: "#3b82f6", - background: "#000000", - }); - - it("should have darker colors at lower steps", () => { - const step50Lum = luminance(hexToRgb(scale["50"])); - const step950Lum = luminance(hexToRgb(scale["950"])); - - expect(step50Lum).toBeLessThan(step950Lum); - }); - }); - - describe("color influence", () => { - it("should produce blue-ish scale for blue accent", () => { - const scale = generateScale({ - accent: "#3b82f6", - background: "#ffffff", - }); - - // Check mid-step for blue dominance - const midRgb = hexToRgb(scale["500"]); - expect(midRgb[2]).toBeGreaterThan(midRgb[0]); // More blue than red - }); - - it("should produce red-ish scale for red accent", () => { - const scale = generateScale({ - accent: "#ef4444", - background: "#ffffff", - }); - - const midRgb = hexToRgb(scale["500"]); - expect(midRgb[0]).toBeGreaterThan(midRgb[2]); // More red than blue - }); - - it("should produce green-ish scale for green accent", () => { - const scale = generateScale({ - accent: "#22c55e", - background: "#ffffff", - }); - - const midRgb = hexToRgb(scale["500"]); - expect(midRgb[1]).toBeGreaterThan(midRgb[0]); // More green than red - }); - }); - - describe("different accent colors", () => { - const colors = [ - "#ef4444", // red - "#22c55e", // green - "#3b82f6", // blue - "#f59e0b", // amber - "#8b5cf6", // violet - "#ec4899", // pink - "#14b8a6", // teal - "#f97316", // orange - ]; - - colors.forEach((accent) => { - it(`should generate valid scale for ${accent}`, () => { - const scale = generateScale({ - accent, - background: "#ffffff", - }); - - expect(Object.keys(scale)).toHaveLength(11); - Object.values(scale).forEach((hex) => { - expect(hex).toMatch(/^#[0-9a-f]{6}$/); - }); - }); - }); - }); - - describe("edge cases", () => { - it("should handle pure white accent", () => { - const scale = generateScale({ - accent: "#ffffff", - background: "#ffffff", - }); - - expect(Object.keys(scale)).toHaveLength(11); - }); - - it("should handle pure black accent", () => { - const scale = generateScale({ - accent: "#000000", - background: "#ffffff", - }); - - expect(Object.keys(scale)).toHaveLength(11); - }); - - it("should handle gray accent", () => { - const scale = generateScale({ - accent: "#808080", - background: "#ffffff", - }); - - expect(Object.keys(scale)).toHaveLength(11); - }); - }); -}); - -describe("Radix utilities", () => { - describe("getRadixScale", () => { - it("should return predefined light scale", () => { - const blueScale = getRadixScale("blue", "light"); - - expect(blueScale).not.toBeNull(); - expect(blueScale).toHaveLength(12); - blueScale!.forEach((color) => { - expect(color).toMatch(/^#[0-9a-f]{6}$/); - }); - }); - - it("should return predefined dark scale", () => { - const blueScale = getRadixScale("blue", "dark"); - - expect(blueScale).not.toBeNull(); - expect(blueScale).toHaveLength(12); - }); - - it("should return null for unknown scale", () => { - const unknown = getRadixScale("notacolor", "light"); - expect(unknown).toBeNull(); - }); - - it("should have proper light mode progression", () => { - const scale = getRadixScale("blue", "light")!; - - // First should be lightest - const firstLum = luminance(hexToRgb(scale[0])); - const lastLum = luminance(hexToRgb(scale[11])); - - expect(firstLum).toBeGreaterThan(lastLum); - }); - - it("should have proper dark mode progression", () => { - const scale = getRadixScale("blue", "dark")!; - - // First should be darkest - const firstLum = luminance(hexToRgb(scale[0])); - const lastLum = luminance(hexToRgb(scale[11])); - - expect(firstLum).toBeLessThan(lastLum); - }); - }); - - describe("getRadixScaleNames", () => { - it("should return array of scale names", () => { - const names = getRadixScaleNames(); - - expect(Array.isArray(names)).toBe(true); - expect(names.length).toBeGreaterThan(5); - - // Should include common colors - expect(names).toContain("blue"); - expect(names).toContain("red"); - expect(names).toContain("green"); - expect(names).toContain("gray"); - }); - - it("should only return valid scale names", () => { - const names = getRadixScaleNames(); - - names.forEach((name) => { - expect(getRadixScale(name, "light")).not.toBeNull(); - }); - }); - }); -}); diff --git a/packages/color-engine/tests/utils.test.ts b/packages/color-engine/tests/utils.test.ts deleted file mode 100644 index c96f25164..000000000 --- a/packages/color-engine/tests/utils.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Tests for core utility functions - */ - -import { describe, it, expect } from "vitest"; -import { - hexToRgb, - rgbToHex, - rgbToHsl, - hslToRgb, - rgbToOklch, - oklchToRgb, - luminance, - contrastRatio, - isValidHex, - getContrastTextColor, - clamp, -} from "../src/core/utils"; - -describe("hexToRgb", () => { - it("should convert 6-digit hex to RGB", () => { - expect(hexToRgb("#ffffff")).toEqual([255, 255, 255]); - expect(hexToRgb("#000000")).toEqual([0, 0, 0]); - expect(hexToRgb("#ff0000")).toEqual([255, 0, 0]); - expect(hexToRgb("#00ff00")).toEqual([0, 255, 0]); - expect(hexToRgb("#0000ff")).toEqual([0, 0, 255]); - }); - - it("should convert 3-digit hex to RGB", () => { - expect(hexToRgb("#fff")).toEqual([255, 255, 255]); - expect(hexToRgb("#000")).toEqual([0, 0, 0]); - expect(hexToRgb("#f00")).toEqual([255, 0, 0]); - }); - - it("should handle hex without #", () => { - expect(hexToRgb("ffffff")).toEqual([255, 255, 255]); - expect(hexToRgb("000")).toEqual([0, 0, 0]); - }); - - it("should throw for invalid hex", () => { - expect(() => hexToRgb("#gg0000")).toThrow(); - expect(() => hexToRgb("#12345")).toThrow(); - }); -}); - -describe("rgbToHex", () => { - it("should convert RGB to hex", () => { - expect(rgbToHex([255, 255, 255])).toBe("#ffffff"); - expect(rgbToHex([0, 0, 0])).toBe("#000000"); - expect(rgbToHex([255, 0, 0])).toBe("#ff0000"); - }); - - it("should clamp values", () => { - expect(rgbToHex([300, 0, 0])).toBe("#ff0000"); - expect(rgbToHex([-10, 0, 0])).toBe("#000000"); - }); -}); - -describe("rgbToHsl / hslToRgb", () => { - it("should round-trip white", () => { - const rgb: [number, number, number] = [255, 255, 255]; - const hsl = rgbToHsl(rgb); - const back = hslToRgb(hsl); - expect(back).toEqual(rgb); - }); - - it("should round-trip black", () => { - const rgb: [number, number, number] = [0, 0, 0]; - const hsl = rgbToHsl(rgb); - const back = hslToRgb(hsl); - expect(back).toEqual(rgb); - }); - - it("should round-trip red", () => { - const rgb: [number, number, number] = [255, 0, 0]; - const hsl = rgbToHsl(rgb); - expect(hsl.h).toBeCloseTo(0); - expect(hsl.s).toBeCloseTo(100); - expect(hsl.l).toBeCloseTo(50); - }); - - it("should round-trip gray", () => { - const rgb: [number, number, number] = [128, 128, 128]; - const hsl = rgbToHsl(rgb); - const back = hslToRgb(hsl); - expect(back[0]).toBeCloseTo(rgb[0], -1); - expect(back[1]).toBeCloseTo(rgb[1], -1); - expect(back[2]).toBeCloseTo(rgb[2], -1); - }); -}); - -describe("rgbToOklch / oklchToRgb", () => { - it("should round-trip white", () => { - const rgb: [number, number, number] = [255, 255, 255]; - const oklch = rgbToOklch(rgb); - expect(oklch.l).toBeCloseTo(1, 1); - expect(oklch.c).toBeCloseTo(0, 1); - - const back = oklchToRgb(oklch); - expect(back[0]).toBeCloseTo(255, -1); - expect(back[1]).toBeCloseTo(255, -1); - expect(back[2]).toBeCloseTo(255, -1); - }); - - it("should round-trip black", () => { - const rgb: [number, number, number] = [0, 0, 0]; - const oklch = rgbToOklch(rgb); - expect(oklch.l).toBeCloseTo(0, 1); - - const back = oklchToRgb(oklch); - expect(back[0]).toBeCloseTo(0, -1); - expect(back[1]).toBeCloseTo(0, -1); - expect(back[2]).toBeCloseTo(0, -1); - }); - - it("should round-trip saturated colors", () => { - const rgb: [number, number, number] = [100, 150, 200]; - const oklch = rgbToOklch(rgb); - const back = oklchToRgb(oklch); - expect(back[0]).toBeCloseTo(rgb[0], -1); - expect(back[1]).toBeCloseTo(rgb[1], -1); - expect(back[2]).toBeCloseTo(rgb[2], -1); - }); -}); - -describe("luminance", () => { - it("should return 1 for white", () => { - expect(luminance([255, 255, 255])).toBeCloseTo(1, 2); - }); - - it("should return 0 for black", () => { - expect(luminance([0, 0, 0])).toBeCloseTo(0, 2); - }); - - it("should handle gray correctly", () => { - const gray = luminance([128, 128, 128]); - expect(gray).toBeGreaterThan(0.1); - expect(gray).toBeLessThan(0.3); - }); -}); - -describe("contrastRatio", () => { - it("should return 21 for black on white", () => { - expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBeCloseTo(21, 0); - }); - - it("should return 21 for white on black", () => { - expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBeCloseTo(21, 0); - }); - - it("should return 1 for same colors", () => { - expect(contrastRatio([128, 128, 128], [128, 128, 128])).toBeCloseTo(1, 1); - }); - - it("should calculate gray on white correctly", () => { - // #cfcfcf on white should be around 1.55:1 - const ratio = contrastRatio([207, 207, 207], [255, 255, 255]); - expect(ratio).toBeGreaterThan(1.4); - expect(ratio).toBeLessThan(1.7); - }); -}); - -describe("isValidHex", () => { - it("should accept valid hex colors", () => { - expect(isValidHex("#fff")).toBe(true); - expect(isValidHex("#ffffff")).toBe(true); - expect(isValidHex("fff")).toBe(true); - expect(isValidHex("FFFFFF")).toBe(true); - expect(isValidHex("#ABC")).toBe(true); - }); - - it("should reject invalid hex colors", () => { - expect(isValidHex("#gg0000")).toBe(false); - expect(isValidHex("#12345")).toBe(false); - expect(isValidHex("not a color")).toBe(false); - expect(isValidHex("#1234567")).toBe(false); - }); -}); - -describe("getContrastTextColor", () => { - it("should return black for white background", () => { - expect(getContrastTextColor("#ffffff")).toBe("#000000"); - }); - - it("should return white for black background", () => { - expect(getContrastTextColor("#000000")).toBe("#ffffff"); - }); - - it("should return appropriate color for mid-tones", () => { - // Light gray should get black text - expect(getContrastTextColor("#dddddd")).toBe("#000000"); - // Dark gray should get white text - expect(getContrastTextColor("#333333")).toBe("#ffffff"); - }); -}); - -describe("clamp", () => { - it("should clamp values", () => { - expect(clamp(5, 0, 10)).toBe(5); - expect(clamp(-5, 0, 10)).toBe(0); - expect(clamp(15, 0, 10)).toBe(10); - }); -}); diff --git a/packages/color-engine/tsconfig.json b/packages/color-engine/tsconfig.json deleted file mode 100644 index bb0db6bc4..000000000 --- a/packages/color-engine/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "@dotui/ts-config/base.json", - "compilerOptions": { - "lib": ["ES2022"], - "baseUrl": ".", - "paths": { - "@dotui/color-engine/*": ["./src/*"] - } - }, - "include": ["src"], - "exclude": ["node_modules"] -} diff --git a/packages/colors/biome.json b/packages/colors/biome.json new file mode 100644 index 000000000..d1d335be7 --- /dev/null +++ b/packages/colors/biome.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "root": false, + "extends": ["@dotui/biome-config/base"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } +} diff --git a/packages/colors/package.json b/packages/colors/package.json new file mode 100644 index 000000000..ba8949507 --- /dev/null +++ b/packages/colors/package.json @@ -0,0 +1,33 @@ +{ + "name": "@dotui/colors", + "private": true, + "version": "0.1.0", + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./material": "./src/material/index.ts", + "./material/schema": "./src/material/schema.ts", + "./contrast": "./src/contrast/index.ts" + }, + "scripts": { + "clean": "git clean -xdf .cache .turbo dist node_modules", + "lint": "biome lint --write .", + "format": "biome format --write .", + "check": "biome check --write .", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@material/material-color-utilities": "^0.3.0", + "colorjs.io": "^0.5.2", + "zod": "^4.2.1" + }, + "devDependencies": { + "@dotui/biome-config": "workspace:*", + "@dotui/ts-config": "workspace:*", + "typescript": "^5.8.3", + "vitest": "^4.0.1" + } +} diff --git a/packages/colors/src/contrast/curve.ts b/packages/colors/src/contrast/curve.ts new file mode 100644 index 000000000..101204691 --- /dev/null +++ b/packages/colors/src/contrast/curve.ts @@ -0,0 +1,196 @@ +/** + * Bezier curve utilities for smooth color interpolation + * Ported from Adobe's contrast-colors library + */ + +interface Point { + x: number; + y: number; +} + +/** + * Bezier curve base calculation + */ +function base3(t: number, p1: number, p2: number, p3: number, p4: number): number { + const t1 = -3 * p1 + 9 * p2 - 9 * p3 + 3 * p4; + const t2 = t * t1 + 6 * p1 - 12 * p2 + 6 * p3; + return t * t2 - 3 * p1 + 3 * p2; +} + +/** + * Calculate Bezier curve length using Gaussian quadrature + */ +export function bezlen( + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + x4: number, + y4: number, + z?: number, +): number { + if (z == null) { + z = 1; + } + z = Math.max(0, Math.min(z, 1)); + const z2 = z / 2; + const n = 12; + const Tvalues = [ + -0.1252, 0.1252, -0.3678, 0.3678, -0.5873, 0.5873, -0.7699, 0.7699, -0.9041, 0.9041, -0.9816, 0.9816, + ]; + const Cvalues = [0.2491, 0.2491, 0.2335, 0.2335, 0.2032, 0.2032, 0.1601, 0.1601, 0.1069, 0.1069, 0.0472, 0.0472]; + let sum = 0; + for (let i = 0; i < n; i++) { + const ct = z2 * (Tvalues[i] ?? 0) + z2; + const xbase = base3(ct, x1, x2, x3, x4); + const ybase = base3(ct, y1, y2, y3, y4); + const comb = xbase * xbase + ybase * ybase; + sum += (Cvalues[i] ?? 0) * Math.sqrt(comb); + } + return z2 * sum; +} + +/** + * Find point on Bezier curve at parameter t + */ +export function findDotsAtSegment( + p1x: number, + p1y: number, + c1x: number, + c1y: number, + c2x: number, + c2y: number, + p2x: number, + p2y: number, + t: number, +): Point { + const t1 = 1 - t; + const t12 = t1 * t1; + const t13 = t12 * t1; + const t2 = t * t; + const t3 = t2 * t; + const x = t13 * p1x + t12 * 3 * t * c1x + t1 * 3 * t * t * c2x + t3 * p2x; + const y = t13 * p1y + t12 * 3 * t * c1y + t1 * 3 * t * t * c2y + t3 * p2y; + return { x, y }; +} + +/** + * Convert Catmull-Rom spline to Bezier curves + */ +export function catmullRom2bezier(crp: number[], z?: boolean): number[][] { + const d: number[][] = []; + let end: Point = { x: +(crp[0] ?? 0), y: +(crp[1] ?? 0) }; + + for (let i = 0, iLen = crp.length; iLen - 2 * (z ? 0 : 1) > i; i += 2) { + const p: Point[] = [ + { x: +(crp[i - 2] ?? 0), y: +(crp[i - 1] ?? 0) }, + { x: +(crp[i] ?? 0), y: +(crp[i + 1] ?? 0) }, + { x: +(crp[i + 2] ?? 0), y: +(crp[i + 3] ?? 0) }, + { x: +(crp[i + 4] ?? 0), y: +(crp[i + 5] ?? 0) }, + ]; + + if (z) { + if (!i) { + p[0] = { x: +(crp[iLen - 2] ?? 0), y: +(crp[iLen - 1] ?? 0) }; + } else if (iLen - 4 === i) { + p[3] = { x: +(crp[0] ?? 0), y: +(crp[1] ?? 0) }; + } else if (iLen - 2 === i) { + p[2] = { x: +(crp[0] ?? 0), y: +(crp[1] ?? 0) }; + p[3] = { x: +(crp[2] ?? 0), y: +(crp[3] ?? 0) }; + } + } else if (iLen - 4 === i) { + const p2Val = p[2]; + if (p2Val) p[3] = p2Val; + } else if (!i) { + p[0] = { x: +(crp[i] ?? 0), y: +(crp[i + 1] ?? 0) }; + } + + const defaultPoint: Point = { x: 0, y: 0 }; + const p0 = p[0] ?? defaultPoint; + const p1 = p[1] ?? defaultPoint; + const p2 = p[2] ?? defaultPoint; + const p3 = p[3] ?? defaultPoint; + + d.push([ + end.x, + end.y, + (-p0.x + 6 * p1.x + p2.x) / 6, + (-p0.y + 6 * p1.y + p2.y) / 6, + (p1.x + 6 * p2.x - p3.x) / 6, + (p1.y + 6 * p2.y - p3.y) / 6, + p2.x, + p2.y, + ]); + end = p2; + } + + return d; +} + +/** + * Approximate Bezier curve length using linear segments + */ +export function bezlen2( + p1x: number, + p1y: number, + c1x: number, + c1y: number, + c2x: number, + c2y: number, + p2x: number, + p2y: number, +): number { + const n = 5; + let x0 = p1x; + let y0 = p1y; + let len = 0; + + for (let i = 1; i < n; i++) { + const { x, y } = findDotsAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, i / n); + len += Math.hypot(x - x0, y - y0); + x0 = x; + y0 = y; + } + + len += Math.hypot(p2x - x0, p2y - y0); + return len; +} + +/** + * Create a lookup table for a Bezier curve segment + * Returns a function that maps x to y values + */ +export function prepareCurve( + p1x: number, + p1y: number, + c1x: number, + c1y: number, + c2x: number, + c2y: number, + p2x: number, + p2y: number, +): (x: number) => number | null { + const len = Math.floor(bezlen2(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y) * 0.75); + const fs: number[] = []; + let oldi = 0; + + for (let i = 0; i <= len; i++) { + const t = i / len; + const xy = findDotsAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t); + const index = Math.round(xy.x); + fs[index] = xy.y; + + if (index - oldi > 1) { + const s = fs[oldi] ?? 0; + const f = fs[index] ?? 0; + for (let j = oldi + 1; j < index; j++) { + fs[j] = s + ((f - s) / (index - oldi)) * (j - oldi); + } + } + oldi = index; + } + + return (x: number): number | null => fs[Math.round(x)] ?? null; +} diff --git a/packages/colors/src/contrast/defaults.ts b/packages/colors/src/contrast/defaults.ts new file mode 100644 index 000000000..6dc3b79a6 --- /dev/null +++ b/packages/colors/src/contrast/defaults.ts @@ -0,0 +1,21 @@ +/** + * Default values for the contrast algorithm + */ + +/** + * Default contrast ratios for 11-step scale (50-950) + * These ratios ensure WCAG compliance across the scale + */ +export const DEFAULT_RATIOS = [1.05, 1.15, 1.3, 1.5, 2, 3, 4.5, 6, 8, 12, 15] as const; + +/** + * Default lightness values for modes + */ +export const DEFAULT_LIGHT_LIGHTNESS = 97; +export const DEFAULT_DARK_LIGHTNESS = 5; + +/** + * Default chroma for neutral scale + * Low chroma creates near-grey colors while maintaining subtle warmth/coolness + */ +export const DEFAULT_NEUTRAL_CHROMA = 10; diff --git a/packages/colors/src/contrast/generate.ts b/packages/colors/src/contrast/generate.ts new file mode 100644 index 000000000..4d9fef82e --- /dev/null +++ b/packages/colors/src/contrast/generate.ts @@ -0,0 +1,376 @@ +/** + * Pure functional implementation of Leonardo color generation + * + * This module provides the same functionality as the class-based implementation + * but using pure functions for better testability and composability. + */ + +import ColorJS from "colorjs.io"; + +import { + convertColorValue, + createScale, + getContrast, + hsluvArray, + multiplyRatios, + ratioName, + removeDuplicates, + round, +} from "./utils"; +import type { Colorspace, ContrastFormula } from "./types"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ColorScaleOptions { + colorKeys: string[]; + colorspace?: Colorspace; + smooth?: boolean; + saturation?: number; +} + +export interface BackgroundScaleOptions { + colorKeys: string[]; + colorspace?: Colorspace; +} + +export interface SearchContrastOptions { + colorKeys: string[]; + colorspace?: Colorspace; + smooth?: boolean; + ratios: number[]; + backgroundRgb: [number, number, number]; + backgroundLightness: number; // 0-1 range + formula: ContrastFormula; +} + +export interface GenerateThemeColorInput { + name: string; + colorKeys: string[]; + colorspace?: Colorspace; + ratios: number[] | Record; + smooth?: boolean; +} + +export interface GenerateThemeBackgroundInput { + name: string; + colorKeys: string[]; + colorspace?: Colorspace; +} + +export interface GenerateThemeOptions { + colors: GenerateThemeColorInput[]; + backgroundColor: string | GenerateThemeBackgroundInput; + lightness?: number; + saturation?: number; + contrast?: number; + formula?: ContrastFormula; +} + +export interface ContrastColorValue { + name: string; + contrast: number; + value: string; +} + +export interface GeneratedTheme { + background: string; + backgroundScale: string[]; + colors: Record; +} + +// ============================================================================ +// Pure Functions +// ============================================================================ + +/** + * Apply saturation modification to color keys using OKLCH colorspace + * This is the key to Leonardo parity - uses OKLCH for saturation adjustment + */ +export function applyColorSaturation(colorKeys: string[], saturation: number): string[] { + if (saturation === 100) { + return colorKeys; + } + + return colorKeys.map((key) => { + const color = new ColorJS(key).to("oklch"); + const currentL = color.coords[0] ?? 0; + const currentC = color.coords[1] ?? 0; + const currentH = color.coords[2] ?? 0; + const newSaturation = currentC * (saturation / 100); + const newColor = new ColorJS("oklch", [currentL, newSaturation, currentH]); + return newColor.to("srgb").toString({ format: "hex" }); + }); +} + +/** + * Generate a color scale function (3000 swatches) for contrast searching + */ +export function generateColorScaleFn(options: ColorScaleOptions): (pos: number) => string { + const { colorKeys, colorspace = "RGB", smooth = false, saturation = 100 } = options; + + const modifiedKeys = applyColorSaturation(colorKeys, saturation); + + return createScale({ + swatches: 3000, + colorKeys: modifiedKeys, + colorspace, + shift: 1, + smooth, + asFun: true, + }) as (pos: number) => string; +} + +/** + * Generate a background color scale (100 lightness steps indexed 0-100) + * Returns array where index = lightness value in HSLuv + */ +export function generateBackgroundScale(options: BackgroundScaleOptions): string[] { + const { colorKeys, colorspace = "RGB" } = options; + + // Create massive scale for background + const backgroundColorScale = createScale({ + swatches: 1000, + colorKeys, + colorspace, + shift: 1, + smooth: false, + }) as string[]; + + // Inject original key colors to ensure they are present + backgroundColorScale.push(...colorKeys); + + // Convert to HSLuv and track indices + const colorObj = backgroundColorScale.map((c, i) => ({ + value: Math.round(hsluvArray(c)[2] ?? 0), + index: i, + })); + + // Remove duplicates by lightness value + const colorObjFiltered = removeDuplicates(colorObj, "value"); + + // Map back to colors + const bgColorArrayFiltered = colorObjFiltered.map((data) => backgroundColorScale[data.index]); + + // Cap at 100 colors, add white back if needed + if (bgColorArrayFiltered.length >= 101) { + bgColorArrayFiltered.length = 100; + bgColorArrayFiltered.push("#ffffff"); + } + + // Convert to RGB format (internal processing format) + return bgColorArrayFiltered.map((color) => convertColorValue(color ?? "#ffffff", "RGB") as string); +} + +/** + * Search for colors matching target contrast ratios using binary search + */ +export function searchContrastColors(options: SearchContrastOptions): string[] { + const { + colorKeys, + colorspace = "RGB", + smooth = false, + ratios, + backgroundRgb, + backgroundLightness, + formula, + } = options; + + const colorLen = 3000; + const colorScale = createScale({ + swatches: colorLen, + colorKeys, + colorspace, + shift: 1, + smooth, + asFun: true, + }) as (pos: number) => string; + + const ccache: Record = {}; + + const getContrast2 = (i: number): number => { + if (ccache[i]) { + return ccache[i]; + } + const c = new ColorJS(colorScale(i)).to("srgb"); + const rgb = [ + Math.round((c.coords[0] ?? 0) * 255), + Math.round((c.coords[1] ?? 0) * 255), + Math.round((c.coords[2] ?? 0) * 255), + ]; + const contrast = getContrast(rgb, backgroundRgb, backgroundLightness, formula); + ccache[i] = contrast; + return contrast; + }; + + const colorSearch = (x: number): number => { + const first = getContrast2(0); + const last = getContrast2(colorLen); + const dir = first < last ? 1 : -1; + const epsilon = 0.01; + x += 0.005 * Math.sign(x); + let step = colorLen / 2; + let dot = step; + let val = getContrast2(dot); + let counter = 100; + + while (Math.abs(val - x) > epsilon && counter) { + counter--; + step /= 2; + if (val < x) { + dot += step * dir; + } else { + dot -= step * dir; + } + val = getContrast2(dot); + } + return round(dot, 3); + }; + + const outputColors: string[] = []; + for (const ratio of ratios) { + outputColors.push(colorScale(colorSearch(+ratio))); + } + return outputColors; +} + +/** + * Parse background input into normalized form + */ +function parseBackgroundInput(backgroundColor: string | GenerateThemeBackgroundInput): { + colorKeys: string[]; + colorspace: Colorspace; +} { + if (typeof backgroundColor === "string") { + return { + colorKeys: [backgroundColor], + colorspace: "RGB", + }; + } + return { + colorKeys: backgroundColor.colorKeys, + colorspace: backgroundColor.colorspace ?? "RGB", + }; +} + +/** + * Calculate lightness from a color using HSLuv + */ +function calculateLightnessFromColor(color: string): number { + const hsluvColor = new ColorJS(String(color)).to("hsluv"); + return round(hsluvColor.coords[2] ?? 0); +} + +/** + * Convert color to RGB array [r, g, b] in 0-255 range + */ +function colorToRgb(color: string): [number, number, number] { + const c = new ColorJS(String(color)).to("srgb"); + return [ + Math.round((c.coords[0] ?? 0) * 255), + Math.round((c.coords[1] ?? 0) * 255), + Math.round((c.coords[2] ?? 0) * 255), + ]; +} + +/** + * Generate a complete theme with contrast-based color scales + * + * This is the main orchestrator function that replaces the Theme class. + * It generates all color scales based on the provided configuration. + */ +export function generateTheme(options: GenerateThemeOptions): GeneratedTheme { + const { + colors, + backgroundColor, + lightness: inputLightness, + saturation = 100, + contrast = 1, + formula = "wcag2", + } = options; + + // Parse background input + const bgInput = parseBackgroundInput(backgroundColor); + + // Generate background scale + const backgroundScale = generateBackgroundScale({ + colorKeys: bgInput.colorKeys, + colorspace: bgInput.colorspace, + }); + + // Determine lightness + let finalLightness: number; + if (inputLightness !== undefined) { + finalLightness = inputLightness; + } else if (typeof backgroundColor === "string") { + finalLightness = calculateLightnessFromColor(backgroundColor); + } else { + finalLightness = 100; // Default to white + } + + // Get background color value at the target lightness + const bgValue = backgroundScale[finalLightness] ?? "#ffffff"; + const bgRgb = colorToRgb(bgValue); + const baseV = finalLightness / 100; + + // Generate colors for each input color + const generatedColors: Record = {}; + + for (const color of colors) { + // Apply saturation modification + const modifiedKeys = applyColorSaturation(color.colorKeys, saturation); + + // Get ratio values and names + let ratioValues: number[]; + let swatchNames: string[] | undefined; + + if (Array.isArray(color.ratios)) { + ratioValues = color.ratios; + } else { + swatchNames = Object.keys(color.ratios); + ratioValues = Object.values(color.ratios); + } + + // Modify target ratios based on contrast multiplier + const adjustedRatios = ratioValues.map((ratio) => multiplyRatios(+ratio, contrast)); + + // Search for contrast-matching colors + const contrastColors = searchContrastColors({ + colorKeys: modifiedKeys, + colorspace: color.colorspace, + smooth: color.smooth, + ratios: adjustedRatios, + backgroundRgb: bgRgb, + backgroundLightness: baseV, + formula, + }); + + // Build output array + const colorValues: ContrastColorValue[] = []; + for (let i = 0; i < contrastColors.length; i++) { + let name: string; + if (!swatchNames) { + const rVal = + ratioName(Array.isArray(color.ratios) ? color.ratios : Object.values(color.ratios), formula)[i] ?? 0; + name = color.name.concat(String(rVal)).replace(/\s+/g, ""); + } else { + name = swatchNames[i] ?? ""; + } + + colorValues.push({ + name, + contrast: adjustedRatios[i] ?? 0, + value: contrastColors[i] ?? "", + }); + } + + generatedColors[color.name] = colorValues; + } + + return { + background: bgValue, + backgroundScale, + colors: generatedColors, + }; +} diff --git a/packages/colors/src/contrast/index.ts b/packages/colors/src/contrast/index.ts new file mode 100644 index 000000000..af2062e48 --- /dev/null +++ b/packages/colors/src/contrast/index.ts @@ -0,0 +1,153 @@ +/** + * Leonardo theme generation - Functional API + * 100% parity with Adobe's contrast-colors library + * + * @example + * import { createTheme } from '@dotui/colors'; + * + * const theme = createTheme({ + * colors: [ + * { name: 'accent', colorKeys: ['#6366f1'], ratios: [1.05, 1.15, 1.3, 1.5, 2, 3, 4.5, 6, 8, 12, 15] }, + * { name: 'success', colorKeys: ['#22c55e'], ratios: [1.05, 1.15, 1.3, 1.5, 2, 3, 4.5, 6, 8, 12, 15] }, + * ], + * backgroundColor: '#ffffff', + * lightness: 97, + * saturation: 100, + * contrast: 1, + * formula: 'wcag2', + * }); + * + * // Returns: + * // { + * // background: 'hsl(0, 0%, 96%)', + * // colors: { + * // accent: { '100': 'hsl(239, 84%, 67%)', '200': 'hsl(238, 78%, 61%)', ... }, + * // success: { '100': 'hsl(142, 76%, 36%)', '200': 'hsl(142, 69%, 31%)', ... }, + * // } + * // } + */ + +import ColorJS from "colorjs.io"; + +import { generateTheme } from "./generate"; +import { SCALE_STEPS } from "./types"; +import type { Colorspace, ContrastFormula } from "./types"; + +export interface ColorInput { + name: string; + colorKeys: string[]; + colorspace?: Colorspace; + ratios: number[] | Record; + smooth?: boolean; +} + +export interface BackgroundColorInput { + name: string; + colorKeys: string[]; + colorspace?: Colorspace; +} + +export interface CreateThemeInput { + colors: ColorInput[]; + backgroundColor: string | BackgroundColorInput; + lightness?: number; + contrast?: number; + saturation?: number; + formula?: ContrastFormula; +} + +export interface CreateThemeOutput { + background: string; + colors: Record>; +} + +/** + * Convert a color value to HSL string format + */ +function toHslString(color: string): string { + const hsl = new ColorJS(color).to("hsl"); + const h = Math.round(Number.isNaN(hsl.coords[0] ?? 0) ? 0 : (hsl.coords[0] ?? 0)); + const s = Math.round(hsl.coords[1] ?? 0); + const l = Math.round(hsl.coords[2] ?? 0); + return `hsl(${h}, ${s}%, ${l}%)`; +} + +/** + * Generate a theme with contrast-based color scales + * + * Uses Leonardo's algorithm for 100% parity with Adobe's contrast-colors library. + * Always outputs HSL format for human readability and ColorPicker compatibility. + * + * @param input - Theme configuration + * @returns Theme output with background and color scales in HSL format + */ +export function createContrastTheme(input: CreateThemeInput): CreateThemeOutput { + const { colors, backgroundColor, lightness, contrast = 1, saturation = 100, formula = "wcag2" } = input; + + // Use the pure functional implementation + const generated = generateTheme({ + colors: colors.map((c) => ({ + name: c.name, + colorKeys: c.colorKeys, + colorspace: c.colorspace, + ratios: c.ratios, + smooth: c.smooth, + })), + backgroundColor: + typeof backgroundColor === "string" + ? backgroundColor + : { + name: backgroundColor.name, + colorKeys: backgroundColor.colorKeys, + colorspace: backgroundColor.colorspace, + }, + lightness, + contrast, + saturation, + formula, + }); + + // Build the output with HSL conversion + const result: CreateThemeOutput = { + background: toHslString(generated.background), + colors: {}, + }; + + // Process each color scale + for (const [colorName, values] of Object.entries(generated.colors)) { + const colorScale: Record = {}; + + // Map values to SCALE_STEPS by index (Leonardo generates 100, 200... but we want 50, 100, 200...) + for (let i = 0; i < values.length; i++) { + const value = values[i]; + if (!value) continue; + + // Use SCALE_STEPS if we have 11 values (standard scale), otherwise use Leonardo's naming + if (values.length === SCALE_STEPS.length) { + const step = SCALE_STEPS[i]; + if (step) { + colorScale[step] = toHslString(value.value); + } + } else { + // For non-standard ratios, extract step from name or use the name directly + const stepMatch = value.name.match(/(\d+)$/); + if (stepMatch?.[1]) { + colorScale[stepMatch[1]] = toHslString(value.value); + } else { + colorScale[value.name] = toHslString(value.value); + } + } + } + + result.colors[colorName] = colorScale; + } + + return result; +} + +// Re-export types +export type { Colorspace, ContrastFormula } from "./types"; + +// New modes/palettes API (matches Material) +export { createContrastThemeOptionsSchema } from "./schema"; +export type { CreateContrastThemeOptions } from "./theme"; diff --git a/packages/colors/src/contrast/schema.ts b/packages/colors/src/contrast/schema.ts new file mode 100644 index 000000000..de13e6701 --- /dev/null +++ b/packages/colors/src/contrast/schema.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +// === Building blocks === + +const ratiosSchema = z.array(z.number().positive()).length(11); + +const paletteDefinitionSchema = z.string().min(1); + +const paletteDefinitionsSchema = z + .object({ + primary: paletteDefinitionSchema, + neutral: paletteDefinitionSchema.optional(), + success: z.union([paletteDefinitionSchema, z.boolean()]).optional(), + danger: z.union([paletteDefinitionSchema, z.boolean()]).optional(), + warning: z.union([paletteDefinitionSchema, z.boolean()]).optional(), + info: z.union([paletteDefinitionSchema, z.boolean()]).optional(), + }) + .catchall(z.union([paletteDefinitionSchema, z.boolean()])); + +const paletteOverrideSchema = z.object({ + color: z.string().min(1).optional(), + ratios: ratiosSchema.optional(), +}); + +export const modeSchema = z.union([ + z.literal(true), + z.object({ + lightness: z.number().min(0).max(100).optional(), + palettes: z.record(z.string(), paletteOverrideSchema).optional(), + }), +]); + +export const contrastFormulaSchema = z.enum(["wcag2", "wcag3"]); + +// === Main schema === + +export const baseContrastThemeOptionsSchema = z.object({ + palettes: paletteDefinitionsSchema, + modes: z.record(z.string(), modeSchema).optional(), + ratios: ratiosSchema.optional(), + formula: contrastFormulaSchema.optional(), + saturation: z.number().min(0).max(100).optional(), +}); + +export const createContrastThemeOptionsSchema = baseContrastThemeOptionsSchema.refine( + (data) => { + const globalKeys = Object.keys(data.palettes); + for (const modeConfig of Object.values(data.modes ?? {})) { + if (modeConfig !== true && modeConfig.palettes) { + for (const key of Object.keys(modeConfig.palettes)) { + if (!globalKeys.includes(key)) return false; + } + } + } + return true; + }, + { + message: "Mode palette overrides can only reference palettes defined in 'palettes'", + }, +); diff --git a/packages/colors/src/contrast/theme.test.ts b/packages/colors/src/contrast/theme.test.ts new file mode 100644 index 000000000..0f151f9a6 --- /dev/null +++ b/packages/colors/src/contrast/theme.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { createContrastTheme } from "./theme"; + +describe("createContrastTheme", () => { + it("creates theme with minimal config", () => { + const theme = createContrastTheme({ + palettes: { + primary: "#6366f1", + }, + }); + + expect(theme.light).toBeDefined(); + expect(theme.dark).toBeDefined(); + expect(theme.light!.scales.primary).toBeDefined(); + expect(theme.light!.scales.neutral).toBeDefined(); + expect(Object.keys(theme.light!.scales.primary!)).toHaveLength(11); + }); + + it("creates theme with semantic colors", () => { + const theme = createContrastTheme({ + palettes: { + primary: "#6366f1", + success: true, + danger: "#ef4444", + }, + }); + + expect(theme.light!.scales.success).toBeDefined(); + expect(theme.light!.scales.danger).toBeDefined(); + }); + + it("creates theme with custom neutral", () => { + const theme = createContrastTheme({ + palettes: { + primary: "#6366f1", + neutral: "#64748b", + }, + }); + + expect(theme.light!.scales.neutral).toBeDefined(); + }); + + it("creates theme with custom lightness", () => { + const theme = createContrastTheme({ + palettes: { + primary: "#6366f1", + }, + modes: { + light: { lightness: 95 }, + dark: { lightness: 8 }, + }, + }); + + expect(theme.light).toBeDefined(); + expect(theme.dark).toBeDefined(); + }); + + it("creates theme with per-mode palette override", () => { + const theme = createContrastTheme({ + palettes: { + primary: "#6366f1", + }, + modes: { + light: true, + dark: { + lightness: 5, + palettes: { + primary: { color: "#4f46e5" }, + }, + }, + }, + }); + + expect(theme.light!.scales.primary).toBeDefined(); + expect(theme.dark!.scales.primary).toBeDefined(); + }); + + it("creates theme with custom ratios", () => { + const theme = createContrastTheme({ + palettes: { + primary: "#6366f1", + }, + ratios: [1.1, 1.2, 1.4, 1.6, 2.2, 3.2, 4.8, 6.5, 8.5, 12.5, 16], + }); + + expect(theme.light!.scales.primary).toBeDefined(); + }); + + it("creates theme with wcag3 formula", () => { + const theme = createContrastTheme({ + palettes: { + primary: "#6366f1", + }, + formula: "wcag3", + }); + + expect(theme.light!.scales.primary).toBeDefined(); + }); + + it("generates 11-step scale (50-950)", () => { + const theme = createContrastTheme({ + palettes: { + primary: "#6366f1", + }, + }); + + const scale = theme.light!.scales.primary!; + expect(scale["50"]).toBeDefined(); + expect(scale["100"]).toBeDefined(); + expect(scale["200"]).toBeDefined(); + expect(scale["300"]).toBeDefined(); + expect(scale["400"]).toBeDefined(); + expect(scale["500"]).toBeDefined(); + expect(scale["600"]).toBeDefined(); + expect(scale["700"]).toBeDefined(); + expect(scale["800"]).toBeDefined(); + expect(scale["900"]).toBeDefined(); + expect(scale["950"]).toBeDefined(); + }); +}); diff --git a/packages/colors/src/contrast/theme.ts b/packages/colors/src/contrast/theme.ts new file mode 100644 index 000000000..0ff47d722 --- /dev/null +++ b/packages/colors/src/contrast/theme.ts @@ -0,0 +1,152 @@ +import type { z } from "zod"; + +import { DEFAULT_MODES, SCALE_STEPS, SEMANTIC_COLORS } from "../shared/constants"; +import { DEFAULT_DARK_LIGHTNESS, DEFAULT_LIGHT_LIGHTNESS, DEFAULT_RATIOS } from "./defaults"; +import { generateBackgroundScale, generateTheme } from "./generate"; +import type { ColorScale, Theme } from "../shared/types"; +import type { createContrastThemeOptionsSchema, modeSchema } from "./schema"; +import type { ContrastFormula } from "./types"; + +// ============================================================================ +// Types +// ============================================================================ + +export type CreateContrastThemeOptions = z.infer; +type Mode = z.infer; + +interface ResolvedModeConfig { + lightness: number; + palettes: Record; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Resolve mode config with defaults + */ +function resolveModeConfig(name: string, config: Mode): ResolvedModeConfig { + if (config === true) { + const lightness = name.toLowerCase().includes("dark") ? DEFAULT_DARK_LIGHTNESS : DEFAULT_LIGHT_LIGHTNESS; + return { lightness, palettes: {} }; + } + + const lightness = + config.lightness ?? (name.toLowerCase().includes("dark") ? DEFAULT_DARK_LIGHTNESS : DEFAULT_LIGHT_LIGHTNESS); + return { lightness, palettes: config.palettes ?? {} }; +} + +/** + * Build base palettes from global config + */ +function buildBasePalettes(palettes: CreateContrastThemeOptions["palettes"]): Map { + const result = new Map(); + + // Primary (required) + result.set("primary", palettes.primary); + + // Neutral - from provided color or derive from primary (desaturated) + if (palettes.neutral) { + result.set("neutral", palettes.neutral); + } else { + // For contrast, we'll use primary as neutral base (generateTheme handles desaturation) + result.set("neutral", palettes.primary); + } + + // Semantic and custom palettes + for (const [name, value] of Object.entries(palettes)) { + if (name === "primary" || name === "neutral") continue; + + if (value === true) { + const defaultColor = SEMANTIC_COLORS[name]; + if (defaultColor) result.set(name, defaultColor); + } else if (typeof value === "string") { + result.set(name, value); + } + } + + return result; +} + +/** + * Get background color from neutral at target lightness + */ +function getBackgroundFromNeutral(neutralColor: string, lightness: number): string { + const scale = generateBackgroundScale({ colorKeys: [neutralColor], colorspace: "OKLCH" }); + return scale[lightness] ?? "#ffffff"; +} + +/** + * Map ContrastColorValue array to ColorScale object + */ +function mapToColorScale(values: { name: string; value: string }[]): ColorScale { + const scale: Partial = {}; + for (let i = 0; i < SCALE_STEPS.length && i < values.length; i++) { + const step = SCALE_STEPS[i]!; + scale[step] = values[i]!.value; + } + return scale as ColorScale; +} + +// ============================================================================ +// Main Export +// ============================================================================ + +/** + * Create a Contrast theme using Leonardo's algorithm + * + * @example + * ```ts + * const theme = createContrastTheme({ + * palettes: { + * primary: "#6366f1", + * success: true, + * }, + * modes: { + * light: true, + * dark: true, + * }, + * }); + * ``` + */ +export function createContrastTheme(options: CreateContrastThemeOptions): Theme { + const basePalettes = buildBasePalettes(options.palettes); + const modes = options.modes ?? DEFAULT_MODES; + const globalRatios = options.ratios ?? [...DEFAULT_RATIOS]; + const formula: ContrastFormula = options.formula ?? "wcag2"; + const saturation = options.saturation ?? 100; + + const theme: Theme = {}; + + for (const [modeName, modeConfig] of Object.entries(modes)) { + const resolved = resolveModeConfig(modeName, modeConfig); + const neutralColor = basePalettes.get("neutral")!; + const background = getBackgroundFromNeutral(neutralColor, resolved.lightness); + + // Call generateTheme for this mode + // Include ALL palettes (including neutral) in colors array + const generated = generateTheme({ + colors: [...basePalettes.entries()].map(([name, color]) => ({ + name, + colorKeys: [resolved.palettes[name]?.color ?? color], + ratios: resolved.palettes[name]?.ratios ?? globalRatios, + })), + backgroundColor: background, + lightness: resolved.lightness, + saturation, + contrast: 1, + formula, + }); + + // Map output to ColorScale format + const scales: Record = {}; + for (const [name, values] of Object.entries(generated.colors)) { + scales[name] = mapToColorScale(values); + } + + theme[modeName] = { scales }; + } + + return theme; +} diff --git a/packages/colors/src/contrast/types.ts b/packages/colors/src/contrast/types.ts new file mode 100644 index 000000000..eb341fd92 --- /dev/null +++ b/packages/colors/src/contrast/types.ts @@ -0,0 +1,40 @@ +/** + * Types for contrast-based algorithm + */ + +/** Standard 11-step color scale (50-950) */ +export interface ColorScale { + "50": string; + "100": string; + "200": string; + "300": string; + "400": string; + "500": string; + "600": string; + "700": string; + "800": string; + "900": string; + "950": string; +} + +/** Step names for 11-step scale */ +export const SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"] as const; + +export type ScaleStep = (typeof SCALE_STEPS)[number]; + +/** Colorspaces supported for interpolation */ +export type Colorspace = + | "RGB" + | "HEX" + | "HSL" + | "HSLuv" + | "HSV" + | "LAB" + | "LCH" + | "OKLAB" + | "OKLCH" + | "CAM02" + | "CAM02p"; + +/** Contrast formula options */ +export type ContrastFormula = "wcag2" | "wcag3"; diff --git a/packages/colors/src/contrast/utils.ts b/packages/colors/src/contrast/utils.ts new file mode 100644 index 000000000..5e90f3ce8 --- /dev/null +++ b/packages/colors/src/contrast/utils.ts @@ -0,0 +1,706 @@ +/** + * Leonardo algorithm utilities + * Migrated to Color.js from chroma-js/ciebase/ciecam02/hsluv/apca-w3 + */ + +import Color from "colorjs.io"; + +import { catmullRom2bezier, prepareCurve } from "./curve"; +import type { Colorspace, ContrastFormula } from "./types"; + +// Colorspace mapping from Leonardo names to Color.js space names +export const colorSpaces: Record = { + CAM02: "cam16-jmh", // Using CAM16 as successor to CIECAM02 + CAM02p: "cam16-jmh", + HEX: "srgb", + HSL: "hsl", + HSLuv: "hsluv", + HSV: "hsv", + LAB: "lab", + LCH: "lch", + RGB: "srgb", + OKLAB: "oklab", + OKLCH: "oklch", +}; + +/** + * Round a number to specified decimal places + */ +export function round(x: number, n = 0): number { + const ten = 10 ** n; + return Math.round(x * ten) / ten; +} + +/** + * Multiply contrast ratios while preserving normalized behavior + */ +export function multiplyRatios(ratio: number, multiplier: number): number { + let r: number; + if (ratio > 1) { + r = (ratio - 1) * multiplier + 1; + } else if (ratio < -1) { + r = (ratio + 1) * multiplier - 1; + } else { + r = 1; + } + return round(r, 2); +} + +/** + * Convert color to CAM16 JMh array (replaces CIECAM02 JCh) + * Returns [J, M, h] where J is lightness (0-100) + */ +export function cArray(c: string): number[] { + const color = new Color(String(c)).to("cam16-jmh"); + return [color.coords[0] ?? 0, color.coords[1] ?? 0, color.coords[2] ?? 0]; +} + +/** + * Convert color to HSLuv array [H, S, L] + */ +export function hsluvArray(c: string): number[] { + const color = new Color(String(c)).to("hsluv"); + return [color.coords[0] ?? 0, color.coords[1] ?? 0, color.coords[2] ?? 0]; +} + +/** + * Filter NaN values to 0 + */ +function filterNaN(x: number): number { + return Number.isNaN(x) ? 0 : x; +} + +/** + * Get color in a specific color space as array + */ +function colorToSpaceArray(color: string, space: string): number[] { + const c = new Color(String(color)).to(space); + return [...c.coords]; +} + +/** + * Create color from space array + */ +function colorFromSpaceArray(coords: number[], space: string): string { + const color = new Color(space, coords as [number, number, number]); + return color.to("srgb").toString({ format: "hex" }); +} + +/** + * Smooth scale interpolation using Catmull-Rom to Bezier conversion + */ +function smoothScale(ColorsArray: number[][], domains: number[], space: string): (d: number) => string { + const points: number[][] = [[], [], []]; + + for (let i = 0; i < ColorsArray.length; i++) { + const color = ColorsArray[i]; + if (color) { + for (let j = 0; j < points.length; j++) { + const point = points[j]; + if (point) { + point.push(domains[i] ?? 0, color[j] ?? 0); + } + } + } + } + + // Handle NaN values in chroma for lch + if (space === "lch") { + const point = points[1]; + if (point) { + for (let i = 1; i < point.length; i += 2) { + if (Number.isNaN(point[i])) { + point[i] = 0; + } + } + } + } + + // Handle leading, trailing, and middle NaN values + points.forEach((point) => { + if (!point) return; + const nans: number[] = []; + + // Leading NaNs + for (let i = 1; i < point.length; i += 2) { + if (Number.isNaN(point[i])) { + nans.push(i); + } else { + nans.forEach((j) => { + point[j] = point[i] ?? 0; + }); + nans.length = 0; + break; + } + } + + // All are grey case - use a safe hue value + if (nans.length) { + const safeHue = 0; + nans.forEach((j) => { + point[j] = safeHue; + }); + } + nans.length = 0; + + // Trailing NaNs + for (let i = point.length - 1; i > 0; i -= 2) { + if (Number.isNaN(point[i])) { + nans.push(i); + } else { + nans.forEach((j) => { + point[j] = point[i] ?? 0; + }); + break; + } + } + + // Other NaNs - remove them + for (let i = 1; i < point.length; i += 2) { + if (Number.isNaN(point[i])) { + point.splice(i - 1, 2); + i -= 2; + } + } + + // Force hue to go on shortest route for hue-based spaces + if (["lch", "hsl", "hsluv", "hsv", "cam16-jmh"].includes(space)) { + let prev = point[1] ?? 0; + let addon = 0; + for (let i = 3; i < point.length; i += 2) { + const p = (point[i] ?? 0) + addon; + const zero = Math.abs(prev - p); + const plus = Math.abs(prev - (p + 360)); + const minus = Math.abs(prev - (p - 360)); + if (plus < zero && plus < minus) { + addon += 360; + } + if (minus < zero && minus < plus) { + addon -= 360; + } + point[i] = (point[i] ?? 0) + addon; + prev = point[i] ?? 0; + } + } + }); + + const prep = points.map((point) => + catmullRom2bezier(point).map((curve) => + prepareCurve( + curve[0] ?? 0, + curve[1] ?? 0, + curve[2] ?? 0, + curve[3] ?? 0, + curve[4] ?? 0, + curve[5] ?? 0, + curve[6] ?? 0, + curve[7] ?? 0, + ), + ), + ); + + return (d: number): string => { + const ch = prep.map((p) => { + for (let i = 0; i < p.length; i++) { + const fn = p[i]; + if (fn) { + const res = fn(d); + if (res != null) { + return res; + } + } + } + return 0; + }) as number[]; + + // Clamp negative chroma/colorfulness for CAM16 + if (space === "cam16-jmh" && (ch[1] ?? 0) < 0) { + ch[1] = 0; + } + + return colorFromSpaceArray(ch, space); + }; +} + +/** + * Create a power scale function + */ +function makePowScale(exp = 1, domains = [0, 1], range = [0, 1]): (x: number) => number { + const d0 = domains[0] ?? 0; + const d1 = domains[1] ?? 1; + const r0 = range[0] ?? 0; + const r1 = range[1] ?? 1; + const m = (r1 - r0) / (d1 ** exp - d0 ** exp); + const c = r0 - m * d0 ** exp; + return (x: number) => m * x ** exp + c; +} + +export interface CreateScaleOptions { + swatches: number; + colorKeys: string[]; + colorspace?: Colorspace; + shift?: number; + fullScale?: boolean; + smooth?: boolean; + distributeLightness?: "linear" | "polynomial"; + sortColor?: boolean; + asFun?: boolean; +} + +/** + * Create a color scale from color keys + */ +export function createScale({ + swatches, + colorKeys, + colorspace = "LAB", + shift = 1, + fullScale = true, + smooth = false, + distributeLightness = "linear", + sortColor = true, + asFun = false, +}: CreateScaleOptions): string[] | ((pos: number) => string) { + const space = colorSpaces[colorspace]; + if (!space) { + throw new Error(`Colorspace "${colorspace}" not supported`); + } + if (!colorKeys) { + throw new Error(`Colorkeys missing: returned "${colorKeys}"`); + } + + let domains: number[]; + + if (fullScale) { + // Set domain based on CAM16 J lightness against full black-to-white scale + domains = colorKeys + .map((key) => { + const cam16 = cArray(key); + return swatches - swatches * ((cam16[0] ?? 0) / 100); + }) + .sort((a, b) => a - b) + .concat(swatches); + + domains.unshift(0); + } else { + // Domains as percentage of available luminosity range + const lums = colorKeys.map((c) => (cArray(c)[0] ?? 0) / 100); + const min = Math.min(...lums); + const max = Math.max(...lums); + + domains = lums + .map((lum) => { + if (lum === 0 || Number.isNaN((lum - min) / (max - min))) return 0; + return swatches - ((lum - min) / (max - min)) * swatches; + }) + .sort((a, b) => a - b); + } + + // Apply power scale transformation + const sqrtDomains = makePowScale(shift, [1, swatches], [1, swatches]); + const transformedDomains = domains.map((d) => Math.max(0, sqrtDomains(d))); + domains = transformedDomains; + + if (distributeLightness === "polynomial") { + const polynomial = (x: number): number => { + return Math.sqrt(Math.sqrt((x ** 2.25 + x ** 4) / 2)); + }; + + const percDomains = domains.map((d) => d / swatches); + domains = percDomains.map((d) => polynomial(d) * swatches); + } + + // Sort colors by lightness (CAM16 J) + const sortedColor = colorKeys + .map((c, i) => ({ colorKeys: cArray(c), index: i })) + .sort((c1, c2) => (c2.colorKeys[0] ?? 0) - (c1.colorKeys[0] ?? 0)) + .map((data) => colorKeys[data.index] ?? colorKeys[0] ?? ""); + + let ColorsArray: string[]; + + if (fullScale) { + ColorsArray = ["#ffffff", ...sortedColor, "#000000"]; + } else { + ColorsArray = sortColor ? sortedColor : colorKeys; + } + + let scale: (pos: number) => string; + let smoothScaleArray: string[] | undefined; + + if (smooth) { + const ColorsSpaceArray = ColorsArray.map((d) => colorToSpaceArray(String(d), space)); + + // Handle NaN in hue for grey colors in LCH + if (space === "lch") { + ColorsSpaceArray.forEach((c) => { + if (c && c[1] !== undefined) { + c[1] = Number.isNaN(c[1]) ? 0 : c[1]; + } + }); + } + + // For CAM16, check if color is grey (low chroma) and mark hue as NaN + if (space === "cam16-jmh") { + for (let i = 0; i < ColorsArray.length; i++) { + const colorAtI = ColorsArray[i]; + if (colorAtI) { + const lch = colorToSpaceArray(colorAtI, "lch"); + if (Number.isNaN(lch[2]) || (lch[1] ?? 0) < 0.01) { + const spaceArrayAtI = ColorsSpaceArray[i]; + if (spaceArrayAtI) { + spaceArrayAtI[2] = NaN; + } + } + } + } + } + + scale = smoothScale(ColorsSpaceArray, domains, space); + smoothScaleArray = new Array(swatches).fill(null).map((_, d) => scale(d)); + } else { + // Create scale function using Color.js range interpolation + scale = (pos: number): string => { + // Find which segment we're in based on domains + const clampedPos = Math.max(0, Math.min(swatches - 1, pos)); + + // Find the two domain indices that bracket our position + let lowerIdx = 0; + for (let i = 0; i < domains.length - 1; i++) { + if (clampedPos >= (domains[i] ?? 0)) { + lowerIdx = i; + } + } + const upperIdx = Math.min(lowerIdx + 1, domains.length - 1); + + const lowerDomain = domains[lowerIdx] ?? 0; + const upperDomain = domains[upperIdx] ?? swatches; + const firstColor = ColorsArray[0] ?? ""; + const lastColor = ColorsArray[ColorsArray.length - 1] ?? ""; + const lowerColor = ColorsArray[lowerIdx] ?? firstColor; + const upperColor = ColorsArray[upperIdx] ?? lastColor; + + // Calculate interpolation position within this segment + const segmentRange = upperDomain - lowerDomain; + const t = segmentRange > 0 ? (clampedPos - lowerDomain) / segmentRange : 0; + + // Interpolate + const c1 = new Color(lowerColor); + const c2 = new Color(upperColor); + const range = Color.range(c1, c2, { space }); + return range(Math.max(0, Math.min(1, t))) + .to("srgb") + .toString({ format: "hex" }); + }; + } + + if (asFun) { + return scale; + } + + if (smooth && smoothScaleArray) { + return smoothScaleArray.filter((el: string | null) => el != null); + } + + // Generate colors array + const Colors: string[] = []; + for (let i = 0; i < swatches; i++) { + Colors.push(scale(i)); + } + return Colors; +} + +/** + * Calculate WCAG 2.1 relative luminance + */ +export function luminance(r: number, g: number, b: number): number { + const a = [r, g, b].map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + return (a[0] ?? 0) * 0.2126 + (a[1] ?? 0) * 0.7152 + (a[2] ?? 0) * 0.0722; +} + +/** + * Get contrast ratio with direction (positive/negative based on theme) + */ +export function getContrast( + color: number[], + base: number[], + baseV?: number, + method: ContrastFormula = "wcag2", +): number { + if (baseV === undefined) { + const baseColor = new Color("srgb", [(base[0] ?? 0) / 255, (base[1] ?? 0) / 255, (base[2] ?? 0) / 255]); + const baseLightness = baseColor.to("hsluv").coords[2] ?? 0; + baseV = round(baseLightness / 100, 2); + } + + const fgColor = new Color("srgb", [(color[0] ?? 0) / 255, (color[1] ?? 0) / 255, (color[2] ?? 0) / 255]); + const bgColor = new Color("srgb", [(base[0] ?? 0) / 255, (base[1] ?? 0) / 255, (base[2] ?? 0) / 255]); + + if (method === "wcag2") { + const colorLum = luminance(color[0] ?? 0, color[1] ?? 0, color[2] ?? 0); + const baseLum = luminance(base[0] ?? 0, base[1] ?? 0, base[2] ?? 0); + + const cr1 = (colorLum + 0.05) / (baseLum + 0.05); + const cr2 = (baseLum + 0.05) / (colorLum + 0.05); + + if (baseV < 0.5) { + // Dark themes + if (cr1 >= 1) { + return cr1; + } + return -cr2; + } + // Light themes + if (cr1 < 1) { + return cr2; + } + if (cr1 === 1) { + return cr1; + } + return -cr1; + } else if (method === "wcag3") { + // Use Color.js APCA contrast + const apcaResult = bgColor.contrast(fgColor, "APCA"); + // Apply direction based on theme + return baseV < 0.5 ? -apcaResult : apcaResult; + } else { + throw new Error(`Contrast calculation method ${method} unsupported; use 'wcag2' or 'wcag3'`); + } +} + +/** + * Find minimum positive ratio value + */ +export function minPositive(r: number[], formula: ContrastFormula): number { + if (!r) { + throw new Error("Array undefined"); + } + if (!Array.isArray(r)) { + throw new Error("Passed object is not an array"); + } + const min = formula === "wcag2" ? 0 : 1; + return Math.min(...r.filter((val) => val >= min)); +} + +/** + * Generate ratio names for output + */ +export function ratioName(r: number[], formula: ContrastFormula): number[] { + if (!r) { + throw new Error("Ratios undefined"); + } + r = r.sort((a, b) => a - b); + + const min = minPositive(r, formula); + const minIndex = r.indexOf(min); + const nArr: number[] = []; + + const rNeg = r.slice(0, minIndex); + const rPos = r.slice(minIndex, r.length); + + // Name negative values + for (let i = 0; i < rNeg.length; i++) { + const d = 1 / (rNeg.length + 1); + const m = d * 100; + const nVal = m * (i + 1); + nArr.push(round(nVal)); + } + + // Name positive values + for (let i = 0; i < rPos.length; i++) { + nArr.push((i + 1) * 100); + } + + nArr.sort((a, b) => a - b); + return nArr; +} + +/** + * Remove duplicates from array by property + */ +export function removeDuplicates>(originalArray: T[], prop: string): T[] { + const newArray: T[] = []; + const lookupObject: Record = {}; + + for (let i = 0; i < originalArray.length; i++) { + const item = originalArray[i]; + if (item) { + lookupObject[String(item[prop])] = item; + } + } + + Object.keys(lookupObject).forEach((i) => { + const item = lookupObject[i]; + if (item) { + newArray.push(item); + } + }); + return newArray; +} + +/** + * Convert color value to specified output format + */ +export function convertColorValue(color: string, format: Colorspace, object = false): string | Record { + if (!color) { + throw new Error(`Cannot convert color value of "${color}"`); + } + const space = colorSpaces[format]; + if (!space) { + throw new Error(`Cannot convert to colorspace "${format}"`); + } + const colorObj = new Color(String(color)).to(space); + const coords = [...colorObj.coords]; + + if (format === "HEX") { + if (object) { + const srgb = colorObj.to("srgb"); + return { + r: Math.round((srgb.coords[0] ?? 0) * 255), + g: Math.round((srgb.coords[1] ?? 0) * 255), + b: Math.round((srgb.coords[2] ?? 0) * 255), + }; + } + return colorObj.to("srgb").toString({ format: "hex" }); + } + + const colorObject: Record = {}; + const newColorObj = coords.map(filterNaN); + + // Build output based on color space + const spaceLetters: Record = { + "cam16-jmh": ["J", "M", "h"], + hsluv: ["H", "S", "L"], + hsl: ["h", "s", "l"], + hsv: ["h", "s", "v"], + lab: ["l", "a", "b"], + lch: ["l", "c", "h"], + oklab: ["l", "a", "b"], + oklch: ["l", "c", "h"], + srgb: ["r", "g", "b"], + }; + + const letters = spaceLetters[space] || ["x", "y", "z"]; + + const formattedValues = newColorObj.map((ch, i) => { + const letter = letters[i] ?? "x"; + let rnd: string | number = round(ch); + colorObject[letter] = rnd; + + if (["lab", "lch", "cam16-jmh"].includes(space)) { + if (!object) { + if (letter.toLowerCase() === "l" || letter === "J") { + rnd = `${rnd}%`; + } + if (letter.toLowerCase() === "h") { + rnd = `${rnd}deg`; + } + } + } else if (space === "srgb") { + // sRGB coords are 0-1, need to scale to 0-255 for RGB output + const scaled = round(ch * 255); + colorObject[letter] = scaled; + rnd = scaled; + } else if (space !== "hsluv") { + if (["s", "l", "v"].includes(letter)) { + colorObject[letter] = round(ch, 2); + if (!object) { + rnd = round(ch * 100); + rnd = `${rnd}%`; + } + } else if (letter === "h" && !object) { + rnd = `${rnd}deg`; + } + } + return rnd; + }); + + if (object) { + return colorObject; + } + + // Map space names for output + const outputSpaceName: Record = { + "cam16-jmh": "cam16", + srgb: "rgb", + }; + const stringName = outputSpaceName[space] || space; + return `${stringName}(${formattedValues.join(", ")})`; +} + +export interface ColorWithModifiedKeys { + _modifiedKeys: string[]; + _colorspace: Colorspace; + _smooth: boolean; +} + +/** + * Search for colors matching target contrast ratios + * Uses binary search for efficiency + */ +export function searchColors( + color: ColorWithModifiedKeys, + bgRgbArray: number[], + baseV: number, + ratioValues: number[], + formula: ContrastFormula, +): string[] { + const colorLen = 3000; + const colorScale = createScale({ + swatches: colorLen, + colorKeys: color._modifiedKeys, + colorspace: color._colorspace, + shift: 1, + smooth: color._smooth, + asFun: true, + }) as (pos: number) => string; + + const ccache: Record = {}; + + const getContrast2 = (i: number): number => { + if (ccache[i]) { + return ccache[i]; + } + const c = new Color(colorScale(i)).to("srgb"); + const rgb = [ + Math.round((c.coords[0] ?? 0) * 255), + Math.round((c.coords[1] ?? 0) * 255), + Math.round((c.coords[2] ?? 0) * 255), + ]; + const contrast = getContrast(rgb, bgRgbArray, baseV, formula); + ccache[i] = contrast; + return contrast; + }; + + const colorSearch = (x: number): number => { + const first = getContrast2(0); + const last = getContrast2(colorLen); + const dir = first < last ? 1 : -1; + const epsilon = 0.01; + x += 0.005 * Math.sign(x); + let step = colorLen / 2; + let dot = step; + let val = getContrast2(dot); + let counter = 100; + + while (Math.abs(val - x) > epsilon && counter) { + counter--; + step /= 2; + if (val < x) { + dot += step * dir; + } else { + dot -= step * dir; + } + val = getContrast2(dot); + } + return round(dot, 3); + }; + + const outputColors: string[] = []; + for (const ratio of ratioValues) { + outputColors.push(colorScale(colorSearch(+ratio))); + } + return outputColors; +} diff --git a/packages/colors/src/index.ts b/packages/colors/src/index.ts new file mode 100644 index 000000000..f3dc4177e --- /dev/null +++ b/packages/colors/src/index.ts @@ -0,0 +1,18 @@ +// Unified API +export { createTheme } from "./theme"; +export { createThemeOptionsSchema, type CreateThemeOptions } from "./schema"; + +// Algorithm-specific exports +export { + createContrastTheme, + createContrastThemeOptionsSchema, + type CreateContrastThemeOptions, +} from "./contrast"; +export { + createMaterialTheme, + createMaterialThemeOptionsSchema, + type CreateThemeOptions as CreateMaterialThemeOptions, +} from "./material"; + +// Shared types +export type { ColorScale, Theme, ThemeMode } from "./shared/types"; diff --git a/packages/colors/src/material/README.md b/packages/colors/src/material/README.md new file mode 100644 index 000000000..5ee666e72 --- /dev/null +++ b/packages/colors/src/material/README.md @@ -0,0 +1,95 @@ +# Material HCT Theme Generator + +Generate color themes using Material Design 3's HCT (Hue, Chroma, Tone) color space. + +## API + +```typescript +import { createTheme, createThemeOptionsSchema, type CreateThemeOptions } from '@dotui/colors/material'; + +const theme = createTheme({ + palettes: { + // Primary (required) - any color format + primary: '#6366f1', + primary: 'rgb(99, 102, 241)', + primary: 'hsl(239, 84%, 67%)', + primary: 'oklch(0.65 0.25 264)', + primary: { color: '#6366f1', tones: [99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10] }, + + // Neutral (optional) - auto-generated from primary if not provided + neutral: '#64748b', + neutral: { color: '#64748b', tones: [...] }, + + // Other palettes - boolean | string | { color, tones? } + success: true, // use default hue (142°) + success: false, // skip this palette + success: '#22c55e', // use this color + success: { color: '#22c55e' }, // same as string + success: { color: '#22c55e', tones: [...] }, // custom tones + + // Custom palettes + brand: '#ff6b00', + accent: { color: 'oklch(0.7 0.15 200)', tones: [...] }, + }, + + modes: { + light: true, + dark: true, + 'high-contrast': { isDark: false, contrast: 0.5 }, + 'deuteranopia': { + isDark: false, + palettes: { + success: { hue: 220 }, + danger: { hue: 320, chroma: 60 }, + }, + }, + }, + + variant: 'tonalSpot', // optional + contrast: 0, // optional, -1 to 1 + tones: [...], // optional, global default +}); +``` + +## Output + +```typescript +{ + light: { + scales: { + primary: { '50': '#...', '100': '#...', ..., '950': '#...' }, + neutral: { ... }, + success: { ... }, + } + }, + dark: { scales: { ... } }, +} +``` + +## Implementation Plan + +### Phase 1: Schema +- [ ] `createThemeOptionsSchema` - validate input +- [ ] `CreateThemeOptions` - TypeScript type +- [ ] Support multiple color formats via colorjs.io + +### Phase 2: Core Generation +- [ ] Parse color → convert to HCT +- [ ] Generate neutral from primary +- [ ] Generate semantic palettes +- [ ] Generate 11-step scales + +### Phase 3: Modes +- [ ] Light/dark default tones +- [ ] Custom modes with isDark +- [ ] Mode palette overrides + +### Phase 4: Variants & Contrast +- [ ] Apply variant to chroma +- [ ] Apply contrast to tones + +## Files + +- `schema.ts` - Zod schema +- `index.ts` - createTheme function +- `defaults.ts` - default values diff --git a/packages/colors/src/material/constants.ts b/packages/colors/src/material/constants.ts new file mode 100644 index 000000000..87289c9da --- /dev/null +++ b/packages/colors/src/material/constants.ts @@ -0,0 +1,11 @@ +import type { ModeVariant } from "./types"; + +export const DEFAULT_LIGHT_TONES = [99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10]; + +export const DEFAULT_DARK_TONES = [10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 95]; + +export const DEFAULT_VARIANT: ModeVariant = "tonalSpot"; + +export const DEFAULT_CONTRAST = 0; + +export const NEUTRAL_CHROMA = 6; diff --git a/packages/colors/src/material/index.ts b/packages/colors/src/material/index.ts new file mode 100644 index 000000000..7c1175178 --- /dev/null +++ b/packages/colors/src/material/index.ts @@ -0,0 +1,148 @@ +import { argbFromRgb, Hct, hexFromArgb, TonalPalette } from "@material/material-color-utilities"; +import Color from "colorjs.io"; + +import { SCALE_STEPS, SEMANTIC_COLORS } from "../shared/constants"; +import { + DEFAULT_CONTRAST, + DEFAULT_DARK_TONES, + DEFAULT_LIGHT_TONES, + DEFAULT_VARIANT, + NEUTRAL_CHROMA, +} from "./constants"; +import type { ColorScale, Theme } from "../shared/types"; +import type { CreateThemeOptions, Mode, ResolvedModeConfig } from "./types"; + +export { createMaterialThemeOptionsSchema } from "./schema"; +export type { ColorScale, Theme, ThemeMode } from "../shared/types"; +export type { CreateThemeOptions } from "./types"; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Parse any color string to HCT + */ +function parseColorToHct(color: string): Hct { + const { r, g, b } = new Color(color).srgb as unknown as { r: number; g: number; b: number }; + return Hct.fromInt(argbFromRgb(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))); +} + +/** + * Create TonalPalette from color string + */ +function createPalette(color: string): TonalPalette { + const { hue, chroma } = parseColorToHct(color); + return TonalPalette.fromHueAndChroma(hue, chroma); +} + +const DEFAULT_MODES = { + light: { isDark: false }, + dark: { isDark: true }, +} as const; + +/** + * Generate ColorScale from TonalPalette + tones + */ +function generateColorScale(palette: TonalPalette, tones: number[]): ColorScale { + return Object.fromEntries(SCALE_STEPS.map((step, i) => [step, hexFromArgb(palette.tone(tones[i]!))])) as ColorScale; +} + +/** + * Resolve mode config with defaults + */ +function resolveModeConfig(name: string, config: Mode): ResolvedModeConfig { + if (config === true) { + const isDark = name.toLowerCase().includes("dark"); + return { + isDark, + variant: DEFAULT_VARIANT, + contrast: DEFAULT_CONTRAST, + tones: isDark ? DEFAULT_DARK_TONES : DEFAULT_LIGHT_TONES, + palettes: {}, + }; + } + + return { + isDark: config.isDark, + variant: config.variant ?? DEFAULT_VARIANT, + contrast: config.contrast ?? DEFAULT_CONTRAST, + tones: config.isDark ? DEFAULT_DARK_TONES : DEFAULT_LIGHT_TONES, + palettes: config.palettes ?? {}, + }; +} + +/** + * Build base palettes from global config + */ +function buildBasePalettes(palettes: CreateThemeOptions["palettes"]): Map { + const result = new Map(); + + // Primary (required) + const primaryHct = parseColorToHct(palettes.primary); + result.set("primary", createPalette(palettes.primary)); + + // Neutral - from provided color or derive from primary + if (palettes.neutral) { + result.set("neutral", createPalette(palettes.neutral)); + } else { + result.set("neutral", TonalPalette.fromHueAndChroma(primaryHct.hue, NEUTRAL_CHROMA)); + } + + // Semantic and custom palettes + for (const [name, value] of Object.entries(palettes)) { + if (name === "primary" || name === "neutral") continue; + + if (value === true) { + const defaultColor = SEMANTIC_COLORS[name]; + if (defaultColor) result.set(name, createPalette(defaultColor)); + } else if (typeof value === "string") { + result.set(name, createPalette(value)); + } + } + + return result; +} + +// ============================================================================ +// Main Export +// ============================================================================ + +/** + * Create a Material theme + * + * @example + * ```ts + * const theme = createMaterialTheme({ + * palettes: { + * primary: "#6366f1", + * success: true, + * }, + * modes: { + * light: { isDark: false }, + * dark: { isDark: true }, + * }, + * }); + * ``` + */ +export function createMaterialTheme(options: CreateThemeOptions): Theme { + const basePalettes = buildBasePalettes(options.palettes); + const modes: Record = options.modes ?? DEFAULT_MODES; + const theme: Theme = {}; + + for (const [modeName, modeConfig] of Object.entries(modes)) { + const resolved = resolveModeConfig(modeName, modeConfig); + const scales: Record = {}; + + for (const [paletteName, basePalette] of basePalettes) { + const override = resolved.palettes[paletteName]; + const palette = override?.color ? createPalette(override.color) : basePalette; + const tones = override?.tones ?? resolved.tones; + scales[paletteName] = generateColorScale(palette, tones); + } + + theme[modeName] = { scales }; + } + + return theme; +} diff --git a/packages/colors/src/material/schema.ts b/packages/colors/src/material/schema.ts new file mode 100644 index 000000000..540d2d4f3 --- /dev/null +++ b/packages/colors/src/material/schema.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +// === Building blocks === + +export const tonesSchema = z.array(z.number().min(0).max(100)).length(11); + +export const paletteDefinitionSchema = z.string().min(1); + +const paletteDefinitionsSchema = z + .object({ + primary: paletteDefinitionSchema, + neutral: paletteDefinitionSchema.optional(), + success: z.union([paletteDefinitionSchema, z.boolean()]).optional(), + danger: z.union([paletteDefinitionSchema, z.boolean()]).optional(), + warning: z.union([paletteDefinitionSchema, z.boolean()]).optional(), + info: z.union([paletteDefinitionSchema, z.boolean()]).optional(), + }) + .catchall(z.union([paletteDefinitionSchema, z.boolean()])); + +export const modeVariantSchema = z.enum([ + "tonalSpot", + "vibrant", + "expressive", + "neutral", + "monochrome", + "fidelity", + "content", + "rainbow", + "fruitSalad", +]); + +const paletteSchema = z.object({ + color: z.string().min(1).optional(), + tones: tonesSchema.optional(), +}); + +export const modeSchema = z.union([ + z.literal(true), + z.object({ + isDark: z.boolean(), + variant: modeVariantSchema.optional(), + contrast: z.number().min(-1).max(1).optional(), + palettes: z.record(z.string(), paletteSchema).optional(), + }), +]); + +// === Main schema === + +export const baseMaterialThemeOptionsSchema = z.object({ + palettes: paletteDefinitionsSchema, + modes: z.record(z.string(), modeSchema).optional(), +}); + +export const createMaterialThemeOptionsSchema = baseMaterialThemeOptionsSchema.refine( + (data) => { + const globalKeys = Object.keys(data.palettes); + for (const modeConfig of Object.values(data.modes ?? {})) { + if (modeConfig !== true && modeConfig.palettes) { + for (const key of Object.keys(modeConfig.palettes)) { + if (!globalKeys.includes(key)) return false; + } + } + } + return true; + }, + { + message: "Mode palette overrides can only reference palettes defined in 'palettes'", + }, +); diff --git a/packages/colors/src/material/types.ts b/packages/colors/src/material/types.ts new file mode 100644 index 000000000..399c14894 --- /dev/null +++ b/packages/colors/src/material/types.ts @@ -0,0 +1,27 @@ +import type { z } from "zod"; + +import type { createMaterialThemeOptionsSchema, modeSchema, modeVariantSchema, tonesSchema } from "./schema"; + +// ============================================================================ +// Schema-inferred types +// ============================================================================ + +export type Tones = z.infer; + +export type ModeVariant = z.infer; + +export type Mode = z.infer; + +export type CreateThemeOptions = z.infer; + +// ============================================================================ +// Internal types +// ============================================================================ + +export type ResolvedModeConfig = { + isDark: boolean; + variant: ModeVariant; + contrast: number; + tones: number[]; + palettes: Record; +}; diff --git a/packages/colors/src/schema.ts b/packages/colors/src/schema.ts new file mode 100644 index 000000000..a9deb7e94 --- /dev/null +++ b/packages/colors/src/schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { baseContrastThemeOptionsSchema } from "./contrast/schema"; +import { baseMaterialThemeOptionsSchema } from "./material/schema"; + +const materialOptionsSchema = baseMaterialThemeOptionsSchema.extend({ + algorithm: z.literal("material"), +}); + +const contrastOptionsSchema = baseContrastThemeOptionsSchema.extend({ + algorithm: z.literal("contrast"), +}); + +export const createThemeOptionsSchema = z.discriminatedUnion("algorithm", [ + materialOptionsSchema, + contrastOptionsSchema, +]); + +export type CreateThemeOptions = z.infer; diff --git a/packages/colors/src/shared/constants.ts b/packages/colors/src/shared/constants.ts new file mode 100644 index 000000000..a0279e735 --- /dev/null +++ b/packages/colors/src/shared/constants.ts @@ -0,0 +1,15 @@ +export const SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"] as const; + +export type ScaleStep = (typeof SCALE_STEPS)[number]; + +export const SEMANTIC_COLORS: Record = { + success: "#22c55e", + danger: "#ef4444", + warning: "#eab308", + info: "#3b82f6", +}; + +export const DEFAULT_MODES = { + light: true, + dark: true, +} as const; diff --git a/packages/colors/src/shared/types.ts b/packages/colors/src/shared/types.ts new file mode 100644 index 000000000..5cc6ebe46 --- /dev/null +++ b/packages/colors/src/shared/types.ts @@ -0,0 +1,26 @@ +/** + * Shared output types for both Material and Contrast themes + */ + +/** 11-step color scale (50-950) */ +export type ColorScale = { + "50": string; + "100": string; + "200": string; + "300": string; + "400": string; + "500": string; + "600": string; + "700": string; + "800": string; + "900": string; + "950": string; +}; + +/** A single mode's output */ +export type ThemeMode = { + scales: Record; +}; + +/** Complete theme output with multiple modes */ +export type Theme = Record; diff --git a/packages/colors/src/theme.test.ts b/packages/colors/src/theme.test.ts new file mode 100644 index 000000000..72e7fad88 --- /dev/null +++ b/packages/colors/src/theme.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it } from "vitest"; + +import { createTheme } from "./theme"; + +describe("createTheme", () => { + describe("material algorithm", () => { + it("creates theme with minimal config", () => { + const theme = createTheme({ + algorithm: "material", + palettes: { + primary: "#6366f1", + }, + }); + + expect(theme.light).toBeDefined(); + expect(theme.dark).toBeDefined(); + expect(theme.light!.scales.primary).toBeDefined(); + expect(theme.light!.scales.neutral).toBeDefined(); + }); + + it("creates theme with all semantic colors", () => { + const theme = createTheme({ + algorithm: "material", + palettes: { + primary: "#6366f1", + neutral: "#64748b", + success: true, + danger: true, + warning: true, + info: true, + }, + }); + + expect(theme.light!.scales.primary).toBeDefined(); + expect(theme.light!.scales.neutral).toBeDefined(); + expect(theme.light!.scales.success).toBeDefined(); + expect(theme.light!.scales.danger).toBeDefined(); + expect(theme.light!.scales.warning).toBeDefined(); + expect(theme.light!.scales.info).toBeDefined(); + }); + + it("creates theme with custom semantic colors", () => { + const theme = createTheme({ + algorithm: "material", + palettes: { + primary: "#6366f1", + success: "#10b981", + danger: "#f43f5e", + }, + }); + + expect(theme.light!.scales.success).toBeDefined(); + expect(theme.light!.scales.danger).toBeDefined(); + }); + + it("creates theme with custom modes", () => { + const theme = createTheme({ + algorithm: "material", + palettes: { + primary: "#6366f1", + }, + modes: { + light: { isDark: false }, + dark: { isDark: true }, + dim: { isDark: true }, + }, + }); + + expect(theme.light).toBeDefined(); + expect(theme.dark).toBeDefined(); + expect(theme.dim).toBeDefined(); + }); + + it("creates theme with mode shorthand", () => { + const theme = createTheme({ + algorithm: "material", + palettes: { + primary: "#6366f1", + }, + modes: { + light: true, + dark: true, + }, + }); + + expect(theme.light).toBeDefined(); + expect(theme.dark).toBeDefined(); + }); + + it("creates theme with custom palettes", () => { + const theme = createTheme({ + algorithm: "material", + palettes: { + primary: "#6366f1", + brand: "#f97316", + accent: "#8b5cf6", + }, + }); + + expect(theme.light!.scales.brand).toBeDefined(); + expect(theme.light!.scales.accent).toBeDefined(); + }); + + it("generates correct scale steps", () => { + const theme = createTheme({ + algorithm: "material", + palettes: { + primary: "#6366f1", + }, + }); + + const scale = theme.light!.scales.primary!; + expect(scale["50"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["100"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["200"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["300"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["400"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["500"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["600"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["700"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["800"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["900"]).toMatch(/^#[0-9a-f]{6}$/i); + expect(scale["950"]).toMatch(/^#[0-9a-f]{6}$/i); + }); + }); + + describe("contrast algorithm", () => { + it("creates theme with minimal config", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + }, + }); + + expect(theme.light).toBeDefined(); + expect(theme.dark).toBeDefined(); + expect(theme.light!.scales.primary).toBeDefined(); + expect(theme.light!.scales.neutral).toBeDefined(); + }); + + it("creates theme with all semantic colors", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + neutral: "#64748b", + success: true, + danger: true, + warning: true, + info: true, + }, + }); + + expect(theme.light!.scales.primary).toBeDefined(); + expect(theme.light!.scales.neutral).toBeDefined(); + expect(theme.light!.scales.success).toBeDefined(); + expect(theme.light!.scales.danger).toBeDefined(); + expect(theme.light!.scales.warning).toBeDefined(); + expect(theme.light!.scales.info).toBeDefined(); + }); + + it("creates theme with custom ratios", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + }, + ratios: [1.1, 1.2, 1.4, 1.6, 2.2, 3.2, 4.8, 6.5, 8.5, 12.5, 16], + }); + + expect(theme.light!.scales.primary).toBeDefined(); + }); + + it("creates theme with wcag3 formula", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + }, + formula: "wcag3", + }); + + expect(theme.light!.scales.primary).toBeDefined(); + }); + + it("creates theme with custom saturation", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + }, + saturation: 80, + }); + + expect(theme.light!.scales.primary).toBeDefined(); + }); + + it("creates theme with custom lightness per mode", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + }, + modes: { + light: { lightness: 95 }, + dark: { lightness: 8 }, + dim: { lightness: 20 }, + }, + }); + + expect(theme.light).toBeDefined(); + expect(theme.dark).toBeDefined(); + expect(theme.dim).toBeDefined(); + }); + + it("creates theme with per-mode palette overrides", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + }, + modes: { + light: true, + dark: { + lightness: 5, + palettes: { + primary: { color: "#818cf8" }, + }, + }, + }, + }); + + expect(theme.light!.scales.primary).toBeDefined(); + expect(theme.dark!.scales.primary).toBeDefined(); + }); + + it("generates correct scale steps", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + }, + }); + + const scale = theme.light!.scales.primary!; + expect(scale["50"]).toBeDefined(); + expect(scale["100"]).toBeDefined(); + expect(scale["200"]).toBeDefined(); + expect(scale["300"]).toBeDefined(); + expect(scale["400"]).toBeDefined(); + expect(scale["500"]).toBeDefined(); + expect(scale["600"]).toBeDefined(); + expect(scale["700"]).toBeDefined(); + expect(scale["800"]).toBeDefined(); + expect(scale["900"]).toBeDefined(); + expect(scale["950"]).toBeDefined(); + }); + }); + + describe("both algorithms produce same structure", () => { + it("both have same scale keys", () => { + const materialTheme = createTheme({ + algorithm: "material", + palettes: { primary: "#6366f1" }, + }); + + const contrastTheme = createTheme({ + algorithm: "contrast", + palettes: { primary: "#6366f1" }, + }); + + const materialKeys = Object.keys(materialTheme.light!.scales.primary!).sort(); + const contrastKeys = Object.keys(contrastTheme.light!.scales.primary!).sort(); + + expect(materialKeys).toEqual(contrastKeys); + expect(materialKeys).toEqual(["100", "200", "300", "400", "50", "500", "600", "700", "800", "900", "950"]); + }); + + it("both create same palette names", () => { + const materialTheme = createTheme({ + algorithm: "material", + palettes: { primary: "#6366f1", success: true, danger: "#ef4444" }, + }); + + const contrastTheme = createTheme({ + algorithm: "contrast", + palettes: { primary: "#6366f1", success: true, danger: "#ef4444" }, + }); + + const materialPalettes = Object.keys(materialTheme.light!.scales).sort(); + const contrastPalettes = Object.keys(contrastTheme.light!.scales).sort(); + + expect(materialPalettes).toEqual(contrastPalettes); + }); + + it("both create same mode names", () => { + const materialTheme = createTheme({ + algorithm: "material", + palettes: { primary: "#6366f1" }, + modes: { light: true, dark: true }, + }); + + const contrastTheme = createTheme({ + algorithm: "contrast", + palettes: { primary: "#6366f1" }, + modes: { light: true, dark: true }, + }); + + const materialModes = Object.keys(materialTheme).sort(); + const contrastModes = Object.keys(contrastTheme).sort(); + + expect(materialModes).toEqual(contrastModes); + expect(materialModes).toEqual(["dark", "light"]); + }); + }); + + describe("real-world configurations", () => { + it("creates a complete design system theme with material", () => { + const theme = createTheme({ + algorithm: "material", + palettes: { + primary: "#6366f1", + neutral: "#64748b", + success: "#22c55e", + danger: "#ef4444", + warning: "#f59e0b", + info: "#3b82f6", + }, + modes: { + light: { isDark: false }, + dark: { isDark: true }, + }, + }); + + // Verify all palettes exist in both modes + for (const mode of ["light", "dark"]) { + expect(theme[mode]).toBeDefined(); + for (const palette of ["primary", "neutral", "success", "danger", "warning", "info"]) { + expect(theme[mode]!.scales[palette]).toBeDefined(); + expect(Object.keys(theme[mode]!.scales[palette]!)).toHaveLength(11); + } + } + }); + + it("creates a complete design system theme with contrast", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + neutral: "#64748b", + success: "#22c55e", + danger: "#ef4444", + warning: "#f59e0b", + info: "#3b82f6", + }, + modes: { + light: { lightness: 97 }, + dark: { lightness: 5 }, + }, + formula: "wcag2", + }); + + // Verify all palettes exist in both modes + for (const mode of ["light", "dark"]) { + expect(theme[mode]).toBeDefined(); + for (const palette of ["primary", "neutral", "success", "danger", "warning", "info"]) { + expect(theme[mode]!.scales[palette]).toBeDefined(); + expect(Object.keys(theme[mode]!.scales[palette]!)).toHaveLength(11); + } + } + }); + + it("creates multi-mode theme for different contexts", () => { + const theme = createTheme({ + algorithm: "contrast", + palettes: { + primary: "#6366f1", + success: true, + }, + modes: { + light: { lightness: 97 }, + dark: { lightness: 5 }, + dim: { lightness: 15 }, + highContrast: { lightness: 100 }, + }, + }); + + expect(Object.keys(theme)).toHaveLength(4); + expect(theme.light).toBeDefined(); + expect(theme.dark).toBeDefined(); + expect(theme.dim).toBeDefined(); + expect(theme.highContrast).toBeDefined(); + }); + }); +}); diff --git a/packages/colors/src/theme.ts b/packages/colors/src/theme.ts new file mode 100644 index 000000000..527337249 --- /dev/null +++ b/packages/colors/src/theme.ts @@ -0,0 +1,14 @@ +import type { Theme } from "./shared/types"; +import { createContrastTheme } from "./contrast/theme"; +import { createMaterialTheme } from "./material"; +import type { CreateThemeOptions } from "./schema"; + +export function createTheme(options: CreateThemeOptions): Theme { + if (options.algorithm === "material") { + const { algorithm: _, ...rest } = options; + return createMaterialTheme(rest); + } + + const { algorithm: _, ...rest } = options; + return createContrastTheme(rest); +} diff --git a/packages/colors/tsconfig.json b/packages/colors/tsconfig.json new file mode 100644 index 000000000..46a3c4c19 --- /dev/null +++ b/packages/colors/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@dotui/ts-config/base.json", + "compilerOptions": { + "lib": ["ES2022"], + "baseUrl": ".", + "paths": { + "@dotui/colors/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..2865c7f88 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,39 @@ +{ + "name": "@dotui/core", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "MIT", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./schemas": "./src/schemas/index.ts", + "./types": "./src/types.ts", + "./react": "./src/react/index.ts", + "./react/style-provider": "./src/react/style-provider.tsx", + "./react/dynamic-component": "./src/react/dynamic-component.tsx", + "./shadcn": "./src/shadcn/index.ts", + "./__registry__/variants": "./src/__registry__/variants.ts" + }, + "scripts": { + "clean": "git clean -xdf .cache .turbo dist node_modules", + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "peerDependencies": { + "react": "^19.2.1", + "react-dom": "^19.2.1" + }, + "dependencies": { + "@dotui/colors": "workspace:*", + "@dotui/types": "workspace:*", + "shadcn": "^3.6.1", + "zod": "^4.2.1" + }, + "devDependencies": { + "@dotui/ts-config": "workspace:*", + "@types/node": "^22.15.3", + "@types/react": "^19.2.2", + "typescript": "^5.8.3" + } +} diff --git a/packages/core/src/__registry__/base.ts b/packages/core/src/__registry__/base.ts new file mode 100644 index 000000000..3b7415302 --- /dev/null +++ b/packages/core/src/__registry__/base.ts @@ -0,0 +1,29 @@ +// AUTO-GENERATED - DO NOT EDIT +// Run "pnpm build" to regenerate + +export const base = [ + { + "name": "base", + "type": "registry:style", + "dependencies": [ + "tailwind-variants", + "clsx", + "tailwind-merge", + "react-aria-components", + "tailwindcss-react-aria-components", + "tw-animate-css", + "tailwindcss-autocontrast" + ], + "registryDependencies": [ + "utils", + "focus-styles", + "theme" + ], + "extends": "none", + "css": { + "@plugin tailwindcss-react-aria-components": {}, + "@plugin tailwindcss-autocontrast": {} + }, + "files": [] + } +] as const; diff --git a/packages/core/src/__registry__/blocks.ts b/packages/core/src/__registry__/blocks.ts new file mode 100644 index 000000000..c4a6a348c --- /dev/null +++ b/packages/core/src/__registry__/blocks.ts @@ -0,0 +1,1378 @@ +// AUTO-GENERATED - DO NOT EDIT +// Run "pnpm build" to regenerate + +export const blocksCategories = [ + { + "name": "Featured", + "slug": "featured" + } +] as const; + +export const blocks = [ + { + "name": "login", + "type": "registry:block", + "dependencies": [ + "@internationalized/date" + ], + "registryDependencies": [ + "button", + "text-field", + "card", + "link" + ], + "description": "A simple login form.", + "categories": [ + "featured", + "authentication" + ], + "meta": { + "containerHeight": 600 + }, + "files": [ + { + "type": "registry:page", + "path": "blocks/auth/login/page.tsx", + "target": "app/login/page.tsx", + "content": `import { LoginForm } from "@dotui/registry/blocks/auth/login/components/login-form"; + +export default function Page() { + return ( +
+ +
+ ); +} +` + }, + { + "type": "registry:component", + "path": "blocks/auth/login/components/login-form.tsx", + "target": "blocks/auth/login/components/login-form.tsx", + "content": `"use client"; + +import { cn } from "@dotui/registry/lib/utils"; +import { Button } from "@dotui/registry/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dotui/registry/ui/card"; +import { Label } from "@dotui/registry/ui/field"; +import { Input } from "@dotui/registry/ui/input"; +import { Link } from "@dotui/registry/ui/link"; +import { TextField } from "@dotui/registry/ui/text-field"; + +export function LoginForm(props: React.ComponentProps<"div">) { + return ( + + + Login to your account + + Enter your email below to login to your account + + + +
+ + + +
+
+
+ +
+
+ Or +
+
+ + + + + +

+ Don't have an account?{" "} + + register + +

+
+
+ ); +} +` + } + ] + }, + { + "name": "cards", + "type": "registry:block", + "registryDependencies": [ + "all" + ], + "description": "A set of cards.", + "categories": [ + "featured", + "showcase" + ], + "meta": { + "containerHeight": 600 + }, + "files": [ + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/cards.tsx", + "target": "blocks/showcase/cards/components/cards.tsx", + "content": `import { AccountMenu } from "@dotui/registry/blocks/showcase/cards/components/account-menu"; +import { Backlog } from "@dotui/registry/blocks/showcase/cards/components/backlog"; +import { Booking } from "@dotui/registry/blocks/showcase/cards/components/booking"; +import { ColorEditorCard } from "@dotui/registry/blocks/showcase/cards/components/color-editor"; +import { Filters } from "@dotui/registry/blocks/showcase/cards/components/filters"; +import { InviteMembers } from "@dotui/registry/blocks/showcase/cards/components/invite-members"; +import { LoginForm } from "@dotui/registry/blocks/showcase/cards/components/login-form"; +import { Notifications } from "@dotui/registry/blocks/showcase/cards/components/notifications"; +import { TeamName } from "@dotui/registry/blocks/showcase/cards/components/team-name"; +import { cn } from "@dotui/registry/lib/utils"; + +export function Cards(props: React.ComponentProps<"div">) { + return ( +
+
+ + +
+ + + + +
+ + +
+ + +
+ +
+
+ ); +} + +export default Cards; +` + }, + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/account-menu.tsx", + "target": "blocks/showcase/cards/components/account-menu.tsx", + "content": `"use client"; + +import { + BookIcon, + ContrastIcon, + LanguagesIcon, + LogOutIcon, + SettingsIcon, + User2Icon, + Users2Icon, +} from "lucide-react"; + +import { cn } from "@dotui/registry/lib/utils"; +import { Avatar } from "@dotui/registry/ui/avatar"; +import { Card, CardContent, CardHeader } from "@dotui/registry/ui/card"; +import { + ListBox, + ListBoxItem, + ListBoxSection, + ListBoxSectionHeader, +} from "@dotui/registry/ui/list-box"; +import { Separator } from "@dotui/registry/ui/separator"; + +export function AccountMenu({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + + +
+

mehdibha

+

+ hello@mehdibha.com +

+
+
+ + + + + Profile + + + + Settings + + + + Documentation + + + + Community + + + + Preferences + + + Theme + + + + Language + + + + + + Log out + + + +
+ ); +} +` + }, + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/backlog.tsx", + "target": "blocks/showcase/cards/components/backlog.tsx", + "content": `"use client"; + +import { + RiCheckboxCircleFill, + RiProgress4Line, + RiProgress6Line, +} from "@remixicon/react"; +import { + AlertTriangle, + Circle, + CircleDashedIcon, + MoreHorizontal, + Settings2Icon, + Zap, +} from "lucide-react"; + +import { Badge } from "@dotui/registry/ui/badge"; +import { Button } from "@dotui/registry/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dotui/registry/ui/card"; +import { Input } from "@dotui/registry/ui/input"; +import { Menu, MenuContent, MenuItem } from "@dotui/registry/ui/menu"; +import { Overlay } from "@dotui/registry/ui/overlay"; +import { SearchField } from "@dotui/registry/ui/search-field"; +import { + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from "@dotui/registry/ui/table"; + +export const statuses = [ + { + value: "draft", + label: "Draft", + variant: "default", + icon: CircleDashedIcon, + }, + { + value: "in progress", + label: "In progress", + variant: "info", + icon: RiProgress4Line, + }, + { + value: "in review", + label: "In review", + variant: "warning", + icon: RiProgress6Line, + }, + { + value: "done", + label: "Done", + variant: "success", + icon: RiCheckboxCircleFill, + }, +] as const; + +export const priorities = [ + { + value: "P0", + label: "P0", + variant: "danger", + icon: AlertTriangle, + }, + { + value: "P1", + label: "P1", + variant: "warning", + icon: Zap, + }, + { + value: "P2", + label: "P2", + variant: "info", + icon: Circle, + }, + { + value: "P3", + label: "P3", + variant: "default", + icon: Circle, + }, +] as const; + +export const types = [ + { + value: "feature", + label: "Feature", + variant: "info", + }, + { + value: "bug", + label: "Bug", + variant: "danger", + }, + { + value: "tech debt", + label: "Tech Debt", + variant: "warning", + }, + { + value: "spike", + label: "Spike", + variant: "info", + }, + { + value: "chore", + label: "Chore", + variant: "neutral", + }, + { + value: "performance", + label: "Performance", + variant: "success", + }, +] as const; + +interface Column { + id: keyof Omit | "actions"; + name: string; + isRowHeader?: boolean; +} + +const columns: Column[] = [ + { name: "Title", id: "title", isRowHeader: true }, + { name: "Priority", id: "priority" }, + { name: "Status", id: "status" }, + { name: "Assignee", id: "assignee" }, + { name: "Story Points", id: "storyPoints" }, + { name: "", id: "actions" }, +]; + +interface Item { + id: number; + title: string; + priority: string; + status: string; + assignee: string; + storyPoints: string; + type: string; +} + +interface User { + username: string; + name: string; + avatar: string; +} + +export const users: User[] = [ + { + username: "shadcn", + name: "shadcn", + avatar: "https://github.com/shadcn.png", + }, + { + username: "tannerlinsley", + name: "Tanner Linsley", + avatar: "https://github.com/tannerlinsley.png", + }, + { + username: "t3dotgg", + name: "Theo Browne", + avatar: "https://github.com/t3dotgg.png", + }, + { + username: "rauchg", + name: "Guillermo Rauch", + avatar: "https://github.com/rauchg.png", + }, + { + username: "leerob", + name: "Lee Robinson", + avatar: "https://github.com/leerob.png", + }, + { + username: "steventey", + name: "Steven Tey", + avatar: "https://github.com/steventey.png", + }, +] as const; + +const data: Item[] = [ + { + id: 1, + title: "Refactor AuthProvider to support SSO + 2FA", + priority: "P0", + status: "in progress", + assignee: "shadcn", + storyPoints: "13", + type: "feature", + }, + { + id: 2, + title: "Fix race condition in payment webhooks", + priority: "P1", + status: "in review", + assignee: "tannerlinsley", + storyPoints: "5", + type: "bug", + }, + { + id: 3, + title: "Migrate legacy API from REST to GraphQL", + priority: "P2", + status: "draft", + assignee: "t3dotgg", + storyPoints: "21", + type: "tech debt", + }, + { + id: 4, + title: "Add Storybook stories for Button variants", + priority: "P3", + status: "done", + assignee: "rauchg", + storyPoints: "3", + type: "chore", + }, + { + id: 5, + title: "Spike: Evaluate Redis vs Kafka for event streaming", + priority: "P2", + status: "in progress", + assignee: "leerob", + storyPoints: "8", + type: "spike", + }, + { + id: 6, + title: "Implement lazy loading for ProductGrid component", + priority: "P1", + status: "draft", + assignee: "steventey", + storyPoints: "5", + type: "performance", + }, +]; + +export function Backlog(props: React.ComponentProps<"div">) { + return ( + + + Backlog + + Here's a list of your tasks for this month. + + + +
+
+ + + +
+
+ + +
+
+ + + {(column) => ( + + {column.name} + + )} + + + {(item) => ( + + + {() => { + const type = types.find((t) => t.value === item.type); + if (!type) return null; + return ( +
+ + {type?.label || item.type} + + {item.title} +
+ ); + }} +
+ + {(() => { + const priority = priorities.find( + (p) => p.value === item.priority, + ); + if (!priority) return null; + return ( + {priority.label} + ); + })()} + + + {(() => { + const status = statuses.find( + (s) => s.value === item.status, + ); + if (!status) return null; + const StatusIcon = status.icon || Circle; + return ( + + + {status?.label || item.status} + + ); + })()} + + + {(() => { + const user = users.find( + (u) => u.username === item.assignee, + ); + if (!user) return null; + return ( +
+ {user.name} + {user.name} +
+ ); + })()} +
+ + + {item.storyPoints} + + + + + + + + Edit + Duplicate + Archive + Delete + + + + +
+ )} +
+
+
+
+ ); +} +` + }, + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/booking.tsx", + "target": "blocks/showcase/cards/components/booking.tsx", + "content": `"use client"; + +import { parseDate } from "@internationalized/date"; + +import { cn } from "@dotui/registry/lib/utils"; +import { Button } from "@dotui/registry/ui/button"; +import { Calendar } from "@dotui/registry/ui/calendar"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@dotui/registry/ui/card"; +import { Label } from "@dotui/registry/ui/field"; +import { Input } from "@dotui/registry/ui/input"; +import { TimeField } from "@dotui/registry/ui/time-field"; + +export function Booking({ className, ...props }: React.ComponentProps<"div">) { + return ( + + + Booking + Pick a time for your meeting. + + + +
+ + + + + + + + +
+
+ + + + +
+ ); +} +` + }, + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/color-editor.tsx", + "target": "blocks/showcase/cards/components/color-editor.tsx", + "content": `"use client"; + +import { cn } from "@dotui/registry/lib/utils"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dotui/registry/ui/card"; +import { ColorEditor } from "@dotui/registry/ui/color-editor"; + +export function ColorEditorCard({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + + Accent color + Edit the accent color of the app. + + + + + + ); +} +` + }, + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/filters.tsx", + "target": "blocks/showcase/cards/components/filters.tsx", + "content": `"use client"; + +// import { ZapIcon } from "lucide-react"; +import { cn } from "@dotui/registry/lib/utils"; +import { Button } from "@dotui/registry/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@dotui/registry/ui/card"; +import { Description, Label } from "@dotui/registry/ui/field"; +import { Slider, SliderControl, SliderOutput } from "@dotui/registry/ui/slider"; +import { Switch } from "@dotui/registry/ui/switch"; +import { Tag, TagGroup, TagList } from "@dotui/registry/ui/tag-group"; +import { ToggleButton } from "@dotui/registry/ui/toggle-button"; +import { ToggleButtonGroup } from "@dotui/registry/ui/toggle-button-group"; + +export function Filters({ className, ...props }: React.ComponentProps<"div">) { + return ( + + + Filters + + +
+ + + Any type + Room + Entire home + +
+ +
+ + +
+ + Trip price, includes all fees +
+ + + + Wifi + TV + Kitchen + Pool + Washer + Dryer + Heating + Hair dryer + EV charger + Gym + BBQ grill + Breakfast + + + + + {/* */} + Instant booking + + +
+ + + + +
+ ); +} +` + }, + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/invite-members.tsx", + "target": "blocks/showcase/cards/components/invite-members.tsx", + "content": `"use client"; + +import { PlusCircleIcon } from "lucide-react"; + +import { ExternalLinkIcon } from "@dotui/registry/icons"; +import { Avatar } from "@dotui/registry/ui/avatar"; +import { Button } from "@dotui/registry/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@dotui/registry/ui/card"; +import { Label } from "@dotui/registry/ui/field"; +import { Input } from "@dotui/registry/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@dotui/registry/ui/select"; +import { Separator } from "@dotui/registry/ui/separator"; +import { TextField } from "@dotui/registry/ui/text-field"; + +const teamMembers = [ + { + name: "shadcn", + email: "shadcn@vercel.com", + avatar: "https://github.com/shadcn.png", + role: "owner", + }, + { + name: "rauchg", + email: "rauchg@vercel.com", + avatar: "https://github.com/rauchg.png", + role: "member", + }, + { + name: "Lee Robinson", + email: "lee@cursor.com", + avatar: "https://github.com/leerob.png", + role: "member", + }, +]; + +export function InviteMembers(props: React.ComponentProps<"div">) { + return ( + + + Invite Members + + Collaborate with members on this project. + + + + + + + +
+
+ + + + + +
+ +
+

Team members

+
+ {teamMembers.map((member) => ( +
+
+ +
+

{member.name}

+

{member.role}

+
+
+ +
+ ))} +
+
+
+
+ + +

+ Learn more about{" "} + + inviting members + + . +

+ +
+
+ ); +} +` + }, + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/login-form.tsx", + "target": "blocks/showcase/cards/components/login-form.tsx", + "content": `"use client"; + +import { cn } from "@dotui/registry/lib/utils"; +import { Button } from "@dotui/registry/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@dotui/registry/ui/card"; +import { Label } from "@dotui/registry/ui/field"; +import { Input } from "@dotui/registry/ui/input"; +import { Link } from "@dotui/registry/ui/link"; +import { TextField } from "@dotui/registry/ui/text-field"; + +export function LoginForm(props: React.ComponentProps<"div">) { + return ( + + + Login to your account + + Enter your email below to login to your account + + + +
+ + + +
+
+
+ +
+
+ Or +
+
+ + + + + +

+ {/* TODO */} + Don't have an account?{" "} + + register + +

+
+
+ ); +} +` + }, + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/notifications.tsx", + "target": "blocks/showcase/cards/components/notifications.tsx", + "content": `import React from "react"; + +import { cn } from "@dotui/registry/lib/utils"; +import { Avatar } from "@dotui/registry/ui/avatar"; +import { Badge } from "@dotui/registry/ui/badge"; +import { Button } from "@dotui/registry/ui/button"; +import { + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@dotui/registry/ui/card"; +import { ListBox, ListBoxItem } from "@dotui/registry/ui/list-box"; +import { Separator } from "@dotui/registry/ui/separator"; +import { Tab, TabList, TabPanel, Tabs } from "@dotui/registry/ui/tabs"; + +export function Notifications({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + + + Notifications + 12 + + + + + + + + + All + Unread + Read + + {["all", "unread", "read"].map((tab) => ( + + + {notifications + .filter((notification) => { + if (tab === "all") return true; + if (tab === "unread") return !notification.read; + if (tab === "read") return notification.read; + return false; + }) + .map((notification, index) => ( + + + +
+ n[0]) + .join("")} + size="md" + /> +
+

+ + {notification.user.name} + {" "} + {notification.content ? ( + notification.content + ) : ( + {notification.text} + )} +

+
+

+ {notification.timestamp} +

+ {notification.action && ( +
+ +
+ )} +
+
+
+
+
+ ))} +
+
+ ))} +
+
+
+ ); +} + +const notifications = [ + { + user: { + name: "Guillermo Rauch", + avatar: "https://avatars.githubusercontent.com/rauchg", + }, + text: "starred your repository dotUI.", + content: ( + <> + starred mehdibha/dotUI. + + ), + read: false, + timestamp: "2 hours ago", + }, + { + user: { + name: "Lee Robinson", + avatar: "https://avatars.githubusercontent.com/leerob", + }, + text: "invited you to the Vercel GitHub organization.", + content: ( + <> + invited you to join Cursor on + GitHub. + + ), + read: false, + action: { label: "View invite" }, + timestamp: "7 hours ago", + }, + { + user: { + name: "Tim Neutkens", + avatar: "https://avatars.githubusercontent.com/timneutkens", + }, + text: "published a new release v14.2.0-canary on vercel/next.js.", + content: ( + <> + published v14.2.0-canary on + vercel/next.js. + + ), + read: false, + action: { label: "See release" }, + timestamp: "Yesterday", + }, + { + user: { + name: "Steven Tey", + avatar: "https://avatars.githubusercontent.com/steven-tey", + }, + text: "opened a pull request: Improve docs.", + content: ( + <> + opened PR: + Improve docs in + mehdibha/dotUI. + + ), + read: true, + action: { label: "Review PR" }, + timestamp: "Yesterday", + }, + { + user: { + name: "Shu Ding", + avatar: "https://avatars.githubusercontent.com/shuding", + }, + text: "starred your repository dotUI.", + content: ( + <> + starred mehdibha/dotUI. + + ), + read: true, + timestamp: "2 days ago", + }, + { + user: { + name: "Delba de Oliveira", + avatar: "https://avatars.githubusercontent.com/delbaoliveira", + }, + text: "commented on issue: Add theme presets.", + content: ( + <> + commented on #128: + Add theme presets. + + ), + read: false, + action: { label: "Reply" }, + timestamp: "3 days ago", + }, +]; +` + }, + { + "type": "registry:component", + "path": "blocks/showcase/cards/components/team-name.tsx", + "target": "blocks/showcase/cards/components/team-name.tsx", + "content": `"use client"; + +import { cn } from "@dotui/registry/lib/utils"; +import { Button } from "@dotui/registry/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@dotui/registry/ui/card"; +import { Input } from "@dotui/registry/ui/input"; +import { TextField } from "@dotui/registry/ui/text-field"; + +export function TeamName({ className, ...props }: React.ComponentProps<"div">) { + return ( + + + Team Name + + This is your team's visible name within the platform. For example, the + name of your company or department. + + + + + + + + +

+ Please use 32 characters at maximum. +

+ +
+
+ ); +} +` + } + ] + }, + { + "name": "animation", + "type": "registry:block", + "registryDependencies": [ + "all" + ], + "categories": [ + "featured", + "showcase" + ], + "meta": { + "containerHeight": 600 + }, + "files": [ + { + "type": "registry:component", + "path": "blocks/showcase/animation/components/animation.tsx", + "target": "blocks/showcase/animation/components/animation.tsx", + "content": `"use client"; + +import React from "react"; + +import { Select, SelectItem } from "@dotui/registry/ui/select"; + +export function Animation({ + className: _className, +}: React.ComponentProps<"div">) { + const [isOpen, setIsOpen] = React.useState(false); + + React.useEffect(() => { + const interval = setInterval(() => { + setIsOpen((prev) => !prev); + }, 1000); + return () => clearInterval(interval); + }, []); + + return ( +
+ +
+ ); +} + +export default Animation; +` + } + ] + } +] as const; diff --git a/packages/core/src/__registry__/hooks.ts b/packages/core/src/__registry__/hooks.ts new file mode 100644 index 000000000..6010b5fe8 --- /dev/null +++ b/packages/core/src/__registry__/hooks.ts @@ -0,0 +1,38 @@ +// AUTO-GENERATED - DO NOT EDIT +// Run "pnpm build" to regenerate + +export const hooks = [ + { + "name": "use-mobile", + "type": "registry:hook", + "files": [ + { + "type": "registry:hook", + "path": "hooks/use-mobile.ts", + "target": "hooks/use-mobile.ts", + "content": `import * as React from "react"; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState( + undefined, + ); + + React.useEffect(() => { + const mql = window.matchMedia(\`(max-width: \${MOBILE_BREAKPOINT - 1}px)\`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} +` + } + ] + } +] as const; diff --git a/packages/core/src/__registry__/icons.ts b/packages/core/src/__registry__/icons.ts new file mode 100644 index 000000000..9d155d53e --- /dev/null +++ b/packages/core/src/__registry__/icons.ts @@ -0,0 +1,976 @@ +// AUTO-GENERATED - DO NOT EDIT +// Run "pnpm build" to regenerate + +export const iconLibraries = [ + { + "name": "lucide", + "label": "Lucide icons", + "package": "lucide-react", + "import": "lucide-react" + }, + { + "name": "remix", + "label": "Remix icons", + "package": "@remixicon/react", + "import": "@remixicon/react" + }, + { + "name": "tabler", + "label": "Tabler icons", + "package": "@tabler/icons-react", + "import": "@tabler/icons-react" + }, + { + "name": "hugeicons", + "label": "Huge icons", + "package": "@hugeicons/react", + "import": "@hugeicons/react" + } +] as const; + +export type IconLibraryName = (typeof iconLibraries)[number]["name"]; + +export const icons = { + "PlusIcon": { + "lucide": "PlusIcon", + "remix": "RiAddLine", + "tabler": "IconPlus", + "hugeicons": "PlusSignIcon" + }, + "PaperclipIcon": { + "lucide": "PaperclipIcon", + "remix": "RiAttachmentLine", + "tabler": "IconPaperclip", + "hugeicons": "AttachmentIcon" + }, + "SparklesIcon": { + "lucide": "SparklesIcon", + "remix": "RiSparklingLine", + "tabler": "IconSparkles", + "hugeicons": "SparklesIcon" + }, + "ShoppingBagIcon": { + "lucide": "ShoppingBagIcon", + "remix": "RiShoppingBagLine", + "tabler": "IconShoppingBag", + "hugeicons": "ShoppingBag01Icon" + }, + "WandIcon": { + "lucide": "WandIcon", + "remix": "RiMagicLine", + "tabler": "IconWand", + "hugeicons": "MagicWand01Icon" + }, + "MousePointerIcon": { + "lucide": "MousePointerIcon", + "remix": "RiCursorLine", + "tabler": "IconPointer", + "hugeicons": "Cursor01Icon" + }, + "MoreHorizontalIcon": { + "lucide": "MoreHorizontalIcon", + "remix": "RiMoreLine", + "tabler": "IconDots", + "hugeicons": "MoreHorizontalCircle01Icon" + }, + "ShareIcon": { + "lucide": "ShareIcon", + "remix": "RiShareLine", + "tabler": "IconShare", + "hugeicons": "Share03Icon" + }, + "BookOpenIcon": { + "lucide": "BookOpenIcon", + "remix": "RiBookOpenLine", + "tabler": "IconBook", + "hugeicons": "BookOpen01Icon" + }, + "GlobeIcon": { + "lucide": "GlobeIcon", + "remix": "RiGlobalLine", + "tabler": "IconWorld", + "hugeicons": "GlobalIcon" + }, + "PenToolIcon": { + "lucide": "PenToolIcon", + "remix": "RiPenNibLine", + "tabler": "IconPencil", + "hugeicons": "QuillWrite01Icon" + }, + "AudioLinesIcon": { + "lucide": "AudioLinesIcon", + "remix": "RiSoundModuleLine", + "tabler": "IconMicrophone", + "hugeicons": "Mic01Icon" + }, + "ArrowUpIcon": { + "lucide": "ArrowUpIcon", + "remix": "RiArrowUpLine", + "tabler": "IconArrowUp", + "hugeicons": "ArrowUp01Icon" + }, + "ChevronDownIcon": { + "lucide": "ChevronDownIcon", + "remix": "RiArrowDownSLine", + "tabler": "IconChevronDown", + "hugeicons": "ArrowDown01Icon" + }, + "SettingsIcon": { + "lucide": "SettingsIcon", + "remix": "RiSettings3Line", + "tabler": "IconSettings", + "hugeicons": "Settings01Icon" + }, + "FolderIcon": { + "lucide": "FolderIcon", + "remix": "RiFolderLine", + "tabler": "IconFolder", + "hugeicons": "Folder01Icon" + }, + "CircleCheckIcon": { + "lucide": "CircleCheckIcon", + "remix": "RiCheckboxCircleLine", + "tabler": "IconCircleCheck", + "hugeicons": "CheckmarkCircle02Icon" + }, + "LightbulbIcon": { + "lucide": "LightbulbIcon", + "remix": "RiLightbulbLine", + "tabler": "IconBulb", + "hugeicons": "BulbIcon" + }, + "ContainerIcon": { + "lucide": "ContainerIcon", + "remix": "RiBox3Line", + "tabler": "IconBox", + "hugeicons": "CubeIcon" + }, + "ZapIcon": { + "lucide": "ZapIcon", + "remix": "RiFlashlightLine", + "tabler": "IconBolt", + "hugeicons": "ZapIcon" + }, + "ServerIcon": { + "lucide": "ServerIcon", + "remix": "RiServerLine", + "tabler": "IconServer", + "hugeicons": "DatabaseIcon" + }, + "InfoIcon": { + "lucide": "InfoIcon", + "remix": "RiInformationLine", + "tabler": "IconInfoCircle", + "hugeicons": "InformationCircleIcon" + }, + "TerminalIcon": { + "lucide": "TerminalIcon", + "remix": "RiTerminalBoxLine", + "tabler": "IconTerminal", + "hugeicons": "SourceCodeIcon" + }, + "CopyIcon": { + "lucide": "CopyIcon", + "remix": "RiFileCopyLine", + "tabler": "IconCopy", + "hugeicons": "Copy01Icon" + }, + "MonitorIcon": { + "lucide": "MonitorIcon", + "remix": "RiComputerLine", + "tabler": "IconDeviceDesktop", + "hugeicons": "ComputerIcon" + }, + "DownloadIcon": { + "lucide": "DownloadIcon", + "remix": "RiDownloadLine", + "tabler": "IconDownload", + "hugeicons": "Download01Icon" + }, + "SearchIcon": { + "lucide": "SearchIcon", + "remix": "RiSearchLine", + "tabler": "IconSearch", + "hugeicons": "Search01Icon" + }, + "UploadIcon": { + "lucide": "UploadIcon", + "remix": "RiUpload2Line", + "tabler": "IconUpload", + "hugeicons": "Upload01Icon" + }, + "CloudCogIcon": { + "lucide": "CloudCogIcon", + "remix": "RiCloudLine", + "tabler": "IconCloudCog", + "hugeicons": "AiCloud01Icon" + }, + "GitBranchIcon": { + "lucide": "GitBranchIcon", + "remix": "RiGitBranchLine", + "tabler": "IconGitBranch", + "hugeicons": "GitBranchIcon" + }, + "BotIcon": { + "lucide": "BotIcon", + "remix": "RiRobotLine", + "tabler": "IconRobot", + "hugeicons": "RoboticIcon" + }, + "SendIcon": { + "lucide": "SendIcon", + "remix": "RiSendPlaneLine", + "tabler": "IconSend", + "hugeicons": "SentIcon" + }, + "MenuIcon": { + "lucide": "MenuIcon", + "remix": "RiMenuLine", + "tabler": "IconMenu", + "hugeicons": "Menu09Icon" + }, + "XIcon": { + "lucide": "XIcon", + "remix": "RiCloseLine", + "tabler": "IconX", + "hugeicons": "Cancel01Icon" + }, + "HomeIcon": { + "lucide": "HomeIcon", + "remix": "RiHomeLine", + "tabler": "IconHome", + "hugeicons": "Home01Icon" + }, + "CircleIcon": { + "lucide": "CircleIcon", + "remix": "RiCircleLine", + "tabler": "IconCircle", + "hugeicons": "CircleIcon" + }, + "LayoutGridIcon": { + "lucide": "LayoutGridIcon", + "remix": "RiLayoutGridLine", + "tabler": "IconLayoutGrid", + "hugeicons": "GridIcon" + }, + "MailIcon": { + "lucide": "MailIcon", + "remix": "RiMailLine", + "tabler": "IconMail", + "hugeicons": "Mail01Icon" + }, + "LinkIcon": { + "lucide": "LinkIcon", + "remix": "RiLinkM", + "tabler": "IconLink", + "hugeicons": "Link01Icon" + }, + "SmileIcon": { + "lucide": "SmileIcon", + "remix": "RiEmotionLine", + "tabler": "IconMoodSmile", + "hugeicons": "SmileIcon" + }, + "CircleAlertIcon": { + "lucide": "CircleAlertIcon", + "remix": "RiErrorWarningLine", + "tabler": "IconExclamationCircle", + "hugeicons": "Alert01Icon" + }, + "UserIcon": { + "lucide": "UserIcon", + "remix": "RiUserLine", + "tabler": "IconUser", + "hugeicons": "UserIcon" + }, + "StarIcon": { + "lucide": "StarIcon", + "remix": "RiStarLine", + "tabler": "IconStar", + "hugeicons": "StarIcon" + }, + "CodeIcon": { + "lucide": "CodeIcon", + "remix": "RiCodeLine", + "tabler": "IconCode", + "hugeicons": "CodeIcon" + }, + "HeartIcon": { + "lucide": "HeartIcon", + "remix": "RiHeartLine", + "tabler": "IconHeart", + "hugeicons": "FavouriteIcon" + }, + "LogOutIcon": { + "lucide": "LogOutIcon", + "remix": "RiLogoutBoxLine", + "tabler": "IconLogout", + "hugeicons": "Logout01Icon" + }, + "MinusIcon": { + "lucide": "MinusIcon", + "remix": "RiSubtractLine", + "tabler": "IconMinus", + "hugeicons": "MinusSignIcon" + }, + "ArrowLeftIcon": { + "lucide": "ArrowLeftIcon", + "remix": "RiArrowLeftLine", + "tabler": "IconArrowLeft", + "hugeicons": "ArrowLeft01Icon" + }, + "MailCheckIcon": { + "lucide": "MailCheckIcon", + "remix": "RiMailCheckLine", + "tabler": "IconMailCheck", + "hugeicons": "MailValidation01Icon" + }, + "ArchiveIcon": { + "lucide": "ArchiveIcon", + "remix": "RiArchiveLine", + "tabler": "IconArchive", + "hugeicons": "Archive02Icon" + }, + "ClockIcon": { + "lucide": "ClockIcon", + "remix": "RiTimeLine", + "tabler": "IconClock", + "hugeicons": "Clock01Icon" + }, + "CalendarPlusIcon": { + "lucide": "CalendarPlusIcon", + "remix": "RiCalendarEventLine", + "tabler": "IconCalendarPlus", + "hugeicons": "CalendarAdd01Icon" + }, + "ListFilterIcon": { + "lucide": "ListFilterIcon", + "remix": "RiFilterLine", + "tabler": "IconFilterPlus", + "hugeicons": "FilterIcon" + }, + "TagIcon": { + "lucide": "TagIcon", + "remix": "RiPriceTag3Line", + "tabler": "IconTag", + "hugeicons": "Tag01Icon" + }, + "Trash2Icon": { + "lucide": "Trash2Icon", + "remix": "RiDeleteBin6Line", + "tabler": "IconTrash", + "hugeicons": "Delete02Icon" + }, + "ArrowRightIcon": { + "lucide": "ArrowRightIcon", + "remix": "RiArrowRightLine", + "tabler": "IconArrowRight", + "hugeicons": "ArrowRight02Icon" + }, + "VolumeX": { + "lucide": "VolumeX", + "remix": "RiVolumeMuteLine", + "tabler": "IconVolume", + "hugeicons": "VolumeOffIcon" + }, + "CheckIcon": { + "lucide": "CheckIcon", + "remix": "RiCheckLine", + "tabler": "IconCheck", + "hugeicons": "Tick02Icon" + }, + "UserRoundXIcon": { + "lucide": "UserRoundXIcon", + "remix": "RiUserUnfollowLine", + "tabler": "IconUserX", + "hugeicons": "UserRemove01Icon" + }, + "AlertTriangleIcon": { + "lucide": "AlertTriangleIcon", + "remix": "RiAlertLine", + "tabler": "IconAlertTriangle", + "hugeicons": "Alert02Icon" + }, + "TrashIcon": { + "lucide": "TrashIcon", + "remix": "RiDeleteBinLine", + "tabler": "IconTrash", + "hugeicons": "Delete01Icon" + }, + "BluetoothIcon": { + "lucide": "BluetoothIcon", + "remix": "RiBluetoothLine", + "tabler": "IconBluetooth", + "hugeicons": "BluetoothIcon" + }, + "MoreVerticalIcon": { + "lucide": "MoreVerticalIcon", + "remix": "RiMore2Line", + "tabler": "IconDotsVertical", + "hugeicons": "MoreVerticalCircle01Icon" + }, + "FileIcon": { + "lucide": "FileIcon", + "remix": "RiFileLine", + "tabler": "IconFile", + "hugeicons": "File01Icon" + }, + "FolderOpenIcon": { + "lucide": "FolderOpenIcon", + "remix": "RiFolderOpenLine", + "tabler": "IconFolderOpen", + "hugeicons": "FolderOpenIcon" + }, + "FileCodeIcon": { + "lucide": "FileCodeIcon", + "remix": "RiFileCodeLine", + "tabler": "IconFileCode", + "hugeicons": "CodeIcon" + }, + "FolderSearchIcon": { + "lucide": "FolderSearchIcon", + "remix": "RiFileSearchLine", + "tabler": "IconFolderSearch", + "hugeicons": "Search01Icon" + }, + "SaveIcon": { + "lucide": "SaveIcon", + "remix": "RiSaveLine", + "tabler": "IconDeviceFloppy", + "hugeicons": "FloppyDiskIcon" + }, + "EyeIcon": { + "lucide": "EyeIcon", + "remix": "RiEyeLine", + "tabler": "IconEye", + "hugeicons": "EyeIcon" + }, + "LayoutIcon": { + "lucide": "LayoutIcon", + "remix": "RiLayoutLine", + "tabler": "IconLayout", + "hugeicons": "Layout01Icon" + }, + "PaletteIcon": { + "lucide": "PaletteIcon", + "remix": "RiPaletteLine", + "tabler": "IconPalette", + "hugeicons": "PaintBoardIcon" + }, + "SunIcon": { + "lucide": "SunIcon", + "remix": "RiSunLine", + "tabler": "IconSun", + "hugeicons": "Sun01Icon" + }, + "MoonIcon": { + "lucide": "MoonIcon", + "remix": "RiMoonLine", + "tabler": "IconMoon", + "hugeicons": "MoonIcon" + }, + "HelpCircleIcon": { + "lucide": "HelpCircleIcon", + "remix": "RiQuestionLine", + "tabler": "IconHelpCircle", + "hugeicons": "HelpCircleIcon" + }, + "FileTextIcon": { + "lucide": "FileTextIcon", + "remix": "RiFileTextLine", + "tabler": "IconFileText", + "hugeicons": "File01Icon" + }, + "CalendarIcon": { + "lucide": "CalendarIcon", + "remix": "RiCalendarLine", + "tabler": "IconCalendar", + "hugeicons": "Calendar01Icon" + }, + "Search": { + "lucide": "Search", + "remix": "RiSearchLine", + "tabler": "IconSearch", + "hugeicons": "Search01Icon" + }, + "CheckCircle2Icon": { + "lucide": "CheckCircle2Icon", + "remix": "RiCheckboxCircleLine", + "tabler": "IconCircleCheckFilled", + "hugeicons": "CheckmarkCircle02Icon" + }, + "CircleDollarSignIcon": { + "lucide": "CircleDollarSignIcon", + "remix": "RiMoneyDollarCircleLine", + "tabler": "IconCoin", + "hugeicons": "DollarCircleIcon" + }, + "ArrowUpRightIcon": { + "lucide": "ArrowUpRightIcon", + "remix": "RiArrowRightUpLine", + "tabler": "IconArrowUpRight", + "hugeicons": "ArrowUpRight01Icon" + }, + "BadgeCheck": { + "lucide": "BadgeCheck", + "remix": "RiVerifiedBadgeLine", + "tabler": "IconRosetteDiscountCheck", + "hugeicons": "CheckmarkBadge02Icon" + }, + "ArrowLeftCircleIcon": { + "lucide": "ArrowLeftCircleIcon", + "remix": "RiArrowLeftCircleLine", + "tabler": "IconCircleArrowLeft", + "hugeicons": "CircleArrowLeft02Icon" + }, + "FlipHorizontalIcon": { + "lucide": "FlipHorizontalIcon", + "remix": "RiFlipHorizontalLine", + "tabler": "IconFlipHorizontal", + "hugeicons": "FlipHorizontalIcon" + }, + "FlipVerticalIcon": { + "lucide": "FlipVerticalIcon", + "remix": "RiFlipVerticalLine", + "tabler": "IconFlipVertical", + "hugeicons": "FlipVerticalIcon" + }, + "RotateCwIcon": { + "lucide": "RotateCwIcon", + "remix": "RiRestartLine", + "tabler": "IconRotateClockwise2", + "hugeicons": "Rotate01Icon" + }, + "Clock2Icon": { + "lucide": "Clock2Icon", + "remix": "RiTimeLine", + "tabler": "IconClockHour2", + "hugeicons": "Clock03Icon" + }, + "CaptionsIcon": { + "lucide": "CaptionsIcon", + "remix": "RiClosedCaptioningLine", + "tabler": "IconTextCaption", + "hugeicons": "ClosedCaptionIcon" + }, + "TrendingUpIcon": { + "lucide": "TrendingUpIcon", + "remix": "RiArrowRightUpLine", + "tabler": "IconTrendingUp", + "hugeicons": "Analytics01Icon" + }, + "ChevronRightIcon": { + "lucide": "ChevronRightIcon", + "remix": "RiArrowRightSLine", + "tabler": "IconChevronRight", + "hugeicons": "ArrowRight01Icon" + }, + "MinimizeIcon": { + "lucide": "MinimizeIcon", + "remix": "RiContractLeftRightLine", + "tabler": "IconMinimize", + "hugeicons": "MinusSignIcon" + }, + "MaximizeIcon": { + "lucide": "MaximizeIcon", + "remix": "RiExpandLeftRightLine", + "tabler": "IconMaximize", + "hugeicons": "PlusSignIcon" + }, + "CreditCardIcon": { + "lucide": "CreditCardIcon", + "remix": "RiBankCardLine", + "tabler": "IconCreditCard", + "hugeicons": "CreditCardIcon" + }, + "CalculatorIcon": { + "lucide": "CalculatorIcon", + "remix": "RiCalculatorLine", + "tabler": "IconCalculator", + "hugeicons": "CalculatorIcon" + }, + "InboxIcon": { + "lucide": "InboxIcon", + "remix": "RiInboxLine", + "tabler": "IconArchive", + "hugeicons": "Archive02Icon" + }, + "FolderPlusIcon": { + "lucide": "FolderPlusIcon", + "remix": "RiFolderAddLine", + "tabler": "IconFolderPlus", + "hugeicons": "FolderAddIcon" + }, + "ScissorsIcon": { + "lucide": "ScissorsIcon", + "remix": "RiScissorsLine", + "tabler": "IconCut", + "hugeicons": "ScissorIcon" + }, + "ClipboardPasteIcon": { + "lucide": "ClipboardPasteIcon", + "remix": "RiClipboardLine", + "tabler": "IconClipboard", + "hugeicons": "ClipboardIcon" + }, + "ListIcon": { + "lucide": "ListIcon", + "remix": "RiListUnordered", + "tabler": "IconList", + "hugeicons": "Menu05Icon" + }, + "ZoomInIcon": { + "lucide": "ZoomInIcon", + "remix": "RiZoomInLine", + "tabler": "IconZoomIn", + "hugeicons": "ZoomInAreaIcon" + }, + "ZoomOutIcon": { + "lucide": "ZoomOutIcon", + "remix": "RiZoomOutLine", + "tabler": "IconZoomOut", + "hugeicons": "ZoomOutAreaIcon" + }, + "BellIcon": { + "lucide": "BellIcon", + "remix": "RiNotification3Line", + "tabler": "IconBell", + "hugeicons": "Notification01Icon" + }, + "ImageIcon": { + "lucide": "ImageIcon", + "remix": "RiImageLine", + "tabler": "IconPhoto", + "hugeicons": "Image01Icon" + }, + "KeyboardIcon": { + "lucide": "KeyboardIcon", + "remix": "RiKeyboardLine", + "tabler": "IconKeyboard", + "hugeicons": "KeyboardIcon" + }, + "LanguagesIcon": { + "lucide": "LanguagesIcon", + "remix": "RiTranslate", + "tabler": "IconLanguage", + "hugeicons": "LanguageCircleIcon" + }, + "ShieldIcon": { + "lucide": "ShieldIcon", + "remix": "RiShieldLine", + "tabler": "IconShield", + "hugeicons": "SecurityIcon" + }, + "PencilIcon": { + "lucide": "PencilIcon", + "remix": "RiPencilLine", + "tabler": "IconPencil", + "hugeicons": "Edit01Icon" + }, + "ActivityIcon": { + "lucide": "ActivityIcon", + "remix": "RiPulseLine", + "tabler": "IconActivity", + "hugeicons": "Cardiogram01Icon" + }, + "PanelLeftIcon": { + "lucide": "PanelLeftIcon", + "remix": "RiLayoutLeftLine", + "tabler": "IconLayoutSidebar", + "hugeicons": "LayoutLeftIcon" + }, + "ArrowDownIcon": { + "lucide": "ArrowDownIcon", + "remix": "RiArrowDownLine", + "tabler": "IconArrowDown", + "hugeicons": "ArrowDown01Icon" + }, + "MessageSquareIcon": { + "lucide": "MessageSquareIcon", + "remix": "RiChat1Line", + "tabler": "IconMessage", + "hugeicons": "Message01Icon" + }, + "WalletIcon": { + "lucide": "WalletIcon", + "remix": "RiWalletLine", + "tabler": "IconWallet", + "hugeicons": "Wallet01Icon" + }, + "Building2Icon": { + "lucide": "Building2Icon", + "remix": "RiBankLine", + "tabler": "IconBuildingBank", + "hugeicons": "BankIcon" + }, + "BadgeCheckIcon": { + "lucide": "BadgeCheckIcon", + "remix": "RiVerifiedBadgeLine", + "tabler": "IconRosetteDiscountCheck", + "hugeicons": "CheckmarkBadge01Icon" + }, + "ChevronsUpDownIcon": { + "lucide": "ChevronsUpDownIcon", + "remix": "RiExpandUpDownLine", + "tabler": "IconSelector", + "hugeicons": "UnfoldMoreIcon" + }, + "CircleDashedIcon": { + "lucide": "CircleDashedIcon", + "remix": "RiLoader3Line", + "tabler": "IconCircleDashed", + "hugeicons": "Loading01Icon" + }, + "EyeOffIcon": { + "lucide": "EyeOffIcon", + "remix": "RiEyeOffLine", + "tabler": "IconEyeClosed", + "hugeicons": "ViewOffIcon" + }, + "MicIcon": { + "lucide": "MicIcon", + "remix": "RiMicLine", + "tabler": "IconMicrophone", + "hugeicons": "VoiceIcon" + }, + "RadioIcon": { + "lucide": "RadioIcon", + "remix": "RiRadioButtonLine", + "tabler": "IconPlayerRecordFilled", + "hugeicons": "RecordIcon" + }, + "ExternalLinkIcon": { + "lucide": "ExternalLinkIcon", + "remix": "RiExternalLinkLine", + "tabler": "IconExternalLink", + "hugeicons": "LinkSquare02Icon" + }, + "RefreshCwIcon": { + "lucide": "RefreshCwIcon", + "remix": "RiRefreshLine", + "tabler": "IconRefresh", + "hugeicons": "RefreshIcon" + }, + "BoldIcon": { + "lucide": "BoldIcon", + "remix": "RiBold", + "tabler": "IconBold", + "hugeicons": "TextBoldIcon" + }, + "ItalicIcon": { + "lucide": "ItalicIcon", + "remix": "RiItalic", + "tabler": "IconItalic", + "hugeicons": "TextItalicIcon" + }, + "UnderlineIcon": { + "lucide": "UnderlineIcon", + "remix": "RiUnderline", + "tabler": "IconUnderline", + "hugeicons": "TextUnderlineIcon" + }, + "TableIcon": { + "lucide": "TableIcon", + "remix": "RiTable2", + "tabler": "IconTable", + "hugeicons": "Table01Icon" + }, + "ChartLineIcon": { + "lucide": "ChartLineIcon", + "remix": "RiLineChartLine", + "tabler": "IconChartLine", + "hugeicons": "ChartLineData01Icon" + }, + "ChartBarIcon": { + "lucide": "ChartBarIcon", + "remix": "RiBarChartLine", + "tabler": "IconChartBar", + "hugeicons": "ChartColumnIcon" + }, + "ChartPieIcon": { + "lucide": "ChartPieIcon", + "remix": "RiPieChartLine", + "tabler": "IconChartPie", + "hugeicons": "PieChartIcon" + }, + "TerminalSquareIcon": { + "lucide": "TerminalSquareIcon", + "remix": "RiTerminalLine", + "tabler": "IconTerminal2", + "hugeicons": "SourceCodeSquareIcon" + }, + "BookOpen": { + "lucide": "BookOpen", + "remix": "RiBookOpenLine", + "tabler": "IconBook", + "hugeicons": "BookOpen02Icon" + }, + "Settings2Icon": { + "lucide": "Settings2Icon", + "remix": "RiSettings4Line", + "tabler": "IconSettings", + "hugeicons": "Settings05Icon" + }, + "FrameIcon": { + "lucide": "FrameIcon", + "remix": "RiCropLine", + "tabler": "IconFrame", + "hugeicons": "CropIcon" + }, + "PieChartIcon": { + "lucide": "PieChartIcon", + "remix": "RiPieChartLine", + "tabler": "IconChartPie", + "hugeicons": "PieChartIcon" + }, + "MapIcon": { + "lucide": "MapIcon", + "remix": "RiMapLine", + "tabler": "IconMap", + "hugeicons": "MapsIcon" + }, + "ShoppingCartIcon": { + "lucide": "ShoppingCartIcon", + "remix": "RiShoppingCartLine", + "tabler": "IconShoppingCart", + "hugeicons": "ShoppingCart01Icon" + }, + "LifeBuoy": { + "lucide": "LifeBuoy", + "remix": "RiLifebuoyLine", + "tabler": "IconLifebuoy", + "hugeicons": "ChartRingIcon" + }, + "Send": { + "lucide": "Send", + "remix": "RiSendPlaneLine", + "tabler": "IconSend", + "hugeicons": "SentIcon" + }, + "AppWindowIcon": { + "lucide": "AppWindowIcon", + "remix": "RiWindowLine", + "tabler": "IconAppWindow", + "hugeicons": "CursorInWindowIcon" + }, + "BookmarkIcon": { + "lucide": "BookmarkIcon", + "remix": "RiBookmarkLine", + "tabler": "IconBookmark", + "hugeicons": "Bookmark01Icon" + }, + "ChevronUpIcon": { + "lucide": "ChevronUpIcon", + "remix": "RiArrowUpSLine", + "tabler": "IconChevronUp", + "hugeicons": "ArrowUp01Icon" + }, + "ChevronLeftIcon": { + "lucide": "ChevronLeftIcon", + "remix": "RiArrowLeftSLine", + "tabler": "IconChevronLeft", + "hugeicons": "ArrowLeft01Icon" + }, + "TriangleAlertIcon": { + "lucide": "TriangleAlertIcon", + "remix": "RiAlertLine", + "tabler": "IconAlertTriangle", + "hugeicons": "Alert02Icon" + }, + "OctagonXIcon": { + "lucide": "OctagonXIcon", + "remix": "RiCloseCircleLine", + "tabler": "IconAlertOctagon", + "hugeicons": "MultiplicationSignCircleIcon" + }, + "Loader2Icon": { + "lucide": "Loader2Icon", + "remix": "RiLoader4Line", + "tabler": "IconLoader", + "hugeicons": "Loading03Icon" + }, + "VolumeOffIcon": { + "lucide": "VolumeOffIcon", + "remix": "RiVolumeMuteLine", + "tabler": "IconVolume", + "hugeicons": "VolumeOffIcon" + }, + "AlertCircleIcon": { + "lucide": "AlertCircleIcon", + "remix": "RiAlertLine", + "tabler": "IconAlertCircle", + "hugeicons": "Alert01Icon" + }, + "User2Icon": { + "lucide": "User2Icon", + "remix": "RiUserLine", + "tabler": "IconUser", + "hugeicons": "UserIcon" + }, + "ArrowRightCircleIcon": { + "lucide": "ArrowRightCircleIcon", + "remix": "RiArrowRightCircleLine", + "tabler": "IconCircleArrowRight", + "hugeicons": "CircleArrowRight02Icon" + }, + "LogInIcon": { + "lucide": "LogInIcon", + "remix": "RiLoginBoxLine", + "tabler": "IconLogin", + "hugeicons": "Login01Icon" + }, + "PenSquareIcon": { + "lucide": "PenSquareIcon", + "remix": "RiEditLine", + "tabler": "IconEdit", + "hugeicons": "Edit02Icon" + }, + "CameraIcon": { + "lucide": "CameraIcon", + "remix": "RiCameraLine", + "tabler": "IconCamera", + "hugeicons": "Camera01Icon" + }, + "PlusSquareIcon": { + "lucide": "PlusSquareIcon", + "remix": "RiAddBoxLine", + "tabler": "IconSquarePlus", + "hugeicons": "PlusSignSquareIcon" + }, + "SquarePenIcon": { + "lucide": "SquarePenIcon", + "remix": "RiEdit2Line", + "tabler": "IconEdit", + "hugeicons": "Edit02Icon" + }, + "Volume1Icon": { + "lucide": "Volume1Icon", + "remix": "RiVolumeDownLine", + "tabler": "IconVolume2", + "hugeicons": "VolumeLowIcon" + }, + "Volume2Icon": { + "lucide": "Volume2Icon", + "remix": "RiVolumeUpLine", + "tabler": "IconVolume", + "hugeicons": "VolumeHighIcon" + }, + "XCircleIcon": { + "lucide": "XCircleIcon", + "remix": "RiCloseCircleLine", + "tabler": "IconCircleX", + "hugeicons": "Cancel01Icon" + }, + "TimerIcon": { + "lucide": "TimerIcon", + "remix": "RiTimerLine", + "tabler": "IconAlarm", + "hugeicons": "Time01Icon" + }, + "PinIcon": { + "lucide": "PinIcon", + "remix": "RiPushpinLine", + "tabler": "IconPinned", + "hugeicons": "PinIcon" + } +} as const; diff --git a/packages/core/src/__registry__/index.ts b/packages/core/src/__registry__/index.ts new file mode 100644 index 000000000..3b8d8ccfa --- /dev/null +++ b/packages/core/src/__registry__/index.ts @@ -0,0 +1,18 @@ +// AUTO-GENERATED - DO NOT EDIT +// Run "pnpm build" to regenerate + +import { base } from "./base"; +import { hooks } from "./hooks"; +import { lib } from "./lib"; +import { ui } from "./ui"; + +/** + * Combined registry of all items (UI, base, lib, hooks) + */ +export const registry = [...base, ...ui, ...lib, ...hooks]; + +export { base } from "./base"; +export { hooks } from "./hooks"; +export { lib } from "./lib"; +export { ui } from "./ui"; +export { icons, iconLibraries } from "./icons"; diff --git a/packages/core/src/__registry__/lib.ts b/packages/core/src/__registry__/lib.ts new file mode 100644 index 000000000..527e5afac --- /dev/null +++ b/packages/core/src/__registry__/lib.ts @@ -0,0 +1,54 @@ +// AUTO-GENERATED - DO NOT EDIT +// Run "pnpm build" to regenerate + +export const lib = [ + { + "name": "utils", + "type": "registry:lib", + "files": [ + { + "type": "registry:lib", + "path": "lib/utils/index.ts", + "target": "lib/utils.ts", + "content": `import { clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import type { ClassValue } from "clsx"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +` + } + ] + }, + { + "name": "focus-styles", + "type": "registry:lib", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:lib", + "path": "lib/focus-styles/basic.ts", + "target": "lib/focus-styles.ts", + "content": `import { tv } from "tailwind-variants"; + +export const focusRing = tv({ + base: "outline-hidden ring-0 ring-border-focus focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-bg", +}); + +export const focusInput = tv({ + base: "ring-0 focus-within:ring-2 focus-within:ring-border-focus", +}); + +export const focusRingGroup = tv({ + base: "outline-hidden ring-0 ring-border-focus group-focus-visible:ring-2 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-bg", +}); +` + } + ] + } + } + } +] as const; diff --git a/packages/core/src/__registry__/ui.ts b/packages/core/src/__registry__/ui.ts new file mode 100644 index 000000000..c45703bb2 --- /dev/null +++ b/packages/core/src/__registry__/ui.ts @@ -0,0 +1,7435 @@ +// AUTO-GENERATED - DO NOT EDIT +// Run "pnpm build" to regenerate + +export const ui = [ + { + "name": "accordion", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/accordion/basic.tsx", + "target": "ui/accordion.tsx", + "content": `"use client"; + +import { + DisclosureGroup as AriaDisclosureGroup, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; + +const accordionStyles = tv({ + base: "**:data-disclosure:not-last:border-b", +}); + +interface AccordionProps + extends React.ComponentProps {} +function Accordion({ className, ...props }: AccordionProps) { + return ( + + accordionStyles({ className: c }), + )} + {...props} + /> + ); +} + +export { Accordion }; + +export type { AccordionProps }; +` + } + ] + } + } + }, + { + "name": "alert", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/alert/basic.tsx", + "target": "ui/alert.tsx", + "content": `import { tv } from "tailwind-variants"; +import type * as React from "react"; +import type { VariantProps } from "tailwind-variants"; + +const alertVariants = tv({ + slots: { + base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border bg-card px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + title: "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", + description: + "col-start-2 grid justify-items-start gap-1 text-muted-foreground text-sm [&_p]:leading-relaxed", + }, + variants: { + variant: { + neutral: { + base: "text-fg", + }, + danger: { + base: "bg-danger-muted text-fg-danger *:data-[slot=alert-description]:text-fg-danger/90 [&>svg]:text-current", + }, + warning: { + base: "text-fg-warning *:data-[slot=alert-description]:text-fg-warning/90 [&>svg]:text-current", + }, + info: { + base: "text-fg-info *:data-[slot=alert-description]:text-fg-info/90 [&>svg]:text-current", + }, + success: { + base: "text-fg-success *:data-[slot=alert-description]:text-fg-success/90 [&>svg]:text-current", + }, + }, + }, + defaultVariants: { + variant: "neutral", + }, +}); + +const { base, title, description } = alertVariants(); + +/* -----------------------------------------------------------------------------------------------*/ + +interface AlertProps + extends React.ComponentProps<"div">, + VariantProps {} + +function Alert({ className, variant, ...props }: AlertProps) { + return ( +
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface AlertTitleProps extends React.ComponentProps<"div"> {} + +function AlertTitle({ className, ...props }: AlertTitleProps) { + return ( +
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface AlertDescriptionProps extends React.ComponentProps<"div"> {} + +function AlertDescription({ className, ...props }: AlertDescriptionProps) { + return ( +
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface AlertActionProps extends React.ComponentProps<"div"> {} + +function AlertAction({ className, ...props }: AlertActionProps) { + return
; +} + +/* -----------------------------------------------------------------------------------------------*/ + +export { Alert, AlertTitle, AlertDescription, AlertAction }; + +export type { + AlertProps, + AlertTitleProps, + AlertDescriptionProps, + AlertActionProps, +}; +` + } + ] + } + } + }, + { + "name": "avatar", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/avatar/basic.tsx", + "target": "ui/avatar.tsx", + "content": `"use client"; + +import * as React from "react"; +import { tv } from "tailwind-variants"; +import type { VariantProps } from "tailwind-variants"; + +import { useImageLoadingStatus } from "@dotui/registry/hooks/use-image-loading-status"; +import { createContext } from "@dotui/registry/lib/context"; +import type { ImageLoadingStatus } from "@dotui/registry/hooks/use-image-loading-status"; + +const avatarStyles = tv({ + slots: { + group: + "-space-x-2 flex flex-wrap *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-bg", + root: "relative inline-flex shrink-0 overflow-hidden rounded-full bg-bg align-middle", + image: "aspect-square size-full", + fallback: "flex size-full select-none items-center justify-center bg-muted", + placeholder: + "flex size-full h-full animate-pulse items-center justify-center bg-muted", + }, + variants: { + size: { + sm: { group: "*:data-[slot=avatar]:size-8", root: "size-8" }, + md: { group: "*:data-[slot=avatar]:size-10", root: "size-10" }, + lg: { group: "*:data-[slot=avatar]:size-12", root: "size-12" }, + }, + }, + defaultVariants: { + size: "md", + }, +}); + +const { group, root, image, fallback, placeholder } = avatarStyles(); + +/* -----------------------------------------------------------------------------------------------*/ + +interface AvatarGroupProps + extends React.ComponentProps<"div">, + VariantProps {} + +const AvatarGroup = ({ className, size, ...props }: AvatarGroupProps) => { + return
; +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface AvatarProps + extends AvatarImageProps, + VariantProps { + fallback?: React.ReactNode; +} +const Avatar = ({ + className, + style, + fallback, + size, + ...props +}: AvatarProps) => { + return ( + + + {fallback} + + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +const [AvatarInternalContext, useAvatarInternalContext] = createContext<{ + status: ImageLoadingStatus; + setStatus: (status: ImageLoadingStatus) => void; +}>({ + name: "AvatarRoot", + strict: true, +}); + +interface AvatarRootProps + extends React.ComponentProps<"span">, + VariantProps {} +function AvatarRoot({ className, size, ...props }: AvatarRootProps) { + const [status, setStatus] = React.useState("idle"); + + return ( + + + + ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface AvatarImageProps extends Omit, "src"> { + src?: string; +} + +function AvatarImage({ + src, + alt, + className, + referrerPolicy, + crossOrigin, + ...props +}: AvatarImageProps) { + const status = useImageLoadingStatus(src, { referrerPolicy, crossOrigin }); + const { setStatus } = useAvatarInternalContext("AvatarImage"); + + React.useLayoutEffect(() => { + if (status !== "idle") { + setStatus(status); + } + }, [status, setStatus]); + + if (status === "loaded") + return ( + {alt} + ); + + return null; +} + +/* -----------------------------------------------------------------------------------------------*/ + +type AvatarFallbackProps = React.HTMLAttributes; + +const AvatarFallback = ({ className, ...props }: AvatarFallbackProps) => { + const { status } = useAvatarInternalContext("AvatarFallback"); + if (status === "error") + return ( + + ); + return null; +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface AvatarPlaceholderProps extends React.ComponentProps<"span"> {} + +const AvatarPlaceholder = ({ className, ...props }: AvatarPlaceholderProps) => { + const { status } = useAvatarInternalContext("AvatarPlaceholder"); + if (["idle", "loading"].includes(status)) + return ; + return null; +}; + +/* -----------------------------------------------------------------------------------------------*/ + +const CompoundAvatar = Object.assign(Avatar, { + Group: AvatarGroup, + Root: AvatarRoot, + Image: AvatarImage, + Fallback: AvatarFallback, + Placeholder: AvatarPlaceholder, +}); + +export { + CompoundAvatar as Avatar, + AvatarGroup, + AvatarRoot, + AvatarImage, + AvatarFallback, + AvatarPlaceholder, +}; + +export type { + AvatarGroupProps, + AvatarProps, + AvatarRootProps, + AvatarImageProps, + AvatarFallbackProps, + AvatarPlaceholderProps, +}; +` + } + ] + } + } + }, + { + "name": "badge", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/badge/basic.tsx", + "target": "ui/badge.tsx", + "content": `import { tv } from "tailwind-variants"; +import type * as React from "react"; +import type { VariantProps } from "tailwind-variants"; + +const badgeStyles = tv({ + base: "inline-flex w-fit shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 py-0.5 font-medium text-xs [&>svg]:pointer-events-none [&>svg]:size-3", + variants: { + variant: { + default: "bg-neutral text-fg-on-neutral", + danger: "bg-danger text-fg-on-danger", + success: "bg-success text-fg-on-success", + warning: "bg-warning text-fg-on-warning", + info: "bg-info text-fg-on-info", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +interface BadgeProps + extends React.ComponentProps<"span">, + VariantProps {} +const Badge = ({ className, variant, ...props }: BadgeProps) => { + return ( + + ); +}; + +export type { BadgeProps }; +export { Badge }; +` + } + ] + } + } + }, + { + "name": "breadcrumbs", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/breadcrumbs/basic.tsx", + "target": "ui/breadcrumbs.tsx", + "content": `"use client"; + +import { ChevronRightIcon } from "lucide-react"; +import { + Breadcrumb as AriaBreadcrumb, + Breadcrumbs as AriaBreadcrumbs, + Link as AriaLink, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type { BreadcrumbsProps as AriaBreadcrumbsProps } from "react-aria-components"; + +const breadcrumbsStyles = tv({ + slots: { + root: "wrap-break-word flex flex-wrap items-center gap-1.5 text-fg-muted text-sm [&_svg]:size-4", + item: "inline-flex items-center gap-1", + link: [ + "focus-reset focus-visible:focus-ring", + "inline-flex items-center gap-1 rounded px-0.5 current:text-fg leading-none transition-colors disabled:cursor-default disabled:not-current:text-fg-disabled hover:[a]:text-fg", + ], + }, +}); + +const { root, item, link } = breadcrumbsStyles(); + +interface BreadcrumbsProps extends AriaBreadcrumbsProps { + ref?: React.RefObject; +} +const Breadcrumbs = ({ + className, + ...props +}: BreadcrumbsProps) => { + return ; +}; + +type BreadcrumbProps = BreadcrumbItemProps & + Omit; +const Breadcrumb = ({ ref, children, ...props }: BreadcrumbProps) => { + return ( + + {composeRenderProps(children, (children, { isCurrent }) => ( + <> + {children} + {!isCurrent && } + + ))} + + ); +}; + +interface BreadcrumbItemProps + extends React.ComponentProps {} +const BreadcrumbItem = ({ className, ...props }: BreadcrumbItemProps) => ( + + item({ className }), + )} + {...props} + /> +); + +interface BreadcrumbLinkProps extends React.ComponentProps {} +const BreadcrumbLink = ({ className, ...props }: BreadcrumbLinkProps) => ( + + link({ className }), + )} + {...props} + /> +); + +export { Breadcrumbs, Breadcrumb, BreadcrumbItem, BreadcrumbLink }; + +export type { + BreadcrumbsProps, + BreadcrumbProps, + BreadcrumbItemProps, + BreadcrumbLinkProps, +}; +` + } + ], + "registryDependencies": [ + "focus-styles" + ] + } + } + }, + { + "name": "button", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/button/basic.tsx", + "target": "ui/button.tsx", + "content": `"use client"; + +import { + Button as AriaButton, + ButtonContext as AriaButtonContext, + Link as AriaLink, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type * as React from "react"; +import type { VariantProps } from "tailwind-variants"; + +import { useButtonAspect } from "@dotui/registry/hooks/use-button-aspect"; +import { createVariantsContext } from "@dotui/registry/lib/context"; +import { Loader } from "@dotui/registry/ui/loader"; + +const buttonStyles = tv({ + base: [ + "relative box-border inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm leading-normal transition-[background-color,border-color,color,box-shadow] data-icon-only:px-0", + "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2", + // svg + "[&_svg]:pointer-events-none [&_svg]:not-with-[size]:size-4 [&_svg]:shrink-0", + // focus state + "focus-reset focus-visible:focus-ring", + // disabled state + "disabled:cursor-default disabled:border-border-disabled disabled:bg-disabled disabled:text-fg-disabled", + // pending state + "pending:cursor-default pending:border-border-disabled pending:bg-disabled pending:text-transparent pending:**:not-data-[slot=spinner]:not-in-data-[slot=spinner]:opacity-0 pending:**:data-[slot=spinner]:text-fg-muted", + ], + variants: { + variant: { + default: + "border pressed:border-border-active bg-neutral pressed:bg-neutral-active text-fg-on-neutral hover:border-border-hover hover:bg-neutral-hover", + primary: + "pending:border-0 bg-primary pressed:bg-primary-active text-fg-on-primary [--color-disabled:var(--neutral-500)] [--color-fg-disabled:var(--neutral-300)] hover:bg-primary-hover disabled:border-0", + quiet: "bg-transparent pressed:bg-inverse/20 text-fg hover:bg-inverse/10", + link: "text-fg underline-offset-4 hover:underline", + warning: + "bg-warning pressed:bg-warning-active text-fg-on-warning hover:bg-warning-hover", + danger: + "bg-danger pressed:bg-danger-active text-fg-on-danger hover:bg-danger-hover", + }, + size: { + sm: "h-8 px-3 has-[>svg]:px-2.5 data-icon-only:not-with-[size]:not-with-[w]:w-8", + md: "h-9 px-4 has-[>svg]:px-3 data-icon-only:not-with-[size]:not-with-[w]:w-9", + lg: "h-10 px-5 has-[>svg]:px-4 data-icon-only:not-with-[size]:not-with-[w]:w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "md", + }, +}); + +type ButtonVariants = VariantProps; + +const [ButtonProvider, useContextProps] = createVariantsContext< + ButtonVariants, + React.ComponentProps +>(AriaButtonContext); + +/* -----------------------------------------------------------------------------------------------*/ + +interface ButtonProps + extends React.ComponentProps, + ButtonVariants { + aspect?: "default" | "square" | "auto"; +} + +const Button = (localProps: ButtonProps) => { + const { + variant, + size, + aspect = "auto", + className, + slot, + style, + children, + ...props + } = useContextProps(localProps); + + const isIconOnly = useButtonAspect(children, aspect); + + return ( + + buttonStyles({ variant, size, className: cn }), + )} + slot={slot} + style={style} + {...props} + > + {composeRenderProps(children, (children, { isPending }) => ( + <> + {isPending && ( + + )} + {typeof children === "string" ? ( + {children} + ) : ( + children + )} + + ))} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface LinkButtonProps + extends React.ComponentProps, + VariantProps { + aspect?: "default" | "square" | "auto"; +} + +const LinkButton = (localProps: LinkButtonProps) => { + const { + variant, + size, + aspect = "auto", + className, + slot, + style, + children, + ...props + } = useContextProps(localProps); + + const isIconOnly = useButtonAspect(children, aspect); + + return ( + + buttonStyles({ variant, size, className: cn }), + )} + slot={slot} + style={style} + {...props} + > + {composeRenderProps(children, (children) => ( + <> + {typeof children === "string" ? ( + {children} + ) : ( + children + )} + + ))} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +export type { ButtonProps, LinkButtonProps }; + +export { Button, LinkButton, ButtonProvider, buttonStyles }; +` + } + ], + "registryDependencies": [ + "loader", + "focus-styles" + ] + }, + "ripple": { + "files": [ + { + "type": "registry:ui", + "path": "ui/button/ripple.tsx", + "target": "ui/button.tsx", + "content": `"use client"; + +import { + Button as AriaButton, + ButtonContext as AriaButtonContext, + Link as AriaLink, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type * as React from "react"; +import type { VariantProps } from "tailwind-variants"; + +import { useButtonAspect } from "@dotui/registry/hooks/use-button-aspect"; +import { createVariantsContext } from "@dotui/registry/lib/context"; +import { Loader } from "@dotui/registry/ui/loader"; + +const buttonStyles = tv({ + base: [ + "ripple relative box-border inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm leading-normal transition-[background-color,border-color,color,box-shadow] data-icon-only:px-0", + "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2", + // focus state + "focus-reset focus-visible:focus-ring", + // disabled state + "disabled:cursor-default disabled:border-border-disabled disabled:bg-disabled disabled:text-fg-disabled", + // pending state + "pending:cursor-default pending:border-border-disabled pending:bg-disabled pending:text-transparent pending:**:not-data-[slot=spinner]:not-in-data-[slot=spinner]:opacity-0 pending:**:data-[slot=spinner]:text-fg-muted", + ], + variants: { + variant: { + default: + "border pressed:border-border-active bg-neutral pressed:bg-neutral-active text-fg-on-neutral hover:border-border-hover hover:bg-neutral-hover", + primary: + "pending:border-0 bg-primary pressed:bg-primary-active text-fg-on-primary [--color-disabled:var(--neutral-500)] [--color-fg-disabled:var(--neutral-300)] hover:bg-primary-hover disabled:border-0", + quiet: "bg-transparent pressed:bg-inverse/20 text-fg hover:bg-inverse/10", + link: "text-fg underline-offset-4 hover:underline", + warning: + "bg-warning pressed:bg-warning-active text-fg-on-warning hover:bg-warning-hover", + danger: + "bg-danger pressed:bg-danger-active text-fg-on-danger hover:bg-danger-hover", + }, + size: { + sm: "h-8 px-3 data-icon-only:not-with-[size]:not-with-[w]:w-8 [&_svg]:size-4", + md: "h-9 px-4 data-icon-only:not-with-[size]:not-with-[w]:w-9 [&_svg]:size-4", + lg: "h-10 px-5 data-icon-only:not-with-[size]:not-with-[w]:w-10 [&_svg]:size-5", + }, + }, + defaultVariants: { + variant: "default", + size: "md", + }, +}); + +type ButtonVariants = VariantProps; + +const [ButtonProvider, useContextProps] = createVariantsContext< + ButtonVariants, + React.ComponentProps +>(AriaButtonContext); + +/* -----------------------------------------------------------------------------------------------*/ + +interface ButtonProps + extends React.ComponentProps, + ButtonVariants { + aspect?: "default" | "square" | "auto"; +} + +const Button = (localProps: ButtonProps) => { + const { + variant, + size, + aspect = "auto", + className, + slot, + style, + children, + ...props + } = useContextProps(localProps); + + const isIconOnly = useButtonAspect(children, aspect); + + return ( + + buttonStyles({ variant, size, className: cn }), + )} + slot={slot} + style={style} + {...props} + > + {composeRenderProps(children, (children, { isPending }) => ( + <> + {isPending && ( + + )} + {typeof children === "string" ? ( + {children} + ) : ( + children + )} + + ))} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface LinkButtonProps + extends React.ComponentProps, + VariantProps { + aspect?: "default" | "square" | "auto"; +} + +const LinkButton = (localProps: LinkButtonProps) => { + const { + variant, + size, + aspect = "auto", + className, + slot, + style, + children, + ...props + } = useContextProps(localProps); + + const isIconOnly = useButtonAspect(children, aspect); + + return ( + + buttonStyles({ variant, size, className: cn }), + )} + slot={slot} + style={style} + {...props} + > + {composeRenderProps(children, (children) => ( + <> + {typeof children === "string" ? ( + {children} + ) : ( + children + )} + + ))} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +export type { ButtonProps, LinkButtonProps }; + +export { Button, LinkButton, ButtonProvider, buttonStyles }; +` + } + ] + } + } + }, + { + "name": "calendar", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/calendar/basic.tsx", + "target": "ui/calendar.tsx", + "content": `"use client"; + +import React from "react"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { + Calendar as AriaCalendar, + CalendarCell as AriaCalendarCell, + CalendarContext as AriaCalendarContext, + CalendarGrid as AriaCalendarGrid, + CalendarGridBody as AriaCalendarGridBody, + CalendarGridHeader as AriaCalendarGridHeader, + CalendarHeaderCell as AriaCalendarHeaderCell, + Heading as AriaHeading, + RangeCalendar as AriaRangeCalendar, + RangeCalendarContext as AriaRangeCalendarContext, + RangeCalendarStateContext as AriaRangeCalendarStateContext, + composeRenderProps, + useSlottedContext, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type { + CalendarProps as AriaCalendarProps, + RangeCalendarProps as AriaRangeCalendarProps, + DateValue, +} from "react-aria-components"; +import type { VariantProps } from "tailwind-variants"; + +import { Button } from "@dotui/registry/ui/button"; + +const calendarStyles = tv({ + slots: { + root: "flex flex-col gap-4", + header: "flex items-center justify-between gap-2", + grid: "w-full border-collapse", + gridHeader: "", + gridHeaderCell: "font-normal text-fg-muted text-xs", + gridBody: "", + }, + variants: { + standalone: { + true: { + root: "rounded-md border bg-bg p-3", + }, + }, + }, +}); + +const calendarCellStyles = tv({ + slots: { + cellRoot: + "flex outside-month:hidden items-center justify-center outline-none selection-end:rounded-r-md selection-start:rounded-l-md", + cell: [ + "focus-reset focus-visible:focus-ring", + "my-1 flex size-8 cursor-pointer unavailable:cursor-default items-center justify-center rounded-md pressed:bg-inverse/20 text-sm unavailable:text-fg-disabled unavailable:not-data-disabled:line-through transition-colors read-only:cursor-default hover:bg-inverse/10 hover:unavailable:bg-transparent hover:read-only:bg-transparent disabled:cursor-default disabled:bg-transparent disabled:text-fg-disabled", + ], + }, + variants: { + variant: { + primary: {}, + accent: {}, + }, + range: { + true: { + cellRoot: + "selected: selected:bg-inverse/10 selected:invalid:bg-danger-muted selected:invalid:text-fg-danger", + cell: "selection-end:invalid:bg-danger selection-start:invalid:bg-danger selection-end:invalid:text-fg-on-danger selection-start:invalid:text-fg-on-danger", + }, + false: { + cell: "selected:invalid:bg-danger selected:invalid:text-fg-on-danger", + }, + }, + }, + compoundVariants: [ + { + variant: "primary", + range: false, + className: { + cell: "selected:bg-primary selected:text-fg-on-primary", + }, + }, + { + variant: "accent", + range: false, + className: { + cell: "selected:bg-accent selected:text-fg-on-accent", + }, + }, + { + variant: "primary", + range: true, + className: { + cell: "selection-end:bg-primary selection-start:bg-primary selection-end:text-fg-on-primary selection-start:text-fg-on-primary", + }, + }, + { + variant: "accent", + range: true, + className: { + cell: "selection-end:bg-accent selection-start:bg-accent selection-end:text-fg-on-accent selection-start:text-fg-on-accent", + }, + }, + ], + defaultVariants: { + variant: "accent", + }, +}); + +const { root, header, grid, gridHeader, gridHeaderCell, gridBody } = + calendarStyles(); + +const { cellRoot, cell } = calendarCellStyles(); + +/* -----------------------------------------------------------------------------------------------*/ + +type CalendarProps = + | ({ + mode?: "single"; + } & AriaCalendarProps) + | ({ + mode: "range"; + } & AriaRangeCalendarProps); + +const Calendar = ({ + mode, + className, + ...props +}: CalendarProps) => { + const rangeCalendarContext = useSlottedContext(AriaRangeCalendarContext); + const calendarContext = useSlottedContext(AriaCalendarContext); + + if (mode === "range" || rangeCalendarContext) { + const standalone = Object.keys(rangeCalendarContext ?? {}).length === 0; + return ( + ["className"], + (className) => root({ standalone, className }), + )} + {...(props as AriaRangeCalendarProps)} + > + {composeRenderProps( + props.children as AriaRangeCalendarProps["children"], + (children) => + children ?? ( + <> + + + + ), + )} + + ); + } + + const standalone = !!calendarContext; + return ( + ["className"], + (className) => root({ standalone, className }), + )} + {...(props as AriaCalendarProps)} + > + {composeRenderProps( + props.children as AriaCalendarProps["children"], + (children) => + children ?? ( + <> + + + + ), + )} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface CalendarHeaderProps extends React.ComponentProps<"header"> {} + +const CalendarHeader = ({ className, ...props }: CalendarHeaderProps) => { + return ( +
+ {props.children ?? ( + <> + + + + + )} +
+ ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface CalendarGridProps + extends React.ComponentProps {} + +const CalendarGrid = ({ className, ...props }: CalendarGridProps) => { + return ( + + {props.children ?? ( + <> + + {(day) => {day}} + + + {(date) => } + + + )} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface CalendarGridHeaderProps + extends React.ComponentProps {} +const CalendarGridHeader = ({ + className, + ...props +}: CalendarGridHeaderProps) => { + return ( + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface CalendarHeaderCellProps + extends React.ComponentProps {} +const CalendarHeaderCell = ({ + className, + ...props +}: CalendarHeaderCellProps) => { + return ( + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface CalendarGridBodyProps + extends React.ComponentProps {} +const CalendarGridBody = ({ className, ...props }: CalendarGridBodyProps) => { + return ( + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface CalendarCellProps + extends React.ComponentProps, + Omit, "range"> {} +const CalendarCell = ({ + variant = "accent", + children, + className, + ...props +}: CalendarCellProps) => { + const rangeCalendarState = React.use(AriaRangeCalendarStateContext); + const range = !!rangeCalendarState; + + return ( + + cellRoot({ + range, + variant, + className, + }), + )} + > + {composeRenderProps( + children, + ( + _, + { + isSelected, + isFocused, + isHovered, + isPressed, + isUnavailable, + isDisabled, + isFocusVisible, + isInvalid, + isOutsideMonth, + isOutsideVisibleRange, + isSelectionEnd, + isSelectionStart, + formattedDate, + }, + ) => ( + + {formattedDate} + + ), + )} + + ); +}; + +export { + Calendar, + CalendarHeader, + CalendarGrid, + CalendarGridHeader, + CalendarHeaderCell, + CalendarGridBody, + CalendarCell, + calendarStyles, +}; + +export type { + CalendarProps, + CalendarHeaderProps, + CalendarGridProps, + CalendarGridHeaderProps, + CalendarHeaderCellProps, + CalendarGridBodyProps, + CalendarCellProps, +}; +` + } + ], + "registryDependencies": [ + "button", + "text", + "focus-styles" + ] + } + } + }, + { + "name": "card", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/card/basic.tsx", + "target": "ui/card.tsx", + "content": `import { tv } from "tailwind-variants"; +import type * as React from "react"; + +const cardStyles = tv({ + slots: { + root: "flex flex-col gap-6 rounded-xl border bg-card py-6 text-fg shadow-sm", + header: + "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", + title: "font-semibold leading-none", + description: "text-fg-muted text-sm", + action: "col-start-2 row-span-2 row-start-1 self-start justify-self-end", + content: "flex-1 px-6", + footer: "flex items-center px-6 [.border-t]:pt-6", + }, +}); + +const { root, header, title, description, action, content, footer } = + cardStyles(); + +/* -----------------------------------------------------------------------------------------------*/ + +interface CardProps extends React.ComponentProps<"div"> {} + +function Card({ className, ...props }: CardProps) { + return
; +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface CardHeaderProps extends React.ComponentProps<"div"> {} + +function CardHeader({ className, ...props }: CardHeaderProps) { + return ( +
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface CardTitleProps extends React.ComponentProps<"div"> {} + +function CardTitle({ className, ...props }: CardTitleProps) { + return ( +
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface CardDescriptionProps extends React.ComponentProps<"div"> {} + +function CardDescription({ className, ...props }: CardDescriptionProps) { + return ( +
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface CardActionProps extends React.ComponentProps<"div"> {} + +function CardAction({ className, ...props }: CardActionProps) { + return ( +
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface CardContentProps extends React.ComponentProps<"div"> {} + +function CardContent({ className, ...props }: CardContentProps) { + return ( +
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface CardFooterProps extends React.ComponentProps<"div"> {} + +function CardFooter({ className, ...props }: CardFooterProps) { + return ( +
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; + +export type { + CardProps, + CardHeaderProps, + CardTitleProps, + CardDescriptionProps, + CardActionProps, + CardContentProps, + CardFooterProps, +}; +` + } + ], + "registryDependencies": [ + "button", + "text", + "focus-styles" + ] + } + } + }, + { + "name": "checkbox-group", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/checkbox-group/basic.tsx", + "target": "ui/checkbox-group.tsx", + "content": `"use client"; + +import { + CheckboxGroup as AriaCheckboxGroup, + composeRenderProps, +} from "react-aria-components"; +import type { CheckboxGroupProps } from "react-aria-components"; + +import { fieldStyles } from "@dotui/registry/ui/field"; + +const { field } = fieldStyles(); + +const CheckboxGroup = ({ className, ...props }: CheckboxGroupProps) => { + return ( + + field({ className }), + )} + {...props} + /> + ); +}; + +export type { CheckboxGroupProps }; +export { CheckboxGroup }; +` + } + ], + "registryDependencies": [ + "field", + "checkbox" + ] + } + } + }, + { + "name": "checkbox", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/checkbox/basic.tsx", + "target": "ui/checkbox.tsx", + "content": `"use client"; + +import { CheckIcon, MinusIcon } from "lucide-react"; +import { + Checkbox as AriaCheckbox, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type * as React from "react"; +import type { CheckboxRenderProps } from "react-aria-components"; + +import { createContext } from "@dotui/registry/lib/context"; +import { cn } from "@dotui/registry/lib/utils"; + +const checkboxStyles = tv({ + slots: { + root: [ + "focus-reset focus-visible:focus-ring", + "flex items-center gap-2 text-sm leading-none has-data-[slot=description]:items-start", + "disabled:cursor-not-allowed disabled:text-fg-disabled", + ], + indicator: [ + "flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-control bg-transparent text-transparent", + "transition-[background-color,border-color,box-shadow,color] duration-75", + // selected state + "selected:border-transparent selected:bg-primary selected:text-fg-on-primary", + // read-only state + "read-only:cursor-default", + // disabled state + "disabled:cursor-not-allowed disabled:border-border-disabled selected:disabled:bg-disabled selected:disabled:text-fg-disabled indeterminate:disabled:bg-disabled", + // invalid state + "invalid:border-border-danger invalid:selected:bg-danger-muted invalid:selected:text-fg-onMutedDanger", + // indeterminate state + "indeterminate:border-transparent indeterminate:bg-primary indeterminate:text-fg-on-primary", + ], + }, +}); + +const { root, indicator } = checkboxStyles(); + +const [InternalCheckboxProvider, useInternalCheckbox] = + createContext({ + strict: true, + }); + +/* -----------------------------------------------------------------------------------------------*/ + +interface CheckboxProps extends React.ComponentProps {} + +const Checkbox = ({ className, ...props }: CheckboxProps) => { + return ( + + props.children + ? root({ className }) + : indicator({ + className: cn(className, "focus-reset focus-visible:focus-ring"), + }), + )} + {...props} + > + {composeRenderProps(props.children, (children, renderProps) => { + return children ? ( + + {children} + + ) : renderProps.isIndeterminate ? ( + + ) : ( + + ); + })} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface CheckboxIndicatorProps extends React.ComponentProps<"div"> {} + +const CheckboxIndicator = ({ className, ...props }: CheckboxIndicatorProps) => { + const ctx = useInternalCheckbox("CheckboxIndicator"); + return ( +
+ {ctx.isIndeterminate ? ( + + ) : ( + + )} +
+ ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +export { Checkbox, CheckboxIndicator }; + +export type { CheckboxProps, CheckboxIndicatorProps }; +` + } + ], + "registryDependencies": [ + "focus-styles" + ] + } + } + }, + { + "name": "color-area", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/color-area/basic.tsx", + "target": "ui/color-area.tsx", + "content": `"use client"; + +import { + ColorArea as AriaColorArea, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; + +import { ColorThumb } from "@dotui/registry/ui/color-thumb"; + +const colorAreaStyles = tv({ + base: "block size-48 min-w-20 rounded-md disabled:[background:var(--color-disabled)]!", +}); + +/* -----------------------------------------------------------------------------------------------*/ + +type ColorAreaProps = React.ComponentProps; + +const ColorArea = ({ className, ...props }: ColorAreaProps) => { + return ( + + colorAreaStyles({ className }), + )} + {...props} + > + {props.children || } + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +export { ColorArea }; + +export type { ColorAreaProps }; +` + } + ], + "registryDependencies": [ + "color-thumb" + ] + } + } + }, + { + "name": "color-editor", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/color-editor/basic.tsx", + "target": "ui/color-editor.tsx", + "content": `import React from "react"; +import { getColorChannels } from "react-aria-components"; + +import { cn } from "@dotui/registry/lib/utils"; +import { ColorArea } from "@dotui/registry/ui/color-area"; +import { ColorField } from "@dotui/registry/ui/color-field"; +import { ColorSlider } from "@dotui/registry/ui/color-slider"; +import { Input } from "@dotui/registry/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@dotui/registry/ui/select"; + +type ColorFormat = "hex" | "rgb" | "hsl" | "hsb"; + +interface ColorEditorProps extends React.ComponentProps<"div"> { + colorFormat?: ColorFormat; + showAlphaChannel?: boolean; + showFormatSelector?: boolean; +} + +const ColorEditor = ({ + colorFormat: ColorFormatProp = "hex", + showAlphaChannel = false, + showFormatSelector = true, + className, + ...props +}: ColorEditorProps) => { + const [colorFormat, setColorFormat] = + React.useState(ColorFormatProp); + + return ( +
+
+ + + {showAlphaChannel && ( + + )} +
+
+ {showFormatSelector && ( + + )} +
+ {colorFormat === "hex" ? ( + + + + ) : ( + getColorChannels(colorFormat).map((channel) => ( + + + + )) + )} +
+
+
+ ); +}; + +export { ColorEditor }; +export type { ColorEditorProps }; +` + } + ], + "registryDependencies": [ + "color-area", + "color-slider", + "select" + ] + } + } + }, + { + "name": "color-field", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/color-field/basic.tsx", + "target": "ui/color-field.tsx", + "content": `"use client"; + +import { + ColorField as AriaColorField, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type * as React from "react"; + +import { fieldStyles } from "@dotui/registry/ui/field/basic"; + +const colorFieldStyles = tv({ + base: [fieldStyles().field({ orientation: "vertical" }), ""], +}); + +interface ColorFieldProps extends React.ComponentProps {} + +const ColorField = ({ className, ...props }: ColorFieldProps) => { + return ( + + colorFieldStyles({ className }), + )} + {...props} + /> + ); +}; + +export { ColorField }; +export type { ColorFieldProps }; +` + } + ], + "registryDependencies": [ + "field", + "input" + ] + } + } + }, + { + "name": "color-picker", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/color-picker/basic.tsx", + "target": "ui/color-picker.tsx", + "content": `"use client"; + +import { useContext } from "react"; +import { + ColorPicker as AriaColorPicker, + ColorPickerStateContext as AriaColorPickerStateContext, + composeRenderProps, +} from "react-aria-components"; +import type { + ColorPickerProps as AriaColorPickerProps, + ColorPickerState, +} from "react-aria-components"; + +import { Button } from "@dotui/registry/ui/button"; +import { ColorSwatch } from "@dotui/registry/ui/color-swatch"; +import { Dialog, DialogContent } from "@dotui/registry/ui/dialog"; +import { Overlay } from "@dotui/registry/ui/overlay"; +import type { ButtonProps } from "@dotui/registry/ui/button"; +import type { + DialogContentProps, + DialogProps, +} from "@dotui/registry/ui/dialog"; + +interface ColorPickerProps + extends AriaColorPickerProps, + Omit {} + +const ColorPicker = ({ + defaultOpen, + isOpen, + onOpenChange, + ...props +}: ColorPickerProps) => { + return ( + + {composeRenderProps(props.children, (children) => ( + + {children} + + ))} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface ColorPickerTriggerProps extends Omit { + children?: React.ReactNode | ((props: ColorPickerState) => React.ReactNode); +} + +const ColorPickerTrigger = ({ + children, + ...props +}: ColorPickerTriggerProps) => { + const state = useContext(AriaColorPickerStateContext)!; + return ( + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface ColorPickerContentProps extends DialogContentProps {} +const ColorPickerContent = ({ + children, + ...props +}: ColorPickerContentProps) => { + return ( + + {children} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +export { ColorPicker, ColorPickerTrigger, ColorPickerContent }; +export type { + ColorPickerProps, + ColorPickerTriggerProps, + ColorPickerContentProps, +}; +` + } + ], + "registryDependencies": [ + "button", + "color-area", + "color-field", + "color-slider", + "color-swatch", + "dialog", + "select" + ] + } + } + }, + { + "name": "color-slider", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/color-slider/basic.tsx", + "target": "ui/color-slider.tsx", + "content": `"use client"; + +import { useSlotId } from "@react-aria/utils"; +import { + ColorSlider as AriaColorSlider, + SliderOutput as AriaSliderOutput, + SliderTrack as AriaSliderTrack, + composeRenderProps, + Provider, + TextContext, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; + +import { ColorThumb } from "@dotui/registry/ui/color-thumb"; + +const colorSliderStyles = tv({ + slots: { + root: "flex flex-col gap-2", + output: "text-fg-muted text-sm", + track: + "relative rounded-md before:absolute before:inset-0 before:z-[-1] before:rounded-[inherit] before:bg-[repeating-conic-gradient(#e6e6e6_0%_25%,#fff_0%_50%)] before:bg-center before:bg-size-[16px_16px] before:content-[''] orientation-horizontal:**:data-[slot=color-thumb]:top-1/2 orientation-vertical:**:data-[slot=color-thumb]:left-1/2 disabled:[background:var(--color-disabled)]!", + }, + variants: { + orientation: { + horizontal: { + root: "w-48", + track: "h-6 w-full", + }, + vertical: { + root: "h-48 items-center", + track: "w-6 flex-1", + }, + }, + }, + defaultVariants: { + orientation: "horizontal", + }, +}); + +const { root, track, output } = colorSliderStyles(); + +/* -----------------------------------------------------------------------------------------------*/ + +interface ColorSliderProps + extends React.ComponentProps {} + +const ColorSlider = ({ className, ...props }: ColorSliderProps) => { + const descriptionId = useSlotId(); + return ( + + + root({ orientation, className: cn }), + )} + aria-describedby={descriptionId} + {...props} + > + {props.children ?? } + + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface ColorSliderControlProps + extends React.ComponentProps {} + +const ColorSliderControl = ({ + className, + ...props +}: ColorSliderControlProps) => { + return ( + + track({ orientation, className: cn }), + )} + {...props} + > + {props.children ?? } + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface ColorSliderOutputProps + extends React.ComponentProps {} + +const ColorSliderOutput = ({ className, ...props }: ColorSliderOutputProps) => { + return ( + + output({ className }), + )} + {...props} + /> + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +export { ColorSlider, ColorSliderControl, ColorSliderOutput }; + +export type { + ColorSliderProps, + ColorSliderControlProps, + ColorSliderOutputProps, +}; +` + } + ], + "registryDependencies": [ + "field", + "color-thumb" + ] + } + } + }, + { + "name": "color-swatch-picker", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/color-swatch-picker/basic.tsx", + "target": "ui/color-swatch-picker.tsx", + "content": `"use client"; + +import { + ColorSwatchPicker as AriaColorSwatchPicker, + ColorSwatchPickerItem as AriaColorSwatchPickerItem, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type React from "react"; + +import { ColorSwatch } from "@dotui/registry/ui/color-swatch"; + +const colorSwatchPickerStyles = tv({ + slots: { + root: "flex flex-wrap gap-1", + item: [ + "relative size-8 rounded-md transition-shadow focus:z-10 *:data-[slot=color-swatch]:size-full *:data-[slot=color-swatch]:rounded-[inherit]", + // focus state + "focus-reset focus-visible:focus-ring", + // disabled state + "disabled:cursor-not-allowed disabled:*:data-[slot=color-swatch]:[background:color-mix(in_oklab,var(--color-disabled)_90%,var(--color))]!", + // selected state + "before:absolute before:inset-0 before:scale-90 selected:before:scale-100 before:rounded-[inherit] before:bg-bg before:opacity-0 selected:before:opacity-100 before:outline-2 before:outline-inverse before:transition-[opacity,scale] before:duration-100 before:content-['']", + ], + }, +}); + +const { root, item } = colorSwatchPickerStyles(); + +/* -----------------------------------------------------------------------------------------------*/ + +interface ColorSwatchPickerProps + extends React.ComponentProps {} + +const ColorSwatchPicker = ({ className, ...props }: ColorSwatchPickerProps) => { + return ( + + root({ className }), + )} + {...props} + /> + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface ColorSwatchPickerItemProps + extends React.ComponentProps {} +const ColorSwatchPickerItem = ({ + className, + style, + ...props +}: ColorSwatchPickerItemProps) => { + return ( + + item({ className }), + )} + style={composeRenderProps( + style, + (style, { color }) => + ({ + "--color": color.toString(), + ...style, + }) as React.CSSProperties, + )} + {...props} + > + + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +const CompoundColorSwatchPicker = Object.assign(ColorSwatchPicker, { + Item: ColorSwatchPickerItem, +}); + +export type { ColorSwatchPickerProps, ColorSwatchPickerItemProps }; +export { + CompoundColorSwatchPicker as ColorSwatchPicker, + ColorSwatchPickerItem, +}; +` + } + ], + "registryDependencies": [ + "focus-styles", + "color-swatch" + ] + } + } + }, + { + "name": "color-swatch", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/color-swatch/basic.tsx", + "target": "ui/color-swatch.tsx", + "content": `"use client"; + +import { + ColorSwatch as AriaColorSwatch, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; + +const colorSwatchStyles = tv({ + base: "relative size-5 rounded-sm border", +}); + +interface ColorSwatchProps + extends React.ComponentProps {} +const ColorSwatch = ({ className, style, ...props }: ColorSwatchProps) => { + return ( + + colorSwatchStyles({ className }), + )} + style={composeRenderProps(style, (style, { color }) => ({ + ...style, + background: \`linear-gradient(\${color}, \${color}), + repeating-conic-gradient(#CCC 0% 25%, white 0% 50%) 50% / 16px 16px\`, + }))} + {...props} + /> + ); +}; + +export type { ColorSwatchProps }; +export { ColorSwatch }; +` + } + ] + } + } + }, + { + "name": "color-thumb", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/color-thumb/basic.tsx", + "target": "ui/color-thumb.tsx", + "content": `"use client"; + +import { ColorThumb as AriaColorThumb } from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type { ColorThumbProps as AriaColorThumbProps } from "react-aria-components"; + +const colorThumbStyles = tv({ + base: [ + "focus-reset focus-visible:focus-ring", + "z-30 size-6 rounded-full border-2 border-white ring-1 ring-black/40 disabled:border-border-disabled disabled:bg-disabled!", + "group-orientation-horizontal/color-slider:top-1/2 group-orientation-vertical/color-slider:left-1/2", + ], +}); + +interface ColorThumbProps extends Omit { + className?: string; +} +const ColorThumb = ({ className, ...props }: ColorThumbProps) => { + return ( + + ); +}; + +export type { ColorThumbProps }; +export { ColorThumb }; +` + } + ], + "registryDependencies": [ + "focus-styles" + ] + } + } + }, + { + "name": "combobox", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/combobox/basic.tsx", + "target": "ui/combobox.tsx", + "content": `"use client"; + +import React from "react"; +import { useResizeObserver } from "@react-aria/utils"; +import { ChevronDownIcon } from "lucide-react"; +import { mergeProps } from "react-aria"; +import { + ComboBox as AriaCombobox, + GroupContext as AriaGroupContext, + PopoverContext as AriaPopoverContext, + composeRenderProps, + Provider, +} from "react-aria-components"; +import type { ComboBoxProps as AriaComboboxProps } from "react-aria-components"; + +import { cn } from "@dotui/registry/lib/utils"; +import { Button } from "@dotui/registry/ui/button"; +import { fieldStyles } from "@dotui/registry/ui/field"; +import { Input, InputAddon, InputGroup } from "@dotui/registry/ui/input"; +import { + ListBox, + ListBoxItem, + ListBoxSection, + ListBoxSectionHeader, + ListBoxVirtualizer, +} from "@dotui/registry/ui/list-box"; +import { Popover } from "@dotui/registry/ui/popover"; +import type { InputGroupProps } from "@dotui/registry/ui/input"; +import type { ListBoxProps } from "@dotui/registry/ui/list-box"; +import type { PopoverProps } from "@dotui/registry/ui/popover"; + +/* -----------------------------------------------------------------------------------------------*/ + +interface ComboboxProps + extends Omit, "className"> { + className?: string; +} +const Combobox = ({ + menuTrigger = "focus", + className, + ...props +}: ComboboxProps) => { + return ( + + {composeRenderProps(props.children, (children) => ( + {children} + ))} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +/** + * This abstraction allows the Combobox to work with InputGroup and + * sync the trigger width with the popover dropdown. + */ + +const ComboboxInner = ({ children }: { children: React.ReactNode }) => { + const [menuWidth, setMenuWidth] = React.useState( + undefined, + ); + + const groupProps = React.use(AriaGroupContext); + const popoverProps = React.use(AriaPopoverContext); + const triggerRef = React.useRef(null); + + const onResize = React.useCallback(() => { + if (triggerRef.current) { + const triggerWidth = triggerRef.current.getBoundingClientRect().width; + setMenuWidth(\`\${triggerWidth}px\`); + } + }, []); + + useResizeObserver({ + ref: triggerRef, + onResize: onResize, + }); + + return ( + + {children} + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface ComboboxInputProps extends InputGroupProps { + placeholder?: string; +} + +const ComboboxInput = ({ placeholder, ...props }: ComboboxInputProps) => { + return ( + + + + + + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface ComboboxContentProps + extends ListBoxProps, + Pick< + PopoverProps, + "placement" | "defaultOpen" | "isOpen" | "onOpenChange" + > { + virtulized?: boolean; +} + +const ComboboxContent = ({ + virtulized, + placement, + defaultOpen, + isOpen, + onOpenChange, + ...props +}: ComboboxContentProps) => { + if (virtulized) { + return ( + + + + + + ); + } + + return ( + + + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ListBoxItem as ComboboxItem, + ListBoxSection as ComboboxSection, + ListBoxSectionHeader as ComboboxSectionHeader, +}; + +export type { ComboboxProps, ComboboxInputProps, ComboboxContentProps }; +` + } + ], + "registryDependencies": [ + "field", + "button", + "input", + "list-box", + "overlay" + ] + } + } + }, + { + "name": "command", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/command/basic.tsx", + "target": "ui/command.tsx", + "content": `"use client"; + +import { SearchIcon } from "lucide-react"; +import { + Autocomplete as AriaAutocomplete, + useFilter, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; + +import { Input, InputAddon, InputGroup } from "@dotui/registry/ui/input"; +import { + ListBox, + ListBoxItem, + ListBoxSection, + ListBoxSectionHeader, + ListBoxVirtualizer, +} from "@dotui/registry/ui/list-box"; +import { SearchField } from "@dotui/registry/ui/search-field"; +import type { ListBoxProps } from "@dotui/registry/ui/list-box"; +import type { PopoverProps } from "@dotui/registry/ui/popover"; +import type { SearchFieldProps } from "@dotui/registry/ui/search-field"; + +const commandStyles = tv({ + slots: { + base: [ + "in-drawer:rounded-[inherit] in-modal:rounded-[inherit] in-popover:rounded-[inherit] rounded-lg not-in-popover:not-in-modal:not-in-drawer:border not-in-popover:not-in-modal:not-in-drawer:bg-card", + "**:data-[slot=list-box]:w-full **:data-[slot=list-box]:border-0 **:data-[slot=list-box]:bg-transparent", + "**:data-[slot=search-field]:w-full **:data-[slot=search-field]:outline-none [&_[data-slot=search-field]_[data-slot=input-group]]:rounded-b-none [&_[data-slot=search-field]_[data-slot=input-group]]:border-0 [&_[data-slot=search-field]_[data-slot=input-group]]:border-b [&_[data-slot=search-field]_[data-slot=input-group]]:bg-transparent", + "in-modal:w-full", + ], + }, +}); + +const { base } = commandStyles(); + +/* -----------------------------------------------------------------------------------------------*/ + +interface CommandProps extends React.ComponentProps<"div"> {} + +function Command({ className, ...props }: CommandProps) { + const { contains } = useFilter({ + sensitivity: "base", + ignorePunctuation: true, + }); + + return ( + +
+
+ ); +} + +/* -----------------------------------------------------------------------------------------------*/ + +interface CommandInputProps extends SearchFieldProps { + placeholder?: string; +} + +const CommandInput = ({ placeholder, ...props }: CommandInputProps) => { + return ( + + {/* TODO: Remove this */} + + + + + + + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface CommandContentProps extends ListBoxProps { + placement?: PopoverProps["placement"]; + virtulized?: boolean; +} + +const CommandContent = ({ + virtulized, + placement, + ...props +}: CommandContentProps) => { + if (virtulized) { + return ( + + + + ); + } + + return ; +}; + +/* -----------------------------------------------------------------------------------------------*/ + +export { + Command, + CommandInput, + CommandContent, + ListBoxItem as CommandItem, + ListBoxSection as CommandSection, + ListBoxSectionHeader as CommandSectionHeader, +}; + +export type { CommandProps, CommandContentProps, CommandInputProps }; +` + } + ] + } + } + }, + { + "name": "date-field", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/date-field/basic.tsx", + "target": "ui/date-field.tsx", + "content": `"use client"; + +import { + DateField as AriaDateField, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type { + DateFieldProps as AriaDateFieldProps, + DateValue, +} from "react-aria-components"; + +import { fieldStyles } from "@dotui/registry/ui/field"; + +const dateFieldStyles = tv({ + base: [fieldStyles().field({ orientation: "vertical" })], +}); + +/* -----------------------------------------------------------------------------------------------*/ + +interface DateFieldProps extends AriaDateFieldProps {} + +const DateField = ({ + className, + ...props +}: DateFieldProps) => { + return ( + + dateFieldStyles({ className }), + )} + {...props} + /> + ); +}; + +export type { DateFieldProps }; +export { DateField }; +` + } + ], + "registryDependencies": [ + "field", + "input" + ] + } + } + }, + { + "name": "date-picker", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/date-picker/basic.tsx", + "target": "ui/date-picker.tsx", + "content": `"use client"; + +import { useContext } from "react"; +import { CalendarIcon } from "lucide-react"; +import { + DateRangePicker as AriaDataRangePicker, + DatePicker as AriaDatePicker, + RangeCalendarContext as AriaRangeCalendarContext, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type { + DateRangePickerProps as AriaDataRangePickerProps, + DatePickerProps as AriaDatePickerProps, + DateValue, +} from "react-aria-components"; + +import { Button } from "@dotui/registry/ui/button"; +import { + DialogContent, + type DialogContentProps, +} from "@dotui/registry/ui/dialog"; +import { DateInput, InputAddon, InputGroup } from "@dotui/registry/ui/input"; +import { Overlay, type OverlayProps } from "@dotui/registry/ui/overlay"; +import type { InputGroupProps } from "@dotui/registry/ui/input"; + +const datePickerStyles = tv({ + base: "flex flex-col items-start gap-2", +}); + +type DatePickerProps = + | ({ + mode?: "single"; + } & AriaDatePickerProps) + | ({ + mode: "range"; + } & AriaDataRangePickerProps); + +const DatePicker = ({ + mode = "single", + className, + ...props +}: DatePickerProps) => { + if (mode === "range") { + return ( + ["className"], + (className) => datePickerStyles({ className }), + )} + {...(props as AriaDataRangePickerProps)} + /> + ); + } + + return ( + ["className"], + (className) => datePickerStyles({ className }), + )} + {...(props as AriaDatePickerProps)} + /> + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface DatePickerInputProps extends InputGroupProps {} + +const DatePickerInput = (props: DatePickerInputProps) => { + const rangeCalendarContext = useContext(AriaRangeCalendarContext); + const mode = rangeCalendarContext ? "range" : "single"; + + return ( + + {mode === "single" ? ( + + ) : ( + <> + + + + + )} + + + + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface DatePickerContentProps + extends DialogContentProps, + Pick {} + +const DatePickerContent = ({ + children, + type = "popover", + mobileType, + popoverProps, + ...props +}: DatePickerContentProps) => { + return ( + + {children} + + ); +}; + +export type { DatePickerProps, DatePickerContentProps, DatePickerInputProps }; +export { DatePicker, DatePickerContent, DatePickerInput }; +` + } + ], + "registryDependencies": [ + "button", + "calendar", + "field", + "input", + "dialog" + ] + } + } + }, + { + "name": "dialog", + "type": "registry:ui", + "defaultVariant": "basic", + "variants": { + "basic": { + "files": [ + { + "type": "registry:ui", + "path": "ui/dialog/basic.tsx", + "target": "ui/dialog.tsx", + "content": `"use client"; + +import { + Dialog as AriaDialog, + DialogTrigger as AriaDialogTrigger, + Heading as AriaHeading, + Text as AriaText, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; +import type * as React from "react"; + +const dialogStyles = tv({ + slots: { + content: + "relative flex flex-col gap-4 in-data-popover:p-4 p-6 outline-none", + header: "flex flex-col gap-2 text-left", + heading: + "font-semibold in-popover:font-medium in-popover:text-base text-lg leading-none", + description: "text-fg-muted text-sm", + body: "flex flex-1 flex-col gap-2", + inset: "-mx-6 in-popover:-mx-4 border bg-muted in-popover:px-4 px-6 py-4", + footer: "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", + }, +}); + +const { content, header, heading, description, body, footer, inset } = + dialogStyles(); + +/* -----------------------------------------------------------------------------------------------*/ + +interface DialogProps extends React.ComponentProps {} + +const Dialog = (props: DialogProps) => { + return ; +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface DialogContentProps extends React.ComponentProps {} + +const DialogContent = ({ className, ...props }: DialogContentProps) => { + return ( + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface DialogHeaderProps extends React.ComponentProps<"header"> {} + +const DialogHeader = ({ className, ...props }: DialogHeaderProps) => { + return
; +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface DialogHeadingProps extends React.ComponentProps {} + +const DialogHeading = ({ className, ...props }: DialogHeadingProps) => { + return ; +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface DialogDescriptionProps + extends Omit, "slot"> {} + +const DialogDescription = ({ className, ...props }: DialogDescriptionProps) => { + return ( + + ); +}; + +/* -----------------------------------------------------------------------------------------------*/ + +interface DialogBodyProps extends React.ComponentProps<"div"> {} + +const DialogBody = ({ className, ...props }: DialogBodyProps) => { + return
; +}; + +/* -----------------------------------------------------------------------------------------------*/ + +type DialogFooterProps = React.ComponentProps<"footer">; + +const DialogFooter = ({ className, ...props }: DialogFooterProps) => { + return