molecule-core/canvas/src/components/Toaster.tsx
Hongming Wang c0eca8d0e1 feat(canvas): warm-paper theme + Tailwind v4 migration
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>
2026-05-03 01:43:55 -07:00

107 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
</>
);
}