From ebd6f6d41413bd3e2e181e17bbcb9d9f6845fcad Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Tue, 18 Aug 2026 19:05:27 +0200 Subject: [PATCH 01/16] Expand theme editor color system --- .../components/theme/generated-theme.tsx | 212 ++++- apps/preskok/components/theme/palette.ts | 649 +++++++++++++ .../components/theme/theme-container.tsx | 27 +- .../components/theme/theme-customizer.tsx | 365 +++++--- apps/preskok/components/theme/themes.ts | 863 +++++++++++------- apps/preskok/content/docs/theme.mdx | 28 +- apps/preskok/package.json | 4 + .../preskok/ui/preskok-ui/color-picker.tsx | 5 +- apps/preskok/scripts/check-theme-bridge.mts | 121 ++- apps/preskok/styles/globals.css | 32 + pnpm-lock.yaml | 35 + 11 files changed, 1789 insertions(+), 552 deletions(-) create mode 100644 apps/preskok/components/theme/palette.ts diff --git a/apps/preskok/components/theme/generated-theme.tsx b/apps/preskok/components/theme/generated-theme.tsx index f74192664..944a3eaad 100644 --- a/apps/preskok/components/theme/generated-theme.tsx +++ b/apps/preskok/components/theme/generated-theme.tsx @@ -1,70 +1,192 @@ -import type React from "react" +import { CheckCircle2Icon, CircleAlertIcon } from "lucide-react" import { twMerge } from "tailwind-merge" -import type { ThemeColorTokenName } from "./themes" - -const TOKEN_GROUPS: Array> = [ - ["background", "foreground"], - ["primary", "primary-foreground"], - ["secondary", "secondary-foreground"], - ["accent", "accent-foreground"], - ["muted", "muted-foreground"], - ["overlay", "overlay-foreground"], - ["card", "card-foreground"], - ["popover", "popover-foreground"], - ["success", "success-foreground"], - ["warning", "warning-foreground"], - ["danger", "danger-foreground"], - ["destructive", "destructive-foreground"], - ["border", "input", "ring"], - ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"], - ["navbar", "navbar-foreground"], - ["sidebar", "sidebar-foreground"], - ["sidebar-primary", "sidebar-primary-foreground"], - ["sidebar-accent", "sidebar-accent-foreground"], - ["sidebar-border", "sidebar-ring"], - ["surface", "surface-foreground"], - ["code", "code-foreground", "code-highlight", "code-number"], - ["selection", "selection-foreground"], -] +import { + THEME_PRIMITIVE_STEPS, + type ThemeContrastCheck, + type ResolvedTheme, +} from "./themes" export function GeneratedTheme({ + theme, + checks, className, - ...props -}: React.ComponentProps<"div">) { +}: { + theme: ResolvedTheme + checks: ThemeContrastCheck[] + className?: string +}) { + const passingChecks = checks.filter((check) => check.passes).length + return (
-
- {TOKEN_GROUPS.map((variables) => ( - - {variables.map((variable) => ( - +
+
+
+

Generated scales

+

+ Steps follow Radix usage roles from canvas through text. +

+
+ + 2 × 12 steps + +
+ +
+ + +
+ +
+
+
+

Text contrast

+

+ WCAG ratios are gated at 4.5:1; APCA is reported as additional + guidance. +

+
+ + {passingChecks === checks.length ? ( + + ) : ( + + )} + {passingChecks}/{checks.length} pass + +
+
+ {checks.map((check) => ( + ))} - +
+
+
+
+ ) +} + +function PalettePreview({ + label, + background, + foreground, + accent, + gray, + panel, + surface, +}: { + label: string + background: string + foreground: string + accent: readonly string[] + gray: readonly string[] + panel: string + surface: string +}) { + return ( +
+
+ {label} + {background} +
+ + +
+ + + +
+
+ ) +} + +function ScalePreview({ + label, + colors, +}: { + label: string + colors: readonly string[] +}) { + return ( +
+ {label} +
+ {colors.map((color, index) => ( + ))}
+
+ Canvas + UI + Text +
) } -function ColorBox(props: React.ComponentProps<"div">) { - return
+function SurfacePreview({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ) } -function ColorBoxItem({ variable }: { variable: ThemeColorTokenName }) { +function ContrastRow({ check }: { check: ThemeContrastCheck }) { return ( -
-
+ - --{variable} + + {check.mode} · {check.label} + + + {check.wcag}:1 · Lc {check.apca} +
) } diff --git a/apps/preskok/components/theme/palette.ts b/apps/preskok/components/theme/palette.ts new file mode 100644 index 000000000..1dde65a2d --- /dev/null +++ b/apps/preskok/components/theme/palette.ts @@ -0,0 +1,649 @@ +import * as RadixColors from "@radix-ui/colors" +import BezierEasing from "bezier-easing" +import Color from "colorjs.io" + +export type ThemeAppearance = "light" | "dark" +export type Scale12 = [T, T, T, T, T, T, T, T, T, T, T, T] + +export type GeneratedPalette = { + accent: Scale12 + accentAlpha: Scale12 + accentWideGamut: Scale12 + accentAlphaWideGamut: Scale12 + accentContrast: string + accentSurface: string + accentSurfaceWideGamut: string + background: string + gray: Scale12 + grayAlpha: Scale12 + grayWideGamut: Scale12 + grayAlphaWideGamut: Scale12 + graySurface: string + graySurfaceWideGamut: string +} + +const STEPS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const +const GRAY_SCALE_NAMES = [ + "gray", + "mauve", + "slate", + "sage", + "olive", + "sand", +] as const +const SCALE_NAMES = [ + ...GRAY_SCALE_NAMES, + "tomato", + "red", + "ruby", + "crimson", + "pink", + "plum", + "purple", + "violet", + "iris", + "indigo", + "blue", + "cyan", + "teal", + "jade", + "green", + "grass", + "brown", + "orange", + "sky", + "mint", + "lime", + "yellow", + "amber", +] as const + +type ScaleName = (typeof SCALE_NAMES)[number] +type GrayScaleName = (typeof GRAY_SCALE_NAMES)[number] +type ColorScale = Scale12 + +const RADIX_COLORS = RadixColors as unknown as Record< + string, + Record +> + +function createReferenceScales( + names: readonly string[], + suffix: string +): Record { + return Object.fromEntries( + names.map((name) => { + const source = RADIX_COLORS[`${name}${suffix}`] + if (!source) { + throw new Error(`Missing Radix color scale: ${name}${suffix}`) + } + + const values = Object.values(source).map((value) => { + return new Color(value).to("oklch") + }) as ColorScale + + return [name, values] + }) + ) +} + +const LIGHT_COLORS = createReferenceScales(SCALE_NAMES, "P3") as Record< + ScaleName, + ColorScale +> +const DARK_COLORS = createReferenceScales(SCALE_NAMES, "DarkP3") as Record< + ScaleName, + ColorScale +> +const LIGHT_GRAY_COLORS = createReferenceScales( + GRAY_SCALE_NAMES, + "P3" +) as Record +const DARK_GRAY_COLORS = createReferenceScales( + GRAY_SCALE_NAMES, + "DarkP3" +) as Record + +const DARK_MODE_EASING = [1, 0, 1, 0] as const +const LIGHT_MODE_EASING = [0, 2, 0, 2] as const + +/** + * Adapted from the MIT-licensed Radix Themes custom palette generator. + * Reference scale geometry is preserved while hue, chroma, and canvas are + * replaced by the exact project colors selected in the editor. + */ +export function generatePalette({ + appearance, + accent, + gray, + background, +}: { + appearance: ThemeAppearance + accent: string + gray: string + background: string +}): GeneratedPalette { + const allScales = appearance === "light" ? LIGHT_COLORS : DARK_COLORS + const grayScales = + appearance === "light" ? LIGHT_GRAY_COLORS : DARK_GRAY_COLORS + const backgroundColor = new Color(background).to("oklch") + const grayBaseColor = new Color(gray).to("oklch") + const grayScale = getScaleFromColor( + grayBaseColor, + grayScales, + backgroundColor + ) + const accentBaseColor = new Color(accent).to("oklch") + let accentScale = getScaleFromColor( + accentBaseColor, + allScales, + backgroundColor + ) + const backgroundHex = toHex(backgroundColor) + const accentBaseHex = toHex(accentBaseColor) + + if (accentBaseHex === "#000000" || accentBaseHex === "#ffffff") { + accentScale = grayScale.map((color) => color.clone()) as ColorScale + } + + const [accent9, accentContrast] = getStep9Colors(accentScale, accentBaseColor) + accentScale[8] = accent9 + accentScale[9] = getButtonHoverColor(accent9, [accentScale]) + + limitTextChroma(accentScale, 10) + limitTextChroma(accentScale, 11) + + const accentHex = accentScale.map(toHex) as Scale12 + const accentWideGamut = accentScale.map(toOklchString) as Scale12 + const accentAlpha = accentHex.map((color) => { + return getAlphaColorSrgb(color, backgroundHex) + }) as Scale12 + const accentAlphaWideGamut = accentWideGamut.map((color) => { + return getAlphaColorP3(color, backgroundHex) + }) as Scale12 + const grayHex = grayScale.map(toHex) as Scale12 + const grayWideGamut = grayScale.map(toOklchString) as Scale12 + const grayAlpha = grayHex.map((color) => { + return getAlphaColorSrgb(color, backgroundHex) + }) as Scale12 + const grayAlphaWideGamut = grayWideGamut.map((color) => { + return getAlphaColorP3(color, backgroundHex) + }) as Scale12 + const accentSurfaceAlpha = appearance === "light" ? 0.8 : 0.5 + let graySurface = "#ffffffcc" + let graySurfaceWideGamut = "color(display-p3 1 1 1 / 80%)" + + if (appearance === "dark") { + graySurface = "#0000000d" + graySurfaceWideGamut = "color(display-p3 0 0 0 / 5%)" + } + + return { + accent: accentHex, + accentAlpha, + accentWideGamut, + accentAlphaWideGamut, + accentContrast: toHex(accentContrast), + accentSurface: getAlphaColorSrgb( + accentHex[1], + backgroundHex, + accentSurfaceAlpha + ), + accentSurfaceWideGamut: getAlphaColorP3( + accentWideGamut[1], + backgroundHex, + accentSurfaceAlpha + ), + background: backgroundHex, + gray: grayHex, + grayAlpha, + grayWideGamut, + grayAlphaWideGamut, + graySurface, + graySurfaceWideGamut, + } +} + +export function deriveGraySource(accent: string) { + const source = new Color(accent).to("oklch") + const hue = Number.isNaN(source.coords[2]) ? 0 : source.coords[2] + const chroma = Math.min(0.025, Math.max(0.006, source.coords[1] * 0.12)) + return toHex(new Color("oklch", [0.58, chroma, hue])) +} + +function limitTextChroma(scale: ColorScale, index: 10 | 11) { + const minimum = Math.max(scale[8].coords[1], scale[7].coords[1]) + scale[index].coords[1] = Math.min(minimum, scale[index].coords[1]) +} + +function getStep9Colors( + scale: ColorScale, + accentBaseColor: Color +): [Color, Color] { + const distance = accentBaseColor.deltaEOK(scale[0]) * 100 + if (distance < 25) { + return [scale[8], getTextColor(scale[8])] + } + + return [accentBaseColor, getTextColor(accentBaseColor)] +} + +function getTextColor(background: Color) { + const white = new Color("oklch", [1, 0, 0]) + if (Math.abs(white.contrastAPCA(background)) < 40) { + const [, chroma, hue] = background.coords + const safeHue = Number.isNaN(hue) ? 0 : hue + return new Color("oklch", [0.25, Math.max(0.08 * chroma, 0.04), safeHue]) + } + + return white +} + +function getButtonHoverColor(source: Color, scales: ColorScale[]) { + const [lightness, chroma, hue] = source.coords + let nextLightness = lightness + 0.03 / (lightness + 0.1) + let nextChroma = chroma + + if (lightness > 0.4) { + nextLightness = lightness - 0.03 / (lightness + 0.1) + if (!Number.isNaN(hue)) { + nextChroma = chroma * 0.93 + } + } + + const hover = new Color("oklch", [nextLightness, nextChroma, hue]) + let closest = hover + let minimumDistance = Number.POSITIVE_INFINITY + + for (const scale of scales) { + for (const color of scale) { + const distance = hover.deltaEOK(color) + if (distance < minimumDistance) { + minimumDistance = distance + closest = color + } + } + } + + hover.coords[1] = closest.coords[1] + hover.coords[2] = closest.coords[2] + return hover +} + +function getScaleFromColor( + source: Color, + scales: Record, + background: Color +) { + const allColors: Array<{ + scale: string + color: Color + distance: number + }> = [] + + for (const [name, scale] of Object.entries(scales)) { + for (const color of scale) { + allColors.push({ + scale: name, + color, + distance: source.deltaEOK(color), + }) + } + } + + allColors.sort((a, b) => a.distance - b.distance) + const closest = allColors.filter((color, index, values) => { + return index === values.findIndex((value) => value.scale === color.scale) + }) + const grayNames = GRAY_SCALE_NAMES as readonly string[] + const onlyGrays = closest.every((color) => grayNames.includes(color.scale)) + + if (!onlyGrays && grayNames.includes(closest[0].scale)) { + while (closest[1] && grayNames.includes(closest[1].scale)) { + closest.splice(1, 1) + } + } + + const colorA = closest[0] + const colorB = closest[1] + if (!colorA || !colorB) { + throw new Error("Could not find reference colors for the palette.") + } + + const a = colorB.distance + const b = colorA.distance + const c = colorA.color.deltaEOK(colorB.color) + const ratio = getMixRatio(a, b, c) + const scaleA = scales[colorA.scale] + const scaleB = scales[colorB.scale] + if (!scaleA || !scaleB) { + throw new Error("Could not resolve reference scales for the palette.") + } + + const scale = STEPS.map((index) => { + return new Color(Color.mix(scaleA[index], scaleB[index], ratio)).to("oklch") + }) as ColorScale + const baseColor = scale.toSorted((first, second) => { + return source.deltaEOK(first) - source.deltaEOK(second) + })[0] + const baseChroma = Math.max(baseColor.coords[1], 0.000_001) + const chromaRatio = source.coords[1] / baseChroma + + for (const color of scale) { + color.coords[1] = Math.min( + source.coords[1] * 1.5, + color.coords[1] * chromaRatio + ) + color.coords[2] = source.coords[2] + } + + if (scale[0].coords[0] > 0.5) { + transposeLightScale(scale, background) + return scale + } + + transposeDarkScale(scale, background) + return scale +} + +function getMixRatio(a: number, b: number, c: number) { + if (a === 0 || b === 0 || c === 0) { + return 0 + } + + const cosA = clampUnit((b ** 2 + c ** 2 - a ** 2) / (2 * b * c)) + const cosB = clampUnit((a ** 2 + c ** 2 - b ** 2) / (2 * a * c)) + const sinA = Math.sin(Math.acos(cosA)) + const sinB = Math.sin(Math.acos(cosB)) + if (sinA === 0 || sinB === 0) { + return 0 + } + + const tangentA = cosA / sinA + const tangentB = cosB / sinB + if (!Number.isFinite(tangentA) || !Number.isFinite(tangentB)) { + return 0 + } + + return Math.min(1, Math.max(0, tangentA / tangentB) * 0.5) +} + +function transposeLightScale(scale: ColorScale, background: Color) { + const lightness = scale.map((color) => color.coords[0]) + const backgroundLightness = clampUnit(background.coords[0]) + const next = transposeProgressionStart( + backgroundLightness, + [1, ...lightness], + [...LIGHT_MODE_EASING] + ) + next.shift() + next.forEach((value, index) => { + scale[index].coords[0] = value + }) +} + +function transposeDarkScale(scale: ColorScale, background: Color) { + const easing: [number, number, number, number] = [...DARK_MODE_EASING] + const referenceLightness = scale[0].coords[0] + const backgroundLightness = clampUnit(background.coords[0]) + const ratio = backgroundLightness / referenceLightness + + if (ratio > 1) { + const maximumRatio = 1.5 + for (let index = 0; index < easing.length; index += 1) { + const metaRatio = (ratio - 1) * (maximumRatio / (maximumRatio - 1)) + easing[index] = + ratio > maximumRatio ? 0 : Math.max(0, easing[index] * (1 - metaRatio)) + } + } + + const lightness = scale.map((color) => color.coords[0]) + const next = transposeProgressionStart( + background.coords[0], + lightness, + easing + ) + next.forEach((value, index) => { + scale[index].coords[0] = value + }) +} + +function transposeProgressionStart( + destination: number, + values: number[], + curve: [number, number, number, number] +) { + const easing = BezierEasing(...curve) + const difference = values[0] - destination + const lastIndex = values.length - 1 + return values.map((value, index) => { + return value - difference * easing(1 - index / lastIndex) + }) +} + +function getAlphaColorSrgb( + targetColor: string, + backgroundColor: string, + targetAlpha?: number +) { + const values = getAlphaColor( + new Color(targetColor).to("srgb").coords, + new Color(backgroundColor).to("srgb").coords, + 255, + 255, + targetAlpha + ) + const coordinates: [number, number, number] = [ + values[0], + values[1], + values[2], + ] + return formatHex( + new Color("srgb", coordinates, values[3]).toString({ + format: "hex", + }) + ) +} + +function getAlphaColorP3( + targetColor: string, + backgroundColor: string, + targetAlpha?: number +) { + const values = getAlphaColor( + new Color(targetColor).to("p3").coords, + new Color(backgroundColor).to("p3").coords, + 255, + 1000, + targetAlpha + ) + const coordinates: [number, number, number] = [ + values[0], + values[1], + values[2], + ] + return new Color("p3", coordinates, values[3]) + .toString({ precision: 4 }) + .replace("color(p3 ", "color(display-p3 ") +} + +function getAlphaColor( + targetRgb: number[], + backgroundRgb: number[], + rgbPrecision: number, + alphaPrecision: number, + targetAlpha?: number +): [number, number, number, number] { + const [targetRed, targetGreen, targetBlue] = targetRgb.map((channel) => { + return Math.round(channel * rgbPrecision) + }) + const [backgroundRed, backgroundGreen, backgroundBlue] = backgroundRgb.map( + (channel) => Math.round(channel * rgbPrecision) + ) + const channels = [ + targetRed, + targetGreen, + targetBlue, + backgroundRed, + backgroundGreen, + backgroundBlue, + ] + if (channels.some((channel) => channel === undefined)) { + throw new Error("Color channel is undefined.") + } + + let desiredRgb = 0 + if ( + targetRed > backgroundRed || + targetGreen > backgroundGreen || + targetBlue > backgroundBlue + ) { + desiredRgb = rgbPrecision + } + + const alphaRed = (targetRed - backgroundRed) / (desiredRgb - backgroundRed) + const alphaGreen = + (targetGreen - backgroundGreen) / (desiredRgb - backgroundGreen) + const alphaBlue = + (targetBlue - backgroundBlue) / (desiredRgb - backgroundBlue) + const alphas = [alphaRed, alphaGreen, alphaBlue] + const isPureGray = alphas.every((alpha) => alpha === alphaRed) + + if (targetAlpha === undefined && isPureGray) { + const value = desiredRgb / rgbPrecision + return [value, value, value, alphaRed] + } + + const maximumAlpha = targetAlpha ?? Math.max(alphaRed, alphaGreen, alphaBlue) + const alpha = + clampPrecision(maximumAlpha * alphaPrecision, alphaPrecision, true) / + alphaPrecision + let red = calculateAlphaChannel(backgroundRed, targetRed, alpha, rgbPrecision) + let green = calculateAlphaChannel( + backgroundGreen, + targetGreen, + alpha, + rgbPrecision + ) + let blue = calculateAlphaChannel( + backgroundBlue, + targetBlue, + alpha, + rgbPrecision + ) + + const blendedRed = blendAlpha(red, alpha, backgroundRed) + const blendedGreen = blendAlpha(green, alpha, backgroundGreen) + const blendedBlue = blendAlpha(blue, alpha, backgroundBlue) + + if (desiredRgb === 0) { + red = correctAlphaRounding(targetRed, backgroundRed, blendedRed, red, false) + green = correctAlphaRounding( + targetGreen, + backgroundGreen, + blendedGreen, + green, + false + ) + blue = correctAlphaRounding( + targetBlue, + backgroundBlue, + blendedBlue, + blue, + false + ) + } else { + red = correctAlphaRounding(targetRed, backgroundRed, blendedRed, red, true) + green = correctAlphaRounding( + targetGreen, + backgroundGreen, + blendedGreen, + green, + true + ) + blue = correctAlphaRounding( + targetBlue, + backgroundBlue, + blendedBlue, + blue, + true + ) + } + + return [red / rgbPrecision, green / rgbPrecision, blue / rgbPrecision, alpha] +} + +function calculateAlphaChannel( + background: number, + target: number, + alpha: number, + precision: number +) { + if (alpha === 0) { + return 0 + } + + return Math.ceil( + clampPrecision( + ((background * (1 - alpha) - target) / alpha) * -1, + precision + ) + ) +} + +function correctAlphaRounding( + target: number, + background: number, + blended: number, + channel: number, + lighten: boolean +) { + const isEligible = lighten ? target >= background : target <= background + if (!isEligible || target === blended) { + return channel + } + + return target > blended ? channel + 1 : channel - 1 +} + +function clampPrecision(value: number, maximum: number, roundUp = false) { + if (Number.isNaN(value)) { + return 0 + } + + const clamped = Math.min(maximum, Math.max(0, value)) + return roundUp ? Math.ceil(clamped) : clamped +} + +function blendAlpha(foreground: number, alpha: number, background: number) { + return Math.round(background * (1 - alpha)) + Math.round(foreground * alpha) +} + +function formatHex(value: string) { + if (!value.startsWith("#")) { + return value + } + + if (value.length === 4 || value.length === 5) { + const characters = [...value.slice(1)] + return `#${characters.map((character) => character.repeat(2)).join("")}` + } + + return value +} + +function toHex(color: Color) { + return formatHex(color.to("srgb").toString({ format: "hex" })).toLowerCase() +} + +function toOklchString(color: Color) { + const lightness = Number((color.coords[0] * 100).toFixed(1)) + return color + .to("oklch") + .toString({ precision: 4 }) + .replace(/(\S+)(.+)/, `oklch(${lightness}%$2`) +} + +function clampUnit(value: number) { + return Math.min(1, Math.max(-1, value)) +} diff --git a/apps/preskok/components/theme/theme-container.tsx b/apps/preskok/components/theme/theme-container.tsx index a073b7136..d960c677e 100644 --- a/apps/preskok/components/theme/theme-container.tsx +++ b/apps/preskok/components/theme/theme-container.tsx @@ -42,10 +42,8 @@ import { } from "@/registry/preskok/ui/preskok-ui/tabs" import { + createThemeArtifacts, DEFAULT_THEME_SELECTION, - generateFigmaThemeJson, - generateTheme, - generateThemeManifestJson, parseThemeManifestJson, type ThemeSelection, } from "./themes" @@ -72,9 +70,8 @@ export function ThemeContainer() { DEFAULT_THEME_SELECTION ) const [open, setOpen] = useState(false) - const css = generateTheme(selectedColors) - const figmaJson = generateFigmaThemeJson(selectedColors) - const manifestJson = generateThemeManifestJson(selectedColors) + const { theme, contrastChecks, css, figmaJson, manifestJson } = + createThemeArtifacts(selectedColors) function copyCss() { void navigator.clipboard.writeText(css) @@ -138,7 +135,7 @@ export function ThemeContainer() {
@@ -167,8 +164,8 @@ export function ThemeContainer() {
@@ -201,7 +198,11 @@ export function ThemeContainer() {
- +
@@ -220,7 +221,7 @@ export function ThemeContainer() { > @@ -236,7 +237,9 @@ export function ThemeContainer() {

In Figma, duplicate the Default mode in the Style collection, rename it for the project, - then use Import mode with this JSON file. + then use Import mode with this JSON file. It + includes both semantic variables and the 12-step primitive + scales.

{ - selectedKey: string - onSelectionChange: (key: Key | Key[] | null) => void - label: string - className?: string - placeholder: string - filterKeys?: Array -} - -const ColorSelect = ({ - className, - selectedKey, - onSelectionChange, - filterKeys, - label, - ...props -}: ColorSelectProps) => { - const filteredKeys = filterKeys - ? Object.keys(colors).filter((key) => filterKeys.includes(key)) - : Object.keys(colors) - - return ( - - ) -} +import { deriveGraySource } from "./palette" +import { + THEME_RADIUS_OPTIONS, + type ThemeAppearanceSelection, + type ThemeRadius, + type ThemeSelection, +} from "./themes" type ThemeCustomizerProps = { selectedColors: ThemeSelection setSelectedColors: React.Dispatch> } +type EditableColor = keyof ThemeAppearanceSelection +type Appearance = "light" | "dark" + export function ThemeCustomizer({ selectedColors, setSelectedColors, }: ThemeCustomizerProps) { - const handleSelectionChange = - (type: keyof typeof selectedColors) => (key: Key | null) => { - if (!key) { - return + const [appearance, setAppearance] = useState("light") + const values = selectedColors[appearance] + + function updateAppearanceColor(type: EditableColor, value: string) { + setSelectedColors((previous) => { + const next = { + ...previous, + [appearance]: { + ...previous[appearance], + [type]: value, + }, } - const value = key.toString() + if (type !== "accent" || previous.grayMode !== "auto") { + return next + } + + return { + ...next, + [appearance]: { + ...next[appearance], + gray: deriveGraySource(value), + }, + } + }) + } - if (type === "radius") { - const radius = THEME_RADIUS_OPTIONS.find((option) => option === value) - if (radius) { - setSelectedColors((previous) => ({ ...previous, radius })) - } - return + function setGrayMode(isAuto: boolean) { + setSelectedColors((previous) => { + if (!isAuto) { + return { ...previous, grayMode: "custom" } } - if (type === "primary") { - setSelectedColors((previous) => ({ - ...previous, - primary: value, - accent: value, - })) - return + return { + ...previous, + grayMode: "auto", + light: { + ...previous.light, + gray: deriveGraySource(previous.light.accent), + }, + dark: { + ...previous.dark, + gray: deriveGraySource(previous.dark.accent), + }, } + }) + } - setSelectedColors((previous) => ({ ...previous, [type]: value })) + function setRadius(key: Key | Key[] | null) { + if (!key || Array.isArray(key)) { + return } - const getFilteredColors = (excludedGray: string) => { - return Object.keys(colors).filter( - (color) => !neutralColors.includes(color) || color === excludedGray + const radius = THEME_RADIUS_OPTIONS.find( + (option) => option === key.toString() ) + if (radius) { + setSelectedColors((previous) => ({ ...previous, radius })) + } } - const filteredPrimaryColors = getFilteredColors(selectedColors.gray) - const filteredAccentColors = getFilteredColors(selectedColors.gray) return ( -
-
- handleSelectionChange("gray")(key as Key)} - label="Gray Color" - placeholder="Select gray color" - filterKeys={neutralColors} - /> - - handleSelectionChange("primary")(key as Key) - } - label="Primary Color" - placeholder="Select primary color" - filterKeys={filteredPrimaryColors} - /> - - handleSelectionChange("accent")(key as Key) - } - label="Accent Color" - placeholder="Select accent color" - filterKeys={filteredAccentColors} - /> - + + + Keeps neutral ramps coordinated. Turn off to choose gray manually. + + + + +
+
+ + { + const key = [...keys][0] + if (key === "solid" || key === "translucent") { + setSelectedColors((previous) => ({ + ...previous, + panelBackground: key, + })) + } + }} + > + Solid + Translucent + +

+ Controls cards, navigation, and raised surfaces. +

+
+ +
+ + +

+ Generates the full radius scale in code and Figma. +

+
+
+
+ ) +} + +function ThemeColorControl({ + label, + description, + value, + isDisabled, + onChange, +}: { + label: string + description: string + value: string + isDisabled?: boolean + onChange: (value: string) => void +}) { + return ( +
+
+ {label} + {value}
+ onChange(color.toString("hex"))} + /> +

{description}

) } diff --git a/apps/preskok/components/theme/themes.ts b/apps/preskok/components/theme/themes.ts index 043e62a9a..3d56d0fd5 100644 --- a/apps/preskok/components/theme/themes.ts +++ b/apps/preskok/components/theme/themes.ts @@ -1,20 +1,26 @@ -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" +type ThemeMode = ThemeAppearance type Shade = keyof (typeof colors)["slate"] -type ForegroundColor = Shade | BlackWhite -type ThemeMode = "light" | "dark" +type PanelBackground = "solid" | "translucent" +type GrayMode = "auto" | "custom" -export const THEME_MANIFEST_VERSION = 1 +export const THEME_MANIFEST_VERSION = 2 export const THEME_RADIUS_OPTIONS = [ "0rem", "0.125rem", @@ -29,10 +35,17 @@ export const THEME_RADIUS_OPTIONS = [ export type ThemeRadius = (typeof THEME_RADIUS_OPTIONS)[number] -export type ThemeSelection = { - primary: string - gray: string +export type ThemeAppearanceSelection = { accent: string + gray: string + background: string +} + +export type ThemeSelection = { + light: ThemeAppearanceSelection + dark: ThemeAppearanceSelection + grayMode: GrayMode + panelBackground: PanelBackground radius: ThemeRadius } @@ -42,9 +55,18 @@ export type ThemeManifest = { } export const DEFAULT_THEME_SELECTION: ThemeSelection = { - primary: "blue", - gray: "zinc", - accent: "zinc", + light: { + accent: "#2563eb", + gray: "#737b8a", + background: "#ffffff", + }, + dark: { + accent: "#3b82f6", + gray: "#737b88", + background: "#09090b", + }, + grayMode: "auto", + panelBackground: "translucent", radius: "0.5rem", } @@ -93,6 +115,16 @@ export const THEME_COLOR_TOKEN_NAMES = [ "chart-5", "surface", "surface-foreground", + "panel", + "panel-foreground", + "panel-solid", + "panel-solid-foreground", + "panel-translucent", + "panel-translucent-foreground", + "accent-surface", + "accent-indicator", + "accent-track", + "scrim", "code", "code-foreground", "code-highlight", @@ -107,6 +139,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 +154,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 +201,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 +230,190 @@ 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( + selection: ThemeSelection, + primitives: Record +) { + return { + light: createColorMode("light", selection, primitives.light), + dark: createColorMode("dark", selection, 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" +function createColorMode( + mode: ThemeMode, + selection: ThemeSelection, + 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 subtleAccentForeground = chooseReadableForeground(palette.accent[2], [ + palette.accent[10], + palette.accent[11], + foreground, + ]) + const mutedForeground = chooseReadableForeground(palette.gray[2], [ + palette.gray[10], + palette.gray[11], + foreground, + ]) + const panelSolid = palette.gray[1] + const panelTranslucent = palette.graySurface + let panel = panelTranslucent + if (selection.panelBackground === "solid") { + panel = panelSolid } - if (isShade300) { - return "300" - } + 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.accent[2], + "accent-foreground": subtleAccentForeground, + 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: panelSolid, + "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": subtleAccentForeground, + "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": panelSolid, + "panel-solid-foreground": foreground, + "panel-translucent": panelTranslucent, + "panel-translucent-foreground": panelForeground, + "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, @@ -478,14 +432,67 @@ function createRadii(radius: ThemeRadius) { export function createThemeTokens(selection: ThemeSelection): ResolvedTheme { assertThemeSelection(selection) + const primitives = { + light: generatePalette({ appearance: "light", ...selection.light }), + dark: generatePalette({ appearance: "dark", ...selection.dark }), + } return { - colors: createColorTokens(selection), + colors: createColorTokens(selection, 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 +513,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 +603,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 +611,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 +672,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 +686,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 +712,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,6 +723,27 @@ function isThemeRadius(value: unknown): value is ThemeRadius { ) } +function assertAppearanceSelection( + appearance: unknown, + name: ThemeMode +): asserts appearance is ThemeAppearanceSelection { + if (!isRecord(appearance)) { + throw new Error(`Theme ${name} appearance must be an object.`) + } + + if (!isHexColor(appearance.accent)) { + throw new Error(`Theme ${name} accent must be a six-digit hex color.`) + } + + if (!isHexColor(appearance.gray)) { + throw new Error(`Theme ${name} gray must be a six-digit hex color.`) + } + + if (!isHexColor(appearance.background)) { + throw new Error(`Theme ${name} background must be a six-digit hex color.`) + } +} + export function assertThemeSelection( selection: unknown ): asserts selection is ThemeSelection { @@ -629,19 +751,18 @@ export function assertThemeSelection( throw new Error("Theme selection must be an object.") } - if (!isKnownColor(selection.primary)) { - throw new Error("Theme primary color is not supported.") - } + assertAppearanceSelection(selection.light, "light") + assertAppearanceSelection(selection.dark, "dark") - if (!isKnownColor(selection.accent)) { - throw new Error("Theme accent color is not supported.") + if (selection.grayMode !== "auto" && selection.grayMode !== "custom") { + throw new Error('Theme gray mode must be "auto" or "custom".') } if ( - typeof selection.gray !== "string" || - !neutralColors.includes(selection.gray) + selection.panelBackground !== "solid" && + selection.panelBackground !== "translucent" ) { - throw new Error("Theme gray color must be a neutral family.") + throw new Error('Theme panel background must be "solid" or "translucent".') } if (!isThemeRadius(selection.radius)) { @@ -651,7 +772,6 @@ export function assertThemeSelection( export function parseThemeManifestJson(source: string): ThemeManifest { let parsed: unknown - try { parsed = JSON.parse(source) } catch { @@ -662,6 +782,13 @@ 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 !== THEME_MANIFEST_VERSION) { throw new Error( `Theme file uses an unsupported schema version. Expected ${THEME_MANIFEST_VERSION}.` @@ -669,9 +796,103 @@ export function parseThemeManifestJson(source: string): ThemeManifest { } assertThemeSelection(parsed.selection) + return { schemaVersion: THEME_MANIFEST_VERSION, selection: parsed.selection } +} + +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 { - schemaVersion: THEME_MANIFEST_VERSION, - selection: parsed.selection, + light: { + accent: lightAccent, + gray, + background: "#ffffff", + }, + dark: { + accent: darkAccent, + gray, + background: "#09090b", + }, + grayMode: "custom", + panelBackground: "translucent", + 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..680448fab 100644 --- a/apps/preskok/content/docs/theme.mdx +++ b/apps/preskok/content/docs/theme.mdx @@ -10,10 +10,16 @@ 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. +Choose exact brand, gray, and canvas colors for light and dark appearances. The +customizer turns those source colors into Radix-style 12-step accent and gray +scales, alpha variants, solid and translucent surfaces, and the complete Preskok +semantic color contract. CSS and Figma exports are generated from the same +resolved values. + +The contrast panel checks normal text pairs against WCAG 2.x at 4.5:1 and also +reports APCA Lc values. A passing result applies to the pairs shown in the +panel—not every possible combination of tokens, font sizes, or overlays in a +product. 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 @@ -22,8 +28,9 @@ 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. +in the project's global stylesheet. The generated CSS includes the Preskok +semantic variables, 12-step opaque and alpha primitives, radius scale, and +Display P3 overrides with sRGB fallbacks. ## Add the theme to Figma @@ -34,10 +41,11 @@ names as Preskok UI components. 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. +The semantic paths match the library's existing variables, such as +`color/light/primary`, `color/dark/primary`, and `radius/lg`. Primitive paths, +including `primitive/color/light/accent/1`, are included for designers who need +the full scale. 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/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/scripts/check-theme-bridge.mts b/apps/preskok/scripts/check-theme-bridge.mts index 85a03c833..44ae20693 100644 --- a/apps/preskok/scripts/check-theme-bridge.mts +++ b/apps/preskok/scripts/check-theme-bridge.mts @@ -1,9 +1,9 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" -import { neutralColors } from "../components/theme/colors" -import colors from "../components/theme/colors.json" import { + createThemeArtifacts, + createThemeContrastChecks, createThemeTokens, DEFAULT_THEME_SELECTION, FIGMA_STYLE_COLOR_TOKEN_NAMES, @@ -13,6 +13,8 @@ import { generateThemeManifestJson, parseThemeManifestJson, THEME_COLOR_TOKEN_NAMES, + THEME_MANIFEST_VERSION, + THEME_PRIMITIVE_STEPS, THEME_RADIUS_OPTIONS, THEME_TOKEN_MAPPINGS, type ThemeSelection, @@ -26,7 +28,6 @@ function extractCssBlock(source: string, selector: string) { const closeBrace = source.indexOf("}", openBrace) assert.notEqual(openBrace, -1, `Missing opening brace for ${selector}`) assert.notEqual(closeBrace, -1, `Missing closing brace for ${selector}`) - return source.slice(openBrace + 1, closeBrace) } @@ -88,9 +89,44 @@ function assertSelection(selection: ThemeSelection) { ) } - for (const token of FIGMA_STYLE_COLOR_TOKEN_NAMES) { - assertColorToken(figma.color.light[token], `color/light/${token}`) - assertColorToken(figma.color.dark[token], `color/dark/${token}`) + for (const mode of ["light", "dark"] as const) { + for (const step of THEME_PRIMITIVE_STEPS) { + assert.equal( + rootVariables[`accent-${step}`] !== undefined, + true, + `CSS must include accent step ${step}` + ) + assertColorToken( + figma.primitive.color[mode].accent[String(step)], + `primitive/color/${mode}/accent/${step}` + ) + assertColorToken( + figma.primitive.color[mode]["accent-alpha"][String(step)], + `primitive/color/${mode}/accent-alpha/${step}` + ) + assertColorToken( + figma.primitive.color[mode].gray[String(step)], + `primitive/color/${mode}/gray/${step}` + ) + assertColorToken( + figma.primitive.color[mode]["gray-alpha"][String(step)], + `primitive/color/${mode}/gray-alpha/${step}` + ) + } + + for (const token of FIGMA_STYLE_COLOR_TOKEN_NAMES) { + assertColorToken(figma.color[mode][token], `color/${mode}/${token}`) + } + + const checks = createThemeContrastChecks(selection).filter((check) => { + 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 +134,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,21 +167,38 @@ 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 }) +} + +const colorSamples = [ + "#000000", + "#ffffff", + "#ff006e", + "#7c3aed", + "#006adc", + "#00a2c7", + "#2e7d32", + "#ffba18", +] as const +for (const accent of colorSamples) { assertSelection({ ...DEFAULT_THEME_SELECTION, - primary: color, - accent: color, + light: { ...DEFAULT_THEME_SELECTION.light, accent }, + dark: { ...DEFAULT_THEME_SELECTION.dark, accent }, }) } -for (const gray of neutralColors) { - assertSelection({ ...DEFAULT_THEME_SELECTION, gray }) -} - -for (const radius of THEME_RADIUS_OPTIONS) { - assertSelection({ ...DEFAULT_THEME_SELECTION, radius }) -} +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), [ @@ -147,6 +207,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 +240,7 @@ assert.equal( ) const manifestJson = generateThemeManifestJson(DEFAULT_THEME_SELECTION) +assert.equal(THEME_MANIFEST_VERSION, 2) assert.deepEqual( parseThemeManifestJson(manifestJson).selection, DEFAULT_THEME_SELECTION @@ -187,16 +250,34 @@ 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, 2) +assert.equal(migrated.selection.light.accent, "#155dfc") +assert.equal(migrated.selection.dark.accent, "#155dfc") +assert.equal(migrated.selection.grayMode, "custom") +assert.equal(migrated.selection.radius, "0.75rem") + assert.throws( - () => parseThemeManifestJson('{"schemaVersion":2,"selection":{}}'), + () => parseThemeManifestJson('{"schemaVersion":3,"selection":{}}'), /unsupported schema version/ ) assert.throws( () => parseThemeManifestJson( - '{"schemaVersion":1,"selection":{"primary":"made-up","gray":"zinc","accent":"blue","radius":"0.5rem"}}' + '{"schemaVersion":2,"selection":{"light":{"accent":"red"}}}' ), - /primary color is not supported/ + /six-digit hex color/ ) assert.throws( () => @@ -207,5 +288,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 V2 checks passed for ${colorSamples.length} source colors, ${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..5f6d06e07 100644 --- a/apps/preskok/styles/globals.css +++ b/apps/preskok/styles/globals.css @@ -96,6 +96,18 @@ --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-panel-translucent: var(--panel-translucent); + --color-panel-translucent-foreground: var( + --panel-translucent-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 +207,16 @@ --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); + --panel-translucent: oklch(1 0 0 / 80%); + --panel-translucent-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 +295,16 @@ --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); + --panel-translucent: oklch(0 0 0 / 5%); + --panel-translucent-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); 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: {} From 8ab1449b99e15db1083d00b7301765316b4574c7 Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Tue, 18 Aug 2026 19:25:54 +0200 Subject: [PATCH 02/16] Simplify theme editor controls --- .../components/theme/generated-theme.tsx | 24 +--- .../components/theme/theme-container.tsx | 22 +-- .../components/theme/theme-customizer.tsx | 136 ++++++------------ apps/preskok/components/theme/themes.ts | 26 +--- apps/preskok/content/docs/theme.mdx | 24 +--- apps/preskok/styles/globals.css | 22 +-- 6 files changed, 67 insertions(+), 187 deletions(-) diff --git a/apps/preskok/components/theme/generated-theme.tsx b/apps/preskok/components/theme/generated-theme.tsx index 944a3eaad..8adee0161 100644 --- a/apps/preskok/components/theme/generated-theme.tsx +++ b/apps/preskok/components/theme/generated-theme.tsx @@ -26,13 +26,8 @@ export function GeneratedTheme({ )} >
-
-
-

Generated scales

-

- Steps follow Radix usage roles from canvas through text. -

-
+
+

Generated scales

2 × 12 steps @@ -61,13 +56,7 @@ export function GeneratedTheme({
-
-

Text contrast

-

- WCAG ratios are gated at 4.5:1; APCA is reported as additional - guidance. -

-
+

Text contrast

- +
@@ -152,11 +141,6 @@ function ScalePreview({ /> ))}
-
- Canvas - UI - Text -
) } diff --git a/apps/preskok/components/theme/theme-container.tsx b/apps/preskok/components/theme/theme-container.tsx index d960c677e..84b8c4e83 100644 --- a/apps/preskok/components/theme/theme-container.tsx +++ b/apps/preskok/components/theme/theme-container.tsx @@ -133,10 +133,7 @@ export function ThemeContainer() { <>
- +
@@ -163,10 +160,7 @@ export function ThemeContainer() {
- +
+ + + Text contrast + + WCAG 2.x uses a 4.5:1 threshold. APCA is shown as additional + guidance. + + + +
+ {checks.map((check) => ( + + ))} +
+
+
+ ) } function ContrastRow({ check }: { check: ThemeContrastCheck }) { return ( -
+
( DEFAULT_THEME_SELECTION ) + const [appearance, setAppearance] = useState("light") const [open, setOpen] = useState(false) const { theme, contrastChecks, css, figmaJson, manifestJson } = createThemeArtifacts(selectedColors) + const previewStyles = { + colorScheme: appearance, + ...Object.fromEntries( + THEME_COLOR_TOKEN_NAMES.map((token) => [ + `--color-${token}`, + theme.colors[appearance][token], + ]) + ), + } as CSSProperties function copyCss() { void navigator.clipboard.writeText(css) @@ -131,78 +143,100 @@ export function ThemeContainer() { return ( <> -
- - -
- -
- - -
-
-
+
+
+
-
-
- - - - +
+ + +
+
+ + +
+ + + +
diff --git a/apps/preskok/components/theme/theme-customizer.tsx b/apps/preskok/components/theme/theme-customizer.tsx index aa2992b20..98ca5a019 100644 --- a/apps/preskok/components/theme/theme-customizer.tsx +++ b/apps/preskok/components/theme/theme-customizer.tsx @@ -1,6 +1,5 @@ "use client" -import { useState } from "react" import type React from "react" import { MoonIcon, SunIcon } from "lucide-react" import type { Key } from "react-aria-components/Select" @@ -16,7 +15,7 @@ import { SelectLabel, SelectTrigger, } from "@/registry/preskok/ui/preskok-ui/select" -import { Switch } from "@/registry/preskok/ui/preskok-ui/switch" +import { Toggle } from "@/registry/preskok/ui/preskok-ui/toggle" import { ToggleGroup, ToggleGroupItem, @@ -30,18 +29,23 @@ import { } from "./themes" type ThemeCustomizerProps = { + actions?: React.ReactNode + appearance: ThemeAppearance selectedColors: ThemeSelection + setAppearance: React.Dispatch> setSelectedColors: React.Dispatch> } type EditableColor = keyof ThemeAppearanceSelection -type Appearance = "light" | "dark" +export type ThemeAppearance = "light" | "dark" export function ThemeCustomizer({ + actions, + appearance, selectedColors, + setAppearance, setSelectedColors, }: ThemeCustomizerProps) { - const [appearance, setAppearance] = useState("light") const values = selectedColors[appearance] function updateAppearanceColor(type: EditableColor, value: string) { @@ -54,6 +58,10 @@ export function ThemeCustomizer({ }, } + if (type === "gray") { + return { ...next, grayMode: "custom" as const } + } + if (type !== "accent" || previous.grayMode !== "auto") { return next } @@ -103,10 +111,11 @@ export function ThemeCustomizer({ } return ( -
-
+
+
{ @@ -125,35 +134,35 @@ export function ThemeCustomizer({ Dark +
-
- updateAppearanceColor("accent", value)} - /> - updateAppearanceColor("background", value)} - /> - {selectedColors.grayMode === "custom" && ( - updateAppearanceColor("gray", value)} - /> - )} -
-
- -
- - - +
+ updateAppearanceColor("accent", value)} + /> + + Auto + + } + onChange={(value) => updateAppearanceColor("gray", value)} + /> + updateAppearanceColor("background", value)} + />
@@ -162,7 +171,7 @@ export function ThemeCustomizer({ value={selectedColors.radius} onChange={setRadius} > - + {THEME_RADIUS_OPTIONS.map((radius) => (
+ + {actions && ( +
{actions}
+ )}
-
+
) } function ThemeColorControl({ label, value, + action, onChange, }: { label: string value: string + action?: React.ReactNode onChange: (value: string) => void }) { return ( -
+
+
+ + {action} +
onChange(color.toString("hex"))} /> - {value}
) } From 72d0a33bf8d669953c2f35c16b053b331436baf5 Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Tue, 18 Aug 2026 20:21:16 +0200 Subject: [PATCH 04/16] Add guided page background treatments --- .../components/theme/theme-customizer.tsx | 194 +++++++++++++++++- apps/preskok/components/theme/themes.ts | 156 ++++++++++++-- apps/preskok/content/docs/theme.mdx | 7 +- apps/preskok/scripts/check-theme-bridge.mts | 70 ++++++- 4 files changed, 394 insertions(+), 33 deletions(-) diff --git a/apps/preskok/components/theme/theme-customizer.tsx b/apps/preskok/components/theme/theme-customizer.tsx index 98ca5a019..1b24d5699 100644 --- a/apps/preskok/components/theme/theme-customizer.tsx +++ b/apps/preskok/components/theme/theme-customizer.tsx @@ -1,13 +1,23 @@ "use client" import type React from "react" -import { MoonIcon, SunIcon } from "lucide-react" +import { CheckIcon, ChevronDownIcon, MoonIcon, SunIcon } from "lucide-react" import type { Key } from "react-aria-components/Select" import { parseColor } from "react-stately/Color" +import { twMerge } from "tailwind-merge" import { Badge } from "@/registry/preskok/ui/preskok-ui/badge" +import { Button } from "@/registry/preskok/ui/preskok-ui/button" import { ColorPicker } from "@/registry/preskok/ui/preskok-ui/color-picker" import { Label } from "@/registry/preskok/ui/preskok-ui/field" +import { + Popover, + PopoverBody, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, +} from "@/registry/preskok/ui/preskok-ui/popover" import { Select, SelectContent, @@ -21,10 +31,12 @@ import { ToggleGroupItem, } from "@/registry/preskok/ui/preskok-ui/toggle-group" -import { deriveGraySource } from "./palette" +import { deriveGraySource, generatePalette } from "./palette" import { + resolveThemeBackground, THEME_RADIUS_OPTIONS, type ThemeAppearanceSelection, + type ThemeBackgroundMode, type ThemeSelection, } from "./themes" @@ -36,9 +48,19 @@ type ThemeCustomizerProps = { setSelectedColors: React.Dispatch> } -type EditableColor = keyof ThemeAppearanceSelection +type EditableColor = "accent" | "gray" | "customBackground" export type ThemeAppearance = "light" | "dark" +const BACKGROUND_OPTIONS = [ + { id: "neutral", label: "Neutral" }, + { id: "pure", label: "Pure" }, + { id: "accent", label: "Brand tint" }, + { id: "custom", label: "Custom" }, +] as const satisfies readonly { + id: ThemeBackgroundMode + label: string +}[] + export function ThemeCustomizer({ actions, appearance, @@ -97,6 +119,16 @@ export function ThemeCustomizer({ }) } + function setBackgroundMode(backgroundMode: ThemeBackgroundMode) { + setSelectedColors((previous) => ({ + ...previous, + [appearance]: { + ...previous[appearance], + backgroundMode, + }, + })) + } + function setRadius(key: Key | Key[] | null) { if (!key || Array.isArray(key)) { return @@ -158,10 +190,11 @@ export function ThemeCustomizer({ } onChange={(value) => updateAppearanceColor("gray", value)} /> - updateAppearanceColor("background", value)} + updateAppearanceColor("customBackground", value)} + onModeChange={setBackgroundMode} />
@@ -200,6 +233,153 @@ export function ThemeCustomizer({ ) } +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 Milestone({ + label, + detail, + complete, + hideOnMobile, +}: (typeof milestones)[number]) { + const StatusIcon = complete ? CheckCircle2Icon : CircleIcon -
- - + return ( +
+
) } From c924947c0cdaa1a3d6a4bc967b49bbd3ebaf16dc Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Tue, 18 Aug 2026 22:29:51 +0200 Subject: [PATCH 07/16] Make theme showcase interactive --- apps/preskok/components/theme/blocks.tsx | 604 ++++++++++++++++++----- 1 file changed, 481 insertions(+), 123 deletions(-) diff --git a/apps/preskok/components/theme/blocks.tsx b/apps/preskok/components/theme/blocks.tsx index 07015b458..6ae9e4f52 100644 --- a/apps/preskok/components/theme/blocks.tsx +++ b/apps/preskok/components/theme/blocks.tsx @@ -1,8 +1,15 @@ +"use client" + +import { useReducer } from "react" import { + CheckCheckIcon, CheckCircle2Icon, + ChevronDownIcon, + ChevronUpIcon, CircleIcon, MoreHorizontalIcon, PlusIcon, + RotateCcwIcon, } from "lucide-react" import { twMerge } from "tailwind-merge" @@ -10,20 +17,59 @@ import { Avatar } from "@/registry/preskok/ui/preskok-ui/avatar" import { Badge } from "@/registry/preskok/ui/preskok-ui/badge" import { Button } from "@/registry/preskok/ui/preskok-ui/button" import { Checkbox } from "@/registry/preskok/ui/preskok-ui/checkbox" +import { Label } from "@/registry/preskok/ui/preskok-ui/field" +import { Input } from "@/registry/preskok/ui/preskok-ui/input" +import { + Menu, + MenuContent, + MenuItem, +} from "@/registry/preskok/ui/preskok-ui/menu" +import { + Popover, + PopoverBody, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, +} from "@/registry/preskok/ui/preskok-ui/popover" import { ProgressBar, ProgressBarHeader, ProgressBarTrack, ProgressBarValue, } from "@/registry/preskok/ui/preskok-ui/progress-bar" +import { TextField } from "@/registry/preskok/ui/preskok-ui/text-field" -const metrics = [ - { label: "Completion", value: "72%", change: "+8% this week" }, - { label: "Open tasks", value: "12", change: "4 due today" }, - { label: "Cycle time", value: "3.4d", change: "0.6d faster" }, -] as const +type TaskIntent = "danger" | "info" | "secondary" | "warning" + +interface ShowcaseTask { + id: string + title: string + detail: string + status: string + intent: TaskIntent + avatar: string + assignee: string + isNew?: boolean +} -const tasks = [ +interface ShowcaseState { + tasks: ShowcaseTask[] + completedTaskIds: Set + isAddTaskOpen: boolean + showAllTasks: boolean + statusMessage: string +} + +type ShowcaseAction = + | { type: "set-add-task-open"; isOpen: boolean } + | { type: "toggle-show-all" } + | { type: "toggle-task"; task: ShowcaseTask; isSelected: boolean } + | { type: "add-task"; task: ShowcaseTask } + | { type: "mark-all-complete" } + | { type: "reset" } + +const initialTasks: ShowcaseTask[] = [ { id: "onboarding-copy", title: "Finalize onboarding copy", @@ -32,7 +78,6 @@ const tasks = [ intent: "warning", avatar: "/avatars/01.png", assignee: "Alex Johnson", - hideOnMobile: false, }, { id: "billing-webhooks", @@ -42,7 +87,6 @@ const tasks = [ intent: "info", avatar: "/avatars/02.png", assignee: "Jamie Rivera", - hideOnMobile: false, }, { id: "mobile-checkout", @@ -52,9 +96,8 @@ const tasks = [ intent: "danger", avatar: "/avatars/03.png", assignee: "Taylor Kim", - hideOnMobile: true, }, -] as const +] const milestones = [ { @@ -77,133 +120,448 @@ const milestones = [ }, ] as const +function createInitialState(statusMessage = ""): ShowcaseState { + return { + tasks: initialTasks.map((task) => ({ ...task })), + completedTaskIds: new Set(), + isAddTaskOpen: false, + showAllTasks: false, + statusMessage, + } +} + +function showcaseReducer( + state: ShowcaseState, + action: ShowcaseAction +): ShowcaseState { + switch (action.type) { + case "set-add-task-open": + return { ...state, isAddTaskOpen: action.isOpen } + case "toggle-show-all": + return { ...state, showAllTasks: !state.showAllTasks } + case "toggle-task": { + const completedTaskIds = new Set(state.completedTaskIds) + + if (action.isSelected) { + completedTaskIds.add(action.task.id) + } else { + completedTaskIds.delete(action.task.id) + } + + const statusMessage = action.isSelected + ? `${action.task.title} marked complete.` + : `${action.task.title} reopened.` + + return { ...state, completedTaskIds, statusMessage } + } + case "add-task": { + const tasks = [action.task, ...state.tasks].slice(0, 3) + const taskIds = new Set(tasks.map((task) => task.id)) + const completedTaskIds = new Set( + [...state.completedTaskIds].filter((id) => taskIds.has(id)) + ) + + return { + ...state, + tasks, + completedTaskIds, + isAddTaskOpen: false, + statusMessage: `${action.task.title} added to priority work.`, + } + } + case "mark-all-complete": + return { + ...state, + completedTaskIds: new Set(state.tasks.map((task) => task.id)), + statusMessage: "All priority work marked complete.", + } + case "reset": + return createInitialState("Project preview reset.") + } +} + export function Blocks() { + const [state, dispatch] = useReducer( + showcaseReducer, + undefined, + createInitialState + ) + const completedTaskCount = state.completedTaskIds.size + const newTaskCount = state.tasks.filter((task) => task.isNew).length + const completion = Math.round( + 72 + (completedTaskCount / state.tasks.length) * 28 + ) + const openTaskCount = Math.max(0, 12 + newTaskCount - completedTaskCount) + const completedTaskLabel = + completedTaskCount === 1 ? "priority task" : "priority tasks" + const completionChange = completedTaskCount + ? `${completedTaskCount} ${completedTaskLabel} done` + : "+8% this week" + const openTasksChange = completedTaskCount + ? `${completedTaskCount} just completed` + : "4 due today" + const metrics = [ + { + label: "Completion", + value: `${completion}%`, + change: completionChange, + }, + { + label: "Open tasks", + value: String(openTaskCount), + change: openTasksChange, + }, + { label: "Cycle time", value: "3.4d", change: "0.6d faster" }, + ] + + function addTask(formData: FormData) { + const title = String(formData.get("taskTitle") ?? "").trim() + + if (!title) { + return + } + + dispatch({ + type: "add-task", + task: { + id: crypto.randomUUID(), + title, + detail: "Planning · Added now", + status: "New", + intent: "secondary", + avatar: "/avatars/04.png", + assignee: "Morgan Lee", + isNew: true, + }, + }) + } + return ( -
+
-
-
-
-

- Checkout launch -

- On track -
-

- Everything the team needs for the August release. -

-
- -
-
- - - -
- - -
-
- -
- {metrics.map((metric) => ( -
-
- {metric.label} -
-
-

- {metric.value} -

-

- {metric.change} -

-
-
- ))} -
+
+ {state.statusMessage} +
+ + + dispatch({ type: "set-add-task-open", isOpen }) + } + onMarkAllComplete={() => dispatch({ type: "mark-all-complete" })} + onReset={() => dispatch({ type: "reset" })} + /> +
-
dispatch({ type: "toggle-show-all" })} + onToggleTask={(task, isSelected) => + dispatch({ type: "toggle-task", task, isSelected }) + } + /> + +
+
+
+ ) +} + +function ProjectHeader({ + isAddTaskOpen, + onAddTask, + onAddTaskOpenChange, + onMarkAllComplete, + onReset, +}: { + isAddTaskOpen: boolean + onAddTask: (formData: FormData) => void + onAddTaskOpenChange: (isOpen: boolean) => void + onMarkAllComplete: () => void + onReset: () => void +}) { + return ( +
+
+
+

+ Checkout launch +

+ On track +
+

+ Everything the team needs for the August release. +

+
+ +
+
+ + + +
+ + + -
- -
- {tasks.map((task) => ( -
- + Add task + + + + Add priority task + + Add one item to the project preview. + + + +
+ + + -
-

- {task.title} -

-

- {task.detail} -

-
-
- - {task.status} - - -
+
+
+ +
- ))} -
-
- - + + + + + + + + + + + Mark all complete + + + + Reset preview + + + +
+ + ) +} + +function MetricsGrid({ + metrics, +}: { + metrics: { label: string; value: string; change: string }[] +}) { + return ( +
+ {metrics.map((metric) => ( +
+
+ {metric.label} +
+
+

+ {metric.value} +

+

+ {metric.change} +

+
+ ))} +
+ ) +} + +function TaskList({ + tasks, + completedTaskIds, + showAllTasks, + onToggleShowAll, + onToggleTask, +}: { + tasks: ShowcaseTask[] + completedTaskIds: Set + showAllTasks: boolean + onToggleShowAll: () => void + onToggleTask: (task: ShowcaseTask, isSelected: boolean) => void +}) { + return ( +
+
+

+ Priority work +

+ +
+ +
+ {tasks.map((task, index) => ( + 1 && !showAllTasks} + onToggle={onToggleTask} + /> + ))}
) } +function ContextualChevron({ isExpanded }: { isExpanded: boolean }) { + return ( + + ) +} + +function TaskRow({ + task, + isCompleted, + hideOnMobile, + onToggle, +}: { + task: ShowcaseTask + isCompleted: boolean + hideOnMobile: boolean + onToggle: (task: ShowcaseTask, isSelected: boolean) => void +}) { + return ( +
+ onToggle(task, isSelected)} + /> +
+

+ {task.title} +

+

+ {task.detail} +

+
+
+ + {isCompleted ? "Done" : task.status} + + +
+
+ ) +} + +function ProjectProgress({ completion }: { completion: number }) { + return ( + + ) +} + function Milestone({ label, detail, From 4e530b6b6f142c755056c5265c416338b4153f8b Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Tue, 18 Aug 2026 22:54:11 +0200 Subject: [PATCH 08/16] Add scheduling controls to theme showcase --- apps/preskok/components/theme/blocks.tsx | 114 ++++++++++++++++-- .../components/theme/theme-container.tsx | 6 +- .../preskok/ui/preskok-ui/date-picker.tsx | 2 +- 3 files changed, 112 insertions(+), 10 deletions(-) diff --git a/apps/preskok/components/theme/blocks.tsx b/apps/preskok/components/theme/blocks.tsx index 6ae9e4f52..fb1413501 100644 --- a/apps/preskok/components/theme/blocks.tsx +++ b/apps/preskok/components/theme/blocks.tsx @@ -1,6 +1,11 @@ "use client" -import { useReducer } from "react" +import { useReducer, useState } from "react" +import { + getLocalTimeZone, + parseDate, + type CalendarDate, +} from "@internationalized/date" import { CheckCheckIcon, CheckCircle2Icon, @@ -11,12 +16,21 @@ import { PlusIcon, RotateCcwIcon, } from "lucide-react" +import type { Selection } from "react-aria-components/GridList" import { twMerge } from "tailwind-merge" import { Avatar } from "@/registry/preskok/ui/preskok-ui/avatar" import { Badge } from "@/registry/preskok/ui/preskok-ui/badge" import { Button } from "@/registry/preskok/ui/preskok-ui/button" import { Checkbox } from "@/registry/preskok/ui/preskok-ui/checkbox" +import { + ChoiceBox, + ChoiceBoxItem, +} from "@/registry/preskok/ui/preskok-ui/choice-box" +import { + DatePicker, + DatePickerTrigger, +} from "@/registry/preskok/ui/preskok-ui/date-picker" import { Label } from "@/registry/preskok/ui/preskok-ui/field" import { Input } from "@/registry/preskok/ui/preskok-ui/input" import { @@ -41,6 +55,9 @@ import { import { TextField } from "@/registry/preskok/ui/preskok-ui/text-field" type TaskIntent = "danger" | "info" | "secondary" | "warning" +type TaskPriority = "normal" | "urgent" + +const defaultTaskDueDate = parseDate("2026-08-28") interface ShowcaseTask { id: string @@ -214,21 +231,38 @@ export function Blocks() { { label: "Cycle time", value: "3.4d", change: "0.6d faster" }, ] - function addTask(formData: FormData) { + function addTask( + formData: FormData, + dueDate: CalendarDate | null, + priority: TaskPriority + ) { const title = String(formData.get("taskTitle") ?? "").trim() if (!title) { return } + const dueDateLabel = dueDate + ? dueDate.toDate(getLocalTimeZone()).toLocaleDateString("en-US", { + day: "numeric", + month: "short", + }) + : null + const priorityLabel = priority === "urgent" ? "Urgent" : "Planning" + const detail = dueDateLabel + ? `${priorityLabel} · Due ${dueDateLabel}` + : `${priorityLabel} · No due date` + const status = priority === "urgent" ? "High priority" : "Scheduled" + const intent: TaskIntent = priority === "urgent" ? "warning" : "secondary" + dispatch({ type: "add-task", task: { id: crypto.randomUUID(), title, - detail: "Planning · Added now", - status: "New", - intent: "secondary", + detail, + status, + intent, avatar: "/avatars/04.png", assignee: "Morgan Lee", isNew: true, @@ -282,11 +316,38 @@ function ProjectHeader({ onReset, }: { isAddTaskOpen: boolean - onAddTask: (formData: FormData) => void + onAddTask: ( + formData: FormData, + dueDate: CalendarDate | null, + priority: TaskPriority + ) => void onAddTaskOpenChange: (isOpen: boolean) => void onMarkAllComplete: () => void onReset: () => void }) { + const [dueDate, setDueDate] = useState( + defaultTaskDueDate + ) + const [priority, setPriority] = useState("normal") + + function handlePriorityChange(selection: Selection) { + if (selection === "all") { + return + } + + const nextPriority = [...selection][0] + + if (nextPriority === "normal" || nextPriority === "urgent") { + setPriority(nextPriority) + } + } + + function submitTask(formData: FormData) { + onAddTask(formData, dueDate, priority) + setDueDate(defaultTaskDueDate) + setPriority("normal") + } + return (
@@ -318,7 +379,10 @@ function ProjectHeader({ Add task - + Add priority task @@ -326,7 +390,7 @@ function ProjectHeader({ -
+ + + + + +
+ + Priority + + + + + +
diff --git a/apps/preskok/components/theme/theme-container.tsx b/apps/preskok/components/theme/theme-container.tsx index 4be6fecc0..378a5ec50 100644 --- a/apps/preskok/components/theme/theme-container.tsx +++ b/apps/preskok/components/theme/theme-container.tsx @@ -110,9 +110,13 @@ export function ThemeContainer() { createThemeArtifacts(selectedColors) const previewStyles = createPreviewStyles(theme, appearance) - function copyCss() { - void navigator.clipboard.writeText(css) - toast.success("CSS copied to clipboard.") + async function copyCss() { + try { + await navigator.clipboard.writeText(css) + toast.success("CSS copied to clipboard.") + } catch { + toast.error("CSS could not be copied.") + } } function downloadCss() { @@ -121,7 +125,6 @@ export function ThemeContainer() { content: css, type: "text/css", }) - toast.success("CSS theme downloaded.") } function downloadFigmaTheme() { @@ -130,7 +133,6 @@ export function ThemeContainer() { content: figmaJson, type: "application/json", }) - toast.success("Figma mode downloaded.") } function downloadManifest() { @@ -139,7 +141,6 @@ export function ThemeContainer() { content: manifestJson, type: "application/json", }) - toast.success("Project theme saved.") } async function loadManifest(files: FileList | null) { @@ -151,7 +152,6 @@ export function ThemeContainer() { try { const manifest = parseThemeManifestJson(await file.text()) setSelectedColors(manifest.selection) - toast.success("Project theme loaded.") } catch (error) { const message = error instanceof Error @@ -163,7 +163,6 @@ export function ThemeContainer() { function resetTheme() { setSelectedColors(DEFAULT_THEME_SELECTION) - toast.success("Theme reset to the Preskok defaults.") } return ( diff --git a/apps/preskok/content/docs/theme.mdx b/apps/preskok/content/docs/theme.mdx index 2dc6e2476..32746fee8 100644 --- a/apps/preskok/content/docs/theme.mdx +++ b/apps/preskok/content/docs/theme.mdx @@ -7,34 +7,3 @@ full: true import { ThemeContainer } from "@/components/theme/theme-container" - -## Use your theme - -Set the brand, page background treatment, and radius. **Neutral** derives the -page from the neutral scale, **Pure** uses white or near-black, and **Brand -tint** uses the quietest accent step. **Custom** keeps a direct color picker as -an advanced option. The editor generates matching CSS and Figma variables. -Save `preskok-theme.json` if you want to edit the theme later. - -## Add the theme to code - -Choose **Get theme → Copy CSS** and replace the `:root` and `.dark` theme blocks -in the project's global stylesheet. - -## 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 semantic paths match the library's existing variables, such as -`color/light/primary`, `color/dark/primary`, and `radius/lg`. Primitive paths, -including `primitive/color/light/accent/1`, are included for designers who need -the full scale. 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). From 796764c707c4e391dff270e364007db8ebe5561e Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Wed, 19 Aug 2026 10:55:28 +0200 Subject: [PATCH 11/16] Align background selection indicator --- .../preskok/components/theme/theme-customizer.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/preskok/components/theme/theme-customizer.tsx b/apps/preskok/components/theme/theme-customizer.tsx index 7598c8033..4bd27ebac 100644 --- a/apps/preskok/components/theme/theme-customizer.tsx +++ b/apps/preskok/components/theme/theme-customizer.tsx @@ -297,9 +297,18 @@ function BackgroundControl({ > {option.label} - {isSelected && ( - - )} + ) })} From 708b4635c65fe989db15d6be9591e8f340cf88b8 Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Wed, 19 Aug 2026 11:07:57 +0200 Subject: [PATCH 12/16] Simplify theme selection states --- .../components/theme/generated-theme.tsx | 14 +++----------- .../components/theme/theme-customizer.tsx | 19 ++++--------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/apps/preskok/components/theme/generated-theme.tsx b/apps/preskok/components/theme/generated-theme.tsx index 0a8c99c38..ac96f6bdd 100644 --- a/apps/preskok/components/theme/generated-theme.tsx +++ b/apps/preskok/components/theme/generated-theme.tsx @@ -1,4 +1,3 @@ -import { CheckCircle2Icon, CircleAlertIcon } from "lucide-react" import { twMerge } from "tailwind-merge" import { Button } from "@/registry/preskok/ui/preskok-ui/button" @@ -113,26 +112,19 @@ function ScalePreview({ function ContrastSummary({ checks }: { checks: ThemeContrastCheck[] }) { const passingChecks = checks.filter((check) => check.passes).length - const allPass = passingChecks === checks.length return ( diff --git a/apps/preskok/components/theme/theme-customizer.tsx b/apps/preskok/components/theme/theme-customizer.tsx index 4bd27ebac..bfffbc86b 100644 --- a/apps/preskok/components/theme/theme-customizer.tsx +++ b/apps/preskok/components/theme/theme-customizer.tsx @@ -1,7 +1,7 @@ "use client" import type React from "react" -import { CheckIcon, ChevronDownIcon, MoonIcon, SunIcon } from "lucide-react" +import { ChevronDownIcon, MoonIcon, SunIcon } from "lucide-react" import type { Key } from "react-aria-components/Select" import { parseColor } from "react-stately/Color" import { twMerge } from "tailwind-merge" @@ -288,8 +288,9 @@ function BackgroundControl({ ) })} From 7d31f632560826afd8514e3b358d7162c8370dc6 Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Wed, 19 Aug 2026 11:20:01 +0200 Subject: [PATCH 13/16] Fix palette scale geometry --- .../components/theme/generated-theme.tsx | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/preskok/components/theme/generated-theme.tsx b/apps/preskok/components/theme/generated-theme.tsx index ac96f6bdd..49d9ee8fa 100644 --- a/apps/preskok/components/theme/generated-theme.tsx +++ b/apps/preskok/components/theme/generated-theme.tsx @@ -1,3 +1,4 @@ +import { Focusable } from "react-aria-components/Tooltip" import { twMerge } from "tailwind-merge" import { Button } from "@/registry/preskok/ui/preskok-ui/button" @@ -9,6 +10,10 @@ import { PopoverHeader, PopoverTitle, } from "@/registry/preskok/ui/preskok-ui/popover" +import { + Tooltip, + TooltipContent, +} from "@/registry/preskok/ui/preskok-ui/tooltip" import type { ThemeAppearance } from "./theme-customizer" import { @@ -44,7 +49,7 @@ export function GeneratedTheme({
-
+
{USAGE_RANGES.map((range) => ( -
+
{THEME_PRIMITIVE_STEPS.map((step) => ( {step} @@ -94,17 +99,35 @@ function ScalePreview({ return (
{label} -
- {colors.map((color, index) => ( - - ))} +
+ {THEME_PRIMITIVE_STEPS.map((step, index) => { + const color = colors[index] + + return ( + + + + + + + {color} + + + ) + })}
) From dd02dddbfc576a6637a6754f86290eb843422249 Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Wed, 19 Aug 2026 11:26:23 +0200 Subject: [PATCH 14/16] Prevent theme option layout shift --- apps/preskok/components/theme/theme-customizer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/preskok/components/theme/theme-customizer.tsx b/apps/preskok/components/theme/theme-customizer.tsx index bfffbc86b..afeb6016d 100644 --- a/apps/preskok/components/theme/theme-customizer.tsx +++ b/apps/preskok/components/theme/theme-customizer.tsx @@ -290,7 +290,7 @@ function BackgroundControl({ className={twMerge( "relative h-auto justify-start gap-3 rounded-xl p-3 text-left hover:border-foreground/25 hover:bg-transparent", isSelected && - "border-2 border-foreground/25 bg-secondary hover:bg-secondary" + "border-foreground/25 bg-secondary shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-foreground)_25%,transparent)] hover:bg-secondary" )} intent="outline" key={option.id} From 22c6cd365fb467de3880c875bc7cfd9e786ed7e5 Mon Sep 17 00:00:00 2001 From: GregorGabric Date: Wed, 19 Aug 2026 11:31:43 +0200 Subject: [PATCH 15/16] Align palette column dividers --- apps/preskok/components/theme/generated-theme.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/preskok/components/theme/generated-theme.tsx b/apps/preskok/components/theme/generated-theme.tsx index 49d9ee8fa..4910b0825 100644 --- a/apps/preskok/components/theme/generated-theme.tsx +++ b/apps/preskok/components/theme/generated-theme.tsx @@ -49,7 +49,7 @@ export function GeneratedTheme({
-
+
{USAGE_RANGES.map((range) => ( -
+
{THEME_PRIMITIVE_STEPS.map((step) => ( Date: Wed, 19 Aug 2026 18:39:56 +0200 Subject: [PATCH 16/16] Expand the interactive theme preview --- apps/preskok/components/theme/blocks.tsx | 521 +++++++++++++++++++---- 1 file changed, 441 insertions(+), 80 deletions(-) diff --git a/apps/preskok/components/theme/blocks.tsx b/apps/preskok/components/theme/blocks.tsx index fb1413501..1f630be7d 100644 --- a/apps/preskok/components/theme/blocks.tsx +++ b/apps/preskok/components/theme/blocks.tsx @@ -1,20 +1,24 @@ "use client" -import { useReducer, useState } from "react" +import { useReducer, useState, type Key } from "react" import { getLocalTimeZone, parseDate, type CalendarDate, } from "@internationalized/date" import { + ActivityIcon, CheckCheckIcon, CheckCircle2Icon, - ChevronDownIcon, - ChevronUpIcon, CircleIcon, + FolderKanbanIcon, + LayoutDashboardIcon, + ListTodoIcon, MoreHorizontalIcon, PlusIcon, + RocketIcon, RotateCcwIcon, + UsersIcon, } from "lucide-react" import type { Selection } from "react-aria-components/GridList" import { twMerge } from "tailwind-merge" @@ -52,17 +56,33 @@ import { ProgressBarTrack, ProgressBarValue, } from "@/registry/preskok/ui/preskok-ui/progress-bar" +import { + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from "@/registry/preskok/ui/preskok-ui/table" +import { + Tab, + TabList, + TabPanel, + Tabs, +} from "@/registry/preskok/ui/preskok-ui/tabs" import { TextField } from "@/registry/preskok/ui/preskok-ui/text-field" type TaskIntent = "danger" | "info" | "secondary" | "warning" type TaskPriority = "normal" | "urgent" +type ProjectView = "activity" | "overview" | "tasks" const defaultTaskDueDate = parseDate("2026-08-28") interface ShowcaseTask { id: string title: string - detail: string + workstream: string + due: string status: string intent: TaskIntent avatar: string @@ -73,14 +93,14 @@ interface ShowcaseTask { interface ShowcaseState { tasks: ShowcaseTask[] completedTaskIds: Set + activeView: ProjectView isAddTaskOpen: boolean - showAllTasks: boolean statusMessage: string } type ShowcaseAction = + | { type: "set-view"; view: ProjectView } | { type: "set-add-task-open"; isOpen: boolean } - | { type: "toggle-show-all" } | { type: "toggle-task"; task: ShowcaseTask; isSelected: boolean } | { type: "add-task"; task: ShowcaseTask } | { type: "mark-all-complete" } @@ -90,7 +110,8 @@ const initialTasks: ShowcaseTask[] = [ { id: "onboarding-copy", title: "Finalize onboarding copy", - detail: "Growth · Due today", + workstream: "Growth", + due: "Today", status: "Review", intent: "warning", avatar: "/avatars/01.png", @@ -99,7 +120,8 @@ const initialTasks: ShowcaseTask[] = [ { id: "billing-webhooks", title: "Wire billing webhooks", - detail: "Platform · Due tomorrow", + workstream: "Platform", + due: "Tomorrow", status: "In progress", intent: "info", avatar: "/avatars/02.png", @@ -108,14 +130,59 @@ const initialTasks: ShowcaseTask[] = [ { id: "mobile-checkout", title: "QA mobile checkout", - detail: "Checkout · Due Friday", + workstream: "Checkout", + due: "Friday", status: "Blocked", intent: "danger", avatar: "/avatars/03.png", assignee: "Taylor Kim", }, + { + id: "tax-rules", + title: "Confirm regional tax rules", + workstream: "Compliance", + due: "26 Aug", + status: "Approved", + intent: "secondary", + avatar: "/avatars/04.png", + assignee: "Morgan Lee", + }, + { + id: "launch-comms", + title: "Schedule launch announcement", + workstream: "Growth", + due: "28 Aug", + status: "Scheduled", + intent: "info", + avatar: "/avatars/05.png", + assignee: "Jordan Bell", + }, ] +const recentActivity = [ + { + id: "blocked-checkout", + avatar: "/avatars/03.png", + assignee: "Taylor Kim", + action: "flagged mobile checkout as blocked", + time: "12 min ago", + }, + { + id: "approved-tax", + avatar: "/avatars/04.png", + assignee: "Morgan Lee", + action: "approved the regional tax rules", + time: "1 hr ago", + }, + { + id: "uploaded-prototype", + avatar: "/avatars/01.png", + assignee: "Alex Johnson", + action: "shared the final checkout prototype", + time: "Yesterday", + }, +] as const + const milestones = [ { label: "Design QA", @@ -141,8 +208,8 @@ function createInitialState(statusMessage = ""): ShowcaseState { return { tasks: initialTasks.map((task) => ({ ...task })), completedTaskIds: new Set(), + activeView: "overview", isAddTaskOpen: false, - showAllTasks: false, statusMessage, } } @@ -152,10 +219,10 @@ function showcaseReducer( action: ShowcaseAction ): ShowcaseState { switch (action.type) { + case "set-view": + return { ...state, activeView: action.view } case "set-add-task-open": return { ...state, isAddTaskOpen: action.isOpen } - case "toggle-show-all": - return { ...state, showAllTasks: !state.showAllTasks } case "toggle-task": { const completedTaskIds = new Set(state.completedTaskIds) @@ -172,7 +239,7 @@ function showcaseReducer( return { ...state, completedTaskIds, statusMessage } } case "add-task": { - const tasks = [action.task, ...state.tasks].slice(0, 3) + const tasks = [action.task, ...state.tasks].slice(0, 5) const taskIds = new Set(tasks.map((task) => task.id)) const completedTaskIds = new Set( [...state.completedTaskIds].filter((id) => taskIds.has(id)) @@ -182,6 +249,7 @@ function showcaseReducer( ...state, tasks, completedTaskIds, + activeView: "tasks", isAddTaskOpen: false, statusMessage: `${action.task.title} added to priority work.`, } @@ -220,15 +288,22 @@ export function Blocks() { const metrics = [ { label: "Completion", + shortLabel: "Ready", value: `${completion}%`, change: completionChange, }, { label: "Open tasks", + shortLabel: "Open", value: String(openTaskCount), change: openTasksChange, }, - { label: "Cycle time", value: "3.4d", change: "0.6d faster" }, + { + label: "Cycle time", + shortLabel: "Cycle", + value: "3.4d", + change: "0.6d faster", + }, ] function addTask( @@ -248,10 +323,8 @@ export function Blocks() { month: "short", }) : null - const priorityLabel = priority === "urgent" ? "Urgent" : "Planning" - const detail = dueDateLabel - ? `${priorityLabel} · Due ${dueDateLabel}` - : `${priorityLabel} · No due date` + const workstream = priority === "urgent" ? "Urgent" : "Planning" + const due = dueDateLabel ?? "No date" const status = priority === "urgent" ? "High priority" : "Scheduled" const intent: TaskIntent = priority === "urgent" ? "warning" : "secondary" @@ -260,7 +333,8 @@ export function Blocks() { task: { id: crypto.randomUUID(), title, - detail, + workstream, + due, status, intent, avatar: "/avatars/04.png", @@ -270,6 +344,16 @@ export function Blocks() { }) } + function changeView(key: Key) { + if (key === "overview" || key === "tasks" || key === "activity") { + dispatch({ type: "set-view", view: key }) + } + } + + function toggleTask(task: ShowcaseTask, isSelected: boolean) { + dispatch({ type: "toggle-task", task, isSelected }) + } + return (
- - dispatch({ type: "set-add-task-open", isOpen }) - } - onMarkAllComplete={() => dispatch({ type: "mark-all-complete" })} - onReset={() => dispatch({ type: "reset" })} - /> - - -
- dispatch({ type: "toggle-show-all" })} - onToggleTask={(task, isSelected) => - dispatch({ type: "toggle-task", task, isSelected }) - } - /> - +
+ + +
+ + + dispatch({ type: "set-add-task-open", isOpen }) + } + onMarkAllComplete={() => dispatch({ type: "mark-all-complete" })} + onReset={() => dispatch({ type: "reset" })} + /> + + + + + Overview + + + Tasks + + {openTaskCount} + + + + Activity + + + + + +
+ + dispatch({ type: "set-view", view: "tasks" }) + } + onToggleTask={toggleTask} + /> + +
+
+ + + + + + + + +
+
) } +function WorkspaceSidebar() { + return ( + + ) +} + +function WorkspaceToolbar() { + return ( +
+
+ +

+ Projects / + Checkout launch +

+
+
+ + + Synced 2 min ago + + +
+
+ ) +} + function ProjectHeader({ isAddTaskOpen, onAddTask, @@ -358,7 +576,7 @@ function ProjectHeader({ On track

- Everything the team needs for the August release. + Commerce platform · Release 2.8 · Ships 28 August

@@ -371,7 +589,7 @@ function ProjectHeader({
@@ -552,7 +771,7 @@ function TaskList({ key={task.id} task={task} isCompleted={completedTaskIds.has(task.id)} - hideOnMobile={index > 1 && !showAllTasks} + hideOnMobile={index > 1} onToggle={onToggleTask} /> ))} @@ -561,29 +780,6 @@ function TaskList({ ) } -function ContextualChevron({ isExpanded }: { isExpanded: boolean }) { - return ( - - ) -} - function TaskRow({ task, isCompleted, @@ -624,7 +820,7 @@ function TaskRow({ {task.title}

- {task.detail} + {task.workstream} · Due {task.due}

@@ -640,6 +836,159 @@ function TaskRow({ ) } +function TaskTable({ + tasks, + completedTaskIds, + onToggleTask, +}: { + tasks: ShowcaseTask[] + completedTaskIds: Set + onToggleTask: (task: ShowcaseTask, isSelected: boolean) => void +}) { + return ( +
+
+
+

+ Release work +

+

+ Priority items across the launch team +

+
+ {tasks.length} shown +
+ +
+ + + Task + Owner + Status + Due + + + {tasks.map((task) => { + const isCompleted = completedTaskIds.has(task.id) + + return ( + + +
+ + onToggleTask(task, isSelected) + } + /> +
+

+ {task.title} +

+

+ {task.workstream} +

+
+
+
+ +
+ + {task.assignee} +
+
+ + + {isCompleted ? "Done" : task.status} + + + + {task.due} + +
+ ) + })} +
+
+
+
+ ) +} + +function ActivityView() { + return ( +
+
+
+ +

+ Recent activity +

+
+ +
+ {recentActivity.map((item) => ( +
+ +
+

+ {item.assignee}{" "} + {item.action} +

+

+ {item.time} +

+
+
+ ))} +
+
+ + +
+ ) +} + function ProjectProgress({ completion }: { completion: number }) { return (
+ +
+
+ +
+

1 launch risk

+

+ Mobile checkout QA needs an owner +

+
+
+
) }