Brings the canvas onto the warm-paper design system already shipped to landing, marketplace, and SaaS surfaces, and migrates the build from Tailwind v3 → v4 to match molecule-app. Plumbing: - swap tailwindcss v3 → v4, drop autoprefixer, add @tailwindcss/postcss - delete tailwind.config.ts (v4 reads tokens from @theme blocks in CSS) - globals.css: @import "tailwindcss" + @plugin "@tailwindcss/typography" - two @theme blocks: warm-paper light defaults + always-dark surface tokens (bg-bg / ink-mute / line-strong) for terminal/console panels - [data-theme="dark"] cascade overrides the warm-paper tokens for dark - React Flow edge stroke + scrollbar + selection colour pull from semantic tokens so they flip with the theme Theme infra (ported from molecule-app, identical contracts): - lib/theme-cookie.ts: mol_theme cookie + boot script (no "use client" so server components can read the constants) - lib/theme-provider.tsx: ThemeProvider + useTheme + cookie writer with Domain=.moleculesai.app so the preference follows the user across canvas/app/market/landing subdomains AND tenant subdomains - lib/theme.ts: ColorToken union + cssVar() helper - components/ThemeToggle.tsx: 3-way System/Light/Dark picker - layout.tsx: SSR cookie read + nonce'd inline boot script (CSP needs the explicit nonce — strict-dynamic doesn't forgive an un-nonce'd inline sibling) + ThemeProvider wrapper + bg-surface/text-ink body Component migration (62 files): - Mechanical bg-zinc-* / text-zinc-* / border-zinc-* / text-white → semantic surface/ink/line tokens via perl negative-lookahead pass (preserves opacity modifiers like /80, /60) - bg-blue-500/600 → bg-accent / bg-accent-strong - text-red-* / amber-* / emerald-* → text-bad / warm / good - Tinted-state banner backgrounds (bg-red-950, bg-amber-950, bg-blue-950 etc.) intentionally left literal — they remain readable on warm-paper in light mode without inventing new state-soft tokens - TerminalTab.tsx skipped — xterm renders to canvas, not DOM - 3 unit-test assertions updated to match new token strings (credits pillTone, AuthGate overlay class, A2AEdge accent) Verification: - pnpm test: 1214/1214 pass - pnpm tsc --noEmit: clean - next build: ✓ Compiled successfully (8 routes) - dev server inspection: html data-theme stamped, body uses bg-surface text-ink, boot script carries nonce, compiled CSS contains both @theme blocks + [data-theme="dark"] override Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
3.2 KiB
TypeScript
118 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import { type TreeNode, getIcon } from "./tree";
|
|
|
|
interface TreeCallbacks {
|
|
selectedPath: string | null;
|
|
onSelect: (path: string) => void;
|
|
onDelete: (path: string) => void;
|
|
expandedDirs: Set<string>;
|
|
onToggleDir: (path: string) => void;
|
|
loadingDir: string | null;
|
|
}
|
|
|
|
export function FileTree({
|
|
nodes,
|
|
selectedPath,
|
|
onSelect,
|
|
onDelete,
|
|
expandedDirs,
|
|
onToggleDir,
|
|
loadingDir,
|
|
depth = 0,
|
|
}: TreeCallbacks & { nodes: TreeNode[]; depth?: number }) {
|
|
return (
|
|
<div>
|
|
{nodes.map((node) => (
|
|
<TreeItem
|
|
key={`${node.path}:${node.isDir ? "dir" : "file"}`}
|
|
node={node}
|
|
selectedPath={selectedPath}
|
|
onSelect={onSelect}
|
|
onDelete={onDelete}
|
|
expandedDirs={expandedDirs}
|
|
onToggleDir={onToggleDir}
|
|
loadingDir={loadingDir}
|
|
depth={depth}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TreeItem({
|
|
node,
|
|
selectedPath,
|
|
onSelect,
|
|
onDelete,
|
|
expandedDirs,
|
|
onToggleDir,
|
|
loadingDir,
|
|
depth,
|
|
}: TreeCallbacks & { node: TreeNode; depth: number }) {
|
|
const isSelected = selectedPath === node.path;
|
|
const expanded = expandedDirs.has(node.path);
|
|
const isLoading = loadingDir === node.path;
|
|
|
|
if (node.isDir) {
|
|
return (
|
|
<div>
|
|
<div
|
|
className="group w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface-card/40 transition-colors cursor-pointer"
|
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
onClick={() => onToggleDir(node.path)}
|
|
>
|
|
<span className="text-[9px] text-ink-soft w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
|
|
<span className="text-[10px]">📁</span>
|
|
<span className="text-[10px] text-ink-mid flex-1">{node.name}</span>
|
|
<button
|
|
aria-label={`Delete ${node.name}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete(node.path);
|
|
}}
|
|
className="text-[9px] text-bad/0 group-hover:text-bad/60 hover:!text-bad transition-colors"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
{expanded && (
|
|
<FileTree
|
|
nodes={node.children}
|
|
selectedPath={selectedPath}
|
|
onSelect={onSelect}
|
|
onDelete={onDelete}
|
|
expandedDirs={expandedDirs}
|
|
onToggleDir={onToggleDir}
|
|
loadingDir={loadingDir}
|
|
depth={depth + 1}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`group flex items-center gap-1 px-2 py-0.5 cursor-pointer transition-colors ${
|
|
isSelected ? "bg-blue-900/30 text-ink" : "hover:bg-surface-card/40 text-ink-mid"
|
|
}`}
|
|
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
|
onClick={() => onSelect(node.path)}
|
|
>
|
|
<span className="text-[9px]">{getIcon(node.name, false)}</span>
|
|
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
|
|
<button
|
|
aria-label={`Delete ${node.name}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete(node.path);
|
|
}}
|
|
className="text-[9px] text-bad/0 group-hover:text-bad/60 hover:!text-bad transition-colors"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|