Files
molecule-core/canvas/src/lib/credits.ts
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

54 lines
2.2 KiB
TypeScript

// credits.ts — small pure helpers for rendering credit state on /orgs.
// Kept out of page.tsx so unit tests can exercise the formatting +
// banner-kind logic in node (no jsdom) without needing to mount React.
export type CreditsBannerKind =
| "none"
| "overage" // paid plan has started burning overage this period
| "out-of-credits" // balance 0, not on a paid plan (trial ran out)
| "trial-tail"; // balance low but not zero, no paid plan yet
export interface CreditsFields {
credits_balance?: number;
plan_monthly_credits?: number;
overage_used_credits?: number;
}
// formatCredits renders an int as a compact string. 9999 → "9999",
// 12345 → "12.3k". Keeps the balance pill narrow enough to fit on one
// line next to the org slug even for the Scale plan's 30k grant.
export function formatCredits(n: number): string {
if (n < 10_000) return String(n);
return `${(n / 1000).toFixed(1)}k`;
}
// pillTone returns the tailwind classnames that color the balance pill.
// Empty / exhausted → red; within 10% of zero → amber; else zinc. The
// 10% threshold matches the banner trigger — one consistent "low"
// signal so the pill and banner agree.
export function pillTone(fields: CreditsFields): string {
const balance = fields.credits_balance ?? 0;
const monthly = fields.plan_monthly_credits ?? 0;
if (balance <= 0) return "bg-red-950 text-red-200 border-red-800";
const ratio = monthly > 0 ? balance / monthly : 1;
if (ratio < 0.1) return "bg-amber-950 text-amber-200 border-amber-800";
return "bg-surface-card text-ink border-line";
}
// bannerKind picks which (if any) banner to show under the balance
// pill. Precedence:
// 1. overage_used > 0 → "overage" (even if balance is refreshed)
// 2. balance <= 0 → "out-of-credits"
// 3. trial + low tail → "trial-tail"
// 4. otherwise → "none"
export function bannerKind(fields: CreditsFields): CreditsBannerKind {
const balance = fields.credits_balance ?? 0;
const monthly = fields.plan_monthly_credits ?? 0;
const overageUsed = fields.overage_used_credits ?? 0;
if (overageUsed > 0) return "overage";
if (balance <= 0) return "out-of-credits";
if (monthly === 0 && balance < 100) return "trial-tail";
return "none";
}