Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ What this means when writing code today:

## Current state of the project

An honest snapshot (July 2026) to calibrate against. Task-level work is tracked in GitHub issues; this is the standing picture.
An honest snapshot (August 2026) to calibrate against. Task-level work is tracked in GitHub issues; this is the standing picture.

- **Solid:** the docs (one last revision pass pending) and the components — they look and behave well individually. Cross-component composition is less proven: treat "these compose cleanly" as a claim to verify, not an assumption.
- **Known-weak:** the components page (several demos misbehave); the presets page (very slow); the presets themselves on both landing and presets page — due to be replaced by fewer, high-fidelity presets of popular systems (Linear, Vercel, …); chart colors (need a full rewrite); site performance overall (never audited — a docs site must feel instant).
- **Queued for rewrite — don't deepen:** the color system and the entire /create builder experience get complete rewrites — the goal is really good foundations, not incremental fixes. The color rewrite starts with a playground to verify generated colors visually (current output is average); the /create rewrite starts with an experience spec — what should building a design system feel like — before any code. The publisher is also queued (see below), and the registry should get a small refactor and clean up. In these areas, verify behavior by reading the code and keep changes shallow.
- **In progress — the /create panel rewrite** (tracking: issue #666). The panel-lab drill-in panel (`www/src/modules/panel-lab/`) is /create's real panel; its chrome (switcher, reset, search, save, export) is wired to the create engine, and chapters are being wired one at a time — one short-lived PR per chapter, merged to main. Rules: the panel schema (`panel-lab/state.ts`) is the source of truth — wiring a chapter means making the engine/preview/export respond to its axes (reuse engine machinery where an axis matches, change the engine where it diverges, build what's new; never bend the panel to old engine params). A chapter carries a WIP chip until its values actually drive the preview and export. The old panel (`www/src/modules/create/panel/`, unmounted) holds working wiring logic — mine it per chapter, delete what's absorbed in the same PR.
- **Queued for rewrite — don't deepen:** the color system gets a complete rewrite — the goal is really good foundations, not incremental fixes; it starts with a playground to verify generated colors visually (current output is average). The publisher is also queued (see below), and the registry should get a small refactor and clean up. In these areas, verify behavior by reading the code and keep changes shallow.
- **AI slop:** much of the codebase was written by AI agents and hasn't all been walked through. When working in an area, leave it simpler than you found it — cut needless comments, indirection, and dead options rather than matching the existing style.

## Structure
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
**Create a design system that looks like your brand — not a preset.**

Design your visual language once — colors, icon library, radius, density — and
export it anywhere: your codebase via the shadcn CLI, or straight into v0,
Bolt, and Lovable.
export it anywhere: your codebase via the shadcn CLI, or straight into v0
(Bolt and Lovable are on the way).

> **Preview** — dotUI is in active development. The builder panel is being
> rebuilt chapter by chapter ([#666](https://github.com/mehdibha/dotUI/issues/666));
> chapters marked WIP aren't wired yet, and APIs may change before 1.0.

<!-- TODO: screenshot/GIF of the /create theme builder -->

Expand All @@ -25,8 +29,8 @@ Bolt, and Lovable.
npx shadcn add @dotui/button
```

Or open your design system directly in **v0**, **Bolt** or **Lovable** and
start prompting with your brand already baked in.
Or open your design system directly in **v0** and start prompting with
your brand already baked in — **Bolt** and **Lovable** are on the way.

3. **Own the code.** Components land in your project as plain React files —
no runtime dependency, restyle or rewrite anything.
Expand Down
8 changes: 7 additions & 1 deletion www/src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ export function Header({ className, items = [] }: HeaderProps) {
<ProgressiveBlur />
</div>
<div className="flex items-center gap-3 md:gap-6">
<Logo />
<span className="flex items-center gap-2">
<Logo />
{/* The whole product is pre-1.0 — one quiet word of context. */}
<span className="rounded-sm border border-border/60 px-1 py-px text-[9px] leading-none font-medium tracking-wide text-fg-muted/80 uppercase select-none">
preview
</span>
</span>
{/* Small screens have no in-page TOC (the MiniTOC is md–xl); surface it
here next to the logo instead. Renders null off docs pages. */}
<DocsTocSelect className="md:hidden" />
Expand Down
2 changes: 1 addition & 1 deletion www/src/modules/control-lab/rows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function RowLabel({
*/
export function ControlGroup({ children }: { children: React.ReactNode }) {
return (
<div className="flex w-full flex-col divide-y divide-bg/50 overflow-hidden rounded-xl bg-muted **:data-preview:rounded-b-none **:data-row:rounded-none **:data-row:bg-transparent">
<div className="flex w-full flex-col gap-px overflow-hidden rounded-lg bg-bg/50 **:data-preview:rounded-b-none **:data-row:rounded-none">
{children}
</div>
)
Expand Down
5 changes: 3 additions & 2 deletions www/src/modules/create/preset/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ export const loadStoredPreset = presetStore.get
export const saveStoredPreset = presetStore.set
export const useStoredPreset = presetStore.useValue

/** Shown for a design system the user hasn't named. */
export const DEFAULT_DESIGN_SYSTEM_NAME = "Untitled"
/** Shown for a design system the user hasn't named — first-time users start
* on the Origin preset, so the unnamed fallback carries its name. */
export const DEFAULT_DESIGN_SYSTEM_NAME = "Origin"

const nameStore = createPersistedStore<string>(
"dotui:design-system-name",
Expand Down
14 changes: 10 additions & 4 deletions www/src/modules/create/save-preset-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Modal } from "@/registry/ui/modal"
import { TextField } from "@/registry/ui/text-field"

import { encodePreset, useDesignSystem, useMyPresets } from "./preset"
import { useDesignSystemName } from "./preset/storage"
import { saveDesignSystemName, useDesignSystemName } from "./preset/storage"

/**
* Snapshots the current design system to a named localStorage preset ("Save as").
Expand Down Expand Up @@ -42,13 +42,19 @@ export function SavePresetDialog({

const trimmed = name.trim()

// The saved name becomes the working system's name — the panel header
// reflects what was just saved.
function saveAsNew() {
save(trimmed || storedName, currentState)
const name = trimmed || storedName
save(name, currentState)
saveDesignSystemName(name)
onOpenChange(false)
}

function updateActive() {
if (active) update(active.id, currentState, trimmed || undefined)
if (!active) return
update(active.id, currentState, trimmed || undefined)
saveDesignSystemName(trimmed || active.name)
onOpenChange(false)
}

Expand Down Expand Up @@ -82,7 +88,7 @@ export function SavePresetDialog({
onChange={setName}
>
<Label>Name</Label>
<Input placeholder="Untitled" />
<Input placeholder="My design system" />
</TextField>
</form>
<div className="flex justify-end gap-2">
Expand Down
2 changes: 1 addition & 1 deletion www/src/modules/create/saved-preset-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ function RenamePresetDialog({
onChange={setName}
>
<Label>Name</Label>
<Input placeholder="Untitled" />
<Input placeholder="My design system" />
</TextField>
</form>
<div className="flex justify-end gap-2">
Expand Down
210 changes: 210 additions & 0 deletions www/src/modules/panel-lab/create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"use client"

/* The lab panel mounted in /create's slot. The chrome (switcher, reset,
search, save, export) is wired to the real create engine here — the
switcher is the existing PresetPicker (saved systems + built-in presets,
live previews, unsaved-changes guard), reachable at ?gallery= like before.
Chapter axes stay lab-local until each chapter is wired in turn. */

import { useMemo, useState } from "react"
import { getRouteApi } from "@tanstack/react-router"

import { cn } from "@/registry/lib/utils"
import { ExportDialog } from "@/modules/create/export"
import {
decodePreset,
encodePreset,
useMyPresets,
} from "@/modules/create/preset"
import {
saveDesignSystemName,
useDesignSystemName,
} from "@/modules/create/preset/storage"
import { SavePresetDialog } from "@/modules/create/save-preset-dialog"
import { SavedPresetActions } from "@/modules/create/saved-preset-actions"
import { UnsavedChangesDialog } from "@/modules/create/unsaved-changes-dialog"
import { PresetPicker } from "@/modules/presets/preset-picker"
import { ORIGIN, PRESETS } from "@/modules/presets/presets-data"

import type { PanelSystem } from "./panel"
import { CHAPTERS } from "./state"
import { useLab } from "./use-lab"
import { PanelB } from "./variants/panel-b"

const routeApi = getRouteApi("/_app/create")

function canon(state: string): string {
if (!state) return ""
return encodePreset(decodePreset(state)) ?? ""
}

/* Origin is the panel's baseline: what first-time users start on, what the
global reset returns to, and what the modified dot diffs against. */
const ORIGIN_CANON = canon(encodePreset(ORIGIN.designSystem) ?? "")

export function LabCreatePanel({ className }: { className?: string }) {
const lab = useLab()
const { preset, gallery } = routeApi.useSearch()
const navigate = routeApi.useNavigate()
const {
presets,
activeId,
setActive,
save,
update,
rename,
duplicate,
remove,
} = useMyPresets()
const storedName = useDesignSystemName()
const [saveOpen, setSaveOpen] = useState(false)
// Preset pick held back by the unsaved-changes guard, awaiting save/discard.
const [pendingPick, setPendingPick] = useState<string | null>(null)

// The header names what's being edited: the active saved system (dotted when
// edited past its snapshot), else the standalone design-system name.
const activeSaved = presets.find((p) => p.id === activeId)
const displayName = activeSaved?.name ?? storedName

// Built-in presets are re-loadable from the gallery, so a freshly applied one
// isn't unsaved work — only edits past it (or past a saved snapshot) are.
// encode∘decode isn't byte-identical (a reload's seeded state re-encodes
// slightly differently), so states compare in canonical form — one roundtrip
// reaches a fixed point.
const builtInStates = useMemo(
() =>
new Set(PRESETS.map((p) => canon(encodePreset(p.designSystem) ?? ""))),
[],
)
const currentState = preset ?? ""
const currentCanon = canon(currentState)
const isDirty = activeSaved
? canon(activeSaved.state) !== currentCanon
: currentCanon !== "" && !builtInStates.has(currentCanon)

// Saved systems decode to full design systems for the picker's mini previews.
const pickerSections = useMemo(() => {
const mine = {
id: "mine",
title: "My systems",
items: presets.map((saved) => ({
id: saved.id,
name: saved.name,
designSystem: decodePreset(saved.state),
})),
}
const builtIn = {
id: "built-in",
title: "Presets",
items: PRESETS.map((p) => ({
id: p.id,
name: p.name,
designSystem: p.designSystem,
})),
}
return presets.length > 0 ? [mine, builtIn] : [builtIn]
}, [presets])

// Apply a state and close the gallery in one navigation — two separate
// navigates would race each other's search updates.
function applyState(encoded: string | undefined) {
navigate({
search: (prev) => ({
...prev,
preset: encoded || undefined,
gallery: undefined,
}),
replace: true,
})
}

function setGalleryOpen(open: boolean) {
navigate({
search: (prev) => ({ ...prev, gallery: open ? true : undefined }),
replace: true,
})
}

function pickPreset(itemId: string) {
const saved = presets.find((p) => p.id === itemId)
if (saved) {
setActive(saved.id)
saveDesignSystemName(saved.name)
applyState(saved.state)
return
}
const builtIn = PRESETS.find((p) => p.id === itemId)
if (!builtIn) return
setActive(undefined)
saveDesignSystemName(builtIn.name)
applyState(encodePreset(builtIn.designSystem))
}

// Applying a preset over unsaved work asks first; over clean state it's instant.
function requestPick(itemId: string) {
if (isDirty) setPendingPick(itemId)
else pickPreset(itemId)
}

function resolvePendingPick(saveFirst: boolean) {
if (saveFirst) {
if (activeSaved) update(activeSaved.id, currentState)
else save(displayName, currentState)
}
if (pendingPick) pickPreset(pendingPick)
setPendingPick(null)
}

const system: PanelSystem = {
name: displayName,
dirty: isDirty,
modified: currentCanon !== ORIGIN_CANON,
onReset: () => pickPreset(ORIGIN.id),
onSave: () => setSaveOpen(true),
renderSwitcher: (trigger) => (
<PresetPicker
isOpen={gallery === true}
onOpenChange={setGalleryOpen}
sections={pickerSections}
selectedId={activeSaved && !isDirty ? activeSaved.id : undefined}
onPick={(item) => requestPick(item.id)}
withPreview
renderItemActions={(item) => {
const saved = presets.find((p) => p.id === item.id)
if (!saved) return null
return (
<SavedPresetActions
saved={saved}
onRename={(name) => rename(saved.id, name)}
onDuplicate={() => duplicate(saved.id)}
onDelete={() => remove(saved.id)}
/>
)
}}
>
{trigger}
</PresetPicker>
),
renderExport: (trigger) => <ExportDialog>{trigger}</ExportDialog>,
}

return (
<div
className={cn(
"relative flex w-full flex-1 flex-col lg:w-76 lg:flex-none lg:shrink-0",
className,
)}
>
<PanelB chapters={CHAPTERS} lab={lab} system={system} />
<SavePresetDialog isOpen={saveOpen} onOpenChange={setSaveOpen} />
<UnsavedChangesDialog
isOpen={pendingPick !== null}
onOpenChange={(open) => {
if (!open) setPendingPick(null)
}}
onSave={() => resolvePendingPick(true)}
onDiscard={() => resolvePendingPick(false)}
/>
</div>
)
}
2 changes: 1 addition & 1 deletion www/src/modules/panel-lab/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function Panel() {
const lab = useLab()
// The chrome is the card — one enclosed surface, no extra wrapper here.
return (
<div className="flex h-[720px] w-[360px] shrink-0 flex-col">
<div className="flex h-[720px] w-[336px] shrink-0 flex-col">
<PanelB chapters={CHAPTERS} lab={lab} />
</div>
)
Expand Down
Loading
Loading