# Frontend Specification Canonical standard for: **anvil**, **flamemail**, **git-on-cloudflare** This document defines the shared frontend conventions that all projects **must** follow. Per-project deviations are called out explicitly; everything else is universal. --- ## 1. Core Stack | Layer | Choice | Version | | ------------- | ------------------------ | ----------------------------- | | UI Framework | React | `^19.x` | | Build Tool | Vite | `^7.x` | | CSS Framework | Tailwind CSS | `v4.x` (CSS-native config) | | Icons | lucide-react | `^0.542+` | | Language | TypeScript (strict mode) | `^5.9+` | | Deploy Target | Cloudflare Workers | via `@cloudflare/vite-plugin` | ### Vite Plugins (always present) 1. `@tailwindcss/vite` -- Tailwind CSS v4 native integration (**no** PostCSS config) 2. `@vitejs/plugin-react` -- React JSX transform + Fast Refresh 3. `@cloudflare/vite-plugin` -- Cloudflare Workers build + dev ### Path Alias All projects use `@` as a path alias to the source root: ```ts // vite.config.ts resolve: { alias: { "@": resolve(__dirname, "src"), }, } // tsconfig.json "paths": { "@/*": ["src/*"] } ``` ### TypeScript Configuration ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "noEmit": true, "skipLibCheck": true, "isolatedModules": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } } } ``` - Bundler module resolution (Vite-compatible) - `react-jsx` automatic runtime (no `import React` needed) - `noEmit` -- Vite handles transpilation; TypeScript is type-checking only --- ## 2. Directory Layout ### Canonical Structure (SPA projects) ``` src/ client/ main.tsx # React entry: createRoot app.tsx # Route definitions only (react-router-dom Routes) components/ app-shell.tsx # Header + + Footer + ToastContainer header.tsx # Standalone Header component footer.tsx # Standalone Footer component toast.tsx # Toast system (module-level singleton + ToastContainer) ui/ # Reusable design-system primitives button.tsx card.tsx input.tsx badge.tsx dialog.tsx empty-state.tsx error-banner.tsx page-header.tsx index.ts # Barrel export index.ts # Barrel export for all components hooks/ # Custom React hooks lib/ # API clients, utilities pages/ # One file per route/page styles/ app.css # Global CSS (single file) shared/ # Shared types/contracts (client + worker) contracts/ worker/ # Cloudflare Worker backend code ``` ### SSR + Islands Extension (git-on-cloudflare) SSR projects keep the same top-level `client/` directory but add SSR-specific sub-directories: ``` src/ client/ components/ # Same as SPA (header.tsx, footer.tsx, ui/, etc.) pages/ # Page components (receive props from registry) islands/ # Interactive widgets hydrated on the client server/ # SSR pipeline (runs on the Worker) render.tsx # renderToReadableStream entry document.tsx # shell (replaces index.html) registry.tsx # View name -> page component + entrypoints map island-host.tsx # Serializes island props for client hydration entries/ # Per-page client entry bundles hydrate.tsx # Generic island hydration helper styles/ app.css shared/ worker/ ``` **Key rule**: There is no `ui/` directory at the `src/` root level. Client-side code always lives under `src/client/`. ### Naming Conventions - **File names**: `kebab-case.tsx` (e.g., `app-shell.tsx`, `page-header.tsx`) - **Component exports**: `PascalCase` (e.g., `AppShell`, `PageHeader`) - **Hook files**: `use-.ts` (e.g., `use-inbox.ts`, `use-polling.ts`) - **Barrel exports**: `index.ts` in `components/`, `components/ui/`, `hooks/`, `pages/` --- ## 3. Styling & Aesthetics ### limic Design Philosophy **"Consistently Distinctive"**: Every app within the `limic.dev` ecosystem shares a consistent technical foundation and base visual language (dark-mode Zinc palette, standard shell components, specific shadows/radii) so users visually recognize they are using a `limic.dev` product. However, within these shared constraints, each application must exhibit a **distinctive, bold, and memorable aesthetic**. - **Aesthetic Direction**: Interpret the project's specific purpose creatively. Pick an aesthetic flavor that fits the tool's tone (e.g., brutalist/utilitarian, premium/refined, retro-terminal, etc.) and inject that flavor via typography, backgrounds, or layouts. - **No Generic Predictability**: Avoid uninspired aesthetics like cliched layouts, overly predictable component patterns, and standard minimalist boilerplate. Design should be deeply intentional. - **Visual Depth**: Create atmosphere and depth. Rather than sticking purely to flat Zinc surfaces, selectively apply contextual effects (gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, or custom cursors) that define the app's unique identity. ### Approach - **Tailwind CSS v4** with CSS-native `@theme` configuration - **No** `tailwind.config.js`, `tailwind.config.ts`, or `postcss.config.*` files - **No** CSS-in-JS, CSS Modules, or Styled Components - **No** `@utility` rules -- all styling via Tailwind utility classes inline in JSX or via React component variants - One global CSS file at `client/styles/app.css` ### Shadows Keep drop shadows subtle. Prefer `shadow-sm` over `shadow-lg`/`shadow-xl`. | Element | Static shadow | Hover shadow | | ----------------- | ------------- | ----------------- | | Logo / brand icon | -- | -- | | Primary button | -- | -- | | Card hover | -- | `hover:shadow-sm` | Never use `shadow-lg` or `shadow-xl` on interactive elements. Reserve `shadow-2xl` for modals/dialogs only. Do not add colored accent shadows (e.g., `shadow-accent-500/10`) to buttons or icons — they read as AI-generated and add visual noise without improving affordance. ### Card & Selection Hover **Never animate border hue shifts** (e.g., zinc -> accent or zinc -> amber via `transition-colors`). The color morph through intermediate tones looks unnatural even at 75ms. Instead: - **Cards / list rows**: instant hover (no `transition-*`), lighten the border within the same hue: `hover:border-zinc-700/60 hover:bg-zinc-900/80`. - **Toggle / selection buttons** (e.g., TTL picker, radio-style options): no transition on the border. Let the selected/unselected state swap instantly via conditional classes. - **Small action buttons**: same rule -- `hover:border-zinc-600` (zinc lightening), no hue shift. - **Reserve `transition-colors`** for elements that only change background or text within the same hue family (e.g., `hover:bg-zinc-700/60`), or for nav links and standalone text links. ### Transitions **Never use `transition-all`** -- it transitions every CSS property (including layout-triggering ones) and causes jank even when nothing changes. Always scope to the properties that actually change: | What changes | Transition class | | ------------------------------- | ------------------------------------------------ | | Color, background, border-color | `transition-colors` | | Box shadow + border | `transition-[border-color,box-shadow]` | | Box shadow + color + background | `transition-[color,background-color,box-shadow]` | | Filter (brightness) + opacity | `transition-[filter,opacity]` | | Opacity only | `transition-opacity` | | Transform (scale, translate) | `transition-transform` | Default transition duration is overridden to **75ms** via `--default-transition-duration` in `@theme` (Tailwind default is 150ms, which feels sluggish on hover). Do not use `hover:brightness-*` on gradient buttons -- it forces the GPU to recompute the filtered gradient each frame. Use a color swap instead (e.g., `hover:from-accent-400 hover:to-accent-500`). ### Performance - **Ambient glow & Textures**: If used, apply radial-gradient glows directly on `body`'s `background-image` alongside `background-color`. **Do not** use a `position: fixed` pseudo-element (`body::before`) — the full-viewport fixed layer forces compositor blending against all scrolling content every frame. However, ambient textures (dot patterns, grain, radial glows) at very low opacity (2-3%) are effectively invisible and add CSS weight for zero visible effect. If the texture isn't perceptible at arm's length, remove it — dead CSS is worse than no texture. If you do use textures, make them visible enough to justify their presence (5-8% opacity minimum for dot patterns). - **`backdrop-blur-sm`** on sticky headers is acceptable. Prefer `backdrop-blur-sm` (4px) over `backdrop-blur-xl` (24px) -- the larger radius is ~6x more expensive per frame and barely distinguishable at high background opacity. Pair with `bg-canvas/95` so the blur is cosmetic, not structural. ### Global CSS Template Every project's `app.css` follows this exact structure. Per-project differences: `--color-accent-*` values and `--color-canvas` (if customized from the default). ```css @import "tailwindcss"; /* Dynamic class safelist (add @source inline(...) entries as needed) */ @theme { --default-transition-duration: 75ms; --font-sans: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; --font-mono: "JetBrains Mono", "SF Mono", "Fira Code", monospace; /* Warm zinc overrides -- see Section 4 Neutral Palette */ --color-zinc-50: #fafaf9; --color-zinc-100: #f5f4f4; --color-zinc-200: #e5e4e5; --color-zinc-300: #d6d4d7; --color-zinc-400: #a3a1a8; --color-zinc-500: #747178; --color-zinc-600: #555259; --color-zinc-700: #423f42; --color-zinc-800: #2a2729; --color-zinc-900: #1b181a; --color-zinc-950: #0c090b; /* Project accent color palette -- replace values per project */ --color-accent-50: ...; --color-accent-100: ...; --color-accent-200: ...; --color-accent-300: ...; --color-accent-400: ...; --color-accent-500: ...; --color-accent-600: ...; --color-accent-700: ...; --color-accent-800: ...; --color-accent-900: ...; /* Lifted warm canvas background -- see Section 4 Surface Hierarchy */ --color-canvas: #221f21; --animate-fade-in: fade-in 0.4s ease-out both; --animate-slide-up: slide-up 0.35s ease-out both; --animate-scale-fade: scale-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; --animate-shimmer: shimmer 1.5s ease-in-out infinite; } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes slide-up { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } @keyframes scale-fade { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } } @keyframes shimmer { from { background-position: -200% 0; } to { background-position: 200% 0; } } html { color-scheme: dark; } html, body { min-height: 100vh; } body { @apply bg-canvas text-zinc-100 antialiased; font-weight: 450; } #root { min-height: 100vh; } button:not(:disabled), select, summary, [role="button"] { cursor: pointer; } *:focus-visible { @apply outline-none ring-2 ring-accent-500/50 ring-offset-2 ring-offset-canvas; } ::selection { @apply bg-accent-500/[0.28] text-accent-50; } /* Scope scrollbar styling to scrollable containers, not * */ body, .overflow-y-auto, .overflow-auto { scrollbar-width: thin; scrollbar-color: theme(--color-zinc-700) transparent; } ``` --- ## 4. Color System ### Dark Mode All projects are **dark-mode primary** (or dark-only). The `` element carries `class="dark"` and `color-scheme: dark` is set on the root. If light mode is supported, it uses the class-based toggle pattern (`html.dark` / `html` without `.dark`) with the user's preference stored in `localStorage` under key `"theme"`, defaulting to `"dark"`. A bootstrap script in `` reads this value and applies the class before first paint to prevent flash. ### Neutral Palette: Warm Zinc Every project uses Tailwind's `zinc` scale as its neutral starting point, **warm-shifted** via `@theme` overrides, with the body background lifted from stock `zinc-950` to a custom `canvas` color. This serves two purposes: 1. **Halation prevention** — the lifted canvas prevents light text from blooming against very dark surfaces (astigmatism accommodation). 2. **Warm tinting** — stock `zinc` leans slightly cool/blue. Overriding with warmer values (R slightly raised, B slightly lowered) removes the cold cast and produces a more comfortable, inviting reading surface without a visible color shift. The warmth should be felt, not seen. Override the zinc scale in `@theme` using the warm values below. This automatically propagates to all Tailwind class usage. For hardcoded hex values in plain CSS files (editor overlays, third-party component styles), use the same warm hex values rather than stock zinc. | Token | Warm hex | Stock hex | Shift | Usage | | ---------- | --------- | --------- | -------- | --------------------------------------------------- | | `canvas` | `#221f21` | `#1f1f22` | +3R, -1B | Body/page background (custom, ~zinc-850) | | `zinc-900` | `#1b181a` | `#18181b` | +3R, -1B | Recessed containers (code blocks, tables, inset UI) | | `zinc-800` | `#2a2729` | `#27272a` | +3R, -1B | Elevated surfaces (menus, dialogs, cards, popovers) | | `zinc-700` | `#423f42` | `#3f3f46` | +3R, -4B | Hover states inside elevated surfaces, borders | | `zinc-600` | `#555259` | `#52525b` | +3R, -2B | Muted icons, disabled states | | `zinc-500` | `#747178` | `#71717a` | +3R, -2B | Muted/placeholder text | | `zinc-400` | `#a3a1a8` | `#a1a1aa` | +2R, -2B | Secondary text | | `zinc-300` | `#d6d4d7` | `#d4d4d8` | +2R, -1B | Near-white text, secondary headings | | `zinc-200` | `#e5e4e5` | `#e4e4e7` | +1R, -2B | Headings, prominent text | | `zinc-100` | `#f5f4f4` | `#f4f4f5` | +1R, -1B | Primary body text | The shift pattern is consistent: raise R by 2-3, lower B by 1-4, leave G unchanged. Darker stops get a proportionally larger shift because the cool cast is more noticeable at low luminance. Lighter stops are barely changed — text readability is unaffected. ### Surface Hierarchy The lifted canvas creates a four-tier depth model without relying on shadows: | Tier | Color | Usage | Visual effect | | ------------ | ---------------------- | ---------------------------------------------------------- | ----------------------------------------------------- | | **Chrome** | `zinc-900` (`#1b181a`) | Header, sidebar, mobile drawer | Darker than body — frames the content area from edges | | **Canvas** | `canvas` (`#221f21`) | Body / main content area | Primary reading surface, brightest baseline | | **Recessed** | `zinc-900` (`#1b181a`) | Code blocks, table wrappers, details/toggle containers | Darker than body — inset feel within content | | **Elevated** | `zinc-800` (`#2a2729`) | Menus, dialogs, modals, dropdowns, search panels, popovers | Lighter than body — floating feel | Chrome and Recessed share `zinc-900` but serve different visual roles: chrome frames the layout from the edges (header border-b, sidebar border-r provide separation), while recessed containers sit within the content area and are distinguished by their own borders. The content area at `canvas` is the brightest surface — the place the eye should rest. Hover states inside elevated surfaces use `zinc-700` (`#423f42`). Resting interactive elements inside overlays (inputs, action buttons) also use `zinc-700` since their container is already `zinc-800`. **Why lifted, not near-black?** Pure dark backgrounds (`#09090b`) cause halation — light text blooms and blurs against very dark surfaces, especially for users with astigmatism. A lifted background at `#221f21` paired with heavier text weight (450) produces a more comfortable reading experience for extended sessions. This is an accessibility decision, not an aesthetic preference. **Why warm-shifted?** Stock zinc has a subtle cool/blue cast (B channel consistently exceeds R). On dark surfaces viewed for extended periods, this cool cast registers as clinical or harsh even when the user can't identify it as "blue." Raising R by 2-3 and lowering B by 1-4 per stop removes the cool cast without introducing a visible warm color. The result is perceived as "neutral" rather than "cool-gray," which better matches the `limic.dev` brand voice of warmth and comfort. ### Accent Color Palette Every project defines its accent as `accent-*` via `@theme`. **Never** use project-specific names (e.g., ~~`flame-*`~~) or raw Tailwind color names (e.g., ~~`indigo-*`~~) for the accent. This ensures that shell components, buttons, nav links, and all accent-referencing classes are identical across projects. | Project | Accent-500 (primary) | Hue Family | | ----------------- | -------------------- | ------------- | | anvil | `#3b82f6` | Blue | | bland | `#9d6ee8` | Warm amethyst | | flamemail | `#f97316` | Orange | | git-on-cloudflare | `#6366f1` | Indigo | The accent palette follows a 50-900 scale identical in structure to Tailwind's built-in color scales. **Choosing accent colors**: Avoid stock Tailwind palette values (especially `violet-500` / `#8b5cf6`) — they are the most recognizable AI-generated color choice. Pick a custom hue that is clearly distinct from any Tailwind default. If using violet/purple, shift the hue warmer (toward 270-278) and reduce saturation from Tailwind's 90% to ~70-75% for a more sophisticated, less electric feel. ### Accent Color Application Pattern | Element | Classes | | ----------------------- | ----------------------------------------------------------------------------- | | Primary CTA button | `bg-accent-600 text-white hover:bg-accent-500` | | Secondary button | `border border-zinc-700/60 bg-zinc-800/60 text-zinc-300 hover:bg-zinc-700/60` | | Active nav item | `bg-accent-500/10 text-accent-400` | | Inputs (focus) | `focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30` | | Header brand icon | `text-accent-400` stroked lucide glyph (no background tile) — see Section 7 | | Unread/active indicator | `bg-accent-500` | Prefer solid accent colors over gradients. Gradient buttons (`from-accent-500 to-accent-600`) and accent-colored shadows (`shadow-accent-500/10`) are the most recognizable AI-generated patterns and should be avoided. A solid `bg-accent-600` with `hover:bg-accent-500` is cleaner and more intentional. ### Accent Colors in Plain CSS Editor overlays, third-party component overrides, and other plain CSS files that need accent colors should reference the `@theme` variables rather than hardcoding hex values: ```css /* Direct color */ color: var(--color-accent-400); /* With opacity (use color-mix, not hardcoded rgba) */ background-color: color-mix(in srgb, var(--color-accent-500) 10%, transparent); ``` This ensures accent color changes propagate everywhere from a single source of truth. ### Semantic Colors | State | Background | Text | Border | | ------- | ------------------- | ------------------ | ----------------------- | | Success | `bg-emerald-500/10` | `text-emerald-400` | `border-emerald-500/20` | | Error | `bg-red-500/10` | `text-red-400` | `border-red-500/20` | | Warning | `bg-amber-500/10` | `text-amber-300` | `border-amber-500/20` | | Info | `bg-accent-500/10` | `text-accent-400` | `border-accent-500/20` | --- ## 5. Typography ### Primary Fonts The limic ecosystem uses `Hanken Grotesk` and `JetBrains Mono` as the shared baseline for body text, inputs, and UI components. **Generic fonts like Arial, Roboto, or Inter are strictly forbidden.** However, to give each app its distinctive aesthetic, **you are heavily encouraged to pair a bold, characterful Display font** for primary headings (`

`, `

`, hero text) alongside the refined `Hanken Grotesk` body font. Consider unconventional choices that elevate the visual interest (e.g., striking serifs, geometric displays, or brutalist grotesques). Loaded via Google Fonts `` tags with `preconnect`: | Font | Weights | Usage | | --------------------- | ------------------------- | ------------------------------------------ | | **Hanken Grotesk** | 400..700 (variable range) | Body, UI elements, secondary headings | | **JetBrains Mono** | 400, 500 | Code blocks, monospace content | | **[Project Display]** | _as needed_ | High-impact headings (Display, H1, Heroes) | Load body fonts with variable font range syntax (e.g., `wght@400..700`) instead of discrete weights. This enables `font-weight: 450` for body text — slightly heavier than regular (400) to counteract halation on dark backgrounds. The 450 weight is set on `` and cascades everywhere that doesn't specify an explicit weight. ```html ``` ### Font Stacks (defined in `@theme`) ```css --font-sans: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif; --font-mono: "JetBrains Mono", "SF Mono", "Fira Code", monospace; ``` ### Heading Scale Headings use tighter tracking and heavier weights for visual hierarchy: | Level | Classes | Usage | | ------- | ------------------------------------------------------------- | --------------------------- | | Display | `text-3xl sm:text-4xl font-extrabold tracking-tight` | Hero headlines, page titles | | H1 | `text-2xl font-bold tracking-tight` | Section titles | | H2 | `text-xl font-semibold` | Card titles, subsections | | H3 | `text-base font-semibold` | List labels, sidebar heads | | Caption | `text-xs font-medium uppercase tracking-widest text-zinc-500` | Overlines, meta labels | - `tracking-tight` (`-0.025em`) on Display and H1 tightens letterforms for impact at large sizes - `tracking-widest` (`0.1em`) on Captions creates a small-caps effect for overlines and meta labels - Never use `font-light` or `font-thin` — insufficient contrast on dark backgrounds, and halation makes thin strokes unreadable for astigmatic users - Body text inherits `font-weight: 450` from the `` rule. Elements with explicit `font-medium` (500) or heavier are unaffected --- ## 6. Animations & Motion ### Philosophy Motion should feel intentional, physical, and **high-impact**. Prefer orchestrated sequences (such as a single, well-choreographed page load with staggered reveals) over scattered, distracting micro-animations. Every animation must serve either **orientation** (where am I?), **feedback** (what did I do?), or **continuity** (what just changed?). To create genuine delight, deeply integrate motion into the aesthetic: use scroll-triggering, surprise hover states, and smooth staggered cascades that breathe life into the UI. ### Standard Keyframes Four keyframes are defined in every project's `app.css`: | Name | Duration | Easing | Effect | | ------------ | -------- | ----------------------------- | ----------------------------------------------- | | `fade-in` | 0.4s | ease-out | Opacity 0 -> 1 | | `slide-up` | 0.35s | ease-out | Opacity 0 + translateY(12px) -> visible | | `scale-fade` | 0.3s | cubic-bezier(0.16, 1, 0.3, 1) | Opacity 0 + scale(0.96) -> visible | | `shimmer` | 1.5s | ease-in-out | Background position sweep (loading placeholder) | ### Entrance Animations - `animate-fade-in` -- main content areas on page load - `animate-slide-up` -- page-level content wrappers, modal/dialog entrances - `animate-scale-fade` -- cards, popovers, dropdown menus (scale implies origin) ### Staggered Reveals When multiple sibling elements enter together (card grids, list items, stat blocks), stagger their `animation-delay` to create a cascade: ```tsx { items.map((item, i) => ( )); } ``` Rules: - Base delay increment: **60ms** per item (fast enough to feel connected, slow enough to perceive the sequence) - Cap at **8 items** (480ms total) -- beyond that, truncate the stagger so the tail doesn't drag - Always set `opacity-0` on the element so it's invisible before the animation fires (`animation-fill-mode: both` in the keyframe handles the final state) ### Interaction Micro-Animations | Interaction | Effect | Implementation | | ------------------- | ---------------------------- | ------------------------------------------------- | | Button press | Subtle scale-down on active | `active:scale-[0.98]` + `transition-transform` | | Card hover lift | Slight upward shift | `hover:-translate-y-0.5` + `transition-transform` | | Icon button hover | Gentle scale | `hover:scale-105` + `transition-transform` | | Toggle state change | Instant swap (no transition) | Conditional classes, no `transition-*` | | Toast entrance | Slide up from bottom-right | `animate-slide-up` | | Dropdown open | Scale from origin + fade | `animate-scale-fade` with `transform-origin` | ### Loading Skeletons Use a shimmer animation for placeholder content: ``` Skeleton: bg-gradient-to-r from-zinc-800/0 via-zinc-700/40 to-zinc-800/0 bg-[length:200%_100%] animate-shimmer rounded-lg ``` ### What Not to Animate - **Layout properties**: `width`, `height`, `top`, `left`, `margin`, `padding` -- triggers layout recalculation - **Border hue shifts**: covered in Section 3 (no zinc -> accent transitions) - **Scroll-linked parallax**: avoid JS-driven scroll animations -- they fight the compositor and add jank - **Below-fold entrances**: only animate elements visible on initial load; below-fold content should already be in its final state when scrolled into view --- ## 7. Page Layout ### HTML Shell (SPA) ```html {project name}
``` SSR projects generate the `` document in `server/document.tsx` instead of a static `index.html`. ### App Shell Component (`app-shell.tsx`) Every project has an `app-shell.tsx` that renders: ```tsx
{/* SPA: | SSR: {children} */}
``` - **SPA projects** use `` from `react-router-dom` for nested routes. - **SSR projects** accept `{children}` as a prop. - The `
` and `