diff --git a/apps/preskok/app/(docs)/[[...slug]]/page.tsx b/apps/preskok/app/(docs)/[[...slug]]/page.tsx index f3ae30ca7..12d7b8975 100644 --- a/apps/preskok/app/(docs)/[[...slug]]/page.tsx +++ b/apps/preskok/app/(docs)/[[...slug]]/page.tsx @@ -97,8 +97,8 @@ async function DocsPageContent(props: DocsPageProps) { hints: "search", })}` - return ( - + const content = ( + <> {doc.title} @@ -130,6 +130,19 @@ async function DocsPageContent(props: DocsPageProps) { })} /> - + ) + + if (isFullPage) { + return ( +
+ {content} +
+ ) + } + + return {content} } diff --git a/apps/preskok/components/theme/blocks.tsx b/apps/preskok/components/theme/blocks.tsx index d6952c171..1f630be7d 100644 --- a/apps/preskok/components/theme/blocks.tsx +++ b/apps/preskok/components/theme/blocks.tsx @@ -1,230 +1,1052 @@ +"use client" + +import { useReducer, useState, type Key } from "react" +import { + getLocalTimeZone, + parseDate, + type CalendarDate, +} from "@internationalized/date" import { - Card as DocsCard, - Cards as DocsCards, -} from "fumadocs-ui/components/card" -import { LayoutDashboardIcon } from "lucide-react" - -// Preskok examples -import AreaChartPreskokDemo from "@/registry/preskok/examples/area-chart-preskok-demo" -import BarChartPreskokDemo from "@/registry/preskok/examples/bar-chart-preskok-demo" -import DropdownPreskokDemo from "@/registry/preskok/examples/dropdown-preskok-demo" -import { ModalPreskokDemo } from "@/registry/preskok/examples/modal-preskok-demo" -import RangeCalendarPreskokDemo from "@/registry/preskok/examples/range-calendar-preskok-demo" + ActivityIcon, + CheckCheckIcon, + CheckCircle2Icon, + 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" + 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 { - Button, - type ButtonProps, -} from "@/registry/preskok/ui/preskok-ui/button" + Menu, + MenuContent, + MenuItem, +} from "@/registry/preskok/ui/preskok-ui/menu" import { - Checkbox, - CheckboxGroup, -} from "@/registry/preskok/ui/preskok-ui/checkbox" + Popover, + PopoverBody, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, +} from "@/registry/preskok/ui/preskok-ui/popover" import { - ComboBox, - ComboBoxContent, - ComboBoxInput, - ComboBoxItem, -} from "@/registry/preskok/ui/preskok-ui/combo-box" -import { Description, Label } from "@/registry/preskok/ui/preskok-ui/field" -import { Link } from "@/registry/preskok/ui/preskok-ui/link" -import { Radio, RadioGroup } from "@/registry/preskok/ui/preskok-ui/radio" + ProgressBar, + ProgressBarHeader, + ProgressBarTrack, + ProgressBarValue, +} from "@/registry/preskok/ui/preskok-ui/progress-bar" import { - Select, - SelectContent, - SelectDescription, - SelectItem, - SelectLabel, - SelectTrigger, -} from "@/registry/preskok/ui/preskok-ui/select" -import { Switch } from "@/registry/preskok/ui/preskok-ui/switch" + 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" -// Local demo data for lists -const roles: Array<{ id: string; name: string; description?: string }> = [ - { id: "admin", name: "Administrator", description: "Full access" }, - { id: "editor", name: "Editor", description: "Edit content" }, - { id: "viewer", name: "Viewer", description: "View only" }, -] +type TaskIntent = "danger" | "info" | "secondary" | "warning" +type TaskPriority = "normal" | "urgent" +type ProjectView = "activity" | "overview" | "tasks" -const users: Array<{ id: string; name: string; image_url?: string }> = [ - { id: "1", name: "Alex Johnson", image_url: "/avatars/01.png" }, - { id: "2", name: "Jamie Rivera", image_url: "/avatars/02.png" }, - { id: "3", name: "Taylor Kim", image_url: "/avatars/03.png" }, +const defaultTaskDueDate = parseDate("2026-08-28") + +interface ShowcaseTask { + id: string + title: string + workstream: string + due: string + status: string + intent: TaskIntent + avatar: string + assignee: string + isNew?: boolean +} + +interface ShowcaseState { + tasks: ShowcaseTask[] + completedTaskIds: Set + activeView: ProjectView + isAddTaskOpen: boolean + statusMessage: string +} + +type ShowcaseAction = + | { type: "set-view"; view: ProjectView } + | { type: "set-add-task-open"; isOpen: boolean } + | { 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", + workstream: "Growth", + due: "Today", + status: "Review", + intent: "warning", + avatar: "/avatars/01.png", + assignee: "Alex Johnson", + }, + { + id: "billing-webhooks", + title: "Wire billing webhooks", + workstream: "Platform", + due: "Tomorrow", + status: "In progress", + intent: "info", + avatar: "/avatars/02.png", + assignee: "Jamie Rivera", + }, + { + id: "mobile-checkout", + title: "QA mobile checkout", + 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 buttonIntents = [ - "primary", - "secondary", - "warning", - "danger", - "outline", - "plain", -] satisfies NonNullable[] + +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", + detail: "18 of 18 screens", + complete: true, + hideOnMobile: false, + }, + { + label: "Engineering", + detail: "24 of 31 tasks", + complete: false, + hideOnMobile: false, + }, + { + label: "Launch checklist", + detail: "7 of 10 items", + complete: false, + hideOnMobile: true, + }, +] as const + +function createInitialState(statusMessage = ""): ShowcaseState { + return { + tasks: initialTasks.map((task) => ({ ...task })), + completedTaskIds: new Set(), + activeView: "overview", + isAddTaskOpen: false, + statusMessage, + } +} + +function showcaseReducer( + state: ShowcaseState, + 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-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, 5) + const taskIds = new Set(tasks.map((task) => task.id)) + const completedTaskIds = new Set( + [...state.completedTaskIds].filter((id) => taskIds.has(id)) + ) + + return { + ...state, + tasks, + completedTaskIds, + activeView: "tasks", + 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", + shortLabel: "Ready", + value: `${completion}%`, + change: completionChange, + }, + { + label: "Open tasks", + shortLabel: "Open", + value: String(openTaskCount), + change: openTasksChange, + }, + { + label: "Cycle time", + shortLabel: "Cycle", + value: "3.4d", + change: "0.6d faster", + }, + ] + + 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 workstream = priority === "urgent" ? "Urgent" : "Planning" + const due = dueDateLabel ?? "No date" + const status = priority === "urgent" ? "High priority" : "Scheduled" + const intent: TaskIntent = priority === "urgent" ? "warning" : "secondary" + + dispatch({ + type: "add-task", + task: { + id: crypto.randomUUID(), + title, + workstream, + due, + status, + intent, + avatar: "/avatars/04.png", + assignee: "Morgan Lee", + isNew: true, + }, + }) + } + + 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 ( -
- - -
-
- {buttonIntents.map((intent) => ( - - ))} -
-
-
+
+
+
+ {state.statusMessage} +
- -
- - -
- Remember me - - Forgot password? - -
- -
-
- - -
-
-
- - -
- - - - - {(item: { id: string; name: string; image_url?: string }) => ( - - - {item.name} - - )} - - -
-
-
+
+ - -
- -
-
+
+ + + dispatch({ type: "set-add-task-open", isOpen }) + } + onMarkAllComplete={() => dispatch({ type: "mark-all-complete" })} + onReset={() => dispatch({ type: "reset" })} + /> - -
- - - - Set all protections to maximum. - - - - - - Encrypt all data at rest and in transit. - - - - - Enable network firewall. - - - - - - - Balance between protection and performance. - - - - - Minimal protection enabled. - - + + Overview + + + Tasks + + {openTaskCount} + + + + Activity + + + + + +
+ + dispatch({ type: "set-view", view: "tasks" }) + } + onToggleTask={toggleTask} + /> + +
+
+ + + + + + + + +
-
- - -
- - {({ isSelected }: { isSelected: boolean }) => ( - <> - - - {isSelected - ? "Dark theme is enabled" - : "Light theme is currently active"} - - - )} - - - - {({ isSelected }: { isSelected: boolean }) => ( - <> - - - {isSelected - ? "Apps can access your location" - : "Location access is disabled"} - - - )} - - - - {({ isSelected }: { isSelected: boolean }) => ( - <> - - - {isSelected - ? "You will receive email notifications" - : "Email notifications are turned off"} - - - )} - +
+
+
+ ) +} + +function WorkspaceSidebar() { + return ( + + ) +} + +function WorkspaceToolbar() { + return ( +
+
+ +

+ Projects / + Checkout launch +

+
+
+ + + Synced 2 min ago + + +
+
+ ) +} + +function ProjectHeader({ + isAddTaskOpen, + onAddTask, + onAddTaskOpenChange, + onMarkAllComplete, + onReset, +}: { + isAddTaskOpen: boolean + 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 ( +
+
+
+

+ Checkout launch +

+ On track +
+

+ Commerce platform · Release 2.8 · Ships 28 August +

+
+ +
+
+ + + +
+ + + + + + Add priority task + + Add one item to the project preview. + + + +
+ + + + + + + + +
+ + Priority + + + + + +
+
+ + +
+
+
+
+
+ + + + + + + Mark all complete + + + + Reset preview + + + +
+
+ ) +} + +function MetricsGrid({ + metrics, +}: { + metrics: { + label: string + shortLabel: string + value: string + change: string + }[] +}) { + return ( +
+ {metrics.map((metric) => ( +
+
+ {metric.shortLabel} + {metric.label} +
+
+

+ {metric.value} +

+

+ {metric.change} +

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

+ Priority work +

+ +
+ +
+ {tasks.map((task, index) => ( + 1} + onToggle={onToggleTask} + /> + ))} +
+
+ ) +} + +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.workstream} · Due {task.due} +

+
+
+ + {isCompleted ? "Done" : task.status} + + +
+
+ ) +} -
- - +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 ( + + ) +} + +function Milestone({ + label, + detail, + complete, + hideOnMobile, +}: (typeof milestones)[number]) { + const StatusIcon = complete ? CheckCircle2Icon : CircleIcon + + return ( +
+
+ ) +} diff --git a/apps/preskok/components/theme/generated-theme.tsx b/apps/preskok/components/theme/generated-theme.tsx index f74192664..4910b0825 100644 --- a/apps/preskok/components/theme/generated-theme.tsx +++ b/apps/preskok/components/theme/generated-theme.tsx @@ -1,70 +1,189 @@ -import type React from "react" +import { Focusable } from "react-aria-components/Tooltip" import { twMerge } from "tailwind-merge" -import type { ThemeColorTokenName } from "./themes" +import { Button } from "@/registry/preskok/ui/preskok-ui/button" +import { + Popover, + PopoverBody, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, +} from "@/registry/preskok/ui/preskok-ui/popover" +import { + Tooltip, + TooltipContent, +} from "@/registry/preskok/ui/preskok-ui/tooltip" -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 type { ThemeAppearance } from "./theme-customizer" +import { + THEME_PRIMITIVE_STEPS, + type ThemeContrastCheck, + type ResolvedTheme, +} from "./themes" + +const USAGE_RANGES = [ + { label: "Backgrounds", className: "col-span-2" }, + { label: "Interactive components", className: "col-span-3" }, + { label: "Borders and separators", className: "col-span-3" }, + { label: "Solid colors", className: "col-span-2" }, + { label: "Accessible text", className: "col-span-2" }, +] as const export function GeneratedTheme({ + theme, + checks, + appearance, className, - ...props -}: React.ComponentProps<"div">) { +}: { + theme: ResolvedTheme + checks: ThemeContrastCheck[] + appearance: ThemeAppearance + className?: string +}) { return ( -
-
- {TOKEN_GROUPS.map((variables) => ( - - {variables.map((variable) => ( - - ))} - - ))} +
+
+

Palette

+ +
+ +
+
+ {USAGE_RANGES.map((range) => ( + + {range.label} + + ))} +
+ +
+ {THEME_PRIMITIVE_STEPS.map((step) => ( + + {step} + + ))} +
+ +
+ + +
) } -function ColorBox(props: React.ComponentProps<"div">) { - return
+function ScalePreview({ + label, + colors, +}: { + label: string + colors: readonly string[] +}) { + return ( +
+ {label} +
+ {THEME_PRIMITIVE_STEPS.map((step, index) => { + const color = colors[index] + + return ( + + + + + + + {color} + + + ) + })} +
+
+ ) +} + +function ContrastSummary({ checks }: { checks: ThemeContrastCheck[] }) { + const passingChecks = checks.filter((check) => check.passes).length + + return ( + + + + + Text contrast + + WCAG 2.x uses a 4.5:1 threshold. APCA is shown as additional + guidance. + + + +
+ {checks.map((check) => ( + + ))} +
+
+
+
+ ) } -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..378a5ec50 100644 --- a/apps/preskok/components/theme/theme-container.tsx +++ b/apps/preskok/components/theme/theme-container.tsx @@ -1,10 +1,7 @@ "use client" -import { Suspense, useState } from "react" -import { - Card as DocsCard, - Cards as DocsCards, -} from "fumadocs-ui/components/card" +import { Suspense, useRef, useState } from "react" +import type { CSSProperties } from "react" import { CodeBlock, Pre } from "fumadocs-ui/components/codeblock" import { ChevronDownIcon, @@ -15,11 +12,16 @@ import { RotateCcwIcon, UploadIcon, } from "lucide-react" +import { UNSAFE_PortalProvider } from "react-aria" import { toast } from "sonner" +import { twMerge } from "tailwind-merge" import { Blocks } from "@/components/theme/blocks" import { GeneratedTheme } from "@/components/theme/generated-theme" -import { ThemeCustomizer } from "@/components/theme/theme-customizer" +import { + ThemeCustomizer, + type ThemeAppearance, +} from "@/components/theme/theme-customizer" import { Button, buttonStyles } from "@/registry/preskok/ui/preskok-ui/button" import { Menu, @@ -42,11 +44,12 @@ import { } from "@/registry/preskok/ui/preskok-ui/tabs" import { + createThemeArtifacts, DEFAULT_THEME_SELECTION, - generateFigmaThemeJson, - generateTheme, - generateThemeManifestJson, parseThemeManifestJson, + THEME_COLOR_TOKEN_NAMES, + THEME_PRIMITIVE_STEPS, + type ResolvedTheme, type ThemeSelection, } from "./themes" @@ -56,6 +59,35 @@ type GeneratedFile = { type: string } +function createPreviewStyles( + theme: ResolvedTheme, + appearance: ThemeAppearance +) { + const colors = theme.colors[appearance] + const primitives = theme.primitives[appearance] + + return { + colorScheme: appearance, + ...Object.fromEntries( + THEME_COLOR_TOKEN_NAMES.flatMap((token) => [ + [`--${token}`, colors[token]], + [`--color-${token}`, colors[token]], + ]) + ), + ...Object.fromEntries( + THEME_PRIMITIVE_STEPS.flatMap((step, index) => [ + [`--accent-${step}`, primitives.accent[index]], + [`--accent-a${step}`, primitives.accentAlpha[index]], + [`--gray-${step}`, primitives.gray[index]], + [`--gray-a${step}`, primitives.grayAlpha[index]], + ]) + ), + "--accent-contrast": primitives.accentContrast, + "--accent-surface-primitive": primitives.accentSurface, + "--gray-surface": primitives.graySurface, + } as CSSProperties +} + function downloadFile({ filename, content, type }: GeneratedFile) { const url = URL.createObjectURL(new Blob([content], { type })) const anchor = document.createElement("a") @@ -71,14 +103,20 @@ export function ThemeContainer() { const [selectedColors, setSelectedColors] = useState( DEFAULT_THEME_SELECTION ) + const [appearance, setAppearance] = useState("light") const [open, setOpen] = useState(false) - const css = generateTheme(selectedColors) - const figmaJson = generateFigmaThemeJson(selectedColors) - const manifestJson = generateThemeManifestJson(selectedColors) + const previewPortalRef = useRef(null) + const { theme, contrastChecks, css, figmaJson, manifestJson } = + 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() { @@ -87,7 +125,6 @@ export function ThemeContainer() { content: css, type: "text/css", }) - toast.success("CSS theme downloaded.") } function downloadFigmaTheme() { @@ -96,7 +133,6 @@ export function ThemeContainer() { content: figmaJson, type: "application/json", }) - toast.success("Figma mode downloaded.") } function downloadManifest() { @@ -105,7 +141,6 @@ export function ThemeContainer() { content: manifestJson, type: "application/json", }) - toast.success("Project theme saved.") } async function loadManifest(files: FileList | null) { @@ -117,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 @@ -129,20 +163,74 @@ export function ThemeContainer() { function resetTheme() { setSelectedColors(DEFAULT_THEME_SELECTION) - toast.success("Theme reset to the Preskok defaults.") } return ( <> -
- - + previewPortalRef.current}> +
-
- -
+
- -
- - - - - - - Copy CSS - - - - Download CSS - - - - Download for Figma - - - - Save project theme - - setOpen(true)}> - - Inspect generated files - - - -
- -
-
- - - - + + + +
+ +
@@ -220,7 +287,7 @@ export function ThemeContainer() { > @@ -234,9 +301,8 @@ 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. + Import this file as a new mode in Figma's + Style collection.

- Commit this small file with a project to reopen the exact - configuration later. + Save this file to edit the same theme later.

{ - 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, generatePalette } from "./palette" +import { + resolveThemeBackground, + THEME_RADIUS_OPTIONS, + type ThemeAppearanceSelection, + type ThemeBackgroundMode, + type ThemeSelection, +} from "./themes" type ThemeCustomizerProps = { + actions?: React.ReactNode + appearance: ThemeAppearance selectedColors: ThemeSelection + setAppearance: React.Dispatch> setSelectedColors: React.Dispatch> } +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, selectedColors, + setAppearance, setSelectedColors, }: ThemeCustomizerProps) { - const handleSelectionChange = - (type: keyof typeof selectedColors) => (key: Key | null) => { - if (!key) { - return + const values = selectedColors[appearance] + + function updateAppearanceColor(type: EditableColor, value: string) { + setSelectedColors((previous) => { + const next = { + ...previous, + [appearance]: { + ...previous[appearance], + [type]: value, + }, + } + + if (type === "gray") { + return { ...next, grayMode: "custom" as const } } - const value = key.toString() + if (type !== "accent" || previous.grayMode !== "auto") { + return next + } - if (type === "radius") { - const radius = THEME_RADIUS_OPTIONS.find((option) => option === value) - if (radius) { - setSelectedColors((previous) => ({ ...previous, radius })) - } - return + return { + ...next, + [appearance]: { + ...next[appearance], + gray: deriveGraySource(value), + }, } + }) + } - if (type === "primary") { - setSelectedColors((previous) => ({ - ...previous, - primary: value, - accent: value, - })) - return + function setGrayMode(isAuto: boolean) { + setSelectedColors((previous) => { + if (!isAuto) { + return { ...previous, grayMode: "custom" } } - setSelectedColors((previous) => ({ ...previous, [type]: value })) + return { + ...previous, + grayMode: "auto", + light: { + ...previous.light, + gray: deriveGraySource(previous.light.accent), + }, + dark: { + ...previous.dark, + gray: deriveGraySource(previous.dark.accent), + }, + } + }) + } + + function setBackgroundMode(backgroundMode: ThemeBackgroundMode) { + setSelectedColors((previous) => ({ + ...previous, + [appearance]: { + ...previous[appearance], + backgroundMode, + }, + })) + } + + 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} +
+
+ { + const key = [...keys][0] + if (key === "light" || key === "dark") { + setAppearance(key) + } + }} + > + + + Light + + + + Dark + + +
+ +
+ updateAppearanceColor("accent", value)} /> - - handleSelectionChange("primary")(key as Key) + + Auto + } - label="Primary Color" - placeholder="Select primary color" - filterKeys={filteredPrimaryColors} + onChange={(value) => updateAppearanceColor("gray", value)} /> - - handleSelectionChange("accent")(key as Key) - } - label="Accent Color" - placeholder="Select accent color" - filterKeys={filteredAccentColors} + updateAppearanceColor("customBackground", value)} + onModeChange={setBackgroundMode} /> - + + + {THEME_RADIUS_OPTIONS.map((radius) => ( + + + {radius.replace("rem", "")} + {radius === "0.5rem" && ( + Default + )} + + + ))} + + +
+ + {actions && ( +
{actions}
+ )} +
+
+ ) +} + +function BackgroundControl({ + appearance, + selection, + onChange, + onModeChange, +}: { + appearance: ThemeAppearance + selection: ThemeAppearanceSelection + onChange: (value: string) => void + onModeChange: (mode: ThemeBackgroundMode) => void +}) { + const selectedOption = BACKGROUND_OPTIONS.find( + (option) => option.id === selection.backgroundMode + ) + const preview = createBackgroundPreview( + appearance, + selection, + selection.backgroundMode + ) + + return ( +
+ + + + + + Page background + + Choose the surface treatment for this appearance. + + + +
+ {BACKGROUND_OPTIONS.map((option) => { + const isSelected = option.id === selection.backgroundMode + const optionPreview = createBackgroundPreview( + appearance, + selection, + option.id + ) + + return ( + + ) + })} +
+ + {selection.backgroundMode === "custom" && ( +
+ + onChange(color.toString("hex"))} + /> +
+ )} +
+
+
+
+ ) +} + +function createBackgroundPreview( + appearance: ThemeAppearance, + selection: ThemeAppearanceSelection, + backgroundMode: ThemeBackgroundMode +) { + const background = resolveThemeBackground(appearance, { + ...selection, + backgroundMode, + }) + const palette = generatePalette({ + appearance, + accent: selection.accent, + gray: selection.gray, + background, + }) + + return { + background, + panel: palette.gray[1], + control: palette.gray[3], + } +} + +function SurfacePreview({ + background, + panel, + control, + size = "sm", +}: { + background: string + panel: string + control: string + size?: "sm" | "lg" +}) { + return ( + \n )}\n\n {typeof children === \"function\" ? children(values) : children}\n\n {values.hasSubmenu && (\n \n )}\n \n )}\n \n )\n}\n\nexport interface MenuHeaderProps extends React.ComponentProps {\n separator?: boolean\n}\n\nconst MenuHeader = ({\n className,\n separator = false,\n ...props\n}: MenuHeaderProps) => (\n \n)\n\nconst { section, header } = dropdownSectionStyles()\n\ninterface MenuSectionProps extends MenuSectionPrimitiveProps {\n ref?: React.Ref\n label?: string\n}\n\nconst MenuSection = ({\n className,\n ref,\n ...props\n}: MenuSectionProps) => {\n return (\n \n {\"label\" in props &&
{props.label}
}\n {props.children}\n \n )\n}\n\nconst MenuSeparator = DropdownSeparator\nconst MenuShortcut: typeof DropdownKeyboard = DropdownKeyboard\nconst MenuLabel = DropdownLabel\nconst MenuDescription = DropdownDescription\n\nexport {\n Menu,\n MenuContent,\n menuContentStyles,\n MenuDescription,\n MenuHeader,\n MenuItem,\n MenuLabel,\n MenuSection,\n MenuSeparator,\n MenuShortcut,\n MenuSubMenu,\n MenuTrigger,\n}\nexport type {\n MenuContentProps,\n MenuItemProps,\n MenuSectionProps,\n MenuTriggerProps,\n}\n", "type": "registry:ui" } ], diff --git a/apps/preskok/registry/preskok/ui/preskok-ui/color-picker.tsx b/apps/preskok/registry/preskok/ui/preskok-ui/color-picker.tsx index 4e1916056..bd8e9bd3a 100644 --- a/apps/preskok/registry/preskok/ui/preskok-ui/color-picker.tsx +++ b/apps/preskok/registry/preskok/ui/preskok-ui/color-picker.tsx @@ -16,6 +16,7 @@ import { ColorField } from "./color-field" import { ColorSlider } from "./color-slider" import { ColorSwatch } from "./color-swatch" import { Description } from "./field" +import { Input } from "./input" import { Popover, PopoverContent, type PopoverContentProps } from "./popover" interface ColorPickerProps @@ -78,7 +79,9 @@ const ColorPicker = ({ />
{eyeDropper && } - + + +
)} diff --git a/apps/preskok/registry/preskok/ui/preskok-ui/date-picker.tsx b/apps/preskok/registry/preskok/ui/preskok-ui/date-picker.tsx index fbc136bfb..5d10de81f 100644 --- a/apps/preskok/registry/preskok/ui/preskok-ui/date-picker.tsx +++ b/apps/preskok/registry/preskok/ui/preskok-ui/date-picker.tsx @@ -67,7 +67,7 @@ export function DatePickerOverlay({ return isMobile ? ( -
+
{range ? ( [slot=label]+[data-slot=icon]]:absolute [&>[slot=label]+[data-slot=icon]]:right-1", diff --git a/apps/preskok/registry/preskok/ui/preskok-ui/menu.tsx b/apps/preskok/registry/preskok/ui/preskok-ui/menu.tsx index 015caf976..2eaaaf692 100644 --- a/apps/preskok/registry/preskok/ui/preskok-ui/menu.tsx +++ b/apps/preskok/registry/preskok/ui/preskok-ui/menu.tsx @@ -80,7 +80,7 @@ interface MenuContentProps } const menuContentStyles = tv({ - base: "grid max-h-[inherit] grid-cols-[auto_1fr] gap-y-1 overflow-x-hidden overflow-y-auto overscroll-contain p-1 outline-hidden [clip-path:inset(0_0_0_0_round_calc(var(--radius-xl)-(--spacing(1))))] *:[[role='group']+[role=group]]:mt-3", + base: "grid max-h-[inherit] grid-cols-[auto_1fr] gap-y-1 overflow-x-hidden overflow-y-auto overscroll-contain p-1 outline-hidden [clip-path:inset(0_0_0_0_round_var(--menu-content-radius))] *:[[role='group']+[role=group]]:mt-3", }) const MenuContent = ({ @@ -89,11 +89,23 @@ const MenuContent = ({ popover, ...props }: MenuContentProps) => { + const { + className: popoverClassName, + placement: popoverPlacement, + ...popoverProps + } = popover ?? {} + return ( { + return check.mode === mode + }) + assert.equal(checks.length, 10) + assert.equal( + checks.every((check) => Number.isFinite(check.apca)), + true, + `${mode} APCA checks must be numeric` + ) } assert.equal(figma.color.light.danger, undefined) @@ -98,9 +147,16 @@ function assertSelection(selection: ThemeSelection) { assert.equal(rootVariables["radius-lg"], selection.radius) assert.equal(rootVariables.radius, "var(--radius-lg)") assert.equal(generateFigmaThemeJson(selection).includes("var(--"), false) + assert.match(css, /@supports \(color: color\(display-p3 1 1 1\)\)/) } assertSelection(DEFAULT_THEME_SELECTION) +const defaultArtifacts = createThemeArtifacts(DEFAULT_THEME_SELECTION) +assert.equal(defaultArtifacts.css, generateTheme(DEFAULT_THEME_SELECTION)) +assert.equal( + defaultArtifacts.figmaJson, + generateFigmaThemeJson(DEFAULT_THEME_SELECTION) +) const globalCss = readFileSync( new URL("../styles/globals.css", import.meta.url), @@ -124,22 +180,70 @@ assert.deepEqual( "The theme bridge must cover the semantic contract in styles/globals.css" ) -for (const color of Object.keys(colors)) { +for (const radius of THEME_RADIUS_OPTIONS) { + assertSelection({ ...DEFAULT_THEME_SELECTION, radius }) +} + +for (const backgroundMode of THEME_BACKGROUND_MODES) { assertSelection({ ...DEFAULT_THEME_SELECTION, - primary: color, - accent: color, + light: { ...DEFAULT_THEME_SELECTION.light, backgroundMode }, + dark: { ...DEFAULT_THEME_SELECTION.dark, backgroundMode }, }) } -for (const gray of neutralColors) { - assertSelection({ ...DEFAULT_THEME_SELECTION, gray }) -} +assert.equal( + resolveThemeBackground("light", { + ...DEFAULT_THEME_SELECTION.light, + backgroundMode: "pure", + }), + "#ffffff" +) +assert.equal( + resolveThemeBackground("dark", { + ...DEFAULT_THEME_SELECTION.dark, + backgroundMode: "pure", + }), + "#09090b" +) +assert.equal( + resolveThemeBackground("light", { + ...DEFAULT_THEME_SELECTION.light, + backgroundMode: "custom", + customBackground: "#f3f4f6", + }), + "#f3f4f6" +) -for (const radius of THEME_RADIUS_OPTIONS) { - assertSelection({ ...DEFAULT_THEME_SELECTION, radius }) +const colorSamples = [ + "#000000", + "#ffffff", + "#ff006e", + "#7c3aed", + "#006adc", + "#00a2c7", + "#2e7d32", + "#ffba18", +] as const +for (const accent of colorSamples) { + assertSelection({ + ...DEFAULT_THEME_SELECTION, + light: { ...DEFAULT_THEME_SELECTION.light, accent }, + dark: { ...DEFAULT_THEME_SELECTION.dark, accent }, + }) } +const defaultChecks = createThemeContrastChecks(DEFAULT_THEME_SELECTION) +assert.equal(defaultChecks.length, 20) +assert.equal( + defaultChecks.every((check) => check.passes), + true, + `Default theme must pass normal-text contrast: ${defaultChecks + .filter((check) => !check.passes) + .map((check) => `${check.mode}/${check.label} ${check.wcag}:1`) + .join(", ")}` +) + const figma = generateFigmaThemeTokens(DEFAULT_THEME_SELECTION) assert.deepEqual(Object.keys(figma.color.light), [ ...FIGMA_STYLE_COLOR_TOKEN_NAMES, @@ -147,6 +251,8 @@ assert.deepEqual(Object.keys(figma.color.light), [ assert.deepEqual(Object.keys(figma.color.dark), [ ...FIGMA_STYLE_COLOR_TOKEN_NAMES, ]) +assert.equal(Object.keys(figma.primitive.color.light.accent).length, 12) +assert.equal(Object.keys(figma.primitive.color.dark.gray).length, 12) assert.deepEqual( Object.fromEntries( Object.entries(figma.radius).map(([name, token]) => [ @@ -178,6 +284,7 @@ assert.equal( ) const manifestJson = generateThemeManifestJson(DEFAULT_THEME_SELECTION) +assert.equal(THEME_MANIFEST_VERSION, 3) assert.deepEqual( parseThemeManifestJson(manifestJson).selection, DEFAULT_THEME_SELECTION @@ -187,16 +294,61 @@ assert.equal( manifestJson, "Manifest generation must be deterministic" ) + +const migrated = parseThemeManifestJson( + JSON.stringify({ + schemaVersion: 1, + selection: { + primary: "blue", + gray: "zinc", + accent: "violet", + radius: "0.75rem", + }, + }) +) +assert.equal(migrated.schemaVersion, 3) +assert.equal(migrated.selection.light.accent, "#155dfc") +assert.equal(migrated.selection.dark.accent, "#155dfc") +assert.equal(migrated.selection.light.backgroundMode, "pure") +assert.equal(migrated.selection.dark.backgroundMode, "pure") +assert.equal(migrated.selection.grayMode, "custom") +assert.equal(migrated.selection.radius, "0.75rem") + +const migratedVersionTwo = parseThemeManifestJson( + JSON.stringify({ + schemaVersion: 2, + selection: { + light: { + accent: "#2563eb", + gray: "#737b8a", + background: "#fff7ed", + }, + dark: { + accent: "#3b82f6", + gray: "#737b88", + background: "#18181b", + }, + grayMode: "auto", + radius: "0.5rem", + }, + }) +) +assert.equal(migratedVersionTwo.schemaVersion, 3) +assert.equal(migratedVersionTwo.selection.light.backgroundMode, "custom") +assert.equal(migratedVersionTwo.selection.light.customBackground, "#fff7ed") +assert.equal(migratedVersionTwo.selection.dark.backgroundMode, "custom") +assert.equal(migratedVersionTwo.selection.dark.customBackground, "#18181b") + assert.throws( - () => parseThemeManifestJson('{"schemaVersion":2,"selection":{}}'), + () => parseThemeManifestJson('{"schemaVersion":4,"selection":{}}'), /unsupported schema version/ ) assert.throws( () => parseThemeManifestJson( - '{"schemaVersion":1,"selection":{"primary":"made-up","gray":"zinc","accent":"blue","radius":"0.5rem"}}' + '{"schemaVersion":3,"selection":{"light":{"accent":"red"}}}' ), - /primary color is not supported/ + /six-digit hex color/ ) assert.throws( () => @@ -207,5 +359,5 @@ assert.throws( ) console.log( - `Theme bridge checks passed for ${Object.keys(colors).length} color families, ${neutralColors.length} neutrals, ${THEME_RADIUS_OPTIONS.length} radii, and ${THEME_COLOR_TOKEN_NAMES.length} semantic tokens.` + `Theme V3 checks passed for ${colorSamples.length} source colors, ${THEME_BACKGROUND_MODES.length} background treatments, ${THEME_RADIUS_OPTIONS.length} radii, ${THEME_PRIMITIVE_STEPS.length} primitive steps per scale, ${THEME_COLOR_TOKEN_NAMES.length} semantic tokens, and ${defaultChecks.length} contrast pairs.` ) diff --git a/apps/preskok/styles/globals.css b/apps/preskok/styles/globals.css index 1a36613df..f9bf44298 100644 --- a/apps/preskok/styles/globals.css +++ b/apps/preskok/styles/globals.css @@ -96,6 +96,14 @@ --color-surface: var(--surface); --color-surface-foreground: var(--surface-foreground); + --color-panel: var(--panel); + --color-panel-foreground: var(--panel-foreground); + --color-panel-solid: var(--panel-solid); + --color-panel-solid-foreground: var(--panel-solid-foreground); + --color-accent-surface: var(--accent-surface); + --color-accent-indicator: var(--accent-indicator); + --color-accent-track: var(--accent-track); + --color-scrim: var(--scrim); --color-code: var(--code); --color-code-foreground: var(--code-foreground); --color-code-highlight: var(--code-highlight); @@ -195,6 +203,14 @@ --surface: oklch(0.98 0 0); --surface-foreground: var(--foreground); + --panel: var(--card); + --panel-foreground: var(--card-foreground); + --panel-solid: var(--card); + --panel-solid-foreground: var(--card-foreground); + --accent-surface: oklch(0.932 0.032 255.585 / 80%); + --accent-indicator: var(--primary); + --accent-track: oklch(0.809 0.105 251.813); + --scrim: oklch(0 0 0 / 50%); --code: var(--surface); --code-foreground: var(--surface-foreground); --code-highlight: oklch(0.96 0 0); @@ -273,6 +289,14 @@ --surface: oklch(0.2 0 0); --surface-foreground: oklch(0.708 0 0); + --panel: var(--card); + --panel-foreground: var(--card-foreground); + --panel-solid: var(--card); + --panel-solid-foreground: var(--card-foreground); + --accent-surface: oklch(0.269 0.007 34.298 / 50%); + --accent-indicator: var(--primary); + --accent-track: oklch(0.374 0.137 265.522); + --scrim: oklch(0 0 0 / 60%); --code: var(--surface); --code-foreground: var(--surface-foreground); --code-highlight: oklch(0.27 0 0); @@ -343,7 +367,7 @@ } } -@variant dark (&:is(.dark *)); +@variant dark (&:is(.dark *):not(.light, .light *)); @variant fixed (&:is(.layout-fixed *)); @utility border-grid { @@ -351,7 +375,7 @@ } @utility section-soft { - @apply from-background to-surface/40 dark:bg-background 3xl:fixed:bg-none bg-gradient-to-b; + @apply bg-gradient-to-b from-background to-surface/40 dark:bg-background 3xl:fixed:bg-none; } @utility theme-container { @@ -359,11 +383,11 @@ } @utility container-wrapper { - @apply 3xl:fixed:max-w-[calc(var(--breakpoint-2xl)+2rem)] mx-auto w-full px-2; + @apply mx-auto w-full px-2 3xl:fixed:max-w-[calc(var(--breakpoint-2xl)+2rem)]; } @utility container { - @apply 3xl:max-w-screen-2xl mx-auto max-w-[1400px] px-4 lg:px-8; + @apply mx-auto max-w-[1400px] px-4 3xl:max-w-screen-2xl lg:px-8; } @utility no-scrollbar { @@ -376,7 +400,7 @@ } @utility border-ghost { - @apply after:border-border relative after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten; + @apply relative after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten; } @utility step { @@ -384,7 +408,7 @@ @apply relative; &:before { - @apply text-muted-foreground right-0 mr-2 hidden size-7 items-center justify-center rounded-full text-center -indent-px font-mono text-sm font-medium md:absolute; + @apply right-0 mr-2 hidden size-7 items-center justify-center rounded-full text-center -indent-px font-mono text-sm font-medium text-muted-foreground md:absolute; content: counter(step); } } @@ -597,8 +621,8 @@ html.dark .shiki span { reset, which our spacing rules above would otherwise re-introduce. */ .prose :where(h1, h2, h3, h4, hr):not( - :where([class~="not-prose"], [class~="not-prose"] *) - ) + :where([class~="not-prose"], [class~="not-prose"] *) + ) + * { margin-top: 0; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 854ab3cca..765b06554 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@internationalized/date': specifier: ^3.12.3 version: 3.12.3 + '@radix-ui/colors': + specifier: 3.0.0 + version: 3.0.0 '@tabler/icons-react': specifier: ^3.44.0 version: 3.44.0(react@19.2.8) @@ -68,12 +71,18 @@ importers: '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(next@16.3.1(@babel/core@7.29.7)(@types/node@26.0.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + bezier-easing: + specifier: ^2.1.0 + version: 2.1.0 clsx: specifier: ^2.1.1 version: 2.1.1 cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + colorjs.io: + specifier: 0.5.2 + version: 0.5.2 culori: specifier: ^4.0.2 version: 4.0.2 @@ -168,6 +177,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@types/bezier-easing': + specifier: ^2.1.6 + version: 2.1.6 '@types/d3-shape': specifier: 3.1.8 version: 3.1.8 @@ -1095,6 +1107,9 @@ packages: cpu: [x64] os: [win32] + '@radix-ui/colors@3.0.0': + resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} + '@radix-ui/number@1.1.3': resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} @@ -2100,6 +2115,10 @@ packages: cpu: [arm64] os: [win32] + '@types/bezier-easing@2.1.6': + resolution: {integrity: sha512-ntD54njPbI4ZUe+Fh+o2ezwqVsOkS61QQdBP2a8hIRhr2m33bkD4GpTPsyJlFg+bB4gG69FVKgKqm+8KKZ00WA==} + deprecated: This is a stub types definition. bezier-easing provides its own type definitions, so you do not need this installed. + '@types/culori@4.0.1': resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} @@ -2433,6 +2452,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bezier-easing@2.1.0: + resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2541,6 +2563,9 @@ packages: collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + colorjs.io@0.5.2: + resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -5587,6 +5612,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.78.0': optional: true + '@radix-ui/colors@3.0.0': {} + '@radix-ui/number@1.1.3': {} '@radix-ui/primitive@1.1.4': {} @@ -6537,6 +6564,10 @@ snapshots: '@turbo/windows-arm64@2.9.18': optional: true + '@types/bezier-easing@2.1.6': + dependencies: + bezier-easing: 2.1.0 + '@types/culori@4.0.1': {} '@types/d3-array@3.2.2': {} @@ -6780,6 +6811,8 @@ snapshots: baseline-browser-mapping@2.10.38: {} + bezier-easing@2.1.0: {} + binary-extensions@2.3.0: {} body-parser@2.3.0: @@ -6894,6 +6927,8 @@ snapshots: collapse-white-space@2.1.0: {} + colorjs.io@0.5.2: {} + comma-separated-tokens@2.0.3: {} commander@11.1.0: {}