-
-
handleSelectionChange("gray")(key as Key)}
- label="Gray Color"
- placeholder="Select gray color"
- filterKeys={neutralColors}
+
+
+ {
+ const key = [...keys][0]
+ if (key === "light" || key === "dark") {
+ setAppearance(key)
+ }
+ }}
+ >
+
+
+ Light
+
+
+
+ Dark
+
+
+
+
+
+ updateAppearanceColor("accent", value)}
/>
-
- handleSelectionChange("primary")(key as Key)
+
+ Auto
+
}
- label="Primary Color"
- placeholder="Select primary color"
- filterKeys={filteredPrimaryColors}
+ onChange={(value) => updateAppearanceColor("gray", value)}
/>
-
- handleSelectionChange("accent")(key as Key)
- }
- label="Accent Color"
- placeholder="Select accent color"
- filterKeys={filteredAccentColors}
+ updateAppearanceColor("customBackground", value)}
+ onModeChange={setBackgroundMode}
/>
-
+
+ {actions && (
+ {actions}
+ )}
+
+
+ )
+}
+
+function BackgroundControl({
+ appearance,
+ selection,
+ onChange,
+ onModeChange,
+}: {
+ appearance: ThemeAppearance
+ selection: ThemeAppearanceSelection
+ onChange: (value: string) => void
+ onModeChange: (mode: ThemeBackgroundMode) => void
+}) {
+ const selectedOption = BACKGROUND_OPTIONS.find(
+ (option) => option.id === selection.backgroundMode
+ )
+ const preview = createBackgroundPreview(
+ appearance,
+ selection,
+ selection.backgroundMode
+ )
+
+ return (
+
+
+
+
+
+
+ Page background
+
+ Choose the surface treatment for this appearance.
+
+
+
+
+ {BACKGROUND_OPTIONS.map((option) => {
+ const isSelected = option.id === selection.backgroundMode
+ const optionPreview = createBackgroundPreview(
+ appearance,
+ selection,
+ option.id
+ )
+
+ return (
+
+ )
+ })}
+
+
+ {selection.backgroundMode === "custom" && (
+
+
+ onChange(color.toString("hex"))}
+ />
+
+ )}
+
+
+
+
+ )
+}
+
+function createBackgroundPreview(
+ appearance: ThemeAppearance,
+ selection: ThemeAppearanceSelection,
+ backgroundMode: ThemeBackgroundMode
+) {
+ const background = resolveThemeBackground(appearance, {
+ ...selection,
+ backgroundMode,
+ })
+ const palette = generatePalette({
+ appearance,
+ accent: selection.accent,
+ gray: selection.gray,
+ background,
+ })
+
+ return {
+ background,
+ panel: palette.gray[1],
+ control: palette.gray[3],
+ }
+}
+
+function SurfacePreview({
+ background,
+ panel,
+ control,
+ size = "sm",
+}: {
+ background: string
+ panel: string
+ control: string
+ size?: "sm" | "lg"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function ThemeColorControl({
+ label,
+ value,
+ action,
+ onChange,
+}: {
+ label: string
+ value: string
+ action?: React.ReactNode
+ onChange: (value: string) => void
+}) {
+ return (
+
+
+
+ {action}
+
onChange(color.toString("hex"))}
+ />
)
}
diff --git a/apps/preskok/components/theme/themes.ts b/apps/preskok/components/theme/themes.ts
index 043e62a9a..c5c34c48c 100644
--- a/apps/preskok/components/theme/themes.ts
+++ b/apps/preskok/components/theme/themes.ts
@@ -1,20 +1,31 @@
-import { parse, rgb } from "culori"
+import Color from "colorjs.io"
+import { formatHex, parse, rgb, wcagContrast } from "culori"
import {
accentColors300,
accentColors400,
accentColors500,
- adjustLightness,
neutralColors,
} from "./colors"
import colors from "./colors.json"
+import {
+ deriveGraySource,
+ generatePalette,
+ type GeneratedPalette,
+ type ThemeAppearance,
+} from "./palette"
-type BlackWhite = "white" | "black"
+export type ThemeMode = ThemeAppearance
type Shade = keyof (typeof colors)["slate"]
-type ForegroundColor = Shade | BlackWhite
-type ThemeMode = "light" | "dark"
+type GrayMode = "auto" | "custom"
-export const THEME_MANIFEST_VERSION = 1
+export const THEME_MANIFEST_VERSION = 3
+export const THEME_BACKGROUND_MODES = [
+ "neutral",
+ "pure",
+ "accent",
+ "custom",
+] as const
export const THEME_RADIUS_OPTIONS = [
"0rem",
"0.125rem",
@@ -28,11 +39,19 @@ export const THEME_RADIUS_OPTIONS = [
] as const
export type ThemeRadius = (typeof THEME_RADIUS_OPTIONS)[number]
+export type ThemeBackgroundMode = (typeof THEME_BACKGROUND_MODES)[number]
-export type ThemeSelection = {
- primary: string
- gray: string
+export type ThemeAppearanceSelection = {
accent: string
+ gray: string
+ backgroundMode: ThemeBackgroundMode
+ customBackground: string
+}
+
+export type ThemeSelection = {
+ light: ThemeAppearanceSelection
+ dark: ThemeAppearanceSelection
+ grayMode: GrayMode
radius: ThemeRadius
}
@@ -42,9 +61,19 @@ export type ThemeManifest = {
}
export const DEFAULT_THEME_SELECTION: ThemeSelection = {
- primary: "blue",
- gray: "zinc",
- accent: "zinc",
+ light: {
+ accent: "#2563eb",
+ gray: "#737b8a",
+ backgroundMode: "neutral",
+ customBackground: "#ffffff",
+ },
+ dark: {
+ accent: "#3b82f6",
+ gray: "#737b88",
+ backgroundMode: "neutral",
+ customBackground: "#09090b",
+ },
+ grayMode: "auto",
radius: "0.5rem",
}
@@ -93,6 +122,14 @@ export const THEME_COLOR_TOKEN_NAMES = [
"chart-5",
"surface",
"surface-foreground",
+ "panel",
+ "panel-foreground",
+ "panel-solid",
+ "panel-solid-foreground",
+ "accent-surface",
+ "accent-indicator",
+ "accent-track",
+ "scrim",
"code",
"code-foreground",
"code-highlight",
@@ -107,6 +144,10 @@ export const FIGMA_STYLE_COLOR_TOKEN_NAMES = THEME_COLOR_TOKEN_NAMES.filter(
(token) => token !== "danger" && token !== "danger-foreground"
)
+export const THEME_PRIMITIVE_STEPS = [
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
+] as const
+
const RADIUS_MULTIPLIERS = {
xs: 0.5,
sm: 0.75,
@@ -118,15 +159,33 @@ const RADIUS_MULTIPLIERS = {
"4xl": 3,
} as const
+const STATUS_COLORS = {
+ success: { light: "#2e7d32", dark: "#46a758" },
+ warning: { light: "#ffc53d", dark: "#f5d90a" },
+ destructive: { light: "#e5484d", dark: "#e5484d" },
+} as const
+
export type ThemeRadiusTokenName = keyof typeof RADIUS_MULTIPLIERS
export type ThemeColorTokens = Record
export type ResolvedTheme = {
colors: Record
+ primitives: Record
radii: Record
selection: ThemeSelection
}
+export type ThemeContrastCheck = {
+ mode: ThemeMode
+ label: string
+ foregroundToken: ThemeColorTokenName
+ backgroundToken: ThemeColorTokenName
+ wcag: number
+ apca: number
+ requiredWcag: number
+ passes: boolean
+}
+
type DtcgColorValue = {
colorSpace: "srgb"
components: [number, number, number]
@@ -147,8 +206,20 @@ type DtcgDimensionToken = {
}
}
+type FigmaPrimitiveMode = {
+ accent: Record
+ "accent-alpha": Record
+ gray: Record
+ "gray-alpha": Record
+ canvas: DtcgColorToken
+ "accent-contrast": DtcgColorToken
+ "accent-surface": DtcgColorToken
+ "gray-surface": DtcgColorToken
+}
+
export type FigmaThemeTokens = {
color: Record>
+ primitive: { color: Record }
radius: Record
}
@@ -164,301 +235,185 @@ export const THEME_TOKEN_MAPPINGS = THEME_COLOR_TOKEN_NAMES.map((token) => {
}
})
-export const getColorValue = (colorKey: string | BlackWhite, shade?: Shade) => {
- if (colorKey === "white") {
- return "oklch(1 0 0)"
- }
-
- if (colorKey === "black") {
- return "oklch(0 0 0)"
- }
-
- if (!shade) {
- throw new Error(`Shade is required for colorKey: ${colorKey}`)
- }
-
- const colorFamily = colors[colorKey as keyof typeof colors]
- if (!colorFamily) {
- throw new Error(`Unknown color family: ${colorKey}`)
- }
-
- return colorFamily[shade]
-}
-
-function getForegroundValue(colorKey: string, foreground: ForegroundColor) {
- if (foreground === "white" || foreground === "black") {
- return getColorValue(foreground)
+function createColorTokens(primitives: Record) {
+ return {
+ light: createColorMode("light", primitives.light),
+ dark: createColorMode("dark", primitives.dark),
}
-
- return getColorValue(colorKey, foreground)
}
-function determineShade(
- isNeutral: boolean,
- isShade500: boolean,
- isShade300: boolean,
- isShade400: boolean,
- isDarkMode = false
-): Shade {
- if (isNeutral) {
- return isDarkMode ? "50" : "950"
- }
-
- if (isShade500) {
- return "500"
- }
-
- if (isShade300) {
- return "300"
- }
+function createColorMode(
+ mode: ThemeMode,
+ palette: GeneratedPalette
+): ThemeColorTokens {
+ const status = createStatusColors(mode, palette)
+ const foreground = palette.gray[11]
+ const primaryForeground = chooseReadableForeground(palette.accent[8], [
+ palette.accentContrast,
+ foreground,
+ "#ffffff",
+ "#000000",
+ ])
+ const subtleBrandForeground = chooseReadableForeground(palette.accent[2], [
+ palette.accent[10],
+ palette.accent[11],
+ foreground,
+ ])
+ const accentForeground = chooseReadableForeground(palette.gray[3], [
+ foreground,
+ palette.gray[11],
+ "#ffffff",
+ "#000000",
+ ])
+ const mutedForeground = chooseReadableForeground(palette.gray[2], [
+ palette.gray[10],
+ palette.gray[11],
+ foreground,
+ ])
+ const panel = palette.gray[1]
+
+ const panelForeground = chooseReadableForeground(
+ flattenColor(panel, palette.background),
+ [foreground, palette.gray[11], "#ffffff", "#000000"]
+ )
+ const surface = palette.grayAlpha[2]
+ const surfaceForeground = chooseReadableForeground(
+ flattenColor(surface, palette.background),
+ [foreground, palette.gray[11], "#ffffff", "#000000"]
+ )
+ const codeNumber = chooseReadableForeground(palette.gray[2], [
+ palette.gray[10],
+ palette.gray[11],
+ ])
+ const selectionForeground = chooseReadableForeground(palette.accent[8], [
+ primaryForeground,
+ "#ffffff",
+ "#000000",
+ ])
- if (isShade400) {
- return "400"
+ return {
+ background: palette.background,
+ foreground,
+ primary: palette.accent[8],
+ "primary-foreground": primaryForeground,
+ secondary: palette.gray[2],
+ "secondary-foreground": foreground,
+ accent: palette.gray[3],
+ "accent-foreground": accentForeground,
+ muted: palette.gray[2],
+ "muted-foreground": mutedForeground,
+ success: status.success.background,
+ "success-foreground": status.success.foreground,
+ warning: status.warning.background,
+ "warning-foreground": status.warning.foreground,
+ danger: status.destructive.background,
+ "danger-foreground": status.destructive.foreground,
+ destructive: status.destructive.background,
+ "destructive-foreground": status.destructive.foreground,
+ card: panel,
+ "card-foreground": panelForeground,
+ popover: panel,
+ "popover-foreground": foreground,
+ overlay: panel,
+ "overlay-foreground": panelForeground,
+ border: palette.gray[5],
+ input: palette.gray[6],
+ ring: palette.accent[7],
+ navbar: panel,
+ "navbar-foreground": panelForeground,
+ sidebar: palette.gray[1],
+ "sidebar-foreground": foreground,
+ "sidebar-primary": palette.accent[3],
+ "sidebar-primary-foreground": subtleBrandForeground,
+ "sidebar-accent": palette.gray[3],
+ "sidebar-accent-foreground": foreground,
+ "sidebar-border": palette.gray[5],
+ "sidebar-ring": palette.accent[7],
+ "chart-1": palette.accent[8],
+ "chart-2": palette.accent[10],
+ "chart-3": palette.accent[6],
+ "chart-4": palette.accent[4],
+ "chart-5": palette.accent[2],
+ surface,
+ "surface-foreground": surfaceForeground,
+ panel,
+ "panel-foreground": panelForeground,
+ "panel-solid": panel,
+ "panel-solid-foreground": foreground,
+ "accent-surface": palette.accentSurface,
+ "accent-indicator": palette.accent[8],
+ "accent-track": palette.accent[4],
+ scrim: "#00000080",
+ code: palette.gray[1],
+ "code-foreground": foreground,
+ "code-highlight": palette.gray[3],
+ "code-number": codeNumber,
+ selection: palette.accent[8],
+ "selection-foreground": selectionForeground,
}
-
- return "600"
}
-function determineForeground(
- isNeutral: boolean,
- isShade400: boolean,
- isDarkMode = false
-): ForegroundColor {
- if (isNeutral) {
- return isDarkMode ? "950" : "50"
+function createStatusColors(mode: ThemeMode, palette: GeneratedPalette) {
+ return Object.fromEntries(
+ Object.entries(STATUS_COLORS).map(([name, values]) => {
+ const scale = generatePalette({
+ appearance: mode,
+ accent: values[mode],
+ gray: palette.gray[8],
+ background: palette.background,
+ })
+ return [
+ name,
+ {
+ background: scale.accent[8],
+ foreground: chooseReadableForeground(scale.accent[8], [
+ scale.accentContrast,
+ palette.gray[11],
+ "#ffffff",
+ "#000000",
+ ]),
+ },
+ ]
+ })
+ ) as Record<
+ keyof typeof STATUS_COLORS,
+ { background: string; foreground: string }
+ >
+}
+
+function chooseReadableForeground(background: string, candidates: string[]) {
+ let best = candidates[0]
+ let bestContrast = 0
+
+ for (const candidate of candidates) {
+ const contrast = wcagContrast(background, candidate)
+ if (contrast >= 4.5) {
+ return candidate
+ }
+
+ if (contrast > bestContrast) {
+ best = candidate
+ bestContrast = contrast
+ }
}
- return isShade400 ? "950" : "white"
+ return best
}
-function getPalette(selection: ThemeSelection) {
- const { primary, accent } = selection
- const isNeutralPrimary = neutralColors.includes(primary)
- const isShade400Primary = accentColors400.includes(primary)
- const isShade500Primary = accentColors500.includes(primary)
- const isShade300Primary = accentColors300.includes(primary)
- const isNeutralAccent = neutralColors.includes(accent)
- const isShade400Accent = accentColors400.includes(accent)
- const isShade500Accent = accentColors500.includes(accent)
- const isShade300Accent = accentColors300.includes(accent)
-
- const lightPrimary = determineShade(
- isNeutralPrimary,
- isShade500Primary,
- isShade300Primary,
- isShade400Primary
- )
- const darkPrimary = determineShade(
- isNeutralPrimary,
- isShade500Primary,
- isShade300Primary,
- isShade400Primary,
- true
- )
- const lightPrimaryForeground = determineForeground(
- isNeutralPrimary,
- isShade400Primary
- )
- const darkPrimaryForeground = determineForeground(
- isNeutralPrimary,
- isShade400Primary,
- true
- )
-
- let lightAccent: Shade = "200"
- let lightAccentForeground: ForegroundColor = "950"
- let darkAccent: Shade = "800"
- let darkAccentForeground: ForegroundColor = "50"
-
- if (!isNeutralAccent) {
- lightAccent = determineShade(
- false,
- isShade500Accent,
- isShade300Accent,
- isShade400Accent
- )
- lightAccentForeground = determineForeground(false, isShade400Accent)
- darkAccent = determineShade(
- false,
- isShade500Accent,
- isShade300Accent,
- isShade400Accent,
- true
- )
- darkAccentForeground = determineForeground(false, isShade400Accent, true)
+function flattenColor(value: string, canvas: string) {
+ const foreground = new Color(value).to("srgb")
+ const background = new Color(canvas).to("srgb")
+ const alpha = foreground.alpha ?? 1
+ if (alpha === 1) {
+ return value
}
- return {
- lightPrimary,
- lightPrimaryForeground,
- darkPrimary,
- darkPrimaryForeground,
- lightAccent,
- lightAccentForeground,
- darkAccent,
- darkAccentForeground,
- isNeutralPrimary,
- }
-}
-
-function createColorTokens(selection: ThemeSelection) {
- const { primary, gray, accent } = selection
- const palette = getPalette(selection)
- const white = getColorValue("white")
- const gray50 = getColorValue(gray, "50")
- const gray100 = getColorValue(gray, "100")
- const gray200 = getColorValue(gray, "200")
- const gray300 = getColorValue(gray, "300")
- const gray400 = getColorValue(gray, "400")
- const gray500 = getColorValue(gray, "500")
- const gray700 = getColorValue(gray, "700")
- const gray800 = getColorValue(gray, "800")
- const gray900 = getColorValue(gray, "900")
- const gray950 = getColorValue(gray, "950")
- const lightForeground = gray950
- const darkForeground = gray50
- const destructive =
- primary === "red"
- ? adjustLightness(getColorValue("red", "600"), -4)
- : getColorValue("red", "600")
- const warning = getColorValue("amber", primary === "amber" ? "200" : "400")
- const destructiveForeground = getColorValue("red", "50")
- const warningForeground = getColorValue("amber", "950")
- const lightBorder = adjustLightness(gray300, 4)
- const darkBorder = adjustLightness(gray700, -10)
- const lightSecondary = gray200
- const darkSecondary = adjustLightness(gray800, -3)
- const lightSurface = gray50
- const darkSurface = gray900
-
- const lightChartShades: Array = palette.isNeutralPrimary
- ? ["900", "700", "600", "500", "400"]
- : ["600", "400", "300", "200", "100"]
- const darkChartShades: Array = palette.isNeutralPrimary
- ? ["800", "700", "500", "400", "300"]
- : ["700", "500", "400", "300", "200"]
- const lightRingShade = palette.isNeutralPrimary ? "950" : "600"
- const darkRingShade = palette.isNeutralPrimary ? "50" : "600"
-
- const light = {
- background: white,
- foreground: lightForeground,
- primary: getColorValue(primary, palette.lightPrimary),
- "primary-foreground": getForegroundValue(
- primary,
- palette.lightPrimaryForeground
- ),
- secondary: lightSecondary,
- "secondary-foreground": lightForeground,
- accent: getColorValue(accent, palette.lightAccent),
- "accent-foreground": getForegroundValue(
- accent,
- palette.lightAccentForeground
- ),
- muted: gray100,
- "muted-foreground": gray500,
- success: getColorValue("emerald", "600"),
- "success-foreground": white,
- warning,
- "warning-foreground": warningForeground,
- danger: destructive,
- "danger-foreground": destructiveForeground,
- destructive,
- "destructive-foreground": destructiveForeground,
- card: white,
- "card-foreground": lightForeground,
- popover: white,
- "popover-foreground": lightForeground,
- overlay: white,
- "overlay-foreground": lightForeground,
- border: lightBorder,
- input: gray300,
- ring: getColorValue(primary, lightRingShade),
- navbar: adjustLightness(gray50, 1),
- "navbar-foreground": lightForeground,
- sidebar: gray100,
- "sidebar-foreground": lightForeground,
- "sidebar-primary": lightSecondary,
- "sidebar-primary-foreground": lightForeground,
- "sidebar-accent": lightSecondary,
- "sidebar-accent-foreground": lightForeground,
- "sidebar-border": lightBorder,
- "sidebar-ring": adjustLightness(gray500, 10),
- "chart-1": getColorValue(primary, lightChartShades[0]),
- "chart-2": getColorValue(primary, lightChartShades[1]),
- "chart-3": getColorValue(primary, lightChartShades[2]),
- "chart-4": getColorValue(primary, lightChartShades[3]),
- "chart-5": getColorValue(primary, lightChartShades[4]),
- surface: lightSurface,
- "surface-foreground": lightForeground,
- code: lightSurface,
- "code-foreground": lightForeground,
- "code-highlight": gray100,
- "code-number": gray500,
- selection: gray950,
- "selection-foreground": white,
- } satisfies ThemeColorTokens
-
- const dark = {
- background: adjustLightness(gray950, -5),
- foreground: darkForeground,
- primary: getColorValue(primary, palette.darkPrimary),
- "primary-foreground": getForegroundValue(
- primary,
- palette.darkPrimaryForeground
- ),
- secondary: darkSecondary,
- "secondary-foreground": darkForeground,
- accent: getColorValue(accent, palette.darkAccent),
- "accent-foreground": getForegroundValue(
- accent,
- palette.darkAccentForeground
- ),
- muted: gray900,
- "muted-foreground": gray400,
- success: getColorValue("emerald", "600"),
- "success-foreground": white,
- warning,
- "warning-foreground": warningForeground,
- danger: destructive,
- "danger-foreground": destructiveForeground,
- destructive,
- "destructive-foreground": destructiveForeground,
- card: adjustLightness(gray900, -3),
- "card-foreground": darkForeground,
- popover: gray900,
- "popover-foreground": darkForeground,
- overlay: adjustLightness(gray900, -3),
- "overlay-foreground": darkForeground,
- border: darkBorder,
- input: adjustLightness(gray700, -5),
- ring: getColorValue(primary, darkRingShade),
- navbar: adjustLightness(gray900, -2),
- "navbar-foreground": darkForeground,
- sidebar: adjustLightness(gray900, -5),
- "sidebar-foreground": darkForeground,
- "sidebar-primary": darkSecondary,
- "sidebar-primary-foreground": darkForeground,
- "sidebar-accent": darkSecondary,
- "sidebar-accent-foreground": darkForeground,
- "sidebar-border": darkBorder,
- "sidebar-ring": gray500,
- "chart-1": getColorValue(primary, darkChartShades[0]),
- "chart-2": getColorValue(primary, darkChartShades[1]),
- "chart-3": getColorValue(primary, darkChartShades[2]),
- "chart-4": getColorValue(primary, darkChartShades[3]),
- "chart-5": getColorValue(primary, darkChartShades[4]),
- surface: darkSurface,
- "surface-foreground": gray400,
- code: darkSurface,
- "code-foreground": gray400,
- "code-highlight": gray800,
- "code-number": gray400,
- selection: gray200,
- "selection-foreground": gray800,
- } satisfies ThemeColorTokens
-
- return { light, dark }
+ const coordinates: [number, number, number] = [0, 1, 2].map((index) => {
+ return (
+ foreground.coords[index] * alpha + background.coords[index] * (1 - alpha)
+ )
+ }) as [number, number, number]
+ return new Color("srgb", coordinates).toString({ format: "hex" })
}
function radiusToPixels(radius: ThemeRadius) {
@@ -467,7 +422,6 @@ function radiusToPixels(radius: ThemeRadius) {
function createRadii(radius: ThemeRadius) {
const basePixels = radiusToPixels(radius)
-
return Object.fromEntries(
Object.entries(RADIUS_MULTIPLIERS).map(([name, multiplier]) => [
name,
@@ -476,16 +430,111 @@ function createRadii(radius: ThemeRadius) {
) as Record
}
+const PURE_BACKGROUNDS: Record = {
+ light: "#ffffff",
+ dark: "#09090b",
+}
+
+export function resolveThemeBackground(
+ mode: ThemeMode,
+ selection: ThemeAppearanceSelection
+) {
+ if (selection.backgroundMode === "custom") {
+ return selection.customBackground
+ }
+
+ const pureBackground = PURE_BACKGROUNDS[mode]
+ if (selection.backgroundMode === "pure") {
+ return pureBackground
+ }
+
+ const referencePalette = generatePalette({
+ appearance: mode,
+ accent: selection.accent,
+ gray: selection.gray,
+ background: pureBackground,
+ })
+
+ if (selection.backgroundMode === "accent") {
+ return referencePalette.accent[0]
+ }
+
+ return referencePalette.gray[0]
+}
+
export function createThemeTokens(selection: ThemeSelection): ResolvedTheme {
assertThemeSelection(selection)
+ const primitives = {
+ light: generatePalette({
+ appearance: "light",
+ accent: selection.light.accent,
+ gray: selection.light.gray,
+ background: resolveThemeBackground("light", selection.light),
+ }),
+ dark: generatePalette({
+ appearance: "dark",
+ accent: selection.dark.accent,
+ gray: selection.dark.gray,
+ background: resolveThemeBackground("dark", selection.dark),
+ }),
+ }
return {
- colors: createColorTokens(selection),
+ colors: createColorTokens(primitives),
+ primitives,
radii: createRadii(selection.radius),
selection,
}
}
+export function createThemeContrastChecks(
+ selection: ThemeSelection
+): ThemeContrastCheck[] {
+ return createThemeContrastChecksFromTheme(createThemeTokens(selection))
+}
+
+function createThemeContrastChecksFromTheme(
+ theme: ResolvedTheme
+): ThemeContrastCheck[] {
+ const pairs = [
+ ["Body", "foreground", "background"],
+ ["Primary action", "primary-foreground", "primary"],
+ ["Secondary", "secondary-foreground", "secondary"],
+ ["Accent", "accent-foreground", "accent"],
+ ["Muted text", "muted-foreground", "muted"],
+ ["Success", "success-foreground", "success"],
+ ["Warning", "warning-foreground", "warning"],
+ ["Destructive", "destructive-foreground", "destructive"],
+ ["Panel", "panel-foreground", "panel"],
+ ["Control surface", "surface-foreground", "surface"],
+ ] as const
+
+ return (["light", "dark"] as const).flatMap((mode) => {
+ return pairs.map(([label, foregroundToken, backgroundToken]) => {
+ const foreground = theme.colors[mode][foregroundToken]
+ const background = flattenColor(
+ theme.colors[mode][backgroundToken],
+ theme.colors[mode].background
+ )
+ const wcag = wcagContrast(background, foreground)
+ const apca = Math.abs(
+ new Color(foreground).contrastAPCA(new Color(background))
+ )
+
+ return {
+ mode,
+ label,
+ foregroundToken,
+ backgroundToken,
+ wcag: Number(wcag.toFixed(2)),
+ apca: Number(apca.toFixed(1)),
+ requiredWcag: 4.5,
+ passes: wcag >= 4.5,
+ }
+ })
+ })
+}
+
function serializeCssVariables(values: Record) {
return Object.entries(values)
.map(([name, value]) => ` --${name}: ${value};`)
@@ -506,14 +555,64 @@ function createCssRadii(radius: ThemeRadius) {
}
}
+function createPrimitiveCssVariables(
+ palette: GeneratedPalette,
+ wideGamut = false
+) {
+ const values: Record = {}
+ const accent = wideGamut ? palette.accentWideGamut : palette.accent
+ const accentAlpha = wideGamut
+ ? palette.accentAlphaWideGamut
+ : palette.accentAlpha
+ const gray = wideGamut ? palette.grayWideGamut : palette.gray
+ const grayAlpha = wideGamut ? palette.grayAlphaWideGamut : palette.grayAlpha
+
+ THEME_PRIMITIVE_STEPS.forEach((step, index) => {
+ values[`accent-${step}`] = accent[index]
+ values[`accent-a${step}`] = accentAlpha[index]
+ values[`gray-${step}`] = gray[index]
+ values[`gray-a${step}`] = grayAlpha[index]
+ })
+
+ values["accent-contrast"] = palette.accentContrast
+ values["accent-surface-primitive"] = wideGamut
+ ? palette.accentSurfaceWideGamut
+ : palette.accentSurface
+ values["gray-surface"] = wideGamut
+ ? palette.graySurfaceWideGamut
+ : palette.graySurface
+ return values
+}
+
export function generateTheme(selection: ThemeSelection) {
- const theme = createThemeTokens(selection)
+ return generateThemeFromTokens(createThemeTokens(selection))
+}
+
+function generateThemeFromTokens(theme: ResolvedTheme) {
+ const { selection } = theme
const light = {
+ ...createPrimitiveCssVariables(theme.primitives.light),
...theme.colors.light,
...createCssRadii(selection.radius),
}
+ const dark = {
+ ...createPrimitiveCssVariables(theme.primitives.dark),
+ ...theme.colors.dark,
+ }
+ const lightWideGamut = createPrimitiveCssVariables(
+ theme.primitives.light,
+ true
+ )
+ const darkWideGamut = createPrimitiveCssVariables(theme.primitives.dark, true)
- return `:root {\n${serializeCssVariables(light)}\n}\n\n.dark {\n${serializeCssVariables(theme.colors.dark)}\n}`
+ return `:root {\n${serializeCssVariables(light)}\n}\n\n.dark {\n${serializeCssVariables(dark)}\n}\n\n@supports (color: color(display-p3 1 1 1)) {\n :root {\n${indentCssVariables(lightWideGamut, 4)}\n }\n\n .dark {\n${indentCssVariables(darkWideGamut, 4)}\n }\n}`
+}
+
+function indentCssVariables(values: Record, spaces: number) {
+ const indentation = " ".repeat(spaces)
+ return Object.entries(values)
+ .map(([name, value]) => `${indentation}--${name}: ${value};`)
+ .join("\n")
}
function clamp(value: number) {
@@ -546,7 +645,6 @@ function toDtcgColor(value: string): DtcgColorValue {
const green = clamp(converted.g)
const blue = clamp(converted.b)
const alpha = clamp(converted.alpha ?? 1)
-
return {
colorSpace: "srgb",
components: [round(red), round(green), round(blue)],
@@ -555,23 +653,53 @@ function toDtcgColor(value: string): DtcgColorValue {
}
}
+function createFigmaToken(value: string): DtcgColorToken {
+ return { $type: "color", $value: toDtcgColor(value) }
+}
+
function createFigmaColorMode(tokens: ThemeColorTokens) {
return Object.fromEntries(
FIGMA_STYLE_COLOR_TOKEN_NAMES.map((name) => [
name,
- {
- $type: "color",
- $value: toDtcgColor(tokens[name]),
- } satisfies DtcgColorToken,
+ createFigmaToken(tokens[name]),
])
)
}
+function createFigmaPrimitiveMode(
+ palette: GeneratedPalette
+): FigmaPrimitiveMode {
+ const createScale = (values: GeneratedPalette["accent"]) => {
+ return Object.fromEntries(
+ THEME_PRIMITIVE_STEPS.map((step, index) => [
+ String(step),
+ createFigmaToken(values[index]),
+ ])
+ )
+ }
+
+ return {
+ accent: createScale(palette.accent),
+ "accent-alpha": createScale(palette.accentAlpha),
+ gray: createScale(palette.gray),
+ "gray-alpha": createScale(palette.grayAlpha),
+ canvas: createFigmaToken(palette.background),
+ "accent-contrast": createFigmaToken(palette.accentContrast),
+ "accent-surface": createFigmaToken(palette.accentSurface),
+ "gray-surface": createFigmaToken(palette.graySurface),
+ }
+}
+
export function generateFigmaThemeTokens(
selection: ThemeSelection
): FigmaThemeTokens {
- const theme = createThemeTokens(selection)
- const radiusTokens = Object.fromEntries(
+ return generateFigmaThemeTokensFromTheme(createThemeTokens(selection))
+}
+
+function generateFigmaThemeTokensFromTheme(
+ theme: ResolvedTheme
+): FigmaThemeTokens {
+ const radius = Object.fromEntries(
Object.entries(theme.radii).map(([name, value]) => [
name,
{
@@ -586,7 +714,13 @@ export function generateFigmaThemeTokens(
light: createFigmaColorMode(theme.colors.light),
dark: createFigmaColorMode(theme.colors.dark),
},
- radius: radiusTokens,
+ primitive: {
+ color: {
+ light: createFigmaPrimitiveMode(theme.primitives.light),
+ dark: createFigmaPrimitiveMode(theme.primitives.dark),
+ },
+ },
+ radius,
}
}
@@ -594,15 +728,24 @@ export function generateFigmaThemeJson(selection: ThemeSelection) {
return `${JSON.stringify(generateFigmaThemeTokens(selection), null, 2)}\n`
}
-export function createThemeManifest(selection: ThemeSelection): ThemeManifest {
- assertThemeSelection(selection)
+export function createThemeArtifacts(selection: ThemeSelection) {
+ const theme = createThemeTokens(selection)
+ const figmaTokens = generateFigmaThemeTokensFromTheme(theme)
return {
- schemaVersion: THEME_MANIFEST_VERSION,
- selection,
+ theme,
+ contrastChecks: createThemeContrastChecksFromTheme(theme),
+ css: generateThemeFromTokens(theme),
+ figmaJson: `${JSON.stringify(figmaTokens, null, 2)}\n`,
+ manifestJson: generateThemeManifestJson(selection),
}
}
+export function createThemeManifest(selection: ThemeSelection): ThemeManifest {
+ assertThemeSelection(selection)
+ return { schemaVersion: THEME_MANIFEST_VERSION, selection }
+}
+
export function generateThemeManifestJson(selection: ThemeSelection) {
return `${JSON.stringify(createThemeManifest(selection), null, 2)}\n`
}
@@ -611,8 +754,8 @@ function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
-function isKnownColor(value: unknown) {
- return typeof value === "string" && Object.hasOwn(colors, value)
+function isHexColor(value: unknown): value is string {
+ return typeof value === "string" && /^#[\dA-Fa-f]{6}$/.test(value)
}
function isThemeRadius(value: unknown): value is ThemeRadius {
@@ -622,26 +765,48 @@ function isThemeRadius(value: unknown): value is ThemeRadius {
)
}
-export function assertThemeSelection(
- selection: unknown
-): asserts selection is ThemeSelection {
- if (!isRecord(selection)) {
- throw new Error("Theme selection must be an object.")
+function assertAppearanceSelection(
+ appearance: unknown,
+ name: ThemeMode
+): asserts appearance is ThemeAppearanceSelection {
+ if (!isRecord(appearance)) {
+ throw new Error(`Theme ${name} appearance must be an object.`)
}
- if (!isKnownColor(selection.primary)) {
- throw new Error("Theme primary color is not supported.")
+ if (!isHexColor(appearance.accent)) {
+ throw new Error(`Theme ${name} accent must be a six-digit hex color.`)
}
- if (!isKnownColor(selection.accent)) {
- throw new Error("Theme accent color is not supported.")
+ if (!isHexColor(appearance.gray)) {
+ throw new Error(`Theme ${name} gray must be a six-digit hex color.`)
+ }
+
+ if (!isHexColor(appearance.customBackground)) {
+ throw new Error(
+ `Theme ${name} custom background must be a six-digit hex color.`
+ )
}
if (
- typeof selection.gray !== "string" ||
- !neutralColors.includes(selection.gray)
+ typeof appearance.backgroundMode !== "string" ||
+ !THEME_BACKGROUND_MODES.some((mode) => mode === appearance.backgroundMode)
) {
- throw new Error("Theme gray color must be a neutral family.")
+ throw new Error(`Theme ${name} background mode is not supported.`)
+ }
+}
+
+export function assertThemeSelection(
+ selection: unknown
+): asserts selection is ThemeSelection {
+ if (!isRecord(selection)) {
+ throw new Error("Theme selection must be an object.")
+ }
+
+ assertAppearanceSelection(selection.light, "light")
+ assertAppearanceSelection(selection.dark, "dark")
+
+ if (selection.grayMode !== "auto" && selection.grayMode !== "custom") {
+ throw new Error('Theme gray mode must be "auto" or "custom".')
}
if (!isThemeRadius(selection.radius)) {
@@ -651,7 +816,6 @@ export function assertThemeSelection(
export function parseThemeManifestJson(source: string): ThemeManifest {
let parsed: unknown
-
try {
parsed = JSON.parse(source)
} catch {
@@ -662,6 +826,20 @@ export function parseThemeManifestJson(source: string): ThemeManifest {
throw new Error("Theme file must contain an object.")
}
+ if (parsed.schemaVersion === 1) {
+ return {
+ schemaVersion: THEME_MANIFEST_VERSION,
+ selection: migrateLegacySelection(parsed.selection),
+ }
+ }
+
+ if (parsed.schemaVersion === 2) {
+ return {
+ schemaVersion: THEME_MANIFEST_VERSION,
+ selection: migrateVersionTwoSelection(parsed.selection),
+ }
+ }
+
if (parsed.schemaVersion !== THEME_MANIFEST_VERSION) {
throw new Error(
`Theme file uses an unsupported schema version. Expected ${THEME_MANIFEST_VERSION}.`
@@ -669,9 +847,156 @@ export function parseThemeManifestJson(source: string): ThemeManifest {
}
assertThemeSelection(parsed.selection)
+ return { schemaVersion: THEME_MANIFEST_VERSION, selection: parsed.selection }
+}
+
+function migrateVersionTwoSelection(value: unknown): ThemeSelection {
+ if (!isRecord(value)) {
+ throw new Error("Version 2 theme selection must be an object.")
+ }
+
+ const light = migrateVersionTwoAppearance(value.light, "light")
+ const dark = migrateVersionTwoAppearance(value.dark, "dark")
+
+ if (value.grayMode !== "auto" && value.grayMode !== "custom") {
+ throw new Error('Theme gray mode must be "auto" or "custom".')
+ }
+
+ if (!isThemeRadius(value.radius)) {
+ throw new Error("Theme radius is not supported.")
+ }
return {
- schemaVersion: THEME_MANIFEST_VERSION,
- selection: parsed.selection,
+ light,
+ dark,
+ grayMode: value.grayMode,
+ radius: value.radius,
+ }
+}
+
+function migrateVersionTwoAppearance(
+ value: unknown,
+ name: ThemeMode
+): ThemeAppearanceSelection {
+ if (!isRecord(value)) {
+ throw new Error(`Theme ${name} appearance must be an object.`)
+ }
+
+ if (!isHexColor(value.accent)) {
+ throw new Error(`Theme ${name} accent must be a six-digit hex color.`)
+ }
+
+ if (!isHexColor(value.gray)) {
+ throw new Error(`Theme ${name} gray must be a six-digit hex color.`)
+ }
+
+ if (!isHexColor(value.background)) {
+ throw new Error(`Theme ${name} background must be a six-digit hex color.`)
+ }
+
+ return {
+ accent: value.accent,
+ gray: value.gray,
+ backgroundMode: "custom",
+ customBackground: value.background,
+ }
+}
+
+function migrateLegacySelection(value: unknown): ThemeSelection {
+ if (!isRecord(value)) {
+ throw new Error("Legacy theme selection must be an object.")
+ }
+
+ if (!isKnownLegacyColor(value.primary)) {
+ throw new Error("Legacy theme primary color is not supported.")
+ }
+
+ if (!isKnownLegacyColor(value.gray) || !neutralColors.includes(value.gray)) {
+ throw new Error("Legacy theme gray color must be a neutral family.")
+ }
+
+ if (!isKnownLegacyColor(value.accent)) {
+ throw new Error("Legacy theme accent color is not supported.")
+ }
+
+ if (!isThemeRadius(value.radius)) {
+ throw new Error("Legacy theme radius is not supported.")
+ }
+
+ const lightAccent = legacyColorToHex(
+ value.primary,
+ getLegacyPrimaryShade(value.primary, false)
+ )
+ const darkAccent = legacyColorToHex(
+ value.primary,
+ getLegacyPrimaryShade(value.primary, true)
+ )
+ const gray = legacyColorToHex(value.gray, "500")
+ return {
+ light: {
+ accent: lightAccent,
+ gray,
+ backgroundMode: "pure",
+ customBackground: "#ffffff",
+ },
+ dark: {
+ accent: darkAccent,
+ gray,
+ backgroundMode: "pure",
+ customBackground: "#09090b",
+ },
+ grayMode: "custom",
+ radius: value.radius,
+ }
+}
+
+function isKnownLegacyColor(value: unknown): value is keyof typeof colors {
+ return typeof value === "string" && Object.hasOwn(colors, value)
+}
+
+function getLegacyPrimaryShade(color: string, dark: boolean): Shade {
+ if (neutralColors.includes(color)) {
+ return dark ? "50" : "950"
+ }
+
+ if (accentColors500.includes(color)) {
+ return "500"
+ }
+
+ if (accentColors300.includes(color)) {
+ return "300"
+ }
+
+ if (accentColors400.includes(color)) {
+ return "400"
+ }
+
+ return "600"
+}
+
+function legacyColorToHex(color: keyof typeof colors, shade: Shade) {
+ const parsed = parse(colors[color][shade])
+ if (!parsed) {
+ throw new Error(`Could not migrate legacy color ${color}-${shade}.`)
+ }
+
+ return formatHex(parsed).toLowerCase()
+}
+
+export function updateAutomaticGray(
+ selection: ThemeSelection,
+ mode: ThemeMode,
+ accent: string
+) {
+ if (selection.grayMode !== "auto") {
+ return selection
+ }
+
+ return {
+ ...selection,
+ [mode]: {
+ ...selection[mode],
+ gray: deriveGraySource(accent),
+ },
}
}
diff --git a/apps/preskok/content/docs/theme.mdx b/apps/preskok/content/docs/theme.mdx
index 866d139f1..32746fee8 100644
--- a/apps/preskok/content/docs/theme.mdx
+++ b/apps/preskok/content/docs/theme.mdx
@@ -7,37 +7,3 @@ full: true
import { ThemeContainer } from "@/components/theme/theme-container"
-
-## One theme, two systems
-
-The customizer resolves the complete Preskok semantic color contract for light
-and dark appearances. CSS and Figma exports are generated from those same
-resolved values, including component surfaces, navigation, charts, feedback
-colors, code surfaces, selection colors, and the complete radius scale.
-
-Keep `preskok-theme.json` in the project repository. It is the small, versioned
-source file that can be loaded into this page again. Generated CSS and Figma JSON
-are outputs of that project theme.
-
-## Add the theme to code
-
-Choose **Get theme → Copy CSS** and replace the `:root` and `.dark` theme blocks
-in the project's global stylesheet. The generated CSS uses the same variable
-names as Preskok UI components.
-
-## Add the theme to Figma
-
-1. Download `preskok-style-mode.json` with **Get theme → Download for Figma**.
-2. Open the local variables panel and select the `Style` collection.
-3. Duplicate the `Default` mode and rename it for the new project.
-4. Open the mode menu, choose **Import mode**, and select the downloaded file.
-5. Apply that `Style` mode to the project page or frame. Continue using the
- existing `Mode` collection to switch between light and dark appearance.
-
-The imported paths match the library's existing variables, such as
-`color/light/primary`, `color/dark/primary`, and `radius/lg`. Do not import this
-file into the `Mode` collection: `Style` selects the project brand, while `Mode`
-selects the light or dark appearance.
-
-Figma imports these files using the
-[Design Tokens Community Group format](https://help.figma.com/hc/en-us/articles/15343816063383-Modes-for-variables).
diff --git a/apps/preskok/package.json b/apps/preskok/package.json
index c213e9a0d..66da37b69 100644
--- a/apps/preskok/package.json
+++ b/apps/preskok/package.json
@@ -16,6 +16,7 @@
},
"dependencies": {
"@internationalized/date": "^3.12.3",
+ "@radix-ui/colors": "3.0.0",
"@tabler/icons-react": "^3.44.0",
"@tailwindcss/postcss": "^4.3.1",
"@tanstack/charts": "0.12.0",
@@ -30,8 +31,10 @@
"@tiptap/starter-kit": "^3.27.1",
"@types/culori": "^4.0.1",
"@vercel/analytics": "^2.0.1",
+ "bezier-easing": "^2.1.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "colorjs.io": "0.5.2",
"culori": "^4.0.2",
"d3-shape": "3.2.0",
"embla-carousel-react": "8.6.0",
@@ -65,6 +68,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
+ "@types/bezier-easing": "^2.1.6",
"@types/d3-shape": "3.1.8",
"@types/mdx": "^2.0.14",
"@types/node": "^26.0.0",
diff --git a/apps/preskok/public/r/dropdown.json b/apps/preskok/public/r/dropdown.json
index 11a65b4e2..ba170b0c7 100644
--- a/apps/preskok/public/r/dropdown.json
+++ b/apps/preskok/public/r/dropdown.json
@@ -14,7 +14,7 @@
"files": [
{
"path": "registry/preskok/ui/preskok-ui/dropdown.tsx",
- "content": "\"use client\"\n\nimport { CheckIcon } from \"lucide-react\"\nimport { composeRenderProps } from \"react-aria-components/composeRenderProps\"\nimport type {\n ListBoxItemProps,\n ListBoxSectionProps,\n TextProps,\n} from \"react-aria-components/ListBox\"\nimport {\n Collection,\n Header,\n ListBoxItem as ListBoxItemPrimitive,\n ListBoxSection,\n Text,\n} from \"react-aria-components/ListBox\"\nimport type { SeparatorProps } from \"react-aria-components/Separator\"\nimport { Separator } from \"react-aria-components/Separator\"\nimport { twJoin, twMerge } from \"tailwind-merge\"\nimport { tv } from \"tailwind-variants\"\n\nimport { Keyboard } from \"./keyboard\"\n\nconst dropdownSectionStyles = tv({\n slots: {\n section: \"col-span-full grid grid-cols-[auto_1fr]\",\n header:\n \"col-span-full px-3 py-2 font-medium text-muted-foreground text-sm/6 sm:px-2.5 sm:py-1.5 sm:text-xs/3\",\n },\n})\n\nconst { section, header } = dropdownSectionStyles()\n\ninterface DropdownSectionProps extends ListBoxSectionProps {\n title?: string\n}\n\nconst DropdownSection = ({\n className,\n children,\n ...props\n}: DropdownSectionProps) => {\n return (\n \n {\"title\" in props && }\n {children}\n \n )\n}\n\nconst dropdownItemStyles = tv({\n base: [\n \"min-w-0 [--mr-icon:--spacing(2.5)] sm:[--mr-icon:--spacing(2)]\",\n \"col-span-full grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] px-3 py-2 supports-[grid-template-columns:subgrid]:grid-cols-subgrid sm:px-2.5 sm:py-1.5\",\n \"not-has-[[slot=description]]:items-center\",\n \"group relative cursor-default select-none rounded-[calc(var(--radius-xl)-(--spacing(1)))] text-base/6 text-foreground outline-0 sm:text-sm/6\",\n \"**:data-[slot=avatar]:*:mr-(--mr-icon) **:data-[slot=avatar]:mr-(--mr-icon) **:data-[slot=avatar]:[--avatar-size:--spacing(6)] sm:**:data-[slot=avatar]:[--avatar-size:--spacing(5)]\",\n \"*:data-[slot=icon]:mr-(--mr-icon) **:data-[slot=icon]:h-5 **:data-[slot=icon]:w-5 **:data-[slot=icon]:shrink-0 has-[[slot=description]]:**:data-[slot=icon]:h-[1lh] sm:**:data-[slot=icon]:h-4 sm:**:data-[slot=icon]:w-4 [&_[data-slot='icon']:not([class*='text-'])]:text-muted-foreground\",\n \"[&>[slot=label]+[data-slot=icon]]:absolute [&>[slot=label]+[data-slot=icon]]:right-1\",\n \"forced-color-adjust-none forced-colors:text-[CanvasText] forced-colors:**:data-[slot=icon]:text-[CanvasText] forced-colors:group-focus:**:data-[slot=icon]:text-[CanvasText]\",\n ],\n variants: {\n intent: {\n danger: [\n \"text-destructive focus:text-destructive [&_[data-slot='icon']:not([class*='text-'])]:text-destructive/70\",\n \"*:[[slot=description]]:text-destructive/80 focus:*:[[slot=description]]:text-destructive focus:*:[[slot=label]]:text-destructive\",\n \"focus:bg-destructive/10 focus:text-destructive forced-colors:focus:text-[Mark] focus:[&_[data-slot='icon']:not([class*='text-'])]:text-destructive\",\n ],\n warning: [\n \"text-warning focus:text-warning [&_[data-slot='icon']:not([class*='text-'])]:text-warning/70\",\n \"*:[[slot=description]]:text-warning/80 focus:*:[[slot=description]]:text-warning focus:*:[[slot=label]]:text-warning\",\n \"focus:bg-warning/10 focus:text-warning focus:[&_[data-slot='icon']:not([class*='text-'])]:text-warning\",\n ],\n },\n isDisabled: {\n true: \"opacity-50 forced-colors:text-[GrayText]\",\n },\n isSelected: {\n true: \"**:data-[slot=icon]:text-foreground\",\n },\n isFocused: {\n true: \"bg-accent text-accent-foreground forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]\",\n },\n isHovered: {\n true: \"bg-accent text-accent-foreground forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]\",\n },\n },\n})\n\ninterface DropdownItemProps extends ListBoxItemProps {\n intent?: \"danger\" | \"warning\"\n}\n\nconst DropdownItem = ({\n className,\n children,\n intent,\n ...props\n}: DropdownItemProps) => {\n const textValue = typeof children === \"string\" ? children : undefined\n return (\n \n dropdownItemStyles({ ...renderProps, intent, className })\n )}\n {...props}\n >\n {composeRenderProps(children, (children, { isSelected }) => (\n <>\n {isSelected && (\n \n )}\n {typeof children === \"string\" ? (\n {children}\n ) : (\n children\n )}\n >\n ))}\n \n )\n}\n\ninterface DropdownLabelProps extends TextProps {\n ref?: React.Ref\n}\n\nconst DropdownLabel = ({ className, ref, ...props }: DropdownLabelProps) => (\n \n)\n\ninterface DropdownDescriptionProps extends TextProps {\n ref?: React.Ref\n}\n\nconst DropdownDescription = ({\n className,\n ref,\n ...props\n}: DropdownDescriptionProps) => (\n \n)\n\nconst DropdownSeparator = ({ className, ...props }: SeparatorProps) => (\n \n)\n\ntype DropdownKeyboardProps = React.ComponentProps & {\n keys?: React.ReactNode\n}\n\nconst DropdownKeyboard = ({ className, ...props }: DropdownKeyboardProps) => {\n return (\n \n )\n}\n\n/**\n * Note: This is not exposed component, but it's used in other components to render dropdowns.\n * @internal\n */\nexport {\n DropdownDescription,\n DropdownItem,\n dropdownItemStyles,\n DropdownKeyboard,\n DropdownLabel,\n DropdownSection,\n dropdownSectionStyles,\n DropdownSeparator,\n}\nexport type {\n DropdownDescriptionProps,\n DropdownItemProps,\n DropdownLabelProps,\n DropdownSectionProps,\n}\n",
+ "content": "\"use client\"\n\nimport { CheckIcon } from \"lucide-react\"\nimport { composeRenderProps } from \"react-aria-components/composeRenderProps\"\nimport type {\n ListBoxItemProps,\n ListBoxSectionProps,\n TextProps,\n} from \"react-aria-components/ListBox\"\nimport {\n Collection,\n Header,\n ListBoxItem as ListBoxItemPrimitive,\n ListBoxSection,\n Text,\n} from \"react-aria-components/ListBox\"\nimport type { SeparatorProps } from \"react-aria-components/Separator\"\nimport { Separator } from \"react-aria-components/Separator\"\nimport { twJoin, twMerge } from \"tailwind-merge\"\nimport { tv } from \"tailwind-variants\"\n\nimport { Keyboard } from \"./keyboard\"\n\nconst dropdownSectionStyles = tv({\n slots: {\n section: \"col-span-full grid grid-cols-[auto_1fr]\",\n header:\n \"col-span-full px-3 py-2 text-sm/6 font-medium text-muted-foreground sm:px-2.5 sm:py-1.5 sm:text-xs/3\",\n },\n})\n\nconst { section, header } = dropdownSectionStyles()\n\ninterface DropdownSectionProps extends ListBoxSectionProps {\n title?: string\n}\n\nconst DropdownSection = ({\n className,\n children,\n ...props\n}: DropdownSectionProps) => {\n return (\n \n {\"title\" in props && }\n {children}\n \n )\n}\n\nconst dropdownItemStyles = tv({\n base: [\n \"min-w-0 [--mr-icon:--spacing(2.5)] sm:[--mr-icon:--spacing(2)]\",\n \"col-span-full grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] px-3 py-2 supports-[grid-template-columns:subgrid]:grid-cols-subgrid sm:px-2.5 sm:py-1.5\",\n \"not-has-[[slot=description]]:items-center\",\n \"group relative cursor-default rounded-[var(--dropdown-item-radius,calc(var(--radius-xl)-(--spacing(1))))] text-base/6 text-foreground outline-0 select-none sm:text-sm/6\",\n \"**:data-[slot=avatar]:mr-(--mr-icon) **:data-[slot=avatar]:[--avatar-size:--spacing(6)] **:data-[slot=avatar]:*:mr-(--mr-icon) sm:**:data-[slot=avatar]:[--avatar-size:--spacing(5)]\",\n \"*:data-[slot=icon]:mr-(--mr-icon) **:data-[slot=icon]:h-5 **:data-[slot=icon]:w-5 **:data-[slot=icon]:shrink-0 has-[[slot=description]]:**:data-[slot=icon]:h-[1lh] sm:**:data-[slot=icon]:h-4 sm:**:data-[slot=icon]:w-4 [&_[data-slot='icon']:not([class*='text-'])]:text-muted-foreground\",\n \"[&>[slot=label]+[data-slot=icon]]:absolute [&>[slot=label]+[data-slot=icon]]:right-1\",\n \"forced-color-adjust-none forced-colors:text-[CanvasText] forced-colors:**:data-[slot=icon]:text-[CanvasText] forced-colors:group-focus:**:data-[slot=icon]:text-[CanvasText]\",\n ],\n variants: {\n intent: {\n danger: [\n \"text-destructive focus:text-destructive [&_[data-slot='icon']:not([class*='text-'])]:text-destructive/70\",\n \"*:[[slot=description]]:text-destructive/80 focus:*:[[slot=description]]:text-destructive focus:*:[[slot=label]]:text-destructive\",\n \"focus:bg-destructive/10 focus:text-destructive forced-colors:focus:text-[Mark] focus:[&_[data-slot='icon']:not([class*='text-'])]:text-destructive\",\n ],\n warning: [\n \"text-warning focus:text-warning [&_[data-slot='icon']:not([class*='text-'])]:text-warning/70\",\n \"*:[[slot=description]]:text-warning/80 focus:*:[[slot=description]]:text-warning focus:*:[[slot=label]]:text-warning\",\n \"focus:bg-warning/10 focus:text-warning focus:[&_[data-slot='icon']:not([class*='text-'])]:text-warning\",\n ],\n },\n isDisabled: {\n true: \"opacity-50 forced-colors:text-[GrayText]\",\n },\n isSelected: {\n true: \"**:data-[slot=icon]:text-foreground\",\n },\n isFocused: {\n true: \"bg-accent text-accent-foreground forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]\",\n },\n isHovered: {\n true: \"bg-accent text-accent-foreground forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]\",\n },\n },\n})\n\ninterface DropdownItemProps extends ListBoxItemProps {\n intent?: \"danger\" | \"warning\"\n}\n\nconst DropdownItem = ({\n className,\n children,\n intent,\n ...props\n}: DropdownItemProps) => {\n const textValue = typeof children === \"string\" ? children : undefined\n return (\n \n dropdownItemStyles({ ...renderProps, intent, className })\n )}\n {...props}\n >\n {composeRenderProps(children, (children, { isSelected }) => (\n <>\n {isSelected && (\n \n )}\n {typeof children === \"string\" ? (\n {children}\n ) : (\n children\n )}\n >\n ))}\n \n )\n}\n\ninterface DropdownLabelProps extends TextProps {\n ref?: React.Ref\n}\n\nconst DropdownLabel = ({ className, ref, ...props }: DropdownLabelProps) => (\n \n)\n\ninterface DropdownDescriptionProps extends TextProps {\n ref?: React.Ref\n}\n\nconst DropdownDescription = ({\n className,\n ref,\n ...props\n}: DropdownDescriptionProps) => (\n \n)\n\nconst DropdownSeparator = ({ className, ...props }: SeparatorProps) => (\n \n)\n\ntype DropdownKeyboardProps = React.ComponentProps & {\n keys?: React.ReactNode\n}\n\nconst DropdownKeyboard = ({ className, ...props }: DropdownKeyboardProps) => {\n return (\n \n )\n}\n\n/**\n * Note: This is not exposed component, but it's used in other components to render dropdowns.\n * @internal\n */\nexport {\n DropdownDescription,\n DropdownItem,\n dropdownItemStyles,\n DropdownKeyboard,\n DropdownLabel,\n DropdownSection,\n dropdownSectionStyles,\n DropdownSeparator,\n}\nexport type {\n DropdownDescriptionProps,\n DropdownItemProps,\n DropdownLabelProps,\n DropdownSectionProps,\n}\n",
"type": "registry:ui"
}
],
diff --git a/apps/preskok/public/r/menu.json b/apps/preskok/public/r/menu.json
index 264bc3ba9..d37f52426 100644
--- a/apps/preskok/public/r/menu.json
+++ b/apps/preskok/public/r/menu.json
@@ -16,7 +16,7 @@
"files": [
{
"path": "registry/preskok/ui/preskok-ui/menu.tsx",
- "content": "\"use client\"\n\nimport { CheckIcon, ChevronRightIcon } from \"lucide-react\"\nimport type { ButtonProps } from \"react-aria-components/Button\"\nimport { Button } from \"react-aria-components/Button\"\nimport { composeRenderProps } from \"react-aria-components/composeRenderProps\"\nimport type {\n MenuItemProps as MenuItemPrimitiveProps,\n MenuProps as MenuPrimitiveProps,\n MenuSectionProps as MenuSectionPrimitiveProps,\n MenuTriggerProps as MenuTriggerPrimitiveProps,\n} from \"react-aria-components/Menu\"\nimport {\n Collection,\n Header,\n MenuItem as MenuItemPrimitive,\n Menu as MenuPrimitive,\n MenuSection as MenuSectionPrimitive,\n MenuTrigger as MenuTriggerPrimitive,\n SubmenuTrigger as SubmenuTriggerPrimitive,\n} from \"react-aria-components/Menu\"\nimport { twJoin, twMerge } from \"tailwind-merge\"\nimport { tv, type VariantProps } from \"tailwind-variants\"\n\nimport { cx } from \"@/lib/primitive\"\n\nimport {\n DropdownDescription,\n dropdownItemStyles,\n DropdownKeyboard,\n DropdownLabel,\n dropdownSectionStyles,\n DropdownSeparator,\n} from \"./dropdown\"\nimport { PopoverContent, type PopoverContentProps } from \"./popover\"\n\nconst Menu = (props: MenuTriggerPrimitiveProps) => (\n \n)\n\nconst MenuSubMenu = ({ delay = 0, ...props }) => (\n \n {props.children}\n \n)\n\ninterface MenuTriggerProps extends ButtonProps {\n ref?: React.Ref\n}\n\nconst MenuTrigger = ({ className, ref, ...props }: MenuTriggerProps) => (\n \n)\n\ninterface MenuContentProps\n extends MenuPrimitiveProps, Pick {\n className?: string\n popover?: Pick<\n PopoverContentProps,\n | \"arrow\"\n | \"className\"\n | \"placement\"\n | \"offset\"\n | \"crossOffset\"\n | \"arrowBoundaryOffset\"\n | \"triggerRef\"\n | \"isOpen\"\n | \"onOpenChange\"\n | \"shouldFlip\"\n >\n}\n\nconst menuContentStyles = tv({\n base: \"grid max-h-[inherit] grid-cols-[auto_1fr] gap-y-1 overflow-y-auto overflow-x-hidden overscroll-contain p-1 outline-hidden [clip-path:inset(0_0_0_0_round_calc(var(--radius-xl)-(--spacing(1))))] *:[[role='group']+[role=group]]:mt-3\",\n})\n\nconst MenuContent = ({\n className,\n placement,\n popover,\n ...props\n}: MenuContentProps) => {\n return (\n \n \n \n )\n}\n\ninterface MenuItemProps\n extends MenuItemPrimitiveProps, VariantProps {}\n\nconst MenuItem = ({ className, intent, children, ...props }: MenuItemProps) => {\n const textValue =\n props.textValue || (typeof children === \"string\" ? children : undefined)\n return (\n \n dropdownItemStyles({\n ...renderProps,\n intent,\n className: hasSubmenu\n ? twMerge(\n intent === \"danger\" &&\n \"open:bg-destructive/10 open:text-destructive\",\n intent === \"warning\" &&\n \"open:bg-warning/10 open:text-warning\",\n intent === undefined && \"open:bg-accent\",\n className\n )\n : className,\n })\n )}\n textValue={textValue}\n {...props}\n >\n {(values) => (\n <>\n {values.isSelected && (\n \n {values.selectionMode === \"single\" && (\n \n )}\n {values.selectionMode === \"multiple\" && (\n \n )}\n \n )}\n\n {typeof children === \"function\" ? children(values) : children}\n\n {values.hasSubmenu && (\n \n )}\n >\n )}\n \n )\n}\n\nexport interface MenuHeaderProps extends React.ComponentProps {\n separator?: boolean\n}\n\nconst MenuHeader = ({\n className,\n separator = false,\n ...props\n}: MenuHeaderProps) => (\n \n)\n\nconst { section, header } = dropdownSectionStyles()\n\ninterface MenuSectionProps extends MenuSectionPrimitiveProps {\n ref?: React.Ref\n label?: string\n}\n\nconst MenuSection = ({\n className,\n ref,\n ...props\n}: MenuSectionProps) => {\n return (\n \n {\"label\" in props && }\n {props.children}\n \n )\n}\n\nconst MenuSeparator = DropdownSeparator\nconst MenuShortcut = DropdownKeyboard\nconst MenuLabel = DropdownLabel\nconst MenuDescription = DropdownDescription\n\nexport {\n Menu,\n MenuContent,\n menuContentStyles,\n MenuDescription,\n MenuHeader,\n MenuItem,\n MenuLabel,\n MenuSection,\n MenuSeparator,\n MenuShortcut,\n MenuSubMenu,\n MenuTrigger,\n}\nexport type {\n MenuContentProps,\n MenuItemProps,\n MenuSectionProps,\n MenuTriggerProps,\n}\n",
+ "content": "\"use client\"\n\nimport { CheckIcon, ChevronRightIcon } from \"lucide-react\"\nimport type { ButtonProps } from \"react-aria-components/Button\"\nimport { Button } from \"react-aria-components/Button\"\nimport { composeRenderProps } from \"react-aria-components/composeRenderProps\"\nimport type {\n MenuItemProps as MenuItemPrimitiveProps,\n MenuProps as MenuPrimitiveProps,\n MenuSectionProps as MenuSectionPrimitiveProps,\n MenuTriggerProps as MenuTriggerPrimitiveProps,\n} from \"react-aria-components/Menu\"\nimport {\n Collection,\n Header,\n MenuItem as MenuItemPrimitive,\n Menu as MenuPrimitive,\n MenuSection as MenuSectionPrimitive,\n MenuTrigger as MenuTriggerPrimitive,\n SubmenuTrigger as SubmenuTriggerPrimitive,\n} from \"react-aria-components/Menu\"\nimport { twJoin, twMerge } from \"tailwind-merge\"\nimport { tv, type VariantProps } from \"tailwind-variants\"\n\nimport { cx } from \"@/lib/primitive\"\n\nimport {\n DropdownDescription,\n dropdownItemStyles,\n DropdownKeyboard,\n DropdownLabel,\n dropdownSectionStyles,\n DropdownSeparator,\n} from \"./dropdown\"\nimport { PopoverContent, type PopoverContentProps } from \"./popover\"\n\nconst Menu = (props: MenuTriggerPrimitiveProps) => (\n \n)\n\nconst MenuSubMenu = ({ delay = 0, ...props }) => (\n \n {props.children}\n \n)\n\ninterface MenuTriggerProps extends ButtonProps {\n ref?: React.Ref\n}\n\nconst MenuTrigger = ({ className, ref, ...props }: MenuTriggerProps) => (\n \n)\n\ninterface MenuContentProps\n extends MenuPrimitiveProps, Pick {\n className?: string\n popover?: Pick<\n PopoverContentProps,\n | \"arrow\"\n | \"className\"\n | \"placement\"\n | \"offset\"\n | \"crossOffset\"\n | \"arrowBoundaryOffset\"\n | \"triggerRef\"\n | \"isOpen\"\n | \"onOpenChange\"\n | \"shouldFlip\"\n >\n}\n\nconst menuContentStyles = tv({\n base: \"grid max-h-[inherit] grid-cols-[auto_1fr] gap-y-1 overflow-x-hidden overflow-y-auto overscroll-contain p-1 outline-hidden [clip-path:inset(0_0_0_0_round_var(--menu-content-radius))] *:[[role='group']+[role=group]]:mt-3\",\n})\n\nconst MenuContent = ({\n className,\n placement,\n popover,\n ...props\n}: MenuContentProps) => {\n const {\n className: popoverClassName,\n placement: popoverPlacement,\n ...popoverProps\n } = popover ?? {}\n\n return (\n \n \n \n )\n}\n\ninterface MenuItemProps\n extends MenuItemPrimitiveProps, VariantProps {}\n\nconst MenuItem = ({ className, intent, children, ...props }: MenuItemProps) => {\n const textValue =\n props.textValue || (typeof children === \"string\" ? children : undefined)\n return (\n \n dropdownItemStyles({\n ...renderProps,\n intent,\n className: hasSubmenu\n ? twMerge(\n intent === \"danger\" &&\n \"open:bg-destructive/10 open:text-destructive\",\n intent === \"warning\" &&\n \"open:bg-warning/10 open:text-warning\",\n intent === undefined && \"open:bg-accent\",\n className\n )\n : className,\n })\n )}\n textValue={textValue}\n {...props}\n >\n {(values) => (\n <>\n {values.isSelected && (\n \n {values.selectionMode === \"single\" && (\n \n )}\n {values.selectionMode === \"multiple\" && (\n \n )}\n \n )}\n\n {typeof children === \"function\" ? children(values) : children}\n\n {values.hasSubmenu && (\n \n )}\n >\n )}\n \n )\n}\n\nexport interface MenuHeaderProps extends React.ComponentProps {\n separator?: boolean\n}\n\nconst MenuHeader = ({\n className,\n separator = false,\n ...props\n}: MenuHeaderProps) => (\n \n)\n\nconst { section, header } = dropdownSectionStyles()\n\ninterface MenuSectionProps extends MenuSectionPrimitiveProps {\n ref?: React.Ref\n label?: string\n}\n\nconst MenuSection = ({\n className,\n ref,\n ...props\n}: MenuSectionProps) => {\n return (\n \n {\"label\" in props && }\n {props.children}\n \n )\n}\n\nconst MenuSeparator = DropdownSeparator\nconst MenuShortcut: typeof DropdownKeyboard = DropdownKeyboard\nconst MenuLabel = DropdownLabel\nconst MenuDescription = DropdownDescription\n\nexport {\n Menu,\n MenuContent,\n menuContentStyles,\n MenuDescription,\n MenuHeader,\n MenuItem,\n MenuLabel,\n MenuSection,\n MenuSeparator,\n MenuShortcut,\n MenuSubMenu,\n MenuTrigger,\n}\nexport type {\n MenuContentProps,\n MenuItemProps,\n MenuSectionProps,\n MenuTriggerProps,\n}\n",
"type": "registry:ui"
}
],
diff --git a/apps/preskok/registry/preskok/ui/preskok-ui/color-picker.tsx b/apps/preskok/registry/preskok/ui/preskok-ui/color-picker.tsx
index 4e1916056..bd8e9bd3a 100644
--- a/apps/preskok/registry/preskok/ui/preskok-ui/color-picker.tsx
+++ b/apps/preskok/registry/preskok/ui/preskok-ui/color-picker.tsx
@@ -16,6 +16,7 @@ import { ColorField } from "./color-field"
import { ColorSlider } from "./color-slider"
import { ColorSwatch } from "./color-swatch"
import { Description } from "./field"
+import { Input } from "./input"
import { Popover, PopoverContent, type PopoverContentProps } from "./popover"
interface ColorPickerProps
@@ -78,7 +79,9 @@ const ColorPicker = ({
/>
{eyeDropper && }
-
+
+
+
>
)}
diff --git a/apps/preskok/registry/preskok/ui/preskok-ui/date-picker.tsx b/apps/preskok/registry/preskok/ui/preskok-ui/date-picker.tsx
index fbc136bfb..5d10de81f 100644
--- a/apps/preskok/registry/preskok/ui/preskok-ui/date-picker.tsx
+++ b/apps/preskok/registry/preskok/ui/preskok-ui/date-picker.tsx
@@ -67,7 +67,7 @@ export function DatePickerOverlay({
return isMobile ? (
-
+
{range ? (
[slot=label]+[data-slot=icon]]:absolute [&>[slot=label]+[data-slot=icon]]:right-1",
diff --git a/apps/preskok/registry/preskok/ui/preskok-ui/menu.tsx b/apps/preskok/registry/preskok/ui/preskok-ui/menu.tsx
index 015caf976..2eaaaf692 100644
--- a/apps/preskok/registry/preskok/ui/preskok-ui/menu.tsx
+++ b/apps/preskok/registry/preskok/ui/preskok-ui/menu.tsx
@@ -80,7 +80,7 @@ interface MenuContentProps
}
const menuContentStyles = tv({
- base: "grid max-h-[inherit] grid-cols-[auto_1fr] gap-y-1 overflow-x-hidden overflow-y-auto overscroll-contain p-1 outline-hidden [clip-path:inset(0_0_0_0_round_calc(var(--radius-xl)-(--spacing(1))))] *:[[role='group']+[role=group]]:mt-3",
+ base: "grid max-h-[inherit] grid-cols-[auto_1fr] gap-y-1 overflow-x-hidden overflow-y-auto overscroll-contain p-1 outline-hidden [clip-path:inset(0_0_0_0_round_var(--menu-content-radius))] *:[[role='group']+[role=group]]:mt-3",
})
const MenuContent = ({
@@ -89,11 +89,23 @@ const MenuContent = ({
popover,
...props
}: MenuContentProps) => {
+ const {
+ className: popoverClassName,
+ placement: popoverPlacement,
+ ...popoverProps
+ } = popover ?? {}
+
return (
{
+ return check.mode === mode
+ })
+ assert.equal(checks.length, 10)
+ assert.equal(
+ checks.every((check) => Number.isFinite(check.apca)),
+ true,
+ `${mode} APCA checks must be numeric`
+ )
}
assert.equal(figma.color.light.danger, undefined)
@@ -98,9 +147,16 @@ function assertSelection(selection: ThemeSelection) {
assert.equal(rootVariables["radius-lg"], selection.radius)
assert.equal(rootVariables.radius, "var(--radius-lg)")
assert.equal(generateFigmaThemeJson(selection).includes("var(--"), false)
+ assert.match(css, /@supports \(color: color\(display-p3 1 1 1\)\)/)
}
assertSelection(DEFAULT_THEME_SELECTION)
+const defaultArtifacts = createThemeArtifacts(DEFAULT_THEME_SELECTION)
+assert.equal(defaultArtifacts.css, generateTheme(DEFAULT_THEME_SELECTION))
+assert.equal(
+ defaultArtifacts.figmaJson,
+ generateFigmaThemeJson(DEFAULT_THEME_SELECTION)
+)
const globalCss = readFileSync(
new URL("../styles/globals.css", import.meta.url),
@@ -124,22 +180,70 @@ assert.deepEqual(
"The theme bridge must cover the semantic contract in styles/globals.css"
)
-for (const color of Object.keys(colors)) {
+for (const radius of THEME_RADIUS_OPTIONS) {
+ assertSelection({ ...DEFAULT_THEME_SELECTION, radius })
+}
+
+for (const backgroundMode of THEME_BACKGROUND_MODES) {
assertSelection({
...DEFAULT_THEME_SELECTION,
- primary: color,
- accent: color,
+ light: { ...DEFAULT_THEME_SELECTION.light, backgroundMode },
+ dark: { ...DEFAULT_THEME_SELECTION.dark, backgroundMode },
})
}
-for (const gray of neutralColors) {
- assertSelection({ ...DEFAULT_THEME_SELECTION, gray })
-}
+assert.equal(
+ resolveThemeBackground("light", {
+ ...DEFAULT_THEME_SELECTION.light,
+ backgroundMode: "pure",
+ }),
+ "#ffffff"
+)
+assert.equal(
+ resolveThemeBackground("dark", {
+ ...DEFAULT_THEME_SELECTION.dark,
+ backgroundMode: "pure",
+ }),
+ "#09090b"
+)
+assert.equal(
+ resolveThemeBackground("light", {
+ ...DEFAULT_THEME_SELECTION.light,
+ backgroundMode: "custom",
+ customBackground: "#f3f4f6",
+ }),
+ "#f3f4f6"
+)
-for (const radius of THEME_RADIUS_OPTIONS) {
- assertSelection({ ...DEFAULT_THEME_SELECTION, radius })
+const colorSamples = [
+ "#000000",
+ "#ffffff",
+ "#ff006e",
+ "#7c3aed",
+ "#006adc",
+ "#00a2c7",
+ "#2e7d32",
+ "#ffba18",
+] as const
+for (const accent of colorSamples) {
+ assertSelection({
+ ...DEFAULT_THEME_SELECTION,
+ light: { ...DEFAULT_THEME_SELECTION.light, accent },
+ dark: { ...DEFAULT_THEME_SELECTION.dark, accent },
+ })
}
+const defaultChecks = createThemeContrastChecks(DEFAULT_THEME_SELECTION)
+assert.equal(defaultChecks.length, 20)
+assert.equal(
+ defaultChecks.every((check) => check.passes),
+ true,
+ `Default theme must pass normal-text contrast: ${defaultChecks
+ .filter((check) => !check.passes)
+ .map((check) => `${check.mode}/${check.label} ${check.wcag}:1`)
+ .join(", ")}`
+)
+
const figma = generateFigmaThemeTokens(DEFAULT_THEME_SELECTION)
assert.deepEqual(Object.keys(figma.color.light), [
...FIGMA_STYLE_COLOR_TOKEN_NAMES,
@@ -147,6 +251,8 @@ assert.deepEqual(Object.keys(figma.color.light), [
assert.deepEqual(Object.keys(figma.color.dark), [
...FIGMA_STYLE_COLOR_TOKEN_NAMES,
])
+assert.equal(Object.keys(figma.primitive.color.light.accent).length, 12)
+assert.equal(Object.keys(figma.primitive.color.dark.gray).length, 12)
assert.deepEqual(
Object.fromEntries(
Object.entries(figma.radius).map(([name, token]) => [
@@ -178,6 +284,7 @@ assert.equal(
)
const manifestJson = generateThemeManifestJson(DEFAULT_THEME_SELECTION)
+assert.equal(THEME_MANIFEST_VERSION, 3)
assert.deepEqual(
parseThemeManifestJson(manifestJson).selection,
DEFAULT_THEME_SELECTION
@@ -187,16 +294,61 @@ assert.equal(
manifestJson,
"Manifest generation must be deterministic"
)
+
+const migrated = parseThemeManifestJson(
+ JSON.stringify({
+ schemaVersion: 1,
+ selection: {
+ primary: "blue",
+ gray: "zinc",
+ accent: "violet",
+ radius: "0.75rem",
+ },
+ })
+)
+assert.equal(migrated.schemaVersion, 3)
+assert.equal(migrated.selection.light.accent, "#155dfc")
+assert.equal(migrated.selection.dark.accent, "#155dfc")
+assert.equal(migrated.selection.light.backgroundMode, "pure")
+assert.equal(migrated.selection.dark.backgroundMode, "pure")
+assert.equal(migrated.selection.grayMode, "custom")
+assert.equal(migrated.selection.radius, "0.75rem")
+
+const migratedVersionTwo = parseThemeManifestJson(
+ JSON.stringify({
+ schemaVersion: 2,
+ selection: {
+ light: {
+ accent: "#2563eb",
+ gray: "#737b8a",
+ background: "#fff7ed",
+ },
+ dark: {
+ accent: "#3b82f6",
+ gray: "#737b88",
+ background: "#18181b",
+ },
+ grayMode: "auto",
+ radius: "0.5rem",
+ },
+ })
+)
+assert.equal(migratedVersionTwo.schemaVersion, 3)
+assert.equal(migratedVersionTwo.selection.light.backgroundMode, "custom")
+assert.equal(migratedVersionTwo.selection.light.customBackground, "#fff7ed")
+assert.equal(migratedVersionTwo.selection.dark.backgroundMode, "custom")
+assert.equal(migratedVersionTwo.selection.dark.customBackground, "#18181b")
+
assert.throws(
- () => parseThemeManifestJson('{"schemaVersion":2,"selection":{}}'),
+ () => parseThemeManifestJson('{"schemaVersion":4,"selection":{}}'),
/unsupported schema version/
)
assert.throws(
() =>
parseThemeManifestJson(
- '{"schemaVersion":1,"selection":{"primary":"made-up","gray":"zinc","accent":"blue","radius":"0.5rem"}}'
+ '{"schemaVersion":3,"selection":{"light":{"accent":"red"}}}'
),
- /primary color is not supported/
+ /six-digit hex color/
)
assert.throws(
() =>
@@ -207,5 +359,5 @@ assert.throws(
)
console.log(
- `Theme bridge checks passed for ${Object.keys(colors).length} color families, ${neutralColors.length} neutrals, ${THEME_RADIUS_OPTIONS.length} radii, and ${THEME_COLOR_TOKEN_NAMES.length} semantic tokens.`
+ `Theme V3 checks passed for ${colorSamples.length} source colors, ${THEME_BACKGROUND_MODES.length} background treatments, ${THEME_RADIUS_OPTIONS.length} radii, ${THEME_PRIMITIVE_STEPS.length} primitive steps per scale, ${THEME_COLOR_TOKEN_NAMES.length} semantic tokens, and ${defaultChecks.length} contrast pairs.`
)
diff --git a/apps/preskok/styles/globals.css b/apps/preskok/styles/globals.css
index 1a36613df..f9bf44298 100644
--- a/apps/preskok/styles/globals.css
+++ b/apps/preskok/styles/globals.css
@@ -96,6 +96,14 @@
--color-surface: var(--surface);
--color-surface-foreground: var(--surface-foreground);
+ --color-panel: var(--panel);
+ --color-panel-foreground: var(--panel-foreground);
+ --color-panel-solid: var(--panel-solid);
+ --color-panel-solid-foreground: var(--panel-solid-foreground);
+ --color-accent-surface: var(--accent-surface);
+ --color-accent-indicator: var(--accent-indicator);
+ --color-accent-track: var(--accent-track);
+ --color-scrim: var(--scrim);
--color-code: var(--code);
--color-code-foreground: var(--code-foreground);
--color-code-highlight: var(--code-highlight);
@@ -195,6 +203,14 @@
--surface: oklch(0.98 0 0);
--surface-foreground: var(--foreground);
+ --panel: var(--card);
+ --panel-foreground: var(--card-foreground);
+ --panel-solid: var(--card);
+ --panel-solid-foreground: var(--card-foreground);
+ --accent-surface: oklch(0.932 0.032 255.585 / 80%);
+ --accent-indicator: var(--primary);
+ --accent-track: oklch(0.809 0.105 251.813);
+ --scrim: oklch(0 0 0 / 50%);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.96 0 0);
@@ -273,6 +289,14 @@
--surface: oklch(0.2 0 0);
--surface-foreground: oklch(0.708 0 0);
+ --panel: var(--card);
+ --panel-foreground: var(--card-foreground);
+ --panel-solid: var(--card);
+ --panel-solid-foreground: var(--card-foreground);
+ --accent-surface: oklch(0.269 0.007 34.298 / 50%);
+ --accent-indicator: var(--primary);
+ --accent-track: oklch(0.374 0.137 265.522);
+ --scrim: oklch(0 0 0 / 60%);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.27 0 0);
@@ -343,7 +367,7 @@
}
}
-@variant dark (&:is(.dark *));
+@variant dark (&:is(.dark *):not(.light, .light *));
@variant fixed (&:is(.layout-fixed *));
@utility border-grid {
@@ -351,7 +375,7 @@
}
@utility section-soft {
- @apply from-background to-surface/40 dark:bg-background 3xl:fixed:bg-none bg-gradient-to-b;
+ @apply bg-gradient-to-b from-background to-surface/40 dark:bg-background 3xl:fixed:bg-none;
}
@utility theme-container {
@@ -359,11 +383,11 @@
}
@utility container-wrapper {
- @apply 3xl:fixed:max-w-[calc(var(--breakpoint-2xl)+2rem)] mx-auto w-full px-2;
+ @apply mx-auto w-full px-2 3xl:fixed:max-w-[calc(var(--breakpoint-2xl)+2rem)];
}
@utility container {
- @apply 3xl:max-w-screen-2xl mx-auto max-w-[1400px] px-4 lg:px-8;
+ @apply mx-auto max-w-[1400px] px-4 3xl:max-w-screen-2xl lg:px-8;
}
@utility no-scrollbar {
@@ -376,7 +400,7 @@
}
@utility border-ghost {
- @apply after:border-border relative after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten;
+ @apply relative after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten;
}
@utility step {
@@ -384,7 +408,7 @@
@apply relative;
&:before {
- @apply text-muted-foreground right-0 mr-2 hidden size-7 items-center justify-center rounded-full text-center -indent-px font-mono text-sm font-medium md:absolute;
+ @apply right-0 mr-2 hidden size-7 items-center justify-center rounded-full text-center -indent-px font-mono text-sm font-medium text-muted-foreground md:absolute;
content: counter(step);
}
}
@@ -597,8 +621,8 @@ html.dark .shiki span {
reset, which our spacing rules above would otherwise re-introduce. */
.prose
:where(h1, h2, h3, h4, hr):not(
- :where([class~="not-prose"], [class~="not-prose"] *)
- )
+ :where([class~="not-prose"], [class~="not-prose"] *)
+ )
+ * {
margin-top: 0;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 854ab3cca..765b06554 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
'@internationalized/date':
specifier: ^3.12.3
version: 3.12.3
+ '@radix-ui/colors':
+ specifier: 3.0.0
+ version: 3.0.0
'@tabler/icons-react':
specifier: ^3.44.0
version: 3.44.0(react@19.2.8)
@@ -68,12 +71,18 @@ importers:
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1(next@16.3.1(@babel/core@7.29.7)(@types/node@26.0.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
+ bezier-easing:
+ specifier: ^2.1.0
+ version: 2.1.0
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ colorjs.io:
+ specifier: 0.5.2
+ version: 0.5.2
culori:
specifier: ^4.0.2
version: 4.0.2
@@ -168,6 +177,9 @@ importers:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
+ '@types/bezier-easing':
+ specifier: ^2.1.6
+ version: 2.1.6
'@types/d3-shape':
specifier: 3.1.8
version: 3.1.8
@@ -1095,6 +1107,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@radix-ui/colors@3.0.0':
+ resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==}
+
'@radix-ui/number@1.1.3':
resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==}
@@ -2100,6 +2115,10 @@ packages:
cpu: [arm64]
os: [win32]
+ '@types/bezier-easing@2.1.6':
+ resolution: {integrity: sha512-ntD54njPbI4ZUe+Fh+o2ezwqVsOkS61QQdBP2a8hIRhr2m33bkD4GpTPsyJlFg+bB4gG69FVKgKqm+8KKZ00WA==}
+ deprecated: This is a stub types definition. bezier-easing provides its own type definitions, so you do not need this installed.
+
'@types/culori@4.0.1':
resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==}
@@ -2433,6 +2452,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ bezier-easing@2.1.0:
+ resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==}
+
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
@@ -2541,6 +2563,9 @@ packages:
collapse-white-space@2.1.0:
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
+ colorjs.io@0.5.2:
+ resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
+
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -5587,6 +5612,8 @@ snapshots:
'@oxlint/binding-win32-x64-msvc@1.78.0':
optional: true
+ '@radix-ui/colors@3.0.0': {}
+
'@radix-ui/number@1.1.3': {}
'@radix-ui/primitive@1.1.4': {}
@@ -6537,6 +6564,10 @@ snapshots:
'@turbo/windows-arm64@2.9.18':
optional: true
+ '@types/bezier-easing@2.1.6':
+ dependencies:
+ bezier-easing: 2.1.0
+
'@types/culori@4.0.1': {}
'@types/d3-array@3.2.2': {}
@@ -6780,6 +6811,8 @@ snapshots:
baseline-browser-mapping@2.10.38: {}
+ bezier-easing@2.1.0: {}
+
binary-extensions@2.3.0: {}
body-parser@2.3.0:
@@ -6894,6 +6927,8 @@ snapshots:
collapse-white-space@2.1.0: {}
+ colorjs.io@0.5.2: {}
+
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}