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>
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
interface Toast {
|
||
id: string;
|
||
message: string;
|
||
type: "success" | "error" | "info";
|
||
}
|
||
|
||
let addToastFn: ((message: string, type?: Toast["type"]) => void) | null = null;
|
||
|
||
/** Call from anywhere to show a toast */
|
||
export function showToast(message: string, type: Toast["type"] = "info") {
|
||
addToastFn?.(message, type);
|
||
}
|
||
|
||
export function Toaster() {
|
||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||
|
||
const dismiss = (id: string) =>
|
||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||
|
||
useEffect(() => {
|
||
addToastFn = (message, type = "info") => {
|
||
const id = Math.random().toString(36).slice(2);
|
||
setToasts((prev) => [...prev.slice(-4), { id, message, type }]);
|
||
// Errors persist until the user explicitly dismisses them.
|
||
// Success / info auto-expire after 4 s.
|
||
if (type !== "error") {
|
||
setTimeout(() => {
|
||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||
}, 4000);
|
||
}
|
||
};
|
||
return () => {
|
||
addToastFn = null;
|
||
};
|
||
}, []);
|
||
|
||
const toastCls = (type: Toast["type"]) =>
|
||
`flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
|
||
type === "success"
|
||
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
|
||
: type === "error"
|
||
? "bg-red-950/90 border border-red-700/40 text-red-200"
|
||
: "bg-surface-sunken/90 border border-line/40 text-ink"
|
||
}`;
|
||
|
||
const pos =
|
||
"fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center";
|
||
|
||
return (
|
||
<>
|
||
{/*
|
||
* Polite live region — success & info notifications.
|
||
* Always rendered so screen readers register it before any toast fires.
|
||
*/}
|
||
<div role="status" aria-live="polite" aria-atomic="false" className={pos}>
|
||
{toasts
|
||
.filter((t) => t.type !== "error")
|
||
.map((toast) => (
|
||
<div key={toast.id} className={toastCls(toast.type)}>
|
||
<span>{toast.message}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => dismiss(toast.id)}
|
||
aria-label="Dismiss notification"
|
||
className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/*
|
||
* Assertive live region — errors only.
|
||
* aria-live="assertive" interrupts the screen reader immediately.
|
||
* Errors never auto-expire; user must dismiss via the × button.
|
||
*/}
|
||
<div
|
||
role="alert"
|
||
aria-live="assertive"
|
||
aria-atomic="false"
|
||
className={pos}
|
||
>
|
||
{toasts
|
||
.filter((t) => t.type === "error")
|
||
.map((toast) => (
|
||
<div key={toast.id} className={toastCls(toast.type)}>
|
||
<span>{toast.message}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => dismiss(toast.id)}
|
||
aria-label="Dismiss notification"
|
||
className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|