diff --git a/.claude/skills/nothing-design/SKILL.md b/.claude/skills/nothing-design/SKILL.md new file mode 100644 index 00000000..32f7f421 --- /dev/null +++ b/.claude/skills/nothing-design/SKILL.md @@ -0,0 +1,177 @@ +--- +name: nothing-design +description: This skill should be used when the user explicitly says "Nothing style", "Nothing design", "/nothing-design", or directly asks to use/apply the Nothing design system. NEVER trigger automatically for generic UI or design tasks. +version: 3.0.0 +allowed-tools: [Read, Write, Edit, Glob, Grep] +--- + +# Nothing-Inspired UI/UX Design System + +A senior product designer's toolkit trained in Swiss typography, industrial design (Braun, Teenage Engineering), and modern interface craft. Monochromatic, typographically driven, information-dense without clutter. Dark and light mode with equal rigor. + +**Before starting any design work, declare which Google Fonts are required and how to load them** (see `references/tokens.md` Section 1). Never assume fonts are already available. + +--- + +## 1. DESIGN PHILOSOPHY + +- **Subtract, don't add.** Every element must earn its pixel. Default to removal. +- **Structure is ornament.** Expose the grid, the data, the hierarchy itself. +- **Monochrome is the canvas.** Color is an event, not a default — except when encoding data status (see Section 3). +- **Type does the heavy lifting.** Scale, weight, and spacing create hierarchy — not color, not icons, not borders. +- **Both modes are first-class.** Dark mode: OLED black. Light mode: warm off-white. Neither is "derived" — both get full design attention. Ask the user which mode to start with. +- **Industrial warmth.** Technical and precise, but never cold. A human hand should be felt. + +--- + +## 2. CRAFT RULES — HOW TO COMPOSE + +### 2.1 Visual Hierarchy: The Three-Layer Rule + +Every screen has exactly **three layers of importance.** Not two, not five. Three. + +| Layer | What | How | +|-------|------|-----| +| **Primary** | The ONE thing the user sees first. A number, a headline, a state. | Doto or Space Grotesk at display size. `--text-display`. 48–96px breathing room. | +| **Secondary** | Supporting context. Labels, descriptions, related data. | Space Grotesk at body/subheading. `--text-primary`. Grouped tight (8–16px) to the primary. | +| **Tertiary** | Metadata, navigation, system info. Visible but never competing. | Space Mono at caption/label. `--text-secondary` or `--text-disabled`. ALL CAPS. Pushed to edges or bottom. | + +**The test:** Squint at the screen. Can you still tell what's most important? If two things compete, one needs to shrink, fade, or move. + +**Common mistake:** Making everything "secondary." Evenly-sized elements with even spacing = visual flatness. Be brave — make the primary absurdly large and the tertiary absurdly small. The contrast IS the hierarchy. + +### 2.2 Font Discipline + +Per screen, use maximum: +- **2 font families** (Space Grotesk + Space Mono. Doto only for hero moments.) +- **3 font sizes** (one large, one medium, one small) +- **2 font weights** (Regular + one other — usually Light or Medium, rarely Bold) + +Think of it as a budget. Every additional size/weight costs visual coherence. Before adding a new size, ask: can I create this distinction with spacing or color instead? + +| Decision | Size | Weight | Color | +|----------|:---:|:---:|:---:| +| Heading vs. body | Yes | No | No | +| Label vs. value | No | No | Yes | +| Active vs. inactive nav | No | No | Yes | +| Hero number vs. unit | Yes | No | No | +| Section title vs. content | Yes | Optional | No | + +**Rule of thumb:** If reaching for a new font-size, it's probably a spacing problem. Add distance instead. + +### 2.3 Spacing as Meaning + +Spacing is the primary tool for communicating relationships. + +``` +Tight (4–8px) = "These belong together" (icon + label, number + unit) +Medium (16px) = "Same group, different items" (list items, form fields) +Wide (32–48px) = "New group starts here" (section breaks) +Vast (64–96px) = "This is a new context" (hero to content, major divisions) +``` + +**If a divider line is needed, the spacing is probably wrong.** Dividers are a symptom of insufficient spacing contrast. Use them only in data-dense lists where items are structurally identical. + +### 2.4 Container Strategy (prefer top) + +1. **Spacing alone** (proximity groups items) +2. A single divider line +3. A subtle border outline +4. A surface card with background change + +Each step down adds visual weight. Use the lightest tool that works. Never box the most important element — let it float on the background. + +### 2.5 Color as Hierarchy + +In a monochrome system, the gray scale IS the hierarchy. Max 4 levels per screen: + +``` +--text-display (100%) → Hero numbers. One per screen. +--text-primary (90%) → Body text, primary content. +--text-secondary (60%) → Labels, captions, metadata. +--text-disabled (40%) → Disabled, timestamps, hints. +``` + +**Red (#D71921) is not part of the hierarchy.** It's an interrupt — "look HERE, NOW." If nothing is urgent, no red on the screen. + +**Data status colors** (success green, warning amber, accent red) are exempt from the "one accent" rule when encoding data values. Apply color to the **value itself**, not labels or row backgrounds. See `references/tokens.md` for the full color system. + +### 2.6 Consistency vs. Variance + +**Be consistent in:** Font families, label treatment (always Space Mono ALL CAPS), spacing rhythm, color roles, component shapes, alignment. + +**Break the pattern in exactly ONE place per screen:** An oversized number, a circular widget among rectangles, a red accent among grays, a Doto headline, a vast gap where everything else is tight. + +This single break IS the design. Without it: sterile grid. With more than one: visual chaos. + +### 2.7 Compositional Balance + +**Asymmetry > symmetry.** Centered layouts feel generic. Favor deliberately unbalanced composition: +- **Large left, small right:** Hero metric + metadata stack. +- **Top-heavy:** Big headline near top, sparse content below. +- **Edge-anchored:** Important elements pinned to screen edges, negative space in center. + +Balance heavy elements with more empty space, not with more heavy elements. + +### 2.8 The Nothing Vibe + +1. **Confidence through emptiness.** Large uninterrupted background areas. Resist filling space. +2. **Precision in the small things.** Letter-spacing, exact gray values, 4px gaps. Micro-decisions compound into craft. +3. **Data as beauty.** `36GB/s` in Space Mono at 48px IS the visual. No illustrations needed. +4. **Mechanical honesty.** Controls look like controls. A toggle = physical switch. A gauge = instrument. +5. **One moment of surprise.** A dot-matrix headline. A circular widget. A red dot. Restraint makes the one expressive moment powerful. +6. **Percussive, not fluid.** Imagine UI sounds: click not swoosh, tick not chime. Design transitions that feel mechanical and precise. + +### 2.9 Visual Variety in Data-Dense Screens + +When 3+ data sections appear on one screen, vary the visual form: + +| Form | Best for | Weight | +|------|----------|--------| +| Hero number (large Doto/Space Mono) | Single key metric | Heavy — use once | +| Segmented progress bar | Progress toward goal | Medium | +| Concentric rings / arcs | Multiple related percentages | Medium | +| Inline compact bar | Secondary metrics in rows | Light | +| Number-only with status color | Values without proportion | Lightest | +| Sparkline | Trends over time | Medium | +| Stat row (label + value) | Simple data points | Light | + +Lead section → heaviest treatment. Secondary → different form. Tertiary → lightest. The FORM varies, the VOICE stays the same. + +--- + +## 3. ANTI-PATTERNS — WHAT TO NEVER DO + +- No gradients in UI chrome +- No shadows. No blur. Flat surfaces, border separation. +- No skeleton loading screens. Use `[LOADING...]` text or segmented spinner. +- No toast popups. Use inline status text: `[SAVED]`, `[ERROR: ...]` +- No sad-face illustrations, cute mascots, or multi-paragraph empty states +- No zebra striping in tables +- No filled icons, multi-color icons, or emoji as UI +- No parallax, scroll-jacking, or gratuitous animation +- No spring/bounce easing. Use subtle ease-out only. +- No border-radius > 16px on cards. Buttons are pill (999px) or technical (4–8px). +- Data visualization: differentiate with **opacity** (100%/60%/30%) or **pattern** (solid/striped/dotted) before introducing color. + +--- + +## 4. WORKFLOW + +1. **Declare fonts** — tell the user which Google Fonts to load (see `references/tokens.md`) +2. **Ask mode** — dark or light? Neither is default. +3. **Sketch hierarchy** — identify the 3 layers before writing any code +4. **Compose** — apply craft rules (Sections 2.1–2.9) +5. **Check tokens** — consult `references/tokens.md` for exact values +6. **Build components** — consult `references/components.md` for patterns +7. **Adapt to platform** — consult `references/platform-mapping.md` for output conventions + +--- + +## 5. REFERENCE FILES + +For detailed token values, component specs, and platform-specific guidance: + +- **`references/tokens.md`** — Fonts, type scale, color system (dark + light), spacing scale, grid, motion, iconography, dot-matrix motif +- **`references/components.md`** — Cards, buttons, inputs, lists, tables, nav, tags, segmented controls, progress bars, charts, widgets, overlays, state patterns +- **`references/platform-mapping.md`** — HTML/CSS, SwiftUI, React/Tailwind, Paper output conventions diff --git a/.claude/skills/nothing-design/references/components.md b/.claude/skills/nothing-design/references/components.md new file mode 100644 index 00000000..d25768db --- /dev/null +++ b/.claude/skills/nothing-design/references/components.md @@ -0,0 +1,153 @@ +# Nothing Design System — Components + +## 1. CARDS / SURFACES + +- Background: `--surface` or `--surface-raised` +- Border: `1px solid --border`, or none. Radius: 12–16px cards, 8px compact, 4px technical +- Padding: 16–24px. No shadows. Flat surfaces, border separation. + +--- + +## 2. BUTTONS + +| Variant | Background | Border | Text | Radius | +|---------|-----------|--------|------|--------| +| Primary | `--text-display` (#FFF) | none | `--black` | 999px (pill) | +| Secondary | transparent | `1px solid --border-visible` | `--text-primary` | 999px | +| Ghost | transparent | none | `--text-secondary` | 0 | +| Destructive | transparent | `1px solid --accent` | `--accent` | 999px | + +All buttons: `Space Mono`, 13px, ALL CAPS, letter-spacing 0.06em, padding 12px 24px. Min height 44px. + +--- + +## 3. INPUTS + +- Underline preferred (`1px solid --border-visible` bottom) or full border 8px radius +- Label above: `--label` style (Space Mono, ALL CAPS, `--text-secondary`) +- Focus: border → `--text-primary`. Error: border → `--accent`, message below in `--accent` +- Data-entry fields: `Space Mono` for input text + +--- + +## 4. LISTS / DATA ROWS + +- Dividers: `1px solid --border`, full-width. Row padding: 12–16px vertical +- Left: label (Space Mono caps, `--text-secondary`). Right: value (`--text-primary`) +- Never alternating row backgrounds. Use dividers. + +**Stat rows:** Label left (Space Mono, ALL CAPS, `--text-secondary`), value right (color = status color), unit adjacent in `--label` size. Trend arrow same color as value. + +**Hierarchical rows:** Sub-items indented 16–24px, same divider treatment. No tree lines or expand/collapse — indentation IS the hierarchy. + +--- + +## 5. TABLES / DATA GRIDS + +- Header: `--label` style, bottom border `--border-visible` +- Cell text: `Space Mono` numeric, `Space Grotesk` text. Cell padding: 12px 16px +- Numbers right, text left. No zebra striping, no cell backgrounds. +- Active row: `--surface-raised` background, left `2px solid --accent` indicator + +--- + +## 6. NAVIGATION + +- Bottom bar mobile, horizontal text bar desktop +- Labels: Space Mono, ALL CAPS. Active: `--text-display` + dot/underline. Inactive: `--text-disabled` +- Bracket `[ HOME ] GALLERY INFO` or pipe `HOME | GALLERY | INFO` +- **Back button:** Circular 40–44px, `--surface` bg, thin chevron `<`, top-left 16px from edges + +--- + +## 7. TAGS / CHIPS + +- Border: `1px solid --border-visible`, no fill. Text: Space Mono, `--caption`, ALL CAPS +- Radius: 999px (pill) or 4px (technical). Padding: 4px 12px. Active: `--text-display` border+text + +--- + +## 8. SEGMENTED CONTROL + +- Container: `1px solid --border-visible`, pill or 8px rounded +- Active: `--text-display` bg, `--black` text (inverted). Inactive: transparent, `--text-secondary` +- Text: Space Mono, ALL CAPS, `--label` size. Height: 36–44px. Transition: 200ms ease-out +- Max 2–4 segments + +--- + +## 9. DATE / PERIOD NAVIGATION + +- Layout: `< LABEL >` — back arrow, label, forward arrow +- Label: Space Mono/Grotesk, ALL CAPS. Arrows: thin chevrons, `--text-secondary`, 44px touch +- No calendar popovers — linear stepping IS the interaction + +--- + +## 10. TOGGLES / SWITCHES + +- Pill track, circle thumb. Off: `--border-visible` track, `--text-disabled` thumb +- On: `--text-display` track, `--black` thumb. Min touch target: 44px + +--- + +## 11. SEGMENTED PROGRESS BARS + +The signature data visualization. Discrete blocks — mechanical, instrument-like. + +**Anatomy:** Label + value above, full-width bar of discrete rectangular segments with 2px gaps below. + +**Segments:** Square-ended blocks, no border-radius. Filled = solid status color. Empty = `--border` (dark) / `#E0E0E0` (light). + +| State | Fill | When | +|-------|------|------| +| Neutral | `--text-display` | Within normal range | +| Over limit | `--accent` | Exceeds target | +| Good | `--success` | Healthy range | +| Moderate | `--warning` | Caution zone | + +**Overflow:** Filled segments continue past "full" mark in status color (typically red). + +**Sizes:** Hero 16–20px, Standard 8–12px, Compact 4–6px height. + +Always pair with numeric readout. Bar = proportion, number = precision. + +--- + +## 12. OTHER DATA VISUALIZATION + +- **Bar charts:** Vertical, white fill, `--border` remainder. Square ends. +- **Gauges:** Thin stroke circles + tick marks, numeric readout centered/adjacent. +- **Dot grids:** Vary opacity/size for heat maps. Uniform spacing. +- **Category differentiation:** Opacity → pattern → line style → color (last resort). +- Always show numeric value alongside any visual. + +**Charts:** Line 1.5–2px `--text-display`, average dashed 1px `--text-secondary`. Axis labels: Space Mono, `--caption`. Grid: `--border`, horizontal only. No area fill, no legend boxes — label lines directly. + +--- + +## 13. WIDGETS (DASHBOARD CARDS) + +- `--surface` bg, 16px radius. Hero metric: large Doto/Space Mono, left-aligned +- Unit: `--label` size, adjacent. Category: ALL CAPS Space Mono top-left +- Instrument gauges: compass, thermometer, dial motifs + +--- + +## 14. OVERLAYS & LAYERING + +No shadows. Layering through background contrast and borders. + +- **Modals:** Backdrop `rgba(0,0,0,0.8)`, dialog `--surface` + `1px solid --border-visible` + 16px radius, centered max 480px. Close: `[ X ]` top-right ghost button. +- **Bottom sheets:** `--surface`, 2px handle bar centered, 16px top radius, drag-to-dismiss. Full-page sheets: title centered + dismiss button right, sections with `--text-secondary` headings. +- **Dropdowns:** `--surface-raised`, `1px solid --border-visible` 8px radius, 44px items. Selected: left 2px accent bar. No shadow. +- **Toasts:** None. Use inline status text: `[SAVED]`, `[ERROR: ...]`. Space Mono, `--caption`, near trigger. + +--- + +## 15. STATE PATTERNS + +- **Error:** Input border → `--accent` + message below. Form-level: summary box `1px solid --accent`. Inline: `[ERROR]` prefix. Never red backgrounds or alert banners. +- **Empty:** Centered, 96px+ padding. Headline `--text-secondary`, 1 sentence description `--text-disabled`. Optional dot-matrix illustration. No mascots. +- **Loading:** Segmented spinner (hardware-style), or segmented bar + percentage. No skeletons — use `[LOADING]` bracket text. +- **Disabled:** Opacity 0.4 or `--text-disabled`. Borders fade to `--border`. diff --git a/.claude/skills/nothing-design/references/platform-mapping.md b/.claude/skills/nothing-design/references/platform-mapping.md new file mode 100644 index 00000000..5d5201ab --- /dev/null +++ b/.claude/skills/nothing-design/references/platform-mapping.md @@ -0,0 +1,64 @@ +# Nothing Design System — Platform Mapping + +## 1. HTML / CSS / WEB + +Load fonts via Google Fonts `` or `@import`. Use CSS custom properties, `rem` for type, `px` for spacing/borders. Dark/light via `prefers-color-scheme` or class toggle. + +```css +:root { + --black: #000000; + --surface: #111111; + --surface-raised: #1A1A1A; + --border: #222222; + --border-visible: #333333; + --text-disabled: #666666; + --text-secondary: #999999; + --text-primary: #E8E8E8; + --text-display: #FFFFFF; + --accent: #D71921; + --accent-subtle: rgba(215,25,33,0.15); + --success: #4A9E5C; + --warning: #D4A843; + --interactive: #5B9BF6; + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + --space-3xl: 64px; + --space-4xl: 96px; +} +``` + +--- + +## 2. SWIFTUI / iOS + +Register fonts in Info.plist, bundle `.ttf` files. Use `@Environment(\.colorScheme)` for mode switching. + +```swift +extension Color { + static let ndBlack = Color(hex: "000000") + static let ndSurface = Color(hex: "111111") + static let ndSurfaceRaised = Color(hex: "1A1A1A") + static let ndBorder = Color(hex: "222222") + static let ndBorderVisible = Color(hex: "333333") + static let ndTextDisabled = Color(hex: "666666") + static let ndTextSecondary = Color(hex: "999999") + static let ndTextPrimary = Color(hex: "E8E8E8") + static let ndTextDisplay = Color.white + static let ndAccent = Color(hex: "D71921") + static let ndSuccess = Color(hex: "4A9E5C") + static let ndWarning = Color(hex: "D4A843") + static let ndInteractive = Color(hex: "5B9BF6") +} +``` + +Light mode values in tokens.md Dark/Light table. Derive Font extension from font stack table (trivial: `.custom("Doto"/"SpaceGrotesk-Regular"/"SpaceMono-Regular", size:)`). + +--- + +## 3. PAPER (DESIGN TOOL) + +Use `get_font_family_info` to verify fonts before writing styles. Direct hex values (no CSS variables). Dark mode as default canvas, light mode as separate artboard. diff --git a/.claude/skills/nothing-design/references/tokens.md b/.claude/skills/nothing-design/references/tokens.md new file mode 100644 index 00000000..38578adc --- /dev/null +++ b/.claude/skills/nothing-design/references/tokens.md @@ -0,0 +1,142 @@ +# Nothing Design System — Tokens + +## 1. TYPOGRAPHY + +### Font Stack + +| Role | Font | Fallback | Weight | +|------|------|----------|--------| +| **Display** | `"Doto"` | `"Space Mono", monospace` | 400–700, variable dot-size | +| **Body / UI** | `"Space Grotesk"` | `"DM Sans", system-ui, sans-serif` | Light 300, Regular 400, Medium 500, Bold 700 | +| **Data / Labels** | `"Space Mono"` | `"JetBrains Mono", "SF Mono", monospace` | Regular 400, Bold 700 | + +**Why these fonts:** Doto = variable dot-matrix (closest to NDot 57). Space Grotesk + Space Mono by Colophon Foundry — same foundry as Nothing's actual typefaces. Shared design DNA. + +### Type Scale + +| Token | Size | Line Height | Letter Spacing | Use | +|-------|------|-------------|----------------|-----| +| `--display-xl` | 72px | 1.0 | -0.03em | Hero numbers, time displays | +| `--display-lg` | 48px | 1.05 | -0.02em | Section heroes, percentages | +| `--display-md` | 36px | 1.1 | -0.02em | Page titles | +| `--heading` | 24px | 1.2 | -0.01em | Section headings | +| `--subheading` | 18px | 1.3 | 0 | Subsections | +| `--body` | 16px | 1.5 | 0 | Body text | +| `--body-sm` | 14px | 1.5 | 0.01em | Secondary body | +| `--caption` | 12px | 1.4 | 0.04em | Timestamps, footnotes | +| `--label` | 11px | 1.2 | 0.08em | ALL CAPS monospace labels | + +### Typographic Rules + +- **Doto:** 36px+ only, tight tracking, never for body text +- **Labels:** Always Space Mono, ALL CAPS, 0.06–0.1em spacing, 11–12px ("instrument panel" labels) +- **Data/Numbers:** Always Space Mono. Units as `--label` size, slightly raised, adjacent +- **Hierarchy:** display (Doto) > heading (Space Grotesk) > label (Space Mono caps) > body (Space Grotesk). Four levels max. + +--- + +## 2. COLOR SYSTEM + +### Primary Palette (Dark Mode) + +| Token | Hex | Contrast on #000 | Role | +|-------|-----|-------------------|------| +| `--black` | `#000000` | — | Primary background (OLED) | +| `--surface` | `#111111` | 1.3:1 | Elevated surfaces, cards | +| `--surface-raised` | `#1A1A1A` | 1.5:1 | Secondary elevation | +| `--border` | `#222222` | — | Subtle dividers (decorative only) | +| `--border-visible` | `#333333` | — | Intentional borders, wireframe lines | +| `--text-disabled` | `#666666` | 4.0:1 | Disabled text, decorative elements | +| `--text-secondary` | `#999999` | 6.3:1 | Labels, captions, metadata | +| `--text-primary` | `#E8E8E8` | 16.5:1 | Body text | +| `--text-display` | `#FFFFFF` | 21:1 | Headlines, hero numbers | + +### Accent & Status Colors + +| Token | Hex | Usage | +|-------|-----|-------| +| `--accent` | `#D71921` | Signal light: active states, destructive, urgent. One per screen as UI element. Never decorative. | +| `--accent-subtle` | `rgba(215,25,33,0.15)` | Accent tint backgrounds | +| `--success` | `#4A9E5C` | Confirmed, completed, connected | +| `--warning` | `#D4A843` | Caution, pending, degraded | +| `--error` | `#D71921` | Shares accent red — errors ARE the accent moment | +| `--info` | `#999999` | Uses secondary text color | +| `--interactive` | `#007AFF` / `#5B9BF6` | Tappable text: links, picker values. Not for buttons. | + +**Data status colors:** `--success` = good/in range, `--warning` = moderate/attention, `--accent` = bad/over limit, `--text-primary` = neutral. Apply color to **value**, not label or background. Labels stay `--text-secondary`. Trend arrows inherit value color. + +### Dark / Light Mode + +| Token | Dark | Light | +|-------|------|-------| +| `--black` | `#000000` | `#F5F5F5` | +| `--surface` | `#111111` | `#FFFFFF` | +| `--surface-raised` | `#1A1A1A` | `#F0F0F0` | +| `--border` | `#222222` | `#E8E8E8` | +| `--border-visible` | `#333333` | `#CCCCCC` | +| `--text-disabled` | `#666666` | `#999999` | +| `--text-secondary` | `#999999` | `#666666` | +| `--text-primary` | `#E8E8E8` | `#1A1A1A` | +| `--text-display` | `#FFFFFF` | `#000000` | +| `--interactive` | `#5B9BF6` | `#007AFF` | + +**Identical across modes:** Accent red, status colors, ALL CAPS labels, fonts, type scale, spacing, component shapes. + +**Dark feel:** Instrument panel in a dark room. OLED black, white data glowing. +**Light feel:** Printed technical manual. Off-white paper (#F5F5F5), black ink. Cards = `#FFFFFF` on off-white page = subtle elevation without shadows. + +--- + +## 3. SPACING + +### Spacing Scale (8px base) + +| Token | Value | Use | +|-------|-------|-----| +| `--space-2xs` | 2px | Optical adjustments only | +| `--space-xs` | 4px | Icon-to-label gaps, tight padding | +| `--space-sm` | 8px | Component internal spacing | +| `--space-md` | 16px | Standard padding, element gaps | +| `--space-lg` | 24px | Group separation | +| `--space-xl` | 32px | Section margins | +| `--space-2xl` | 48px | Major section breaks | +| `--space-3xl` | 64px | Page-level vertical rhythm | +| `--space-4xl` | 96px | Hero breathing room | + +--- + +## 4. MOTION & INTERACTION + +- **Duration:** 150–250ms micro, 300–400ms transitions +- **Easing:** `cubic-bezier(0.25, 0.1, 0.25, 1)` — subtle ease-out. No spring/bounce. +- Prefer opacity over position. Elements fade, don't slide. +- Hover: border/text brightens. No scale, no shadows. +- No parallax, scroll-jacking, gratuitous animation. + +--- + +## 5. ICONOGRAPHY + +- Monoline, 1.5px stroke, no fill. 24x24 base, 20x20 live area. Round caps/joins. +- Color inherits text color. Max 5–6 strokes. +- Preferred: Lucide (thin), Phosphor (thin). Never filled or multi-color. + +--- + +## 6. DOT-MATRIX MOTIF + +**When to use:** Hero typography (Doto), decorative grid backgrounds, dot-grid data viz, loading indicators, empty state illustrations. + +### CSS Implementation +```css +.dot-grid { + background-image: radial-gradient(circle, var(--border-visible) 1px, transparent 1px); + background-size: 16px 16px; +} +.dot-grid-subtle { + background-image: radial-gradient(circle, var(--border) 0.5px, transparent 0.5px); + background-size: 12px 12px; +} +``` + +Dots 1–2px, uniform 12–16px grid. Opacity 0.1–0.2 for backgrounds, full for data. Never as container border or button style. diff --git a/specs/028-nothing-design-redesign/README.md b/specs/028-nothing-design-redesign/README.md new file mode 100644 index 00000000..850bd21e --- /dev/null +++ b/specs/028-nothing-design-redesign/README.md @@ -0,0 +1,109 @@ +# 028 — Nothing Design Redesign + +## Overview + +This spec proposes replacing the AI Developer Hub's current, inconsistent theming with one +coherent visual system inspired by Nothing's instrument-panel / printed-manual aesthetic. The +existing UI leans on a green-primary shadcn theme (`--primary: oklch(0.78 0.19 120)`) applied +unevenly across screens, with mixed type, ad-hoc accent usage, and shadow-heavy surfaces. + +**Goal:** a single source of truth — `mockups/styles/nothing.css` — that styles every screen with +the same monochrome canvas, one disciplined red interrupt, a type-driven hierarchy, and flat +bordered surfaces. Both dark and light are first-class, not an afterthought. Dark reads as an +instrument panel; light reads as a printed manual. + +The `mockups/` folder contains a standalone, framework-free HTML mockup per screen plus a gallery +index, all sharing `nothing.css`. These are static design references — the eventual implementation +maps these tokens onto the app's real `src/app/globals.css`. + +## Font declaration + +Three families, each with one job. Maximum two families visible per screen plus a single Doto hero +moment. + +| Role | Family | Usage | +| --- | --- | --- | +| **Display / hero** | **Doto** | Hero display numbers and titles only, 36px+. | +| **UI / body** | **Space Grotesk** | All interface chrome, headings, and prose. | +| **Labels + all data** | **Space Mono** | ALL-CAPS tracked labels, plus every number / metric / table value. | + +Exact `@import` line used at the top of `mockups/styles/nothing.css`: + +```css +@import url('https://fonts.googleapis.com/css2?family=Doto:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;700&family=Space+Mono:wght@400;700&display=swap'); +``` + +These are exposed as tokens: + +```css +--font-display: "Doto", "Space Mono", monospace; +--font-ui: "Space Grotesk", "DM Sans", system-ui, sans-serif; +--font-mono: "Space Mono", "JetBrains Mono", "SF Mono", monospace; +``` + +## How to view + +Open **`mockups/index.html`** in a browser. It is a self-contained gallery — no build step, no +server. Each card links to a sibling screen mockup. Use the Dark / Light toggle (top-right of every +page) to verify both modes; the choice persists across pages via `localStorage`. + +## Screens + +| # | Screen | Route | Mockup | +| --- | --- | --- | --- | +| 01 | Dashboard | `/` | `mockups/dashboard.html` | +| 02 | AI Tools | `/tools` | `mockups/tools.html` | +| 03 | Users | `/users` | `mockups/users.html` | +| 04 | License Assignments | `/assignments` | `mockups/assignments.html` | +| 05 | Access Requests | `/requests` | `mockups/requests.html` | +| 06 | Budget | `/budget` | `mockups/budget.html` | +| 07 | Reports | `/reports` | `mockups/reports.html` | +| 08 | GitHub Copilot | `/copilot` | `mockups/copilot.html` | +| 09 | Claude Console | `/claude` | `mockups/claude.html` | +| 10 | Invoices | `/invoices` | `mockups/invoices.html` | +| 11 | Settings | `/settings` | `mockups/settings.html` | +| 12 | Sign In | `/login` | `mockups/login.html` | + +## Nothing principles applied + +- **Monochrome canvas + one red interrupt.** Greyscale carries the entire hierarchy. The `--accent` + red (`#d71921`) appears at most once per screen — an active state, a destructive action, or the + single urgent number. If nothing is urgent, there is no red. +- **Type-driven 3-layer hierarchy.** Exactly three layers per screen: one large primary (hero number + or title), secondary context, and small tertiary metadata. The size contrast *is* the hierarchy — + no boxes or color needed to separate them. +- **Flat bordered surfaces / no shadows.** Cards and panels are defined by 1px borders and spacing, + never drop shadows, gradients, or blur. Status colors apply to value text only, never to row + backgrounds or labels. +- **Segmented-bar data viz.** The signature visualization is a segmented progress bar (~20 discrete + segments) rather than smooth fills; overflow segments turn red. Charts are thin inline SVG lines, + labeled directly with no legend boxes or area fills. +- **Both modes first-class.** Every value is a token — no hardcoded `#fff` / `#000`. Flipping the + toggle yields two intentional looks: dark = instrument panel, light = printed manual. + +## Migration notes + +`mockups/styles/nothing.css` is authored as the proposed replacement for the app's +`src/app/globals.css` design tokens. The Nothing tokens map directly onto the existing shadcn token +names, so adoption is largely a values swap (plus wiring the three font families and switching the +dark-mode selector). The headline change is retiring the **oklch green primary** +(`oklch(0.78 0.19 120)`) in favor of a monochrome canvas with a single red interrupt. + +Key token swaps (current `globals.css` → proposed Nothing equivalent): + +| shadcn token | Current value | Nothing equivalent (dark / light) | +| --- | --- | --- | +| `--background` | `oklch(0.16 0 0)` dark · `oklch(1 0 0)` light | `--black` `#000000` / `#f5f5f5` | +| `--foreground` | `oklch(0.97 0 0)` dark · `oklch(0.145 0 0)` light | `--text-primary` `#e8e8e8` / `#1a1a1a` (display text → `--text-display` `#ffffff` / `#000000`) | +| `--card` / `--popover` | `oklch(0.21 0 0)` dark · `oklch(1 0 0)` light | `--surface` `#111111` / `#ffffff` (raised → `--surface-raised` `#1a1a1a` / `#f0f0f0`) | +| `--primary` | `oklch(0.78 0.19 120)` **green** (both modes) | `--text-display` (monochrome) — primary actions become high-contrast greyscale, **not** a brand hue | +| `--accent` | `oklch(0.25 0.03 120)` / `oklch(0.95 0.03 120)` green-tinted | `--accent` `#d71921` red interrupt (subtle fill → `--accent-subtle`) | +| `--destructive` | `oklch(0.704 0.191 22.216)` / `oklch(0.577 0.245 27.325)` | `--error` `#d71921` (unified with the single accent red) | +| `--border` / `--input` | `oklch(1 0 0 / 10%)` dark · `oklch(0.91 0 0)` light | `--border` `#222222` / `#e8e8e8` (visible → `--border-visible` `#333333` / `#cccccc`) | +| `--ring` | `oklch(0.78 0.19 120)` green | `--border-visible` / `--text-primary` focus, no green ring | +| Fonts | shadcn default (system / Geist) | `--font-ui` Space Grotesk, `--font-mono` Space Mono, `--font-display` Doto (see `@import` above) | + +Status colors (`--success #4a9e5c`, `--warning #d4a843`) are new value-text-only tokens with no +direct shadcn equivalent; the existing rainbow `--chart-1..5` would collapse to greyscale + +segmented bars. The app's `.dark` class selector maps to the mockup's `:root` (dark) default and +`:root[data-theme="light"]` override pattern. diff --git a/specs/028-nothing-design-redesign/implementation-notes.html b/specs/028-nothing-design-redesign/implementation-notes.html new file mode 100644 index 00000000..8125be93 --- /dev/null +++ b/specs/028-nothing-design-redesign/implementation-notes.html @@ -0,0 +1,286 @@ + + + + + + AI Developer Hub — Nothing Redesign · Implementation Notes + + + + + +
+
+
+ SPEC 028 · IMPLEMENTATION NOTES +

Nothing Redesign — Build Log

+
+
+ + +
+
+ +

A running record of how the implementation interprets, extends, or departs from + spec 028 and its implementation-plan.html. Updated continuously as phases land. + Categories: decisions, deviations, + tradeoffs, open questions.

+ + +
+
00

Phase status

+
+
P0 · Tokens & fontsDONE · build green
+
P1 · Primitives & overlaysDONE · verified
+
P2 · App shell & layoutDONE · verified both modes
+
P3 · Shared tables & chartsDONE
+
P4 · Page migrationDONE · shared-driven + tint sweep
+
P5 · QA gate & PRDONE · grep gate clean, a11y audited, PR open
+
+
+ + +
+
01

Design decisions

+

Choices made where the spec or plan left room for interpretation.

+ +
+ Decision + P0shadcn --accent stays NEUTRAL; the Nothing red routes through --destructive +

The plan's flagged naming collision: shadcn's --accent is a subtle hover background used + all over (bg-accent, data-[state]:bg-accent), NOT the loud Nothing red. + So --accent is mapped to a neutral surface (#f0f0f0 light / #1a1a1a + dark) and the single red interrupt + every error/destructive state route through + --destructive = #d71921 (also --ring). This dodges the collision + cleanly and means re-skinned primitives keep their hover semantics. New tokens shadcn lacks were added + and registered in @theme inline: --color-ink (100% display text), + --color-faint (40% tertiary), --color-success, --color-warning, + --color-seg-empty, --color-destructive-subtle, --color-border-strong.

+
+ +
+ Decision + P0Direct per-mode token assignment (two blocks), not a raw-token+alias indirection +

Values are assigned straight onto the shadcn var names per mode — light on :root, dark on + .dark — mirroring the file's original two-block shape. This keeps the diff legible and the + theme mechanism identical to before (next-themes attribute="class", defaultTheme + and system retained). --radius drops to 0.5rem (technical scale); + cards get an explicit 14px in P1. The color vocabulary for downstream phases: + text-ink (hero/headings, 100%), text-foreground (body, 90%), + text-muted-foreground (labels, 60%), text-faint (tertiary, 40%), + bg-primary/text-primary-foreground (greyscale fill), + text/border/bg-destructive (the one red), text-success/text-warning + (value text only).

+
+ +
+ Decision + P0next/font variable names are font-specific, not --font-sans directly +

To avoid a circular --font-sans: var(--font-sans) in Tailwind v4's @theme inline, + next/font injects --font-space-grotesk / --font-space-mono / --font-doto, + and @theme inline chains --font-sans/--font-mono/--font-display + to those (with fallbacks). The mockup vocabulary alias --font-ui is also defined + (= var(--font-sans)) so any ported Nothing class keeps resolving.

+
+ +
+ Decision + P0Nothing component classes become React components, not global CSS classes +

The mockups use generic class names (.card, .grid, .label, + .table, .row). Dumping those into globals.css would collide with + Tailwind utilities (notably .grid) and arbitrary classNames. Instead the app + keeps using re-skinned shadcn primitives (Button, Card, Badge, …) plus a small set of new, clearly-named + React components for Nothing-specific idioms (<SegmentedBar>, <Metric>, + <StatusText>, <LoadingState>, <PeriodNav>). Only + non-colliding type/utility helpers are added globally. This is the idiomatic React/Tailwind path and + keeps the mockup look without the collision risk.

+
+ +
+ Decision + P3Stacked bar charts: cap at top-4 + "Other" with a monotonic grey ramp + separators +

Greyscale has only ~4–5 perceptually-distinct steps, so the Claude console's stacked daily-spend + charts (by workspace and by user) were illegible once my P0 swap replaced their spectral palette with + greys — 8–9 stacked grey segments blur together. Fix (in response to review feedback): fold all but the + top 4 ranks into an aggregated "Other" (≤5 segments), colour them in rank order + along a single monotonic ramp --chart-1…--chart-5 (no repeats, so the legend reads as a + dark→light gradient), and draw a 1px --card separator stroke on each + segment so adjacent tones keep crisp edges. Full per-row precision stays in the tooltip + the + workspace/user list below. The "Use workspace colors" toggle still switches to per-workspace hues for + users who need to track many specific workspaces. Ranked (non-stacked) bars like "Top 10 users" keep the + grey ramp as-is — separated, axis-labelled bars don't suffer the adjacency problem.

+
+
+ + +
+
02

Deviations

+

Intentional departures from the spec/plan, with rationale.

+ +
+ Deviation + P2Sonner kept as a flattened transitional toast; full 183-site removal staged +

The plan bans toasts and removes Sonner, replacing all ~183 toast.* sites with inline + <StatusText>. The inline primitive + useInlineStatus hook are built and + adopted on migrated surfaces. But removing the global <Toaster/> while 183 call sites + remain turns them into silent no-ops (lost success/error + lost screen-reader announcements) — a + regression that can't be safely verified across 41 files in one pass. So the global Toaster stays + mounted, re-skinned to the Nothing look (flat, no shadow, Space Mono, monochrome + surface, status color on text only, red errors), and toast→inline conversion proceeds incrementally on + key forms. Net: feedback never breaks and looks on-system; finishing the long tail of conversions + + deleting Sonner is tracked as remaining work (P5 grep gate will still flag residual toast.*).

+
+ +
+ Deviation + P2Simplified shell: fixed rail + mobile drawer, no collapsible/Cmd+B/cookie +

The plan keeps the shadcn SidebarProvider machinery (collapsible rail, Cmd+B, cookie + persistence, mobile Sheet) and re-skins it. I replaced it with a plain CSS-grid .app + + a self-contained responsive sidebar: a fixed 248px desktop rail and a lightweight mobile drawer + (state + backdrop). The Nothing design has a fixed rail (no collapse), so the collapsible/keyboard/cookie + features aren't part of the target; dropping them removes a dependency on the large sidebar.tsx + and keeps the shell legible. sidebar.tsx remains in the repo (unused by the shell). + Nav is text-only (Lucide row icons dropped) to match the mockups' text nav.

+
+ +
+ Deviation + P0Light-mode status colors darkened for WCAG, unlike the mockups +

The mockups (and nothing.css) use the same --success #4a9e5c / + --warning #d4a843 in both modes. As value text on the light canvas those fail the + plan's own P5 a11y gate (≥4.5:1): amber #d4a843 on #f5f5f5 is ~1.9:1 and the + green is ~3:1. Since the spec mandates status colors be value-text-only AND demands ≥4.5:1, I kept the + spec values in dark mode (they pass on black) and darkened the light-mode values to + --success #2e7d32 and --warning #835f00. Trade: light-mode warning reads + more olive/brown than the mockup amber; revert if you prefer literal mockup hues over the a11y gate.

+

Measured (verified in-browser P5): light — foreground 15.96, destructive 4.76, + success 4.70, warning 5.35 on the #f5f5f5 canvas (all ≥4.5 ✓; warning was bumped from + #946c00=4.37 to #835f00=5.35 to clear the canvas). dark — foreground 17.14, + success 6.34, warning 9.48 (✓). The brand red #d71921 is 4.05 on #000 / 3.64 on + #111: it clears WCAG AA for graphical/UI (≥3:1) and large text, and matches the value the spec's + own contrast table documents as accepted, but is just under the 4.5 normal-text bar on the dark canvas. + Per the spec's explicit "use #d71921 verbatim in both modes" brand decision, the red is kept as-is + (the redesign uses it mostly for borders/fills/large numbers + uppercase mono labels); Lighthouse + accessibility scored 98 on the audited route.

+
+
+ + +
+
03

Tradeoffs

+

Alternatives considered and why the chosen path won.

+ +
+ Tradeoff + P1Parallel agent fan-out to re-skin primitives vs hand-editing all 22 +

The 4 defining primitives (button/badge/card/input) + the net-new shared helpers were hand-built for + control; the remaining 18 were re-skinned by one agent each in a single parallel workflow (each owns one + file, so no write conflicts), against a shared "Nothing primitive contract" + per-file spec. Faster and + consistent, at the cost of a review pass — caught by the build/lint gate and a browser spot-check.

+
+ +
+ Tradeoff + P3Re-theme Recharts in place vs rebuilding every chart as CSS/SVG +

Per the plan's P3 gate: re-theme the shared ui/chart.tsx wrapper once (mono axes, no legend + boxes, shadowless tooltip, monochrome ramp) and keep Recharts for line/bar (preserves + tooltips/responsiveness/accessibility), rebuilding only the idioms Nothing forbids (donut → segmented + proportion; area fill → line). The signature segmented bar becomes a reusable React + <SegmentedBar> replacing the continuous SpendProgressBar / multi-marker bar.

+
+
+ + +
+
04

Open questions

+

Things worth a confirm or a later revision.

+ +
+ Open question + "One red per screen" on dense admin screens +

The admin dashboard legitimately surfaces several reds at once: the active-nav bar, the budget-at-risk + card (border + icon + tag), and failed-invoice dots in the activity timeline. The plan's mitigation is + to demote the active-nav / active-row indicator to neutral --ink when an error/at-risk + state is present on that view. Confirm you want that auto-demotion logic wired globally, or whether a + per-screen judgement (e.g. dashboard keeps the budget red as the one moment, nav goes neutral) is + enough. Currently the nav active bar is always red.

+
+ +
+ Open question + Theme toggle placement — sidebar footer vs page topbar +

The mockups put the Dark/Light toggle in each page's top-right action area. Because the 40 routes + already render their own page headers (no shared Topbar contract was force-adopted), I placed the + segmented toggle in the sidebar footer instead (always visible, no per-page change). If you want it in + the topbar to match the mockups exactly, that requires a shared <Topbar> the pages + adopt — a larger P4 change.

+
+
+ + +
+
05

Remaining work (not yet done)

+

What this pass delivered: the full design system (P0 tokens+fonts, P1 every primitive/overlay, + P2 shell, P3 shared tables + chart wrapper + segmented bar + loaders + monochrome charts), verified in a + browser on an isolated Neon branch in both modes. Because the app is built on shared components, the + token + primitive + shell + table/chart re-skin already propagates the Nothing look to essentially every + route. The items below are the scoped follow-ups that remain.

+ +
+ +
+

AI Developer Hub · Spec 028 — Nothing Design Redesign · Implementation Notes · + Styled with the Nothing design system (tokens only). Dark default; flip the toggle top-right.

+
+ + diff --git a/specs/028-nothing-design-redesign/implementation-plan.html b/specs/028-nothing-design-redesign/implementation-plan.html new file mode 100644 index 00000000..43294635 --- /dev/null +++ b/specs/028-nothing-design-redesign/implementation-plan.html @@ -0,0 +1,977 @@ + + + + + + AI Developer Hub — Nothing Redesign · Implementation Plan + + + + + +
+ + + + +
+
+
+ SPEC 028 · IMPLEMENTATION PLAN +

Nothing Design Redesign

+
+
+ + +
+
+ +

Migrate the AI Developer Hub from a stock shadcn/ui oklch system to one coherent + Nothing design system — monochrome canvas, a single red interrupt, Space Grotesk / + Space Mono / Doto, flat surfaces, inline status text. This document is itself styled with + nothing.css: we eat our own dog food.

+ + +
+
+ 00 +

Overview & goals

+
+ +

The redesign is a presentation-layer migration of ~40 routes and 14 subsystems + to the Nothing design language. No server actions, data shapes, or business logic change. The + dominant cost drivers are the toast removal (~183 toast.* call sites), the + ~20 Recharts components, and the page-by-page Card→.card / + status→.tag / form→.field conversion across every route.

+ +

Why migrate

+
    +
  • One coherent, intentional system in both dark and light, instead of stock shadcn defaults that drift per screen.
  • +
  • Instrument-panel hierarchy: greyscale carries the structure; color is reserved as an interrupt, not decoration.
  • +
  • Remove accessibility/UX anti-patterns already proven present in the codebase: toast-only feedback, skeleton shimmer, red alert banners, calendar popovers, multi-hue charts.
  • +
  • Retire the drifting green: the app's lime --primary oklch(0.78 0.19 120) becomes greyscale fills, and the single Nothing interrupt reuses the red the app already ships as --destructive — so the accent isn't a new color, just a re-scoping of existing tokens.
  • +
+ +

Current state

+
    +
  • Tokens: oklch shadcn "new-york" semantic vars in src/app/globals.css; light is the :root default, dark is the .dark override via next-themes attribute="class", defaultTheme="system".
  • +
  • Accent: --primary = green/chartreuse, reused as --ring, --chart-1, --sidebar-primary. --destructive is a separate red.
  • +
  • Font: Inter via next/font/google applied globally to <body>; no monospace data font, no display font.
  • +
  • Feedback: Sonner <Toaster/> mounted in layout; toasts everywhere. Skeleton loaders in 15 loading.tsx route files. A destructive shadcn alert banner in the global layout. shadcn Sidebar shell with Lucide row icons + filled active background.
  • +
+ +

Target state

+
    +
  • One Nothing token layer in globals.css, re-keyed onto the existing :root(light)/.dark(dark) selectors so next-themes keeps working unchanged. shadcn semantic var names stay aliased to Nothing tokens so no primitive loses color.
  • +
  • Three-family type stack (Doto / Space Grotesk / Space Mono) via next/font; ALL-CAPS Space Mono labels, Space Mono numbers, Doto for 36px+ hero numbers only.
  • +
  • CSS-grid .app shell, text-nav sidebar with a 2px red active bar, topbar eyebrow + title + actions, inline [SAVED]/[ERROR] status, [LOADING…], segmented bars and direct-labeled charts.
  • +
+ +
+
How to read this doc & the mockups
+

The 12 mockups demonstrate layout and hierarchy and render the final accent: + the stock Nothing red #d71921, linked from nothing.css + verbatim. The brand accent was evaluated — Unic lime #A4C400 was considered + and rejected (see §01) — and the decision is to keep Nothing red, so + the mockups already show the target. Each section links the mockup(s) that map to the screens + it covers.

+
+ + +
+ + +
+
+ 01 +

Design foundation & color decisions

+
+ +

The token layer is the foundational dependency for every other subsystem. The + migration re-keys the Nothing value sets onto the app's existing :root(light) / + .dark(dark) selectors — do not copy nothing.css's + :root[data-theme="light"] selectors verbatim. Keep the next-themes class strategy.

+ +

Token migration map

+

Every current oklch shadcn token re-points to a Nothing token (kept aliased so + bg-background, text-muted-foreground, etc. keep resolving).

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Current shadcn token (oklch)Nothing tokenNotes
--backgroundvar(--black)Canvas. Dark #000 / light #F5F5F5.
--foregroundvar(--text-primary)Body text. #e8e8e8 dark / #1a1a1a light.
--card / --popovervar(--surface) / var(--surface-raised)Cards → surface; dropdowns/popovers → surface-raised. No translucency (bg-card/60 dropped).
--card-foreground / --popover-foregroundvar(--text-primary)Plus add --text-display for titles/strong cells (shadcn lacks this).
--primaryvar(--text-display) (fills) + --accent for the one interruptPrimary fills become greyscale white/black per Nothing; red is reserved for the single per-screen interrupt. --primary-foreground--black.
--secondaryvar(--surface-raised) / transparent + 1px borderCollapse shadcn secondary(fill)+outline onto one bordered .btn--secondary.
--muted / --muted-foregroundvar(--surface) / var(--text-secondary)Muted backgrounds become neutral surfaces; muted text → secondary.
--accent (shadcn hover tint)var(--surface-raised) (neutral)Naming collision: shadcn --accent is a subtle hover bg, NOT the Nothing loud interrupt. Keep bg-accent neutral; reserve the Nothing red interrupt under a deliberately-scoped token. Audit all bg-accent/data-[state]:bg-accent usages.
--destructivevar(--accent) = #D71921Unified with --accent — errors ARE the red accent (Nothing default). A --danger alias = --accent is kept only for readable code naming.
--border / --inputvar(--border) / var(--border-visible)--border for dividers; --border-visible for interactive edges/inputs.
--ringvar(--accent) (both modes)Focus moves from 3px ring to border-color brighten; red passes in both modes, so no per-mode variant.
--chart-1var(--text-display)chart-1 == primary today; re-derive as monochrome top series. Red reserved for over/active.
--chart-2..5greys via opacity ramp (--text-secondary, --border-visible)Drop the multi-hue palette + duplicated 8-color spectral FALLBACK_PALETTE. var(--destructive) chart usages → the --accent red.
--radius (0.625rem)--radius-tech 4 / --radius-compact 8 / --radius-card 14 / --radius-pillCap card radius at 14px (≤16px rule); pills for buttons/tags.
--sidebar-* (primary/accent/...)Nothing nav tokens--sidebar-primary == primary today → active nav becomes --text-display text + 2px --accent left bar.
+
+ +

Brand accent decision confirmed: Nothing red

+

The single interrupt stays the stock Nothing red #D71921 — the + mockups already render it. Error and destructive states share this same red, per the Nothing + default "errors ARE the accent moment": no separate danger color, no per-mode variant.

+ +
+
--accent#D71921 · the one interrupt (also errors)
+
--success#4A9E5C · in-range data
+
--warning#D4A843 · attention data
+
+ +
    +
  • --accent = #D71921 is the single interrupt: active nav indicator (2px left bar), the one emphasis/active-state moment per screen, segmented-bar "over/active", table active-row rail, selected dropdown/command item left bar, brand dot, error/destructive surfaces, ::selection (with --black text). Hold the rule: at most ONE red moment per screen.
  • +
  • Errors share the accent: btn--destructive, .tag--accent, .field.is-error, .error-msg, .status-text--err and the active-tab underline all resolve to the same --accent red — nothing.css verbatim, nothing to decouple. (A --danger alias = --accent may be kept purely for readable code naming.)
  • +
  • Rejected alternative — Unic lime #A4C400: brand-aligned and matches the app's current green --primary, but it would force a light-mode a11y workaround (lime fails ~2:1 on #F5F5F5, needing a darkened ~#4F6800 for any accent-as-text/border) and a danger-red decoupling so delete/error states don't read as lime. Rejected in favor of one color and one contrast story.
  • +
+ +

Contrast — red passes both modes

+

#D71921 needs no per-mode variant: it clears WCAG as text and as a graphical + element on both canvases, and as a fill it carries contrast through white/black text. There is + exactly ONE accent color to verify.

+ +
+
+ As text / thin border — pass +
+
#D71921 on #F5F5F5 (light)~4.76:1 · PASS
+
#D71921 on #000 (dark)~4.05:1 · PASS
+
#D71921 on #111 surface≥3:1 · graphical OK
+
+
+
+ As fill — text carries it +
+
#FFF text on #D71921 fill~5.2:1 · PASS
+
fill vs canvas (graphical)≥3:1 · OK
+
identical dark + lightno variant
+
+
+
+ +
    +
  • Use #D71921 verbatim in both modes for text, thin borders (2px nav bar, tab underline, table rail, .tag--active), fills, and strokes — no darkened light-mode variant needed.
  • +
  • --accent-subtle stays an rgba of #D71921 (already correct in nothing.css for both modes); no recompute.
  • +
  • Because errors share the accent, P5 verifies one accent color in light mode — no separate lime/danger contrast matrix.
  • +
  • Status colors (--success / --warning) stay on value text only, never on labels or backgrounds.
  • +
+ +

Fonts

+

Replace Inter with the three-family Nothing stack via next/font/google + (self-hosted, auto fallback-metrics) — not the mockup's render-blocking + Google @import.

+
+ + + + + + + +
FamilyRoleLoader configToken
Space GroteskUI / body (new default)weight ['300','400','500','700'], variable:'--font-sans', display:'swap', preload:true--font-sans (alias --font-ui)
Space MonoALL labels + ALL numbers/dataweight ['400','700'], variable:'--font-mono', display:'swap'--font-mono
DotoHero display 36px+ ONLYweight ['400','500','700'] (pin if variable axis unreliable), variable:'--font-display', display:'swap', preload:false--font-display
+
+
    +
  • Register --font-sans/--font-mono/--font-display in @theme inline (Tailwind v4, no tailwind.config) so font-sans/font-mono utilities AND the Nothing .ui/.mono/.doto classes both resolve.
  • +
  • Naming reconciliation: mockups use --font-ui; Tailwind expects --font-sans. Define both, with --font-ui: var(--font-sans). Chain tokens to the next/font-injected var name (not a raw string) to preserve fallback metrics.
  • +
  • The 28 existing font-mono call sites (18 files) re-point automatically to Space Mono once the token is wired — no call-site changes, just an audit for width regressions (count badges, KPI cards, table columns).
  • +
  • Transactional email (src/emails/invite-email.tsx) cannot use next/font — document the gap; recommend it stays system-font.
  • +
+ +

Dark/light strategy

+

Keep next-themes attribute="class" with the .dark class and the + three states light/dark/system. Re-key Nothing's dark values onto .dark + and light values onto plain :root. The segmented theme toggle is wired to + setTheme (next-themes), not the mockup's data-theme/ + theme.js. Decision: keep system as a third segment or as the default. + suppressHydrationWarning already present guards FOUC; decide whether to remove + disableTransitionOnChange for a subtle cross-fade.

+
+ + +
+
+ 02 +

Component migration matrix

+
+

Re-skin the 16 shadcn primitives in place (rewrite CVA/className + strings; keep imports) so the ~30 consuming components keep working. Mockup references: + tools.html + settings.html + users.html.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
shadcn primitiveNothing treatment / classEffortNotes
button.tsx — default (bg-primary green).btn .btn--primary--text-display fill, --black text, pill, Space Mono 13px CAPS, min-height 44pxMPrimary fill is greyscale white, NOT the accent. Add btn--sm (36px). Keep asChild/Slot.
button.tsx — secondary / outline.btn--secondary — transparent, 1px --border-visible, hover border→text-primaryMCollapse shadcn secondary(fill)+outline onto one bordered variant.
button.tsx — ghost.btn--ghost + .btn--icon (44×44) for icon-only row actionsSTable row View/Edit/More pattern.
button.tsx — destructive (bg-destructive).btn--destructive — transparent, 1px --danger, danger text, hover --danger-subtleMOutline (1px), not solid fill; resolves to the shared --accent red (the --danger alias).
button.tsx — linkInline text link in --interactive, underline-offset hoverSNot a .btn; --interactive is for tappable text.
button.tsx — size scale (xs/sm/lg/icon-*)Reduce to default(44) + --sm(36) + --icon; map xs→sm, lg→defaultMAudit dense table callers; sanction a 32px .icon-btn exception (users.html) vs 44px.
card.tsx (rounded-xl, shadow-sm).card / .card--raised / .card--bare / .card--pad-lg — 14px radius, no shadowSCardTitle→.heading; CardDescription→.caption t-secondary.
input.tsx (full border box).field > label.label + .control (underline) or .field--boxedMDefault is underline; label ALL-CAPS above; input text Space Mono. Error: .is-error + .error-msg.
textarea.tsx.control on a textarea; keep field-sizing-contentSNo dedicated class; boxed may read better for multi-line.
label.tsx (text-sm font-medium).label (Space Mono 11px CAPS 0.1em) for field captions; sentence-case for inline checkbox/switch labelsSTwo registers: instrument label vs body label.
checkbox.tsx (bg-primary checked)Square, 4px radius, 1px --border-visible; checked → --text-display fill + --black checkMNEW pattern (no nothing.css class). Greyscale, NOT the accent. Keep Lucide CheckIcon.
switch.tsx (bg-primary checked).switch / .switch.on — track --text-display on, thumb --blackSGreyscale, NOT the accent. Ensure ≥44px hit area.
select.tsx (trigger + content shadow-md)Trigger = .btn--secondary --sm or .field .control; content = surface-raised, 8px, 44px items, left 2px accent bar, NO shadowLBiggest restyle: drop zoom/slide; selected indicator becomes a left accent bar, not a right CheckIcon.
badge.tsx (filled pill chips).tag (+ --success/--warning/--accent/--active/--tech) — border-only, optional .led dotMCore inversion: fill→border. default→--active, destructive→--accent(danger). Add success/warning variants.
separator.tsx.divider (1px --border); vertical keeps w-pxSNear 1:1.
tooltip.tsx (dark bubble + zoom/slide)Restyle: surface-raised, 1px border-visible, Space Mono caption, opacity-fade only (drop zoom/slide/scale)MNothing prefers inline labels. Decide whether to retain hover tooltips at all; audit title= icon buttons.
tabs.tsx (segmented pill + line variant).tabs > .tab(.active) text tabs; in-content tabs use NEUTRAL underline; segmented filters → .segmentedMsettings.html overrides active underline to --border-visible so accent stays the single interrupt; only nav is accent.
table.tsx (hover bg-muted).table — label headers + border-visible; --border dividers; .num mono right; .cell-strong first col; .is-active = surface-raised + 2px left accentLNo zebra. Touches TanStack column defs in consumers — coordinate with tables work.
form.tsx (text-destructive messages)FormLabel→.label; FormDescription→.caption t-secondary; FormMessage→.error-msg / .field.is-errorMError text points at --danger (= the --accent red). Logic unchanged.
calendar.tsx (react-day-picker popover)For period selection → .period-nav linear stepper. Restyle DayPicker to tokens only for true arbitrary-date pickersLLargest semantic shift; calendar effectively deprecated for month/period navigation.
+
+

Foundational fonts/radii also flow through here: Inter→Space Grotesk/Mono/Doto; + --radius 0.625rem → Nothing 4/8/14/pill; sonner Toaster removal → inline + .status-text. All covered as cross-cutting items in §03.

+
+ + +
+
+ 03 +

Anti-patterns to resolve

+
+

Four Nothing violations, all confirmed in real code, touch nearly every interactive + screen. Three of the four already have proven classes in nothing.css; the only true gap is a + hardware-style segmented spinner (must be authored, respecting prefers-reduced-motion).

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Anti-patternWhere it livesResolutionScale
Toast popups (Sonner)Single <Toaster/> in layout.tsx:65 feeds 183 toast.* call sites / 39 files (invoice-upload 14, scheduled-jobs 12, assignment-detail 12, assignments 11, user-detail 10, budget-detail 10, github-integration 8, tool-detail 8, …)Inline [SAVED]/[ERROR: …] via .status-text/--ok/--err near each trigger; build one shared <StatusText>/useInlineStatus with aria-live="polite"; remove the global Toaster only after the primitive exists. Bulk/multi-event outcomes get an inline summary box.L
Skeleton loadersui/skeleton.tsx used 108× / 16 files; 15 are route loading.tsx (app, assignments, reports, users[/[id]], tools[/[id]], invoices, copilot[/seats,/billing,/analytics], budget[/[id]]) + sidebar SidebarMenuSkeleton + budget-report[LOADING…] via .loading-text + a NEW hardware-style segmented/dot-matrix spinner. Collapse the 15 near-identical grids to a shared <LoadingState label/>. Drop Card scaffolds.M
Alert banner (red)alert-banner.tsx (shadcn Alert variant=destructive, AlertTriangle) at layout.tsx:58; + ~8 other <Alert> consumers (api-preview, cost-tracking, sync/ingestion tables, setup/login forms)Flat inline status box: 1px border (--danger critical / --warning approaching) on BORDER + value text only, never a background fill; [ X ] ghost dismiss; .stat-row utilization rows. Keep localStorage fingerprint + aria-live.M
Calendar date popoversExactly 2 sites: assignments-client.tsx:281 & assignment-detail-client.tsx:359 (single "Assigned Date", future-date guard, dropdown caption)Per components.md §9 prefer linear .period-nav stepping; but these are single-date fields — recommend a Space Mono underline date input. Reserve .period-nav for genuine period browsing (dashboard/reports/claude month nav). Documented exception.M
Status-as-floating-popovererror-popover.tsx (ingestion/sync tables)Inline expandable .status-text--err (click-to-expand), no Popover.S
+
+ +
+
Error-heavy subsystem — one red, watch the count
+

Error/destructive is the dominant accent use here (toast.error, Alert destructive, + failed outcome badges). Under the confirmed unified-red model these all resolve to the same + --accent #D71921 — nothing to decouple. The watch-item is quantity: + a dialog or row showing an active/selected state and an error can surface two reds. + Hold "one red region per screen" — let the error be the red moment and de-emphasize the + active indicator when both are present.

+
+
+ + +
+
+ 04 +

App shell & navigation

+
+

Replace the shadcn SidebarProvider/Sidebar/SidebarInset + composition with the Nothing CSS-grid .app shell + sticky text-nav .sidebar. + The shell owns the single red accent moment per screen (active nav bar). Every mockup shares this + shell — see + dashboard.html + for the canonical rendered chrome.

+ +
+ + + + + + + + + + + + + + +
Shell elementFrom (shadcn)To (Nothing)Effort
Root gridSidebarProvider + Sidebar + SidebarInset (flex)CSS-grid .app + sticky .sidebar + .main (max-width 1280px). Keep SidebarProvider context ONLY for mobile open state — re-skin, don't delete.L
Wordmark<h2>AI Developer Hub</h2>.brand > .dot (red) + .wordmark "AI·HUB" (Space Mono CAPS 0.14em)S
Nav itemsSidebarMenuButton + Lucide icons, "Navigation" group label.nav > a.nav-item ALL-CAPS Space Mono; drop the group label; icons optional (decide monoline vs text-only). Role filtering + active-path logic carry over.M
Active statedata-[active]:bg-sidebar-accent (filled).nav-item.active::before 2px left bar in --accent + text→--text-display. THE single accent moment per screen (red, both modes).S
User chip / footerSidebarFooter DropdownMenu (My Profile / Sign Out).sidebar-footer > .user-chip (.avatar initials + name/role) + a .nav-item Sign Out. Decide where "My Profile" goes (chip→/profile, or keep a shadowless Nothing dropdown).M
Topbar<header h-14 border-b> with only SidebarTrigger.topbar > {.label.eyebrow + h1.display-md} + .topbar-actions. Needs a shared <Topbar eyebrow title actions> contract pages consume in P4.L
Mobile triggerSidebarTrigger (PanelLeftIcon)NET-NEW: re-skinned monoline trigger at mobile breakpoint; add a @media rule to off-canvas the sidebar. Mockups are desktop-only — design/approve this.M
Theme toggleSun/Moon DropdownMenu (Light/Dark/System)Segmented .theme-toggle (DARK | LIGHT [| SYSTEM]) wired to next-themes setTheme. Reconcile 2-state mockup vs 3-state app.M
Breadcrumbshadcn Breadcrumb (budget only, ChevronRight)Fold into the topbar .eyebrow as a Space Mono "SECTION / CONTEXT" trail (only 1 real consumer). Preserve aria-current / nav[aria-label=breadcrumb] if kept standalone.S
Alert bannershadcn Alert variant=destructive (red bg)Bordered inline status box (1px --danger critical / --warning approaching), value-only color, [ X ] ghost dismiss. Keep aria-live + localStorage dismiss + null guards.M
+
+ +
+
In-content tabs stay neutral
+

The shell owns the red accent (active nav bar). Therefore in-content tab underlines and + progress "within-range" fills are NEUTRAL (--border-visible / --text-display) + — proven by the scoped overrides in settings.html, reports.html, copilot.html and claude.html. + This keeps "one interrupt per screen" intact.

+
+
+ + +
+
+ 05 +

Charts & data visualization

+
+

~20 Recharts components + 2 Sparkline impls + 3 non-Recharts bar/gauge visuals, + all routed through one shared wrapper (ui/chart.tsx). Migrate the wrapper FIRST — + every chart inherits its grid/tooltip/legend/axis styling. References: + reports.html + claude.html + copilot.html + budget.html.

+ +

Recharts theming rules

+
    +
  • Monochrome-first categories: differentiate by opacity → pattern → line-style → color (color last). Use 2–3 greys (--text-display / --text-secondary / --border-visible) + an opacity ramp. Delete the duplicated 8-color spectral FALLBACK_PALETTE; define one shared getCategoryStyle(rank).
  • +
  • Reserve red for one over/active series only. Demote it from its current default --chart-1 data role; var(--destructive) chart usages → the --accent red.
  • +
  • Bars: square ends (radius=[0,0,0,0]), fill --text-display, empty/remainder --border, over-limit --accent (red).
  • +
  • Lines: 1.5–2px --text-display current; secondary by opacity; projection/average 1px dashed --text-secondary (3 3); no dots (or one end dot); horizontal-only grid --border; direct line labels, no legend boxes.
  • +
  • No area fill, no gradients, no shadows. Remove the viewerSpendArea linearGradient; restyle the tooltip card to surface-raised + 1px border-visible + 8px radius (drop shadow-xl).
  • +
  • Axis/labels: Space Mono at caption/label size, --text-disabled/--text-secondary; set tick={{fontFamily:'var(--font-mono)'}} centrally in the wrapper.
  • +
  • Light-mode strokes: the red accent passes as a stroke/axis color in light mode (~4.76:1), so no darkened variant is needed; keep non-accent series differentiated by opacity, not hue.
  • +
+ +

Segmented-bar / gauge / sparkline approach

+
    +
  • Segmented bar (signature): .seg-bar / .seg-bar--hero / .seg-bar--compact of ~20 discrete .seg; .is-filled (text-display) / .is-good / .is-warn / .is-over (accent). Build one reusable React component computing segment counts from percentages; replaces the rounded continuous SpendProgressBar, the multi-marker budget-health bar, and the per-model mini bars.
  • +
  • Gauge / proportion: the forbidden donut (copilot/activity-distribution) becomes a 4-row segmented bar or horizontal stacked proportion bar with direct .legend labels and numeric readouts (sign-off needed). Pies are banned.
  • +
  • Sparkline: standardize on ui/sparkline.tsx (already Nothing-correct: 1.5px currentColor polyline + end dot). Delete the duplicate Recharts reports/sparkline.tsx and re-point consumers.
  • +
  • Re-theme vs rebuild (P3 gate): re-theme the wrapper once and keep Recharts for line/bar (preserves tooltips/responsiveness); rebuild only the donut and area chart where Nothing forbids the idiom. Mockups render some charts as pure CSS columns/seg-bars — decide per chart family.
  • +
+ +

Ripple files: cumulative-pacing, twelve-month-bar, cost-distribution-histogram, + workspace-daily, plan-vs-actual, forecast-cumulative, daily-by-user, top-users, billing-trend, + cost-utilization, language, editor, usage-trend, global-metrics, spend-trend, my-usage. Verify each + in BOTH modes for legibility/contrast.

+
+ + +
+
+ 06 +

Dialogs & overlays

+
+

User priority — exhaustive inventory. Nothing overlay spec (components.md §14): + NO shadows; layering via background contrast + 1px --border-visible; modals = backdrop + rgba(0,0,0,0.8) + 16px radius + ghost [ X ]; dropdowns = surface-raised + + 8px + 44px items + left 2px accent bar on selected; fade-only (no zoom/slide); toasts BANNED.

+ +

Primitives (6)

+
+ + + + + + + + + + + + +
PrimitiveFromNothing patternEffort
dialog.tsx — Overlaybg-black/50 + fadeBackdrop bg-black/80; fade onlyS
dialog.tsx — Contentrounded-lg bg-background shadow-lg zoom-in-95--surface + 1px --border-visible + 16px, NO shadow, fade only; cap ~480px but allow wide overridesM
dialog.tsx — [X] closeopacity-70 icon, data-[state]:bg-accentBordered/ghost [ X ] top-right; remove accent tintS
alert-dialog.tsxshadow-lg, rounded-lg, bg-black/50Same Nothing modal; AlertDialogAction destructive → .btn--destructive (--danger outline)M
sheet.tsx4 sides, w-3/4, shadow-lg, slide 300/500ms--surface, NO shadow; right edge 1px border-visible; bottom-sheet adds 16px top radius + handle; soften slideM
popover.tsxrounded-md bg-popover shadow-md zoom+slide--surface-raised + 1px border-visible + 8px, NO shadow, fade onlyS
dropdown-menu.tsxshadow-md, focus:bg-accent, items ~32pxsurface-raised + 8px, no shadow, 44px items, selected = left 2px --accent bar; destructive item text --dangerM
command.tsxCommandDialog p-0, data-[selected]:bg-accentOn Nothing modal; selected → left 2px accent bar + surface-raised row (not full-row tint); 44px rows; input underline border-bM
+
+ +

Feature dialogs, sheets & comboboxes

+
+ + + + + + + + + + + + + + + + + + + + + + + +
ComponentTypeNothing patternEffort
edit-user-dialog.tsxDialogNothing modal + .field labels + boxed controls; footer secondary Cancel + primary Save; toast→inline statusM
invite-link-dialog.tsxDialogRead-only boxed .control; Copy = .btn--icon secondary; Send = primary; copied feedback inline [COPIED] (not toast)M
sync/backfill-dialog.tsxDialogTrigger ghost btn→.btn--ghost; date boxed control; result [SAVED]/[ERROR]M
requests/[id]/approval-dialog.tsxDialog (wide)Keep max-w-3xl; preview/source panes 1px border-visible NO bg-muted; labels .label; toast→inline. dangerouslySetInnerHTML preview staysL
requests/[id]/rejection-dialog.tsxDialogTextarea→Space Mono control; Send = .btn--destructive (--danger); toast→inlineM
requests/[id]/completion-dialog.tsxDialog (wide, wizard)Keep wide; 2-step nav; Select+Textarea+date Nothing-styled; toast→inline summaryL
invoices/sync-results-dialog.tsxDialog (wide)OutcomeBadge→.tag border-only (success/warning/accent), NO bg-green/blue/amber/red-100; scrollable .table; secondary Close + primary ApplyM
budget/.../billed-cost-dialog.tsxDialogNothing modal; 4 boxed .control; ghost Cancel + primary Save; inline statusM
settings/license-templates/template-editor-dialog.tsxDialog (max-w-4xl)Wide override; 3-col labels .label; preview pane 1px border-visible no fill; warning → 1px --warning summary (no amber bg); Delete = .btn--destructive; toast→inlineL
reset-password-dialog.tsxAlertDialogNothing alert modal; Checkbox→Nothing checkbox; Reset = .btn--destructive; chains to InviteLinkDialog; toast→inlineS
budget/.../delete-billed-cost-dialog.tsxAlertDialogRemove literal bg-destructive override; destructive Action = .btn--destructive (--danger outline)M
users/users-table.tsx (in-table)AlertDialog ×2Deactivate {name}? + batch Send invites → inherit alert mapping; Deactivate = --danger; row menu items 44px + accent barS
settings/sync/github-member-sync-sheet.tsxSheet (right)Nothing sheet; SummaryCards→.card/stat-rows; conflict box→1px --danger (no bg tint); Tabs→.tabs; Tables→.table; Loader2→[LOADING…]; toast→inline. Embeds combobox + inline form + member cardsL
error-popover.tsxPopoverInline expandable [ERROR: …] status text; or restyle popover surface; no floating revealS
user-combobox.tsxPopover + CommandTrigger→.btn--secondary; selected = left accent bar (not Check tick); single thin chevron; shouldFilter=false preservedS
user-search-combobox.tsxCommandsurface-raised 8px; status Badge→.tag (no fill); empty→[TYPE TO SEARCH]; Cancel→ghost; debounce keptS
data-table-faceted-filter.tsxPopover + CommandNothing popover; selected options→left accent bar; count→.tag; CheckIcon stroke 1.5S
DropdownMenu row actions (×10)DropdownMenuusers/tools/budget/assignments[/[id]]/claude-users/integrations/ingestion/tool-detail/user-detail — one primitive fix covers chrome; per-file work is destructive-item + label tweaksM
ui/sonner.tsx + ~25 call sitesGlobal toastRemove Toaster; per-surface inline [SAVED]/[ERROR] status with auto-clear + aria-live. Defining change of this subsystemL
+
+ +
+
Wide-modal exception
+

Keep max-w-3xl/max-w-4xl for approval / completion / template-editor / + sync-results / member-sync — these are data-dense 2–3-column editors, not confirmations. + Document as an accepted deviation from the §14 480px modal cap.

+
+
+ + +
+
+ 07 +

Page-by-page migration

+
+

All 40 routes + the AuthGuard access-denied gate, grouped by section, with mapped mockup, effort and notes. Subpages + (detail / new / import) included.

+ +

Dashboard

+
+ + + + + + +
RouteKey componentsMockupEffortNotes
/ (admin)admin-dashboard, budget-hero (SpendProgressBar), this-month-card, kpi-grid, spend-trend-card (Recharts), insights-grid, activity-timeline, jump-to-rowdashboard.htmlL1:1 mockup map. Hero→seg-bar + Doto; at-risk = the one accent (red --danger). Shared SpendProgressBar/Timeline/KpiWithMom/WhatChanged migrate as a unit.
/ ?as=viewerviewer-dashboard, identity-card (SyncCallout), my-usage-card (AreaChart), personal-kpis, my-tools-table, personal-activity, catalog-link-cardnone (derive)LNo mockup — author/sign-off. SyncCallout tinted boxes→inline status; AreaChart→line; preview banner→border/text only.
+
+ +

Tools / Users / Assignments / Requests

+
+ + + + + + + + + + + + + + + + +
RouteKey componentsMockupEffortNotes
/toolstools-table (DataTable), status/vendor faceted filters, Archive AlertDialogtools.htmlMInherits shared DataTable re-skin; status Badge→.tag--success + .led.
/tools/newnew-tool-form (RHF, useFieldArray tiers)tools.htmlMCards→.card; repeatable tier rows; ALL-CAPS field labels.
/tools/[id]tool-detail-client, Edit form, Archive AlertDialog, Access Tiers + Add/Edit Tier Dialogs (Switch), Change Historytools.htmlLDual edit/read-only; tier dialogs Nothing-styled; history→.stat-list.
/usersusers-table (8 cols, 6 faceted filters, discipline icons), batch-invite + deactivate AlertDialogs, ResetPasswordDialogusers.htmlLPending users = the single accent hero. .filter-chip promote to nothing.css.
/users/newnew-user-form (Selects), InviteLinkDialog on successusers.htmlMUser Details Card→.card + .field.
/users/[id]user-detail-client, GitHub Profile Card, Edit form, Reset/Deactivate, Assigned Tools (revoke/reactivate), Assign License Dialog, AdminCostSection (Recharts), Change Historyusers.htmlLLarge multi-section; API-key reveal/copy preserved; AdminCostSection deferred to charts.
/users/importbulk-import-form (CSV, preview table, Valid/Invalid/Update/New badges, invite links)users.htmlMNo red row tint — error to Status column only; changed cells→.cell-strong.
/assignmentsassignments-client (DataTable), Assign License Dialog (UserCombobox + date Popover+Calendar), EditAssignmentDialog (API key reveal), sync-managed Badge+Tooltip, revoke AlertDialogassignments.htmlLDate popover → Space Mono date input (documented exception).
/assignments/[id]assignment-detail-client (dual edit/read, tier Select, date Popover+Calendar, API key), Comments (Textarea + counter)assignments.htmlMBack link→.back-btn; comments→.stat-list.
/assignments/importbulk-assignment-import-form (CSV preview, masked API key, server-error notice)assignments.htmlMborder-destructive bg tint→inline [ERROR] in Status column.
/requestsrequests-table, status faceted filter, statusBadge() (rejected=destructive), REQ-### mono linksrequests.htmlMRejected stays red --danger; active-row left accent rail.
/requests/[id]request-detail-client, Requester Card, payload (FormPayloadList), Sent messages (border-l-primary + prose), action bar (Approve/Reject/Complete/Cancel), native confirm()requests.htmlMReplace native confirm() with Nothing modal; message left border→--border-visible.
+
+ +

Budget / Invoices / Reports

+
+ + + + + + + + + + + + + +
RouteKey componentsMockupEffortNotes
/budgetactive-budget landing + EmptyBudgetStatebudget.htmlSEmpty→.empty.
/budget/historybudget-table (DataTable), back-linkbudget.htmlMInherits shared DataTable.
/budget/newnew-budget-form (RHF, fiscalYear/totalAmount/periodType Selects)budget.htmlMperiodType→.segmented (Monthly/Quarterly).
/budget/[id]budget-detail-client, budget-health-hero (MultiMarkerBar), past-month-spotlight, period-allocations-table (inline inputs, sub-rows), 3 dialogsbudget.htmlLMulti-marker bar→seg-bar + legend (hardest piece). Colored row tints→value/tag only.
/invoicesinvoices-table (DataTable), Download Tooltip; ADD archived-invoices hero countinvoices.htmlSServer must surface total count for the new Doto hero.
/invoices/newinvoice-upload-form (file picker, ConfidenceInput low-confidence amber, Duplicate AlertDialog), aria-liveinvoices.htmlMLow-confidence underline→--warning; Loader2→[LOADING…]; toast→inline.
/invoices/bulkbulk-upload-form (idle/uploading/reviewing/saving/done/error machine), editable rows, outcome badges, results tableinvoices.htmlL6 states→[STATUS]; duplicate→tag--warning (confirm vs accent); skipped rows opacity 0.4.
/reports (overview)reports-nav (tabs), OverviewPanel: KpiWithMom ×4, BudgetHealthHero, WhatChanged, tool-adoption + spend-by-circle Tables, Sparklinereports.htmlLTabs NEUTRAL underline. MoM "up" delta flips from red→tag--active/neutral.
/reports/budgetbudget-report, at-a-glance (SpendProgressBar), plan-vs-actual-chart + forecast-cumulative-chart (Recharts), per-tool-breakdown, past-month-spotlightreports.htmlLTwo Recharts charts re-themed; sub-row bg tints→surface-raised + indentation.
+
+ +

Copilot / Claude consoles

+
+ + + + + + + + + + + + + +
RouteKey componentsMockupEffortNotes
/copilot (Overview)copilot-tab-bar, overview-cards (5-up KPI), usage-trend-chartcopilot.htmlMTabs NEUTRAL underline; Acceptance Rate = Doto hero.
/copilot/seatsseats-table (DataTable, avatar+login, plan/status badges)copilot.htmlMSeat-user cells; badges→.tag.
/copilot/seats/[userId]seat detail: back btn, avatar, status/plan badges, field grid, Sync History timelinecopilot.htmlMUnmatched amber→--warning on value; timeline badges→.tag.
/copilot/billing4 KPI cards, billing-trend-chart, cost-utilization-chart, Monthly Billing Details tablecopilot.htmlLBars→.hist-bars; dual-axis line re-themed.
/copilot/analyticsmetrics-freshness-card (amber), language-chart, editor-chart, activity-distribution (DONUT)copilot.htmlLDonut→seg-bars/stat-list; freshness amber bg→border-only .alert.
/claude (Workspaces)claude-tabs, sync-button, global-metrics-client (stacked BarChart + est ghost), historical-trend-card (segmented), org-credits-panel, workspace-budget-list, sync-status-pillclaude.htmlLStacked chart→CSS columns; 8-color palette→grey tiers; progress bars→seg-bars; toasts→inline.
/claude/usersusers-month-picker, sync-status-pill, user-kpi-strip, daily-by-user-chart, top-users bar, cost-distribution-histogram, top-movers-chips, bespoke users-table (cmdk filters, Sparkline, Switch)claude.htmlLMonth picker→.mini-select/period-nav; histogram bucket destructive→--danger.
/claude/users/[userId]user-detail-client (breadcrumb, badges, MonthPicker, pricing-unresolved amber, KpiStrip, daily + model breakdown + top dates, 12-month bars)claude.htmlM12-month→.hist-bars with .is-proj.
/claude/workspaces/[workspaceId]workspace-detail-client (breadcrumb, color dot, Default/Over-budget badges, inline limit edit, KpiStrip, daily chart, top users, model breakdown)claude.htmlMOver-budget badge stays warning-toned; projected KPI = the one red interrupt; displayColor kept as dot only.
+
+ +

Settings / Profile

+
+ + + + + + + + + + + +
RouteKey componentsMockupEffortNotes
/settings/appearance3-up theme grid (useThemePreference)settings.htmlS.theme-grid/.theme-opt; isSaving→[SAVED].
/settings/integrationsgithub-integration-client (PAT input, Validate, org Select, Connect/Disconnect AlertDialog), claude-code-status-cardsettings.htmlMDisconnect = --danger; scopes in .code chips; toast→inline.
/settings/syncsync-dashboard (polling 30s/5s), scheduled-jobs-table, manual-jobs-table, github-member-sync-sheet, OutcomeBadge, ErrorPopoversettings.htmlLSync hero Doto fraction + seg-bar; Suspense skeleton→[LOADING…]; live .poll LED.
/settings/ingestioningestion-filters-section (rules table, Switch, create/edit Dialog, native confirm), ingestion-history-table (DataTable, faceted filters)settings.htmlLBlacklist Badge→neutral tag--tech (not red); confirm()→Nothing modal.
/settings/license-templatestemplates-client (per-tool cards, TemplateRow), template-editor-dialog (3-col)settings.htmlMkind Badges→neutral tags; wide editor exception.
/settings/api-previewapi-preview-client (email/month form, Send, response Badge, Copy JSON, JsonViewer), not-configured Alertsettings.htmlMJSON viewer multi-color→monochrome; Loader2/toast→inline.
/profileprofile-client, profile-header (avatar, badges), profile-assignments, cost-tracking-section (MonthPicker, CostChart, destructive Alert)none (settings vocab)MAvatar→bordered .avatar; CostChart Nothing-themed; unresolved-pricing→--warning value.
+
+ +

Auth

+
+ + + + + + + +
RouteKey componentsMockupEffortNotes
/login(auth)/layout (2-col), login page Card, login-form (RHF, signIn, Alert destructive, Loader2)login.htmlMFully mocked; copy scoped .auth-split/.auth-pane; per-route theme toggle; brand panel neutral (not an accent fill). Submit stays white-on-black, NOT the accent.
/setup-password/[token]setup-password page (3 error branches: expired/consumed/invalid), setup-password-form (New/Confirm, success toast)none (login vocab)MToast→inline [PASSWORD SET]; expired/invalid = --danger, consumed = neutral; tinted chips→monoline glyphs.
AuthGuard (Access Denied)server gate + admin "Access Denied" Cardshell emptyS.empty (informational, no red).
+
+
+ + +
+
+ 08 +

Phasing & sequencing

+
+

Hard-sequence foundation → primitives → shell → shared-data → + pages → QA so shared surfaces stabilize before per-page work. shadcn semantic var names stay + aliased throughout so primitives never lose color mid-migration.

+ +
+
P0Foundation: Decisions, Tokens & FontsL
+

Ratify the brand-accent decision, port the Nothing token layer into globals.css (re-keyed onto next-themes .dark, NOT data-theme), load the three-family font stack via next/font.

+
+
Includes
Design tokens, theming & dark/light · Typography & font loading (Inter → Doto/Space Grotesk/Space Mono)
+
Depends on
+
Exit
Full Nothing token set on :root(light)/.dark(dark); --accent=#D71921 single interrupt (errors share it, no decoupling; red verified ≥4.5:1 text / ≥3:1 graphical both modes); radii 4/8/14/pill; --ease. shadcn names aliased (no primitive loses color). @theme inline registers font tokens; next/font loads all three (Space Grotesk preloaded, Doto preload:false), Inter removed. Utility classes available. typecheck + build pass; one route smoke-tested both modes, no FOUC.
+
+
+ +
+
P1Primitives & Overlays Re-skinXL
+

Re-skin the 16 control primitives + 6 overlay primitives in place; build shared cross-cutting helpers (inline status-text, [LOADING], segmented spinner, Nothing dropdown/modal/sheet chrome).

+
+
Includes
shadcn primitives · Overlays · Anti-pattern resolution (toasts/skeletons/banner/date-popovers)
+
Depends on
P0
+
Exit
All primitives Nothing-styled (pill/underline/flat/no-shadow, Space Mono caps, square greyscale checkbox/switch, border-only tags). Overlays use surface/surface-raised, 1px border-visible, 16/8px, 0.8 backdrop, NO shadow, fade-only, [X] ghost close, left 2px accent on selected. Destructive primitives point at --danger (outline). Shared <StatusText>/useInlineStatus + [LOADING]/spinner documented. Variant collapse reviewed vs call sites. Verified vs tools.html/settings.html both modes; typecheck/lint/build green.
+
+
+ +
+
P2App Shell, Sidebar, Topbar & Global LayoutXL
+

Migrate the shell so global chrome is correct and owns the one accent moment. Establish the Topbar contract. Remove global Toaster + AlertBanner from layout.tsx.

+
+
Includes
App shell, sidebar, topbar, breadcrumbs & alert banner
+
Depends on
P0, P1
+
Exit
CSS-grid .app with re-skinned shadcn sidebar machinery preserved (Cmd+B, cookie, mobile Sheet/useIsMobile); ALL-CAPS nav with 2px red active bar; AI·HUB wordmark + dot; user chip + Sign Out; segmented toggle wired to next-themes (System retained). Topbar exposes eyebrow+title+actions + mobile SidebarTrigger (net-new, approved). Global Toaster removed; AlertBanner→flat bordered status box (red critical / amber approaching) keeping aria-live + dismiss. Breadcrumb folded into eyebrow. Verified vs every shared shell both modes + mobile; build green.
+
+
+ +
+
P3Shared Data Layer: Tables & ChartsXL
+

Re-skin the shared DataTable + ui/table + column header + faceted filters ONCE (cascades to ~9 tables); migrate the shared Recharts wrapper + shared viz (SpendProgressBar→seg-bar, Sparkline consolidation, Timeline, KpiWithMom, WhatChanged, budget-health-hero). Resolve re-theme-vs-rebuild.

+
+
Includes
Data tables (TanStack) & filters · Charts (Recharts) & data viz
+
Depends on
P0, P1, P2
+
Exit
Tables: label headers, --border dividers (no zebra), .num mono right, .cell-strong, hover=surface, active row=surface-raised + 2px left accent; search=.search; pager=segmented+period-nav. OutcomeBadge/status→.tag/.led (failed=red). Chart wrapper: horizontal-only grid, mono axes, shadowless tooltip, no legend boxes, monochrome ramp (opacity>pattern>color), red for one over/active series. SpendProgressBar→seg-bar; one canonical Sparkline; donut + area replacements signed off. Re-theme-vs-rebuild decided per family. Verified both modes (red strokes pass in light).
+
+
+ +
+
P4Page Migration by SectionXL
+

Migrate route content for all feature areas. Sequence by risk: Auth first (most-referenced, mocked), then Dashboards, then CRUD list/detail, then the chart-heavy financial + console screens.

+
+
Includes
Auth · Dashboards (admin+viewer) · Tools/Users/Assignments/Requests · Budget/Invoices/Reports · Copilot/Claude · Settings/Profile
+
Depends on
P0, P1, P2, P3
+
Exit
All ~40 routes Nothing-correct in both modes vs mockups; viewer + setup-password composed/signed off. Every former toast is inline [SAVED]/[ERROR] with aria-live; every skeleton/loading.tsx is [LOADING]; no red row/tinted-bg statuses (value text only); exactly one accent interrupt per screen audited; native confirm() replaced; JSON viewer monochrome. Per-section typecheck/lint/build green.
+
+
+ +
+
P5Anti-pattern Sweep, A11y & Visual QA GateL
+

Final cross-cutting verification: zero orphaned toasts/skeletons/shadows/zebra/calendar-popovers; automated contrast/a11y (the single red accent in both modes); mockup-fidelity visual regression across all routes in both themes + mobile; no stale oklch leaks in edge states. Gate before merge.

+
+
Includes
Anti-pattern resolution · All subsystems (verification pass)
+
Depends on
P4
+
Exit
Lint/grep gate: zero toast.*/sonner imports, zero <Skeleton>/animate-pulse, zero shadow-* on chrome, zero calendar popovers except sanctioned exceptions. axe + Lighthouse pass; WCAG check confirms the red accent and amber/green status pass ≥4.5:1 text / ≥3:1 graphical in both modes, and no screen shows more than one red region. Visual regression matches mockups across 40 routes in dark+light+mobile. "One accent interrupt per screen" audited. Full typecheck/lint/build/test green; ready to merge as one coordinated replacement.
+
+
+
+ + +
+
+ 09 +

Risks & mitigations

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RiskSeverityMitigation
One-red-per-screen at scale — accent and error/destructive share #D71921, so a screen with BOTH an active/selected state AND an error or destructive surface can show two reds, breaking the "single interrupt" rule. Most acute in the error-heavy console/sync/dialog flows.MediumMake it a hard checklist item in P4/P5: exactly one red region per screen. When an error is present, IT is the red moment — render the active-nav / active-row / tab indicator in neutral --text-display on that view. The consistency sweep that cleared the mockups (neutral in-content tabs) is the template; the P5 manual audit asserts the per-route count in both modes.
Recharts theming effort — ~20 Recharts components + 2 sparklines must go monochrome (square bars, no area fill, no legend boxes, direct labels, mono axes, shadowless tooltips). Mockups render many as pure CSS/inline-SVG (no 1:1 map); pies/donut + area are bans needing redesign.HighP3 gate per chart family: re-theme the shared wrapper once; keep Recharts for line/bar; rebuild only donut (→segmented/stacked-proportion) and area (→line). Shared getCategoryStyle(rank) (opacity>pattern>color); delete duplicated 8-color palettes. Verify both modes; the red accent strokes pass in light (no darkened variant).
Removing Sonner is app-wide behavioral — ~183 toast.* sites / 39 files are the ONLY success/error feedback (incl. multi-event "sent 12 of 15", bulk created/updated/skipped/failed). Naive removal = silent failures + lost screen-reader announcements.HighBuild ONE shared inline-status primitive (StatusText/useInlineStatus, aria-live=polite) in P1 BEFORE touching call sites; remove the global Toaster only in P2 once it exists. Per-form placement convention + inline summary-box for bulk outcomes. P5 lint/grep gate asserts zero remaining sonner imports/toast.* calls.
Font FOUT/CLS & loading — three families via the mockup's render-blocking @import would reintroduce FOUT/CLS + payload; Inter→Space Grotesk/Mono glyph-width changes can overflow tight UI; Doto variable axis may be unreliable in next/font.MediumUse next/font/google per family (self-hosted, auto fallback metrics), NOT @import; display:'swap', preload Space Grotesk only, preload:false on Doto/Space Mono. Pin Doto weights ['400','500','700'] if the axis misbehaves; validate hero rendering. Define both --font-sans and --font-ui (aliased). Screenshot-diff key routes for width overflow.
Reconciling theme strategy — app authors LIGHT as :root default + .dark via class; nothing.css authors DARK as :root + data-theme=light. Mis-mapping inverts colors. Also --primary==--ring==--chart-1==--sidebar-primary, so the accent change recolors buttons/rings/sidebar-active/primary-chart at once. Toggle is 2-state in mockup vs 3-state in app.MediumRe-key nothing.css value sets onto the existing :root(light)/.dark(dark) selectors (don't copy data-theme). Keep attribute=class + System. Change primary/ring/chart-1/sidebar-primary in one coordinated edit and verify all four surfaces together. Wire the segmented toggle to next-themes (not theme.js); suppressHydrationWarning guards FOUC.
Scope/regression across ~40 routes — shared blast radius: tokens (every screen), 16 primitives (every screen), shared DataTable (~9 tables), shared viz (SpendProgressBar/Timeline/KpiWithMom/WhatChanged used by dashboard AND reports AND claude). One bad shared rule regresses the whole app; cannot ship incrementally per page.HighHard-sequence P0→P4 so shared surfaces stabilize before page work. Keep shadcn var names aliased so primitives never lose color mid-migration. Migrate shared viz as a single unit and regression-test ALL consumers. Mockup-based visual regression from P1 onward + final P5 cross-route gate in both themes + mobile before the single-shot merge.
+
+
+ + +
+
+ 10 +

Testing & verification

+
+
    +
  • Per-phase build gates: run pnpm typecheck, pnpm lint (zero warnings) and pnpm build at the end of every phase and before merging any P4 page-section batch. A red gate is blocking; no phase advances until its predecessor is green.
  • +
  • Mockup-fidelity visual regression: use the chrome-devtools / playwright-skill to render each migrated route at desktop + mobile widths in BOTH themes and diff against the matching mockups/*.html. Build the harness in P1 (primitives vs tools.html/settings.html); run cumulatively through P4/P5.
  • +
  • Auth-gated coverage: use the agent-browser-session skill to mint a NextAuth session cookie for the seeded agent user so the browser reaches every non-public route without the /login flow. Respect the agent deny-list (no DELETE /api/users, invite, reset-password, etc.). Test login.html / setup-password directly via the public routes.
  • +
  • Automated a11y + contrast: run Lighthouse (lighthouse_audit) and axe (chrome-devtools a11y-debugging skill) on representative routes in BOTH themes, with a SPECIFIC assertion that the red accent #D71921 and the amber/green status colors meet ≥4.5:1 text / ≥3:1 graphical in both modes, and that no screen shows more than one red region. Verify focus rings/borders, tap targets (44px vs sanctioned 32px compact icon-btn), keyboard nav.
  • +
  • Anti-pattern grep/lint gate (P5): assert zero remaining from "sonner" / toast. sites, zero <Skeleton>/animate-pulse, zero shadow-* on chrome, zero zebra striping, zero calendar popovers except documented exceptions, zero stale oklch leaks in edge states (rails/disabled/focus/skeleton). Confirm exactly one accent interrupt per screen via a manual checklist.
  • +
  • Shared-component regression sweeps: after P1 and P3, re-test EVERY consumer route of each shared component (not just one) — e.g. verify the DataTable re-skin against tools/users/assignments/copilot-seats/ingestion/invoices simultaneously, and seg-bar/timeline/KPI against dashboard+reports+claude.
  • +
  • Functional behavior preservation: confirm API-key reveal/copy stays masked-by-default, RHF/Zod still fires (.field.is-error/.error-msg), TanStack sort/filter/pagination works, sync polling (30s/5s) updates rows with the new live LED, theme toggle persists to DB without FOUC, and bulk import/invite multi-event outcomes surface via the inline summary-box pattern.
  • +
+
+ + +
+
+ 11 +

Rollout & effort

+
+ +

Rollout strategy

+

Execute the entire migration on a single long-lived feature branch (off main) as a + coordinated full replacement — the goal is a complete swap, and the shared-surface blast radius + (tokens, 16 primitives, shared DataTable, shared viz) makes true page-by-page production rollout + impossible without leaving the app half-styled. Within the branch, sequence strictly P0→P5 so + foundation stabilizes before consumers.

+
    +
  • No broken middle: because next-themes is class-based and shadcn var names are aliased to Nothing tokens, author the Nothing token VALUES so the app compiles and renders at every commit. Keep the old oklch values reachable behind a build flag / scoped wrapper class until P5 to A/B against mockups and bail per-surface if a shared edit regresses.
  • +
  • No permanent dual theme: do NOT ship a user-facing "classic vs nothing" toggle (doubles maintenance, contradicts full-replacement). The parallel theme is a dev-time scaffold only, removed before merge.
  • +
  • Preview review: land the branch behind a Vercel preview so reviewers click through all ~40 routes (agent-browser-session cookie) in both themes before merge.
  • +
  • Single merge: merge to main as one squashed/coordinated PR only after the P5 gate (visual regression + a11y/contrast + grep gate + green build) passes; no interim commits to main. Per project memory, if any gh-aw workflow files are touched, recompile lock.yml in the same commit (not expected here).
  • +
+ +

Sequencing notes

+

P0 (tokens+fonts) is foundational for EVERYTHING and lands first; the accent decision is + settled (Nothing red #D71921, errors share it), so token values can be written without further sign-off. + P1 (primitives+overlays) precedes shell/pages because ~30 components import the primitives; the + shared inline-status + [LOADING] helpers are built here. P2 (shell) precedes pages + because it removes the global Toaster/AlertBanner, defines the Topbar contract, and owns the single + red active-nav accent so in-content tabs stay NEUTRAL. P3 (shared tables + chart wrapper + shared + viz) precedes pages because ~9 tables and 6 dashboard/reports/console screens inherit from them. P4 + (pages) is ordered Auth → Dashboards → CRUD → financial/console (which stress P3 + hardest). P5 is the cross-cutting cleanup + QA gate.

+

Cross-cutting decisions to lock at P0/P1 so they don't churn: accent = + Nothing red #D71921 as the single per-screen interrupt, shared by destructive/error (no separate danger + color, no darkened variant — red passes both modes); keep next-themes class strategy + + System; full toast removal via inline status; full skeleton removal via [LOADING]; + period-nav vs Space-Mono date input for single-date pickers; wide-modal exception + (max-w-3xl/4xl) for data-dense editors; sanctioned 32px compact icon-btn in + dense tables. The pleasant surprise: the app ALREADY ships red as --destructive, + so the Nothing red accent reuses an existing token while the green --primary collapses to greyscale.

+ +

Total effort

+
+
+
P0 · FoundationL
+
P1 · Primitives + OverlaysXL
+
P2 · ShellXL
+
P3 · Shared Tables + ChartsXL
+
P4 · Page Migration (largest by volume)XL
+
P5 · QA gateL
+
TOTALXL · multi-week, focused team
+
+
+

A full-app redesign across ~40 routes and 14 + subsystems. Dominant cost drivers: ~183 toast call sites (app-wide behavioral refactor), ~20 Recharts + components (re-theme/rebuild), and the page-by-page Card→.card / + status→.tag / form→.field conversion across every route. + De-risked substantially by (a) complete on-contract mockups for 12 screens + nothing.css shipping + nearly every needed class, and (b) the app already shipping red as --destructive, so the accent reuses an existing + token while the green --primary collapses to greyscale. + ~60% of the leverage lands from a handful of shared-surface edits (P0–P3).

+
+ +
+

AI Developer Hub · Spec 028 — Nothing Design Redesign · Implementation Plan · + Styled with the Nothing design system (tokens only). Dark default; flip the toggle top-right.

+
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/_CONTRACT.md b/specs/028-nothing-design-redesign/mockups/_CONTRACT.md new file mode 100644 index 00000000..3be6459f --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/_CONTRACT.md @@ -0,0 +1,175 @@ +# Mockup Authoring Contract — Nothing redesign + +Every screen mockup is a standalone `.html` file in this `mockups/` folder. It MUST link the shared +`./styles/nothing.css` and `./styles/theme.js` and reuse the shell + component classes below verbatim. +This file is the single source of consistency. Do not invent new color/spacing values, new fonts, or +ad-hoc component styles. If a screen needs something truly bespoke, add a small scoped ` + + +
+ + +
+
+
+
LICENSES
+

License Assignments

+
+
+ + + + Bulk Import + + +
+ + +
+
+
+ + + + +
+
+ ACTIVE LICENSE ASSIGNMENTS + 142assignments + Track user-to-tool license assignments +
+
+
MONTHLY SNAPSHOT$2,684.00
+
MANAGED BY SYNC38
+
REVOKED · 30D6
+
+
+ +
+ + +
+ + + + + + + + + + + + + +
+ +
+ + +
+
+ SHOWING 1–5 OF 142 · SORTED BY ASSIGNED ↓ + + 136 Active + 6 Revoked + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UserToolTierMonthly CostStatusSourceWorkspace + Assigned + + + + Actions
+ + TS + + Tobias Studer + tobias.studer@unic.com + + + Anthropic APIBoost · usage-billed$0.00ActiveManualdefault-workspaceMay 19, 2026 + + + + + + + + +
+ + AU + + Admin User + admin@unic.com + + + CursorTeam$40.00ActiveManualteam-alphaMar 01, 2026 + + + + + + + +
+ + MR + + Marco Rossi + marco.rossi@unic.com + + + ClaudePro$20.00ActiveManualFeb 02, 2026 + + + + + + + +
+ + SC + + Sarah Chen + sarah.chen@unic.com + + + GitHub CopilotBusiness$19.00 + + Active + Sync + + Copilot Syncunic-engineeringJan 14, 2026 + + + + + Managed by sync + +
+ + LF + + Lena Fischer + lena.fischer@unic.com + + + GitHub CopilotBusiness$19.00 + + Revoked + Sync + + Copilot Syncunic-engineeringNov 08, 2025 + + + + + Managed by sync + +
+
+ + +
+ + ROWS PER PAGE + + + + + + + + + PAGE 1 OF 15 + + + + + + + + + +
+
+ + + +
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/budget.html b/specs/028-nothing-design-redesign/mockups/budget.html new file mode 100644 index 00000000..137da8e4 --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/budget.html @@ -0,0 +1,436 @@ + + + + + + AI Developer Hub — Budget · Nothing + + + + +
+ + +
+
+
+
SPEND TRACKING
+

Budget

+
+
+ All budgets + + + New Budget + +
+ + +
+
+
+ + + + + + + +
+
+ ANNUAL AI TOOL BUDGET +
+ FY 2026 Budget + Active +
+
+ Monthly allocation +
+ + +
+ +
+
+
+ At risk + Variance +$3,200.00 through May 2026 +
+

+ Tracking $3,200.00 over the closed-period plan — + actual $52,300.00 vs $49,100.00 expected through May. + Projected to land under ceiling. +

+
+
+ ANNUAL CEILING + 240,000USD + $18,500.00 unallocated +
+
+ + +
+ SPEND vs CEILING · FY 2026 + Ceiling $240,000.00 +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+ Actual YTD · $100,520.00 + Allocated remaining + Planned YTD · $100,000.00 + Expected through May 2026 · $49,100.00 +
+ +
+ + +
+
+ BILLED YTD +
+ 96,400USD + + $4,120.00 running API +
+
+
+ ACTUAL YTD +
+ 100,520USD + $8,300.00 in Jun 2026 so far +
+
+
+ PROJECTED YEAR-END +
+ 231,800USD + $8,200.00 under ceiling +
+
+
+ VARIANCE THRU MAY 2026 +
+ +3,200USD + $52,300.00 vs $49,100.00 expected +
+
+
+
+ + +
+
+ PAST MONTH SPOTLIGHT +
+ May 2026 — $19,800.00 actual vs $20,000.00 planned + On track · within plan +
+
+
+ PLANNED + $20,000.00 + Allocated +
+
+ EXPECTED + $18,500.00 + From license assignments +
+
+ ACTUAL + $19,800.00 + $18,940.00 billed + $860.00 API +
+
+ VARIANCE % + +7.0% + +$1,300.00 vs expected +
+
+
+
+ + +
+
+ PERIOD ALLOCATIONS & BILLED COSTS + 12 monthly periods · click to expand line items +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PeriodPlannedExpectedActualVariance% Diff
+ + Jan 2026$18,500.00$18,940.00-$420.00-2% + + + +
+
+
+ + GitHub Copilot Business — March invoice + Ref: INV-2026-001 · 2026-03-31 + + + $4,200.00 + + + +
+ +
+ + + Anthropic API + [LIVE] + + Workspace: Default · Updated Jun 2, 2026 14:05 + + $4,120.00 (API) +
+
+
+ + May 2026$18,500.00$21,300.00+$2,800.00+15% + +
+ + + + Jun 2026 + Current + + $18,500.00$8,300.00 (API) + +
Total through May 2026$238,000.00$92,500.00$96,640.00+$4,140.00
+ + +
+ Inline-edit any Planned cell, then save. Archived budgets are read-only. +
+ Allocations exceed budget by $4,000.00 + +
+
+
+
+ + +
+
+ DIALOGS & STATES + Add / edit billed cost · delete · empty +
+ +
+ +
+
+ Add Billed Cost + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ Add Cost / Save Changes + +
+
+
+ +
+ +
+ CONFIRM · ALERTDIALOG +

Delete billed cost?

+

+ This will permanently remove the billed cost entry + “GitHub Copilot Business - March invoice” + ($4,200.00). This action cannot be undone. +

+
+ + +
+
+ + +
+ EMPTY · NO ACTIVE BUDGET +
+
No active budget for the current fiscal year.
+
Annual AI tool budget planning.
+ +
+
+
+
+
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/claude.html b/specs/028-nothing-design-redesign/mockups/claude.html new file mode 100644 index 00000000..bb3cc785 --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/claude.html @@ -0,0 +1,593 @@ + + + + + + AI Developer Hub — Claude Console · Nothing + + + + + +
+ + +
+
+
+
ANTHROPIC API
+

Claude Console

+

Org-wide usage, budgets, and Anthropic sync status.

+
+
+ + + Synced 12m ago · next in 48m + + + +
+ + +
+
+
+ + + + + + + +
+ CLAUDE CONSOLE · WORKSPACES +
+ + +
+
+ + +
+
+
+ CLAUDE API SPENDING · TOTAL · JUN 2026 + $18,420.55USD · MTD billed +
+ + + + $612.40 est. today + + + + + +14% vs prior month + +
+
+ Today is an estimate — shown as a dashed ghost bar, never merged into billed actuals. +
+
+ + +
ORG KPI STRIP
+
+ +
+ TOTAL · JUN 2026 +
+ $18,420.55 + incl. +$612.40 est. today +
+
+ +
+ MOM DELTA +
+ +$2,310.00 + + + +14% vs prior month + +
+
+ + +
+ PROJECTED MONTH-END +
+ $31,800.00 + + + + 106% of $30,000.00 · incl. est. today + +
+
+ +
+ WORKSPACES OVER 80% +
+ 2/ 9 + Platform Eng · 112% +
+
+ +
+ + +
+
+
+
Daily spend by workspace
+
Stacked · top 8 workspaces + Other · $18,420.55 this period
+
+
+ USE WORKSPACE COLORS + +
+
+ +
+
+ $600$300$0 +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ Jun 124681012141618202224262728293031Jun 1Today +
+ + +
+ Platform Eng + Data Science + Default Workspace · Other + Today (est.) ≈ $612 +
+
+ + +
+ + +
+
+
+
Historical trend
+
Last 12 months · monthly totals, pacing vs prior months
+
+
+
+
+ + + +
+
+ Platform Eng +96% + QA Automation +143% +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ JulSepNovJanMarJun +
+
+ Jan $12.4k → Jun $18.4k · dashed = projected month-end overlay +
+
+ + +
+
+
+
Organization Billing
+
Monthly billing budget · thresholds at 80% / 100%
+
+ +
+ +
+
+ CURRENT SPEND + $18,420.55 · 61% of $30,000.00 +
+
+ PROJECTED MONTH-END + $31,800.00 · 106% of budget +
+
+ MONTHLY BUDGET + $30,000.00 +
+
+ +
+
+ UTILIZATION · +$612.40 EST. TODAY + 61% +
+ +
+ + + + + +
+
+ Credit balance is not exposed by the Anthropic API — view in console. +
+
+
+ +
+ + +
+
+
+
Workspace Budgets
+
Spend / limit · utilization · 6-month trend · per-workspace limits
+
+
+ HIDE $0 + NO LIMIT + +
+
+ +
+ + +
+
+
+ + Platform Eng +
+
+ Over budget + 112% + Synced +
+
+
$7,240.00 of $6,500.00
+
+ 112% + + + + + on pace $9,800 (151%) +
+
+ + + + +96% + +
+
+
+ + +
+
+
+ + Data Science +
+
+ 82% + Synced +
+
+
$4,110.00 of $5,000.00
+
+ 82% + + + + + on pace $5,560 (111%) +
+
+ + + + +181% + +
+
+
+ + +
+
+ +
+ Default + Synced +
+
+
$980.00 of no limit
+
+ no limit + + + + + no projection +
+
+ + flat +
+
+
+ + +
+
+ +
+ new + Stale +
+
+
$1,250.00 of $2,000.00
+
+ 63% + + + + + on pace $1,690 (85%) +
+
+ + + + +143% + +
+
+
+ +
+ + + +
+ + + + +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/copilot.html b/specs/028-nothing-design-redesign/mockups/copilot.html new file mode 100644 index 00000000..03466e24 --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/copilot.html @@ -0,0 +1,425 @@ + + + + + + AI Developer Hub — GitHub Copilot · Nothing + + + + + +
+ + +
+
+
+
INTEGRATIONS / GITHUB
+

GitHub Copilot

+

GitHub Copilot usage analytics, seat management, and billing insights.

+
+
+ +
+ + +
+
+
+ + + + + + + +
+ +
+
Copilot metrics are stale — last data 9 days ago (2026-05-24)
+
GitHub finalizes Copilot usage with a ~3 day lag, so a small gap is expected. A nine-day gap usually means the sync stalled or the token is missing the read:org scope. Check integration status or run a backfill.
+
+
+ + +
+
+ ACCEPTANCE RATE · LAST 28 DAYS + 31% + + + ↑ 2.4 pts vs prior 28 days + +
+
+

Suggestions accepted across the last 28 days

+

The headline efficiency signal that sits beside Total Seats and Active Seats. Acceptance rate is the share of inline Copilot suggestions developers chose to keep — the clearest measure of whether the investment is translating into shipped code.

+
+
SUGGESTIONS · 28D1,284,902
+
ACCEPTANCES · 28D398,320
+
+
+
+ + +

OVERVIEW

+
+
+ TOTAL SEATS + +
142
+
+
+ ACTIVE SEATS + +
118
+ 83% utilization +
+
+ ACCEPTANCE RATE + +
31%
+
+
+ SUGGESTIONS + +
1,284,902
+
+
+ ACCEPTANCES + +
398,320
+
+
+ + +
+
+
+

Usage Trends

+

Daily Copilot suggestions, acceptances, and active users

+
+ 28D +
+ +
+ + + + + + + + + + +
+ May 5May 12May 19May 26 +
+
+ +
+ Suggestions + Acceptances + Active Users +
+ +

May 19 — 46,210 suggestions / 14,890 acceptances / 96 users

+
+ + +

SEATS · TAB

+
+
+ 142 synced license assignments + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
UserMatched UserPlanStatusAssigned
+ + OC + + + Alex RiveraBusinessActive3/14/2026
+ + MD + + + UnmatchedBusinessPending4/02/2026
+ + HJ + + + Jane ParkEnterpriseActive1/21/2026
+
+ + +

BILLING OVERVIEW · TAB

+
+
+ CURRENT MONTH COST + +
$2,820.00
+
+
+ CUMULATIVE COST + +
$19,740.00
+
+
+ COST / ACTIVE USER + +
$23.90
+
+
+ PLAN + +
Business
+ 142 total / 118 active seats +
+
+ + +
+

Monthly Billing Details

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MonthCostSeatsActiveCost / User
Mar 2026$2,820.00142118$23.90
Feb 2026$2,760.00138115$24.00
Jan 2026$2,640.00132109$24.22
+
+ + +

ANALYTICS · TAB

+
+ +
+ BY LANGUAGE +
+
+
TYPESCRIPT42%
+
+ +
+
+
+
PYTHON27%
+
+ +
+
+
+
JAVASCRIPT19%
+
+ +
+
+
+
GO12%
+
+ +
+
+
+
+ + +
+ BY EDITOR +
+
+
VS CODE71%
+
+ +
+
+
+
JETBRAINS21%
+
+ +
+
+
+
NEOVIM8%
+
+ +
+
+
+
+ ACTIVITY DISTRIBUTION +
+
POWER USERS38
+
REGULAR52
+
OCCASIONAL28
+
INACTIVE24
+
+
+
+ + +
+
+ UTILIZATION TREND · ACTIVE USERS VS TOTAL SEATS + 83% +
+
+ + + + + +
May 5May 12May 19May 26
+
+
+ Active Users + Total Seats +
+
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/dashboard.html b/specs/028-nothing-design-redesign/mockups/dashboard.html new file mode 100644 index 00000000..60b2f5fa --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/dashboard.html @@ -0,0 +1,568 @@ + + + + + + AI Developer Hub — Dashboard · Nothing + + + + + +
+ + +
+
+
+
OVERVIEW
+

Dashboard

+
+
+ + + + + Preview as viewer + + + + All sources synced · 12m ago + + + + Open Budget report + + + + +
+ + +
+
+
+ + + + +
+ + +
+
+
+ + + Budget health · on track +
+ Within budget +
+ +
+ YTD BILLED + $184,250 + 61.4% of $300,000 ceiling +
+ + +
+ Actual YTD · projected year-end · ceiling + $291,400 proj. +
+
+ + + + + + + + +
+
+ $0 + Projected $291.4K + Ceiling $300K +
+ +
+ +

+ Linear trend lands at $291,400 by year-end — within the + $300,000 ceiling. +

+
+ Sep 2025 was 4.2% under plan + + Open Budget report + + + +
+
+ + +
+
+ This month · October 2025 + +5.9% over +
+ +
+
+ Actual so far + $26,480 +
+
+ Billed + $22,150 +
+
+ Anthropic API + $4,330 +
+
+ Planned + $25,000 +
+
+ Variance + +$1,480 +
+
+ +
+ 28 of 31 days elapsed · pace projects $29.3K full month +
+
+ + +
+
+ Key indicators + Month over month · vs September 2025 +
+ +
+
+ Active users +
+ 142 + across 6 teams +
+
+ +
+ Active tools +
+ 9 + all sources synced +
+
+ +
+ Active licenses +
+ 318 + + + + +12 · vs 306 last month + +
+
+ +
+ Expected monthly +
+ $27,940 + + + + +8.2% · vs $25,820 last month + +
+
+ Copilot acceptance 31% · 87 devs +
+
+
+ + +
+
+
+ +
+
+
+ Stacked monthly cost · last 12 months + Licenses (billed) + Anthropic API (running) +
+ avg $17,420/mo +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NovDecJanFebMarApr + MayJunJulAugSepOct +
+ Fading bars represent future periods with no billed data yet +
+ + +
+
+ YTD billed + $184,250 +
+ +
+
+
+ Licenses (billed) + $159,300 +
+
+ + + + +
+ 86% of YTD +
+ +
+
+ Anthropic API (running) + $24,950 +
+
+ + + +
+ 14% of YTD +
+
+
+ +
+
+
+ + +
+ What changed this month +
+ +
+
+ + +
+
Budget on track
+

Projected $291.4K for the year — 97% of the $300.0K ceiling, with headroom to spare.

+
+
+
+ +
+
+ + +
+
GitHub Copilot seats grew 72 → 87
+

+15 seats added this month — a 21% month-over-month increase in active assignments.

+
+
+
+ +
+
+ + +
+
Expected monthly spend ↑ 8.2%
+

Up from $25.8K to $27.9K vs last month, driven mostly by new Copilot seats. Sep 2025 was 4.2% under plan.

+
+
+
+ + +
+
+ + +
+
Anthropic workspace approaching limit
+

Engineering is at 92% of its monthly cap with 3 days left in the period. Review allocation before it blocks usage.

+
+
+
+ +
+
+ + +
+ + +
+
+ Top tools by monthly cost + View all 9 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolVendorActive usersTotal monthly cost
GitHub CopilotGitHub87$1,653.00
Anthropic APIAnthropic34$4,330.00
CursorAnysphere52$1,040.00
ChatGPT EnterpriseOpenAI61$1,525.00
Perplexity ProPerplexity28$560.00
+
+ + +
+
+ Recent activity + History +
+ +
+
+ + + + Invoice from Anthropic — Added · $4,330 + + + 2h ago +
+ +
+ + + + Jordan Lee assigned GitHub Copilot + + + 5h ago +
+ +
+ + + + Invoice from GitHub — Added · $1,272 + + + Yesterday +
+ +
+ + + + Priya Nair revoked from Cursor + + + 2d ago +
+ +
+ + + + Invoice acme-oct.pdfFiltered (duplicate) + + + 3d ago +
+
+
+
+ + +
+ Jump to + +
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/index.html b/specs/028-nothing-design-redesign/mockups/index.html new file mode 100644 index 00000000..b9c628dc --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/index.html @@ -0,0 +1,236 @@ + + + + + + AI Developer Hub — Nothing Redesign · Gallery + + + + + + + + diff --git a/specs/028-nothing-design-redesign/mockups/invoices.html b/specs/028-nothing-design-redesign/mockups/invoices.html new file mode 100644 index 00000000..722818d9 --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/invoices.html @@ -0,0 +1,269 @@ + + + + + + AI Developer Hub — Invoices · Nothing + + + + +
+ + +
+
+
+
BILLING / INVOICE ARCHIVE
+

Invoices

+
+ +
+ + + + +
+ ARCHIVED INVOICES + 42invoices + Total PDF invoices uploaded and archived · admin-only view, sorted newest first +
+ + +
+
+
+ INVOICE ARCHIVE TABLE + 42 archived · showing 1–5 of 42 +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Invoice Number + + + + Date + + + + Amount + + + + Vendor + + + Budget PeriodUploaded ByDownload
INV-1042Mar 3, 2026$12,480.00Anthropic2026-Q1Tobias Studer + + + +
INV-2087Feb 28, 2026$3,250.50GitHub2026-02Tobias Studer + + + +
INV-9921Feb 14, 2026$899.00Cursor2026-Q1Admin + + + +
INV-1043Jan 31, 2026$12,480.00Anthropic2026-01Tobias Studer + + + +
INV-7755Jan 9, 2026$540.00 + + + +
+ + +
+
+ ROWS PER PAGE +
+ + + + +
+
+
+ + + + PAGE 1 / 5 + + + +
+
+
+ +
+ + +
+ + +
+ ROW ACTION · DOWNLOAD +
+
+ CONTROL + + + + Ghost icon button + + +
+
+ TOOLTIP + Download +
+
+ ARIA-LABEL + Download invoice INV-1042 +
+
+ TARGET + /api/invoices/{id}/pdf +
+
+
+ + +
+ EMPTY STATE · NO INVOICES +
+
No invoices archived yet.
+
Shown only when the archive is empty
+ + + Upload your first invoice + +
+
+
+ + +
+ UPLOAD & SYNC STATUS · FROM /invoices/new & /invoices/bulk +
+ Synced + Linked + Unlinked · — + Duplicate + Low confidence + High confidence +
+

+ Single upload runs AI field extraction with confidence highlighting on Invoice Number / Date / Amount / Vendor; a duplicate triggers a "Duplicate Invoice Detected" dialog (Skip vs Overwrite). Bulk upload accepts a ZIP of PDFs and returns a sync-results review. +

+
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/login.html b/specs/028-nothing-design-redesign/mockups/login.html new file mode 100644 index 00000000..79cb313b --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/login.html @@ -0,0 +1,175 @@ + + + + + + AI Developer Hub — Sign In · Nothing + + + + + +
+
+ +
+ + + + + +
+ + +
+ +
+ +
+ AI DEVELOPER HUB +

Welcome back

+

Sign in to your AI Developer Hub account

+
+ +
+ + + + +
+ + +
+ +
+ + +
+ + +
+ +
+ Access is invite-only. New here? Check your email for an invite link. +
+ +
+
+ + +
+ SECURE SESSION · NEXTAUTH +
+ + +
+
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/reports.html b/specs/028-nothing-design-redesign/mockups/reports.html new file mode 100644 index 00000000..466cdcf7 --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/reports.html @@ -0,0 +1,633 @@ + + + + + + AI Developer Hub — Reports · Nothing + + + + + +
+ + +
+
+
+
ANALYTICS
+

Reports

+

Tool adoption, license utilization, and spending insights

+
+
+ Overview + Budget report +
+ + +
+
+
+ + + + + + + + + +
+
+ ACTIVE USERS +
+ 142 +
+
+ +
+ ACTIVE TOOLS +
+ 6 +
+
+ +
+ ACTIVE LICENSES +
+ 318 + + 12 + + +
+

vs 306 last month

+
+ +
+ EXPECTED MONTHLY +
+ $48,920 + + 6.4% + + +
+ + + + + +
+ JanFebMarAprMayNow +
+

Licenses only — API usage not included

+
+
+ + + + +
+
+
+ + + + +
+
+ BUDGET HEALTH + Within budget +
+ + $184,200USD YTD SPEND +

+ YTD spend is $184,200 — 61.4% of the $300,000 ceiling. + Linear trend lands at $278,400 by year-end, within the ceiling. + Apr 2026 was 4.2% under plan. +

+
+
+ + Open Budget report + + +
+
+ + + + +
+
+ What changed this month + Auto-generated from license + spend data · static rules (v1) +
+
+ +
+
+ + + +
+ Budget on track + Projected $278.4k (93% of $300.0k). +
+
+
+ +
+
+ + + +
+ GitHub Copilot grew 88 → 96 seats + ▲ 8 seats vs last month (9% MoM). +
+
+
+ +
+
+ + + +
+ Expected monthly spend rose + ▲ 6.4%$46.0k$48.9k vs last month. +
+
+
+ +
+
+ + + +
+ Apr 2026 was 4.2% under plan + ▼ 4.2% vs the planned monthly amount. +
+
+
+
+
+ + + + +
+
+ Tool adoption + Active assignments and expected monthly cost · MoM license delta +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolVendorActive usersMoMExpected monthly
GitHub CopilotGitHub96+8$1,824.00
CursorAnysphere74+5$2,960.00
ClaudeAnthropic58−2$1,160.00
ChatGPT EnterpriseOpenAI42±0$2,520.00
GeminiGoogle26+3$520.00
+
+ + + + +
+
+ Spend by circle + License distribution and expected cost by team · sorted by monthly cost +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CircleUsersLicensesExpected cost$ / user
Engineering64142$21,840.00$341.25
Product2861$9,120.00$325.71
Data & AI1948$7,640.00$402.11
Design1427$3,980.00$284.29
Unassigned1740$6,340.00$372.94
+
+ + + + + +
+
+ BUDGET TAB + /reports/budget +
+ + +
+
+
+ FY 2026 budget at a glance + 5 of 12 periods complete · Actual = billed + running API +
+ On track +
+ + +
+ UTILIZATION · 61.4% OF CEILING CONSUMED YTD + $184,200 / $300,000 +
+
+ + + + + + + +
+
+ 0Projected $278.4kCeiling $300k +
+ + +
+
+ ANNUAL CEILING + $300,000 +

12 × $25,000 planned

+
+
+ ACTUAL YTD + $184,200 +

$176,400 billed + $7,800 API

+
+
+ AVG MONTHLY RUN-RATE + $36,840 +

vs $25,000 planned (+47.4%)

+
+
+ PROJECTED YEAR-END + $278,400 +

within $300,000 ceiling

+
+
+
+ + +
+
+ PAST MONTH SPOTLIGHT + May 2026 — $23,960 actual vs $25,000 planned +
+ +
+
+ PLANNED + $25,000 + Allocated monthly +
+
+ ACTUAL + $23,960 + $21,200 billed + $2,760 API +
+
+ VARIANCE + −4.2% + −$1,040 vs plan +
+
+ + +
+
PLANNED$25,000
+
+ + + + + + + +
+
BILLED + API$23,960
+
+ + + + + + + +
+
+ Planned + Billed + API +
+
+ + + TOP PER-TOOL CHANGES VS LAST MONTH +
+
+ GITHUB COPILOT + $1,680 → $1,824 ↑ +$144 (+8.6%) +
+
+ CLAUDE + $1,240 → $1,160 ↓ −$80 (−6.5%) +
+
+
+ + +
+
+ Plan vs actual — FY 2026 + + Planned + Billed + API (running) + Forecast + +
+ + +
+ + +
+ + + + + + + + + + + + + + +
+
+
+ JanFebMar + AprMayJun +
+

+ Y-axis: $25k / $50k… · dashed line = planned monthly average. + Actual bars turn red when over plan — none over plan this fiscal year. +

+
+ + +
+
+ Forecast — cumulative spend + + Actual cumulative + Forecast cumulative + +
+ + + + + + + + + + + + +
+ Ceiling $300,000 + Actual May $184,200 · Forecast Dec $278,400 +
+
+ JanMarMayJulSepDec +
+

+ Solid = actual, dashed = linear-regression forecast. Below 3 completed months this shows: + “The forecast requires at least 3 months of completed spending.” +

+
+ + +
+
+ Per-tool breakdown + Sorted by YTD spent · license-derived + running-API attribution +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolYTD spentCurrent monthlyProjected EOYShare
Cursor$26,400$2,960$44,16028.4%
GitHub Copilot$19,920$1,824$31,10421.4%
ChatGPT Enterprise$17,640$2,520$32,76019.0%
+ + + + Anthropic API + Running API + + $7,800$2,760$26,6408.4%
↳ Workspace · Production$1,940
↳ Workspace · Research$820
+

+ License-derived rows reflect active seat assignments. The Anthropic API row is metered from running + usage and expands to a workspace-level breakdown. +

+
+ + +
+
+
No active budget
+
When no budget exists for the fiscal year, the Budget tab shows this state.
+ + + Create a budget + +
+
+
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/requests.html b/specs/028-nothing-design-redesign/mockups/requests.html new file mode 100644 index 00000000..4295572f --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/requests.html @@ -0,0 +1,351 @@ + + + + + + AI Developer Hub — Access Requests · Nothing + + + + +
+ + +
+
+
+
INBOX / APPROVALS
+

Access Requests

+
+
+ + +
+ + +
+
+
+ + + + +
+
+ PENDING REVIEW + 7requests +
+

+ Routed from Microsoft Forms via Power Automate. Any admin can claim any request — first to act wins. +

+
+ +
+ + +
+
+ + + + + + +
+
+ ROWS +
+ + + + +
+
+
+ + +
+
+ LICENSE REQUESTS · NEWEST FIRST + Showing 1–10 of 42 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRequesterTool / tierStatusDecided byAge
REQ-042 + + Mara Holzer + mara.holzer@unic.com + + + + GitHub Copilot + Business + + Pending review3 hours agoOpen
REQ-041 + + Tobias Studer + tobias.studer@unic.com + + + + Claude + Team + + ApprovedLena Brunner1 day agoOpen
REQ-039 + + David Kümin + david.kuemin@unic.com + + + + Cursor + Pro + + CompletedLena Brunner4 days agoOpen
REQ-036 + + Anita Frei + anita.frei@unic.com + + + + ChatGPT Enterprise + Standard + + RejectedTobias Studer6 days agoOpen
REQ-031 + + Pascal Meier + pascal.meier@unic.com + + GitHub CopilotCancelled2 weeks agoOpen
REQ-029 + + Sofia Bianchi + sofia.bianchi@unic.com + + + + Claude + Pro + + ApprovedTobias Studer2 weeks agoOpen
REQ-027 + + Jonas Widmer + jonas.widmer@unic.com + + + + GitHub Copilot + Business + + CompletedLena Brunner3 weeks agoOpen
REQ-024 + + Elena Vogt + elena.vogt@unic.com + + + + Cursor + Pro + + CompletedTobias Studer1 month agoOpen
REQ-022 + + Marco Steiner + marco.steiner@unic.com + + + + ChatGPT Enterprise + Standard + + RejectedLena Brunner1 month agoOpen
REQ-019 + + Nadia Keller + nadia.keller@unic.com + + GitHub CopilotCancelled2 months agoOpen
+ + +
+ Page 1 of 5 +
+ + +
+
+
+ + +
+
+ ROW → DETAIL DRILL-IN + /requests/{id} +
+
+
+ WHAT THE DETAIL PAGE SHOWS +
+
STATUS BADGEPending review
+
REQUESTER CARDName · Email · Hub match · Assignment #
+
REQUEST PAYLOADTool · Tier · Raw Forms fields
+
SENT MESSAGESApproval · Completion · Rejection (Teams)
+
+
+
+ ACTION BAR · FIRST-WRITE-WINS +

+ Any admin can act on this request. First-write-wins — the first decision is recorded and the + request leaves the pending queue. Admin-only route (AuthGuard requiredRole = "admin"). +

+
+ Approve + Complete + Cancel request + Reject +
+
+
+
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/settings.html b/specs/028-nothing-design-redesign/mockups/settings.html new file mode 100644 index 00000000..f40edd9d --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/settings.html @@ -0,0 +1,531 @@ + + + + + + AI Developer Hub — Settings · Nothing + + + + + +
+ + +
+
+
+
SETTINGS · SYNC STATUS
+

Settings

+
+
+
+ + LIVE · 30s POLL +
+ + +
+ + +
+
+
+ + + + + + + +
+
+
+ SYNC SOURCES HEALTHY + 5 / 6sources + One source partial after last run · last successful sync 12 minutes ago +
+ +
+
+ SOURCE OUTCOMES · LAST RUN + 6 SOURCES +
+ +
+ 4 success + 1 partial + 1 never synced +
+
+
+
+ +
+ + +
+
+ Scheduled Jobs + Auto-refresh · 5s fast poll while any source is running +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SourceScheduleLast RunStatusCreatedUpdatedSkippedActions
GitHub Copilot BillingDaily at 6:00 AM UTC12 minutes agoSuccess0843
Anthropic API UsageEvery 6 hours1 hour agoSuccess142180
Anthropic API CostsDaily at 7:00 AM UTC1 hour agoPartial3052
GitHub MembersManual only2 days agoSuccess4110
Invoice-Period MatchingManual only3 hours agoSuccess6212 + +
Claude Team InvoicesManual onlyNever syncedNeverVia upload
+
+
+ + +
+
+ Sync History + 20 most recent manual sync events +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SourceTriggered ByRun TimeStatusCreatedUpdatedSkipped
GitHub Copilot BillingTobias StuderJun 2, 2026, 9:14 AMSuccess0843
Anthropic API CostsTobias StuderJun 2, 2026, 7:02 AMPartial3052
Invoice-Period MatchingTobias StuderJun 2, 2026, 6:30 AMSuccess6212
GitHub MembersTobias StuderMay 31, 2026, 4:48 PMSuccess4110
Anthropic API UsageTobias StuderMay 30, 2026, 11:05 AM[ FAILED ]000
+
+
+ +
+ + + + +
+
+ Appearance · Theme + [ SAVED ] +
+
+
+ Theme + Select your preferred color scheme. +
+
+ + + +
+
+
+ + +
+
+ Integrations +
+
+ + +
+
+
+ GITHUB ORGANIZATION + unic-com +
+ Connected +
+ +
+
CONNECTEDMar 12, 2026
+
LAST SYNCEDJun 2, 2026
+
SCOPES REQUIREDread:org · read:user · manage_billing:copilot
+
+ +
+ + +
+ +
+ + +
+
+ + +
+
+
+ CLAUDE CODE + Anthropic API +
+ Connected +
+ +
+
WORKSPACEUnic AI Platform
+
LAST CHECKEDJun 2, 2026, 8:55 AM
+
ADMIN KEYANTHROPIC_ADMIN_API_KEY · set
+
+ +

+ Status is read-only. When unconfigured, set the + ANTHROPIC_ADMIN_API_KEY environment variable + to enable Anthropic API cost tracking. +

+
+
+
+ + +
+
+ GitHub Member Sync + Overlay · opened from GitHub Members · Sync Now +
+ +
+ +
+
+
GITHUB MEMBERS56 total
+
SYSTEM USERS50 total
+
LAST MEMBER SYNCMay 31, 2026, 4:48 PM
+
STATUSConnected
+
+
+ + + +
+
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/styles/nothing.css b/specs/028-nothing-design-redesign/mockups/styles/nothing.css new file mode 100644 index 00000000..9598fd07 --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/styles/nothing.css @@ -0,0 +1,463 @@ +/* ============================================================================= + NOTHING DESIGN SYSTEM — AI Developer Hub redesign + Single source of truth for all mockups. Dark + light, monochrome + one red. + Fonts: Doto (hero/display), Space Grotesk (UI/body), Space Mono (data/labels). + ============================================================================= */ + +@import url('https://fonts.googleapis.com/css2?family=Doto:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;700&family=Space+Mono:wght@400;700&display=swap'); + +/* --------------------------------------------------------------------------- + 1. TOKENS — dark is the default canvas; light is a first-class override + --------------------------------------------------------------------------- */ +:root { + /* greyscale (dark) */ + --black: #000000; + --surface: #111111; + --surface-raised: #1a1a1a; + --border: #222222; + --border-visible: #333333; + --text-disabled: #666666; + --text-secondary: #999999; + --text-primary: #e8e8e8; + --text-display: #ffffff; + + /* accent + status (identical across modes) */ + --accent: #d71921; + --accent-subtle: rgba(215, 25, 33, 0.15); + --success: #4a9e5c; + --warning: #d4a843; + --error: #d71921; + --info: #999999; + --interactive: #5b9bf6; + + /* segmented-bar empty track */ + --seg-empty: #222222; + + /* fonts */ + --font-display: "Doto", "Space Mono", monospace; + --font-ui: "Space Grotesk", "DM Sans", system-ui, sans-serif; + --font-mono: "Space Mono", "JetBrains Mono", "SF Mono", monospace; + + /* type scale */ + --display-xl: 72px; + --display-lg: 48px; + --display-md: 36px; + --heading: 24px; + --subheading: 18px; + --body: 16px; + --body-sm: 14px; + --caption: 12px; + --label: 11px; + + /* spacing (8px base) */ + --space-2xs: 2px; + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + --space-3xl: 64px; + --space-4xl: 96px; + + /* radii */ + --radius-tech: 4px; + --radius-compact: 8px; + --radius-card: 14px; + --radius-pill: 999px; + + /* motion */ + --ease: cubic-bezier(0.25, 0.1, 0.25, 1); + --dur-micro: 180ms; + --dur-trans: 320ms; + + --sidebar-w: 248px; +} + +:root[data-theme="light"] { + --black: #f5f5f5; + --surface: #ffffff; + --surface-raised: #f0f0f0; + --border: #e8e8e8; + --border-visible: #cccccc; + --text-disabled: #999999; + --text-secondary: #666666; + --text-primary: #1a1a1a; + --text-display: #000000; + --interactive: #007aff; + --seg-empty: #e0e0e0; + --accent-subtle: rgba(215, 25, 33, 0.10); +} + +/* --------------------------------------------------------------------------- + 2. RESET + BASE + --------------------------------------------------------------------------- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { font-size: 16px; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } + +body { + background: var(--black); + color: var(--text-primary); + font-family: var(--font-ui); + font-weight: 400; + line-height: 1.5; + letter-spacing: 0; + transition: background var(--dur-trans) var(--ease), color var(--dur-trans) var(--ease); +} + +a { color: inherit; text-decoration: none; } +button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; } +ul { list-style: none; } +svg { display: block; } + +::selection { background: var(--accent); color: #fff; } + +/* --------------------------------------------------------------------------- + 3. TYPOGRAPHY UTILITIES + --------------------------------------------------------------------------- */ +.doto { font-family: var(--font-display); font-weight: 500; letter-spacing: -0.02em; } +.mono { font-family: var(--font-mono); } +.ui { font-family: var(--font-ui); } + +/* instrument-panel label: Space Mono, ALL CAPS, tracked out */ +.label { + font-family: var(--font-mono); + font-size: var(--label); + line-height: 1.2; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-secondary); +} +.caption { + font-family: var(--font-mono); + font-size: var(--caption); + letter-spacing: 0.04em; + color: var(--text-secondary); +} + +/* display hero numbers */ +.display-xl { font-family: var(--font-display); font-size: var(--display-xl); line-height: 1.0; letter-spacing: -0.03em; color: var(--text-display); } +.display-lg { font-family: var(--font-display); font-size: var(--display-lg); line-height: 1.05; letter-spacing: -0.02em; color: var(--text-display); } +.display-md { font-family: var(--font-ui); font-size: var(--display-md); line-height: 1.1; letter-spacing: -0.02em; font-weight: 500; color: var(--text-display); } +.heading { font-family: var(--font-ui); font-size: var(--heading); line-height: 1.2; letter-spacing: -0.01em; font-weight: 500; color: var(--text-display); } +.subheading { font-family: var(--font-ui); font-size: var(--subheading); line-height: 1.3; font-weight: 400; color: var(--text-primary); } + +/* text-color helpers */ +.t-display { color: var(--text-display); } +.t-primary { color: var(--text-primary); } +.t-secondary { color: var(--text-secondary); } +.t-disabled { color: var(--text-disabled); } +.t-accent { color: var(--accent); } +.t-success { color: var(--success); } +.t-warning { color: var(--warning); } + +/* --------------------------------------------------------------------------- + 4. APP SHELL — sidebar + main + --------------------------------------------------------------------------- */ +.app { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; } + +.sidebar { + position: sticky; top: 0; align-self: start; + height: 100vh; + display: flex; flex-direction: column; + border-right: 1px solid var(--border); + padding: var(--space-xl) var(--space-lg); + background: var(--black); +} + +.brand { + display: flex; align-items: center; gap: var(--space-sm); + margin-bottom: var(--space-2xl); +} +.brand .dot { width: 9px; height: 9px; border-radius: var(--radius-pill); background: var(--accent); } +.brand .wordmark { + font-family: var(--font-mono); + font-size: var(--body-sm); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-display); +} + +.nav { display: flex; flex-direction: column; gap: var(--space-2xs); } +.nav-section { margin-top: var(--space-lg); margin-bottom: var(--space-xs); padding-left: var(--space-sm); } +.nav-item { + position: relative; + display: flex; align-items: center; gap: var(--space-sm); + padding: 10px var(--space-sm); + font-family: var(--font-mono); + font-size: var(--label); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-disabled); + border-radius: var(--radius-tech); + transition: color var(--dur-micro) var(--ease), background var(--dur-micro) var(--ease); +} +.nav-item svg { width: 16px; height: 16px; stroke-width: 1.5; opacity: 0.7; } +.nav-item:hover { color: var(--text-primary); } +.nav-item.active { color: var(--text-display); } +.nav-item.active::before { + content: ""; position: absolute; left: -1px; top: 50%; transform: translateY(-50%); + width: 2px; height: 18px; background: var(--accent); +} +.nav-item.active svg { opacity: 1; } + +.sidebar-footer { margin-top: auto; padding-top: var(--space-lg); border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: var(--space-xs); } +.user-chip { display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm); } +.avatar { width: 32px; height: 32px; border-radius: var(--radius-pill); border: 1px solid var(--border-visible); display: grid; place-items: center; font-family: var(--font-mono); font-size: var(--caption); color: var(--text-primary); } + +/* main scroll area */ +.main { min-width: 0; padding: var(--space-2xl) var(--space-2xl) var(--space-4xl); max-width: 1280px; } + +/* topbar: page title + actions */ +.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-lg); margin-bottom: var(--space-2xl); } +.topbar .eyebrow { margin-bottom: var(--space-sm); } +.topbar-actions { display: flex; align-items: center; gap: var(--space-md); flex-shrink: 0; } + +/* --------------------------------------------------------------------------- + 5. THEME TOGGLE (segmented [ DARK | LIGHT ]) + --------------------------------------------------------------------------- */ +.theme-toggle { display: inline-flex; border: 1px solid var(--border-visible); border-radius: var(--radius-pill); overflow: hidden; } +.theme-toggle button { + padding: 8px 16px; + font-family: var(--font-mono); font-size: var(--label); letter-spacing: 0.1em; text-transform: uppercase; + color: var(--text-secondary); + transition: color var(--dur-micro) var(--ease), background var(--dur-micro) var(--ease); +} +.theme-toggle button.active { background: var(--text-display); color: var(--black); } + +/* --------------------------------------------------------------------------- + 6. CARDS / SURFACES + --------------------------------------------------------------------------- */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-card); + padding: var(--space-lg); +} +.card--raised { background: var(--surface-raised); } +.card--bare { background: transparent; border: none; padding: 0; } +.card--pad-lg { padding: var(--space-xl); } +.card-label { display: block; margin-bottom: var(--space-md); } + +.grid { display: grid; gap: var(--space-md); } +.grid--2 { grid-template-columns: repeat(2, 1fr); } +.grid--3 { grid-template-columns: repeat(3, 1fr); } +.grid--4 { grid-template-columns: repeat(4, 1fr); } +.grid--kpi { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } +@media (max-width: 1080px) { .grid--3, .grid--4 { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 720px) { .grid--2, .grid--3, .grid--4 { grid-template-columns: 1fr; } } + +/* --------------------------------------------------------------------------- + 7. METRIC / HERO NUMBER + --------------------------------------------------------------------------- */ +.metric { display: flex; flex-direction: column; gap: var(--space-sm); } +.metric-value { font-family: var(--font-mono); font-size: var(--display-lg); line-height: 1; letter-spacing: -0.02em; color: var(--text-display); display: flex; align-items: baseline; gap: var(--space-xs); } +.metric-value.is-doto { font-family: var(--font-display); } +.metric-unit { font-family: var(--font-mono); font-size: var(--label); letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-secondary); } +.metric-trend { font-family: var(--font-mono); font-size: var(--caption); display: inline-flex; align-items: center; gap: 4px; } +.metric-hero .metric-value { font-size: var(--display-xl); } + +/* --------------------------------------------------------------------------- + 8. STAT ROWS / LISTS + --------------------------------------------------------------------------- */ +.stat-list { display: flex; flex-direction: column; } +.stat-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); padding: 14px 0; border-bottom: 1px solid var(--border); } +.stat-row:last-child { border-bottom: none; } +.stat-row .label { color: var(--text-secondary); } +.stat-row .value { font-family: var(--font-mono); font-size: var(--body-sm); color: var(--text-primary); display: inline-flex; align-items: baseline; gap: 4px; } +.value--success { color: var(--success); } +.value--warning { color: var(--warning); } +.value--accent { color: var(--accent); } +.value--display { color: var(--text-display); } +.indent { padding-left: var(--space-lg); } + +/* --------------------------------------------------------------------------- + 9. SEGMENTED PROGRESS BAR (signature viz) + --------------------------------------------------------------------------- */ +.seg-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: var(--space-sm); } +.seg-bar { display: flex; gap: 2px; height: 10px; width: 100%; } +.seg-bar--hero { height: 18px; } +.seg-bar--compact { height: 5px; } +.seg { flex: 1; background: var(--seg-empty); } +.seg.is-filled { background: var(--text-display); } +.seg.is-good { background: var(--success); } +.seg.is-warn { background: var(--warning); } +.seg.is-over { background: var(--accent); } + +/* --------------------------------------------------------------------------- + 10. BUTTONS + --------------------------------------------------------------------------- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: var(--space-sm); + min-height: 44px; padding: 12px 24px; + font-family: var(--font-mono); font-size: 13px; letter-spacing: 0.06em; text-transform: uppercase; + border-radius: var(--radius-pill); + transition: background var(--dur-micro) var(--ease), border-color var(--dur-micro) var(--ease), color var(--dur-micro) var(--ease); +} +.btn svg { width: 16px; height: 16px; stroke-width: 1.5; } +.btn--primary { background: var(--text-display); color: var(--black); } +.btn--primary:hover { background: var(--text-primary); } +.btn--secondary { border: 1px solid var(--border-visible); color: var(--text-primary); } +.btn--secondary:hover { border-color: var(--text-primary); } +.btn--ghost { color: var(--text-secondary); border-radius: 0; padding: 12px 0; } +.btn--ghost:hover { color: var(--text-primary); } +.btn--destructive { border: 1px solid var(--accent); color: var(--accent); } +.btn--destructive:hover { background: var(--accent-subtle); } +.btn--sm { min-height: 36px; padding: 8px 16px; font-size: var(--label); } +.btn--icon { padding: 0; width: 44px; height: 44px; } + +/* circular back button */ +.back-btn { width: 44px; height: 44px; border-radius: var(--radius-pill); border: 1px solid var(--border-visible); display: grid; place-items: center; color: var(--text-primary); transition: border-color var(--dur-micro) var(--ease); } +.back-btn:hover { border-color: var(--text-primary); } + +/* --------------------------------------------------------------------------- + 11. TAGS / CHIPS / STATUS + --------------------------------------------------------------------------- */ +.tag { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 12px; + border: 1px solid var(--border-visible); border-radius: var(--radius-pill); + font-family: var(--font-mono); font-size: var(--caption); letter-spacing: 0.06em; text-transform: uppercase; + color: var(--text-secondary); +} +.tag--tech { border-radius: var(--radius-tech); } +.tag--active { border-color: var(--text-display); color: var(--text-display); } +.tag--success { border-color: var(--success); color: var(--success); } +.tag--warning { border-color: var(--warning); color: var(--warning); } +.tag--accent { border-color: var(--accent); color: var(--accent); } +.tag .led { width: 6px; height: 6px; border-radius: var(--radius-pill); background: currentColor; } + +/* inline status text [SAVED] */ +.status-text { font-family: var(--font-mono); font-size: var(--caption); letter-spacing: 0.06em; color: var(--text-secondary); } +.status-text--ok { color: var(--success); } +.status-text--err { color: var(--accent); } + +/* --------------------------------------------------------------------------- + 12. TABLES + --------------------------------------------------------------------------- */ +.table { width: 100%; border-collapse: collapse; } +.table thead th { + text-align: left; padding: 0 16px 12px; + font-family: var(--font-mono); font-size: var(--label); letter-spacing: 0.1em; text-transform: uppercase; + color: var(--text-secondary); font-weight: 400; + border-bottom: 1px solid var(--border-visible); +} +.table tbody td { padding: 14px 16px; border-bottom: 1px solid var(--border); font-size: var(--body-sm); color: var(--text-primary); vertical-align: middle; } +.table tbody tr:last-child td { border-bottom: none; } +.table .num { text-align: right; font-family: var(--font-mono); } +.table th.num { text-align: right; } +.table tr--active td, .table tr.is-active td { background: var(--surface-raised); } +.table tr.is-active td:first-child { box-shadow: inset 2px 0 0 var(--accent); } +.table tbody tr { transition: background var(--dur-micro) var(--ease); } +.table tbody tr:hover td { background: var(--surface); } +[data-theme="light"] .table tbody tr:hover td { background: var(--surface-raised); } +.cell-strong { color: var(--text-display); font-family: var(--font-ui); } + +/* --------------------------------------------------------------------------- + 13. SEGMENTED CONTROL / TABS + --------------------------------------------------------------------------- */ +.segmented { display: inline-flex; border: 1px solid var(--border-visible); border-radius: var(--radius-compact); overflow: hidden; } +.segmented button { padding: 9px 18px; font-family: var(--font-mono); font-size: var(--label); letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-secondary); transition: background var(--dur-micro) var(--ease), color var(--dur-micro) var(--ease); } +.segmented button.active { background: var(--text-display); color: var(--black); } + +/* text tab bar */ +.tabs { display: flex; gap: var(--space-xl); border-bottom: 1px solid var(--border); } +.tab { position: relative; padding: 0 0 14px; font-family: var(--font-mono); font-size: var(--label); letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-disabled); transition: color var(--dur-micro) var(--ease); } +.tab:hover { color: var(--text-primary); } +.tab.active { color: var(--text-display); } +.tab.active::after { content: ""; position: absolute; left: 0; bottom: -1px; width: 100%; height: 2px; background: var(--accent); } + +/* period nav < LABEL > */ +.period-nav { display: inline-flex; align-items: center; gap: var(--space-md); } +.period-nav .nav-arrow { width: 32px; height: 32px; display: grid; place-items: center; color: var(--text-secondary); } +.period-nav .nav-arrow:hover { color: var(--text-primary); } +.period-nav .period-label { font-family: var(--font-mono); font-size: var(--body-sm); letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-display); min-width: 140px; text-align: center; } + +/* --------------------------------------------------------------------------- + 14. TOGGLES / SWITCHES + --------------------------------------------------------------------------- */ +.switch { position: relative; width: 46px; height: 26px; border-radius: var(--radius-pill); background: var(--border-visible); transition: background var(--dur-micro) var(--ease); flex-shrink: 0; } +.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: var(--radius-pill); background: var(--text-disabled); transition: transform var(--dur-micro) var(--ease), background var(--dur-micro) var(--ease); } +.switch.on { background: var(--text-display); } +.switch.on::after { transform: translateX(20px); background: var(--black); } + +/* --------------------------------------------------------------------------- + 15. INPUTS / FIELDS + --------------------------------------------------------------------------- */ +.field { display: flex; flex-direction: column; gap: var(--space-sm); } +.field > label { color: var(--text-secondary); } +.field .control { + width: 100%; padding: 10px 0; background: transparent; + border: none; border-bottom: 1px solid var(--border-visible); + color: var(--text-display); font-family: var(--font-mono); font-size: var(--body); + transition: border-color var(--dur-micro) var(--ease); +} +.field .control::placeholder { color: var(--text-disabled); } +.field .control:focus { outline: none; border-bottom-color: var(--text-primary); } +.field.is-error .control { border-bottom-color: var(--accent); } +.field .error-msg { font-family: var(--font-mono); font-size: var(--caption); color: var(--accent); } +.field--boxed .control { border: 1px solid var(--border-visible); border-radius: var(--radius-compact); padding: 12px 14px; } + +/* select / search */ +.search { display: inline-flex; align-items: center; gap: var(--space-sm); border: 1px solid var(--border-visible); border-radius: var(--radius-pill); padding: 8px 16px; color: var(--text-secondary); } +.search input { background: none; border: none; outline: none; color: var(--text-primary); font-family: var(--font-mono); font-size: var(--body-sm); min-width: 180px; } +.search svg { width: 16px; height: 16px; stroke-width: 1.5; } + +/* --------------------------------------------------------------------------- + 16. DIVIDERS / MISC LAYOUT + --------------------------------------------------------------------------- */ +.divider { height: 1px; background: var(--border); border: none; margin: var(--space-xl) 0; } +.section-gap { margin-top: var(--space-2xl); } +.row { display: flex; align-items: center; gap: var(--space-md); } +.row--between { justify-content: space-between; } +.row--wrap { flex-wrap: wrap; } +.stack { display: flex; flex-direction: column; } +.stack--sm { gap: var(--space-sm); } +.stack--md { gap: var(--space-md); } +.stack--lg { gap: var(--space-lg); } +.spacer { flex: 1; } + +/* --------------------------------------------------------------------------- + 17. DOT-MATRIX MOTIF + --------------------------------------------------------------------------- */ +.dot-grid { background-image: radial-gradient(circle, var(--border-visible) 1px, transparent 1px); background-size: 16px 16px; } +.dot-grid-subtle { background-image: radial-gradient(circle, var(--border) 0.5px, transparent 0.5px); background-size: 12px 12px; } + +/* --------------------------------------------------------------------------- + 18. CHART HELPERS (mockup-scale; designers use inline SVG for lines) + --------------------------------------------------------------------------- */ +.bars { display: flex; align-items: flex-end; gap: 6px; height: 140px; } +.bars .bar { flex: 1; background: var(--text-display); min-height: 2px; } +.bars .bar.is-muted { background: var(--border-visible); } +.bars .bar.is-accent { background: var(--accent); } +.axis { display: flex; justify-content: space-between; margin-top: var(--space-sm); } +.axis span { font-family: var(--font-mono); font-size: var(--label); letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-disabled); } + +.gauge-num { font-family: var(--font-mono); color: var(--text-display); } + +/* concentric ring legend dot */ +.legend { display: flex; flex-wrap: wrap; gap: var(--space-md) var(--space-lg); } +.legend-item { display: inline-flex; align-items: center; gap: var(--space-sm); font-family: var(--font-mono); font-size: var(--caption); letter-spacing: 0.04em; color: var(--text-secondary); } +.legend-item .swatch { width: 10px; height: 10px; } + +/* --------------------------------------------------------------------------- + 19. EMPTY / LOADING + --------------------------------------------------------------------------- */ +.empty { text-align: center; padding: var(--space-4xl) var(--space-xl); } +.empty .empty-title { font-family: var(--font-ui); font-size: var(--subheading); color: var(--text-secondary); margin-bottom: var(--space-sm); } +.empty .empty-sub { font-family: var(--font-mono); font-size: var(--caption); color: var(--text-disabled); } +.loading-text { font-family: var(--font-mono); font-size: var(--caption); letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-secondary); } + +/* --------------------------------------------------------------------------- + 20. AUTH (centered, chrome-less) + --------------------------------------------------------------------------- */ +.auth-screen { min-height: 100vh; display: grid; place-items: center; padding: var(--space-xl); } +.auth-card { width: 100%; max-width: 400px; } + +/* --------------------------------------------------------------------------- + 21. REDUCED MOTION + --------------------------------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: 1ms !important; animation-duration: 1ms !important; } +} diff --git a/specs/028-nothing-design-redesign/mockups/styles/theme.js b/specs/028-nothing-design-redesign/mockups/styles/theme.js new file mode 100644 index 00000000..088cf5df --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/styles/theme.js @@ -0,0 +1,31 @@ +/* Nothing redesign mockups — dark/light theme toggle. + Dark is the default canvas. Choice persists across mockup pages via localStorage. */ +(function () { + var KEY = "nd-mock-theme"; + var root = document.documentElement; + + function apply(theme) { + if (theme === "light") root.setAttribute("data-theme", "light"); + else root.removeAttribute("data-theme"); // dark = absence of attribute + try { localStorage.setItem(KEY, theme); } catch (e) {} + document.querySelectorAll(".theme-toggle button").forEach(function (b) { + b.classList.toggle("active", b.dataset.theme === theme); + }); + } + + // restore before paint + var saved = "dark"; + try { saved = localStorage.getItem(KEY) || "dark"; } catch (e) {} + apply(saved); + + document.addEventListener("click", function (e) { + var btn = e.target.closest(".theme-toggle button"); + if (btn) apply(btn.dataset.theme); + }); + + // re-sync toggle button state once DOM is ready (script may load in ) + document.addEventListener("DOMContentLoaded", function () { + var current = root.getAttribute("data-theme") === "light" ? "light" : "dark"; + apply(current); + }); +})(); diff --git a/specs/028-nothing-design-redesign/mockups/tools.html b/specs/028-nothing-design-redesign/mockups/tools.html new file mode 100644 index 00000000..ac063ae0 --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/tools.html @@ -0,0 +1,370 @@ + + + + + + AI Developer Hub — AI Tools · Nothing + + + + +
+ + +
+
+
+
AI TOOL REGISTRY
+

AI Tools

+

Manage your AI tool registry

+
+
+ + + Add Tool + +
+ + +
+
+
+ + + + +
+
+ TOOLS IN REGISTRY + 6tools +
+
+ ACTIVE LICENSES IN USE + 122 +
+
+ + +
+ +
+ STATUS +
+ + + +
+ VENDOR + +
+
+ + +
+
+ AI TOOLS TABLE + 6 of 6 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameVendorActive LicensesStatusActions
GitHub CopilotGitHub42Active +
+ + + + + + + +
+
Claude APIAnthropic30Active +
+ + + + + + + +
+
CursorAnysphere25Active +
+ + + + + + + +
+
ChatGPT EnterpriseOpenAI18Active +
+ + + + + + + +
+
Gemini Code AssistGoogle7Active +
+ + + + + + + +
+
TabnineTabnine0Archived +
+ + + + + + + +
+
+ + +
+
+ ROWS PER PAGE + +
+
+ Page 1 of 1 + + +
+
+
+ + +
+ ROW ACTION — ARCHIVE CONFIRMATION +
+ +
+
+ Archive GitHub Copilot? +

+ This tool has 42 active license(s). Revoke them before archiving. +

+
+
+ + +
+
+ +
+
+ Archive Tabnine? +

+ This will archive the tool and make it unavailable for new assignments. +

+
+
+ [ TOOL ARCHIVED ] +
+ + +
+
+
+
+
+ + +
+
+ ADD NEW TOOL · /TOOLS/NEW + Admin only +
+ +
+ +
+ TOOL DETAILS +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ ACCESS TIERS + +
+
+
+ TIER 1 + at least one required +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+ Cancel + +
+
+
+
+ + +
+ NO-RESULTS STATE +
+
+
No results.
+
No tools match the active search and filters.
+
+
+
+ +
+
+ + diff --git a/specs/028-nothing-design-redesign/mockups/users.html b/specs/028-nothing-design-redesign/mockups/users.html new file mode 100644 index 00000000..904535d6 --- /dev/null +++ b/specs/028-nothing-design-redesign/mockups/users.html @@ -0,0 +1,290 @@ + + + + + + AI Developer Hub — Users · Nothing + + + + + +
+ + +
+
+
+
DIRECTORY
+

Users

+

Manage company user directory

+
+ +
+ + + + +
+
+ USERS PENDING SETUP + 4 users + Have not set a password yet · invite links valid 72h +
+
+ + + Send Invites to All Pending (4) + + +
+
+ +
+ + +
+ + + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailCircleDisciplineRoleProfileSetup StatusStatusActions
Anna Kelleranna.keller@unic.comZurich DigitalDeveloperviewerboostPendingActive + + + + + +
Marco Brunnermarco.brunner@unic.comBern PlatformConceptionadminmaxedPendingActive + + + + + +
Sophie Vogelsophie.vogel@unic.comLausanne CloudBusinessviewerindiePendingActive + + + + + +
Tobias Studertobias.studer@unic.comZurich DigitalDeveloperadminPendingActive + + + + + +
Lukas Meierlukas.meier@unic.comDeveloperviewerActiveInactive + + + +
+ +
+ 5 of 5 users +
+ ROWS +
+ + + + +
+
+ + Page 1 / 1 + +
+
+
+
+
+ + +
+
+ CONFIRMATION DIALOGS + admin-only · destructive actions require confirm +
+
+
+ SEND INVITES TO ALL PENDING +
+ Send invites to all pending users? + This will send invite emails to 4 user(s) who have not yet set up their password. Each user receives a unique invite link valid for 72 hours. +
+ + +
+
+
+
+ DEACTIVATE USER +
+ Deactivate this user? + Deactivating revokes all active license assignments. Reset Password sends a fresh setup link. Both actions open a confirmation dialog first. +
+ + +
+
+
+
+
+ +
+
+ + diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 69e9f292..98f0afc2 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,5 +1,3 @@ -import { Bot } from "lucide-react"; - export default function AuthLayout({ children, }: { @@ -7,23 +5,25 @@ export default function AuthLayout({ }) { return (
- {/* Decorative side panel — visible on large screens */} -
-
- - AI Developer Hub + {/* Decorative side panel — neutral surface + dot-matrix, NOT an accent fill */} +
+
+
-
-

+

+

“Centralise your AI tool budgets, licences, and usage data in one place — so your team can focus on building.”

-
- Budget Tracker · Licence Manager · Usage Reports +
+ Budget Tracker · Licence Manager · Usage Reports
-

- © {new Date().getFullYear()} AI Developer Hub +

+ © {new Date().getFullYear()} AI Developer Hub

diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 54439069..c6750555 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -16,13 +16,13 @@ export default async function LoginPage({ const { callbackUrl } = await searchParams; return ( - +
- + Welcome back diff --git a/src/app/(auth)/setup-password/[token]/page.tsx b/src/app/(auth)/setup-password/[token]/page.tsx index cdb8cdb4..66428844 100644 --- a/src/app/(auth)/setup-password/[token]/page.tsx +++ b/src/app/(auth)/setup-password/[token]/page.tsx @@ -21,13 +21,13 @@ export default async function SetupPasswordPage({ if (!result.success) { return ( - +
- + AI Developer Hub
@@ -73,13 +73,13 @@ export default async function SetupPasswordPage({ } return ( - +
- + Set your password diff --git a/src/app/(auth)/setup-password/[token]/setup-password-form.tsx b/src/app/(auth)/setup-password/[token]/setup-password-form.tsx index 7d2db20a..3d0d37a7 100644 --- a/src/app/(auth)/setup-password/[token]/setup-password-form.tsx +++ b/src/app/(auth)/setup-password/[token]/setup-password-form.tsx @@ -6,7 +6,7 @@ import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { AlertCircle, Loader2 } from "lucide-react"; -import { toast } from "sonner"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { setupPasswordSchema } from "@/lib/validators"; import { setupPassword } from "@/actions/invite"; import { Button } from "@/components/ui/button"; @@ -28,6 +28,7 @@ interface SetupPasswordFormProps { export function SetupPasswordForm({ token, userName }: SetupPasswordFormProps) { const router = useRouter(); + const status = useInlineStatus(); const [error, setError] = useState(null); const form = useForm({ @@ -56,15 +57,15 @@ export function SetupPasswordForm({ token, userName }: SetupPasswordFormProps) { }); if (signInResult?.ok) { - toast.success("Password set successfully. Redirecting..."); + // Navigation is the feedback for the success path. router.push("/"); router.refresh(); } else { // Fallback: send to login page if auto-sign-in fails - toast.success("Password set successfully. Please sign in."); router.push("/login"); } } else { + status.error(result.error); setError(result.error); } } @@ -138,6 +139,9 @@ export function SetupPasswordForm({ token, userName }: SetupPasswordFormProps) { "Set Password" )} +
+ +
); diff --git a/src/app/assignments/[id]/assignment-detail-client.tsx b/src/app/assignments/[id]/assignment-detail-client.tsx index c80e5611..139f40fb 100644 --- a/src/app/assignments/[id]/assignment-detail-client.tsx +++ b/src/app/assignments/[id]/assignment-detail-client.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { toast } from "sonner"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { format, parseISO } from "date-fns"; @@ -32,6 +31,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Separator } from "@/components/ui/separator"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { Form, FormControl, @@ -96,6 +96,8 @@ export function AssignmentDetailClient({ isAdmin, }: Props) { const router = useRouter(); + const detailStatus = useInlineStatus(); + const commentStatus = useInlineStatus(); const [revealedKey, setRevealedKey] = useState(null); const [revealing, setRevealing] = useState(false); const [commentBody, setCommentBody] = useState(""); @@ -126,11 +128,14 @@ export function AssignmentDetailClient({ const tool = await getToolWithTiers(assignment.tool.id); setTiers(tool?.accessTiers.filter((t) => t.isActive) ?? []); } catch { - toast.error("Failed to load tiers"); + detailStatus.error("Failed to load tiers"); } finally { setLoadingTiers(false); } - }, [assignment.tool.id]); + // detailStatus is now a memoized object (stable unless its status changes), + // so this callback no longer churns every render and the mount effect runs + // once instead of looping. + }, [assignment.tool.id, detailStatus]); useEffect(() => { if (isAdmin && assignment.status === "active") { @@ -153,14 +158,14 @@ export function AssignmentDetailClient({ const result = await updateAssignment(payload); if (result.success) { if (result.warning) { - toast.warning(result.warning); + detailStatus.info(result.warning); } else { - toast.success("Assignment updated"); + detailStatus.ok("Saved"); } form.reset({ ...payload, apiKey: "" }); router.refresh(); } else { - toast.error(result.error); + detailStatus.error(result.error); } } @@ -176,10 +181,10 @@ export function AssignmentDetailClient({ if (result.success) { setRevealedKey(result.data.plaintext); } else { - toast.error(result.error); + detailStatus.error(result.error); } } catch { - toast.error("Failed to reveal API key"); + detailStatus.error("Failed to reveal API key"); } finally { setRevealing(false); } @@ -188,14 +193,14 @@ export function AssignmentDetailClient({ async function handleCopyApiKey() { const key = revealedKey; if (!key) { - toast.error("Reveal the API key first to copy it"); + detailStatus.error("Reveal the key first"); return; } try { await navigator.clipboard.writeText(key); - toast.success("API key copied to clipboard"); + detailStatus.ok("Copied"); } catch { - toast.error("Failed to copy to clipboard"); + detailStatus.error("Copy failed"); } } @@ -209,14 +214,14 @@ export function AssignmentDetailClient({ body: commentBody.trim(), }); if (result.success) { - toast.success("Comment added"); + commentStatus.ok("Added"); setCommentBody(""); router.refresh(); } else { - toast.error(result.error); + commentStatus.error(result.error); } } catch { - toast.error("Failed to add comment"); + commentStatus.error("Failed to add comment"); } finally { setSubmittingComment(false); } @@ -234,7 +239,7 @@ export function AssignmentDetailClient({ Back to Assignments -

+

- +
+ + +
) : ( @@ -635,6 +643,7 @@ export function AssignmentDetailClient({ )}
+ {isAdmin && }
)} @@ -697,14 +706,17 @@ export function AssignmentDetailClient({

{commentBody.length}/2000 characters

- +
+ + +
diff --git a/src/app/assignments/assignments-client.tsx b/src/app/assignments/assignments-client.tsx index 9865fe9b..97c7fc65 100644 --- a/src/app/assignments/assignments-client.tsx +++ b/src/app/assignments/assignments-client.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { ColumnDef } from "@tanstack/react-table"; @@ -17,6 +16,7 @@ import { getToolWithTiers } from "@/actions/tools"; import { updateAssignmentSchema } from "@/lib/validators"; import type { UpdateAssignmentInput } from "@/lib/validators"; import { DataTable, arrayIncludesFilterFn } from "@/components/data-table"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { UserCombobox } from "@/components/user-combobox"; import { formatCurrency, formatDate, cn, formatDateOnly, NO_WORKSPACE_SENTINEL } from "@/lib/utils"; import type { AiTool, User, AccessTier } from "@/types"; @@ -119,6 +119,7 @@ function EditAssignmentDialog({ const [saving, setSaving] = useState(false); const [showApiKey, setShowApiKey] = useState(false); const [datePickerOpen, setDatePickerOpen] = useState(false); + const status = useInlineStatus(); const form = useForm({ resolver: zodResolver(updateAssignmentSchema), @@ -151,11 +152,11 @@ function EditAssignmentDialog({ const tool = await getToolWithTiers(assignment.tool.id); setTiers(tool?.accessTiers.filter((t) => t.isActive) ?? []); } catch { - toast.error("Failed to load tiers"); + status.error("Tiers load failed"); } finally { setLoadingTiers(false); } - }, [assignment.tool.id]); + }, [assignment.tool.id, status]); useEffect(() => { if (open) { @@ -189,17 +190,17 @@ function EditAssignmentDialog({ const result = await updateAssignment(payload); if (result.success) { if (result.warning) { - toast.warning(result.warning); + status.info(result.warning); } else { - toast.success("Assignment updated"); + status.ok("Updated"); } setOpen(false); onSaved(); } else { - toast.error(result.error); + status.error(result.error); } } catch { - toast.error("An unexpected error occurred"); + status.error("Unexpected error"); } finally { setSaving(false); } @@ -210,9 +211,9 @@ function EditAssignmentDialog({ if (value) { try { await navigator.clipboard.writeText(value); - toast.success("Copied to clipboard"); + status.ok("Copied"); } catch { - toast.error("Failed to copy to clipboard"); + status.error("Copy failed"); } } } @@ -400,7 +401,8 @@ function EditAssignmentDialog({ )} /> - + + diff --git a/src/app/assignments/import/bulk-assignment-import-form.tsx b/src/app/assignments/import/bulk-assignment-import-form.tsx index 9dc62472..585d2946 100644 --- a/src/app/assignments/import/bulk-assignment-import-form.tsx +++ b/src/app/assignments/import/bulk-assignment-import-form.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { bulkImportAssignments } from "@/actions/assignments"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { Button } from "@/components/ui/button"; import { Card, @@ -84,6 +84,7 @@ function parseCSV(text: string): ParsedAssignment[] { export function BulkAssignmentImportForm() { const router = useRouter(); + const status = useInlineStatus(); const [rows, setRows] = useState([]); const [importing, setImporting] = useState(false); const [serverErrors, setServerErrors] = useState([]); @@ -123,19 +124,17 @@ export function BulkAssignmentImportForm() { if (result.success) { const imported = result.data?.imported ?? 0; const failed = result.data?.failed ?? 0; - toast.success( - `Import complete: ${imported} imported, ${failed} failed` - ); if (failed > 0 && result.data?.errors) { setServerErrors(result.data.errors); + status.error(`${imported} imported, ${failed} failed`); } else { router.push("/assignments"); } } else { - toast.error(result.error ?? "Import failed"); + status.error(result.error ?? "Import failed"); } } catch { - toast.error("An unexpected error occurred"); + status.error("Unexpected error"); } finally { setImporting(false); } @@ -145,7 +144,7 @@ export function BulkAssignmentImportForm() {
-

Bulk Import Assignments

+

Bulk Import Assignments

Upload a CSV file to import license assignments in bulk.

@@ -255,6 +254,7 @@ export function BulkAssignmentImportForm() { > Cancel + )}
diff --git a/src/app/assignments/loading.tsx b/src/app/assignments/loading.tsx index bba60c28..fdddf935 100644 --- a/src/app/assignments/loading.tsx +++ b/src/app/assignments/loading.tsx @@ -1,29 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; +import { LoadingState } from "@/components/ui/loading-state"; export default function AssignmentsLoading() { - return ( -
-
-
- - -
- -
- - - - - - -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
-
-
- ); + return ; } diff --git a/src/app/budget/[id]/loading.tsx b/src/app/budget/[id]/loading.tsx index e1f0ca3c..f6f51c70 100644 --- a/src/app/budget/[id]/loading.tsx +++ b/src/app/budget/[id]/loading.tsx @@ -1,37 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; +import { LoadingState } from "@/components/ui/loading-state"; export default function BudgetDetailLoading() { - return ( -
-
- - -
- -
- {Array.from({ length: 3 }).map((_, i) => ( - - - - - - - ))} -
- - - - - - -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
-
-
- ); + return ; } diff --git a/src/app/budget/budget-list-actions.tsx b/src/app/budget/budget-list-actions.tsx index fbac0b33..0dd23f10 100644 --- a/src/app/budget/budget-list-actions.tsx +++ b/src/app/budget/budget-list-actions.tsx @@ -3,8 +3,8 @@ import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { AlertDialog, AlertDialogAction, @@ -38,6 +38,7 @@ interface BudgetListActionsProps { export function BudgetListActions({ id, fiscalYear, status }: BudgetListActionsProps) { const router = useRouter(); const [showArchiveDialog, setShowArchiveDialog] = useState(false); + const archiveStatus = useInlineStatus(); return (
@@ -80,19 +81,19 @@ export function BudgetListActions({ id, fiscalYear, status }: BudgetListActionsP + Cancel { try { const result = await archiveBudget({ id }); if (result.success) { - toast.success("Budget archived"); router.refresh(); } else { - toast.error(result.error); + archiveStatus.error(result.error); } } catch { - toast.error("An unexpected error occurred"); + archiveStatus.error("Unexpected error"); } }} > diff --git a/src/app/budget/components/budget-detail-client.tsx b/src/app/budget/components/budget-detail-client.tsx index e455ebe1..1b49b57c 100644 --- a/src/app/budget/components/budget-detail-client.tsx +++ b/src/app/budget/components/budget-detail-client.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { createBilledCost, deleteBilledCost, @@ -45,6 +45,9 @@ export function BudgetDetailClient({ showBreadcrumb = true, }: Props) { const router = useRouter(); + const allocStatus = useInlineStatus(); + const billedStatus = useInlineStatus(); + const deleteStatus = useInlineStatus(); const periods = budget.periods; const isArchived = budget.status === "archived"; @@ -83,10 +86,10 @@ export function BudgetDetailClient({ setSaving(false); if (result.success) { - toast.success("Allocations saved"); + allocStatus.ok("Saved"); router.refresh(); } else { - toast.error(result.error); + allocStatus.error(result.error); } } @@ -101,7 +104,7 @@ export function BudgetDetailClient({ setAddSaving(true); const amountCents = Math.round(parseFloat(addForm.amountDollars) * 100); if (isNaN(amountCents) || amountCents <= 0) { - toast.error("Please enter a valid amount"); + billedStatus.error("Invalid amount"); setAddSaving(false); return; } @@ -114,11 +117,10 @@ export function BudgetDetailClient({ }); setAddSaving(false); if (result.success) { - toast.success("Billed cost added"); setAddDialogOpen(false); router.refresh(); } else { - toast.error(result.error); + billedStatus.error(result.error); } } @@ -138,7 +140,7 @@ export function BudgetDetailClient({ setEditSaving(true); const amountCents = Math.round(parseFloat(editForm.amountDollars) * 100); if (isNaN(amountCents) || amountCents <= 0) { - toast.error("Please enter a valid amount"); + billedStatus.error("Invalid amount"); setEditSaving(false); return; } @@ -151,11 +153,10 @@ export function BudgetDetailClient({ }); setEditSaving(false); if (result.success) { - toast.success("Billed cost updated"); setEditDialogOpen(false); router.refresh(); } else { - toast.error(result.error); + billedStatus.error(result.error); } } @@ -170,11 +171,10 @@ export function BudgetDetailClient({ const result = await deleteBilledCost({ id: deleteEntry.id }); setDeleteSaving(false); if (result.success) { - toast.success("Billed cost deleted"); setDeleteDialogOpen(false); router.refresh(); } else { - toast.error(result.error); + deleteStatus.error(result.error); } } @@ -202,7 +202,10 @@ export function BudgetDetailClient({ - Period allocations & billed costs +
+ Period allocations & billed costs + +
+
+ + +
+
-

FY {budget.fiscalYear} Budget

+

FY {budget.fiscalYear} Budget

{budget.status} diff --git a/src/app/budget/components/budget-health-hero.tsx b/src/app/budget/components/budget-health-hero.tsx index 48ed4f9d..5227bbe1 100644 --- a/src/app/budget/components/budget-health-hero.tsx +++ b/src/app/budget/components/budget-health-hero.tsx @@ -392,7 +392,7 @@ function MultiMarkerBar({ {lastClosedLabel && (
@@ -404,7 +404,7 @@ function MultiMarkerBar({ {lastClosedLabel && ( )} diff --git a/src/app/budget/components/period-allocations-table.tsx b/src/app/budget/components/period-allocations-table.tsx index f6a8c0ae..5a03c885 100644 --- a/src/app/budget/components/period-allocations-table.tsx +++ b/src/app/budget/components/period-allocations-table.tsx @@ -44,9 +44,9 @@ function rowClassFor(args: { isUnderExpected: boolean; isFuture: boolean; }): string { - if (args.isCurrent) return "border-l-4 border-primary bg-primary/5"; - if (args.isOverExpected) return "bg-destructive/10"; - if (args.isUnderExpected) return "bg-emerald-500/5"; + if (args.isCurrent) return "border-l-4 border-primary"; + if (args.isOverExpected) return "border-l-4 border-destructive"; + if (args.isUnderExpected) return "border-l-4 border-success"; if (args.isFuture) return "text-muted-foreground"; return ""; } @@ -207,7 +207,7 @@ export function PeriodAllocationsTable({ {formatCurrency(expected)} 0 ? "text-sky-700 dark:text-sky-300" : "" + runningCents > 0 ? "text-foreground" : "" } > {formatCurrency(actualCents)} @@ -310,15 +310,15 @@ export function PeriodAllocationsTable({ {periodRunning && ( - + Anthropic API live @@ -330,7 +330,7 @@ export function PeriodAllocationsTable({ )} - + {formatCurrency(periodRunning.runningCostCents)} @@ -346,7 +346,7 @@ export function PeriodAllocationsTable({ ? `ws-${period.id}-${ws.workspaceId}` : `ws-${period.id}-idx-${index}` } - className="bg-sky-500/[0.03] dark:bg-sky-500/5" + className="bg-muted/20" > +

{label}

diff --git a/src/app/budget/history/page.tsx b/src/app/budget/history/page.tsx index 0a45c421..1eaf7e34 100644 --- a/src/app/budget/history/page.tsx +++ b/src/app/budget/history/page.tsx @@ -29,7 +29,7 @@ export default async function BudgetHistoryPage() {
-

Budget history

+

Budget history

Every fiscal year — active and archived.

diff --git a/src/app/budget/loading.tsx b/src/app/budget/loading.tsx index bfd4a83f..8a614a73 100644 --- a/src/app/budget/loading.tsx +++ b/src/app/budget/loading.tsx @@ -1,34 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; +import { LoadingState } from "@/components/ui/loading-state"; export default function BudgetLoading() { - return ( -
-
-
- - -
- -
- - - - - - - -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - -
- ))} -
- -
-
-
- ); + return ; } diff --git a/src/app/budget/new/new-budget-form.tsx b/src/app/budget/new/new-budget-form.tsx index 5bab84c0..27735e52 100644 --- a/src/app/budget/new/new-budget-form.tsx +++ b/src/app/budget/new/new-budget-form.tsx @@ -4,8 +4,8 @@ import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { toast } from "sonner"; import { createBudget } from "@/actions/budget"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -41,6 +41,7 @@ type NewBudgetInput = z.infer; export function NewBudgetForm() { const router = useRouter(); + const status = useInlineStatus(); const currentYear = new Date().getFullYear(); const form = useForm({ @@ -60,17 +61,16 @@ export function NewBudgetForm() { }); if (result.success) { - toast.success("Budget created"); router.push(`/budget/${result.data.id}`); } else { - toast.error(result.error); + status.error(result.error); } } return (
-

Create Annual Budget

+

Create Annual Budget

Set up a new fiscal year budget for AI tools

@@ -162,6 +162,7 @@ export function NewBudgetForm() { > Cancel +
diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index 3c46f19c..45e2af5c 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -58,7 +58,7 @@ function EmptyBudgetState({ isAdmin }: { isAdmin: boolean }) { return (
-

Budget

+

Budget

Annual AI tool budget planning.

diff --git a/src/app/claude/page.tsx b/src/app/claude/page.tsx index 17325f61..766b633b 100644 --- a/src/app/claude/page.tsx +++ b/src/app/claude/page.tsx @@ -21,6 +21,7 @@ import { HistoricalTrendCard } from "@/components/claude/historical-trend-card"; import { SyncButton } from "@/components/claude/sync-button"; import { ClaudeTabs } from "@/components/claude/claude-tabs"; import { getCurrentMonth, getUtcDaysInMonth } from "@/lib/utils"; +import { LoadingState } from "@/components/ui/loading-state"; import { Bot } from "lucide-react"; export const metadata: Metadata = { title: "Claude API Spending" }; @@ -69,7 +70,7 @@ export default async function ClaudePage() {
-

Claude API Spending

+

Claude API Spending

Org-wide usage, budgets, and Anthropic sync status.

@@ -79,7 +80,7 @@ export default async function ClaudePage() { - }> + }>
-

+

{detail.user.name || detail.user.email}

{detail.user.name && ( diff --git a/src/app/claude/users/[userId]/user-detail-client.tsx b/src/app/claude/users/[userId]/user-detail-client.tsx index 48b1c672..7f4f05e9 100644 --- a/src/app/claude/users/[userId]/user-detail-client.tsx +++ b/src/app/claude/users/[userId]/user-detail-client.tsx @@ -12,6 +12,7 @@ import { getUserDetail } from "@/actions/anthropic-users"; import { Badge } from "@/components/ui/badge"; import { AlertTriangle, TrendingDown, TrendingUp } from "lucide-react"; import { formatCurrency } from "@/lib/utils"; +import { InlineSpinner } from "@/components/ui/loading-state"; import type { UserDetail } from "@/types"; type Props = { @@ -45,11 +46,11 @@ export function UserDetailClient({ userId, initial }: Props) { detail.momDeltaPct === null ? ( — no spend last month ) : detail.momDeltaPct >= 0 ? ( - + +{detail.momDeltaPct}% vs prior month ) : ( - + {detail.momDeltaPct}% vs prior month @@ -73,12 +74,7 @@ export function UserDetailClient({ userId, initial }: Props) { ? `+${formatCurrency(detail.momDeltaCents)}` : `-${formatCurrency(Math.abs(detail.momDeltaCents))}`, caption: momCaption, - tone: - detail.momDeltaPct === null - ? "default" - : detail.momDeltaPct >= 0 - ? "success" - : "danger", + tone: "default", }, { label: "Projected Month-End", @@ -110,17 +106,10 @@ export function UserDetailClient({ userId, initial }: Props) { detail.availableMonths.length > 0 ? detail.availableMonths : [month] } /> - {isPending && ( - - Loading… - - )} + {isPending && }
{detail.hasUnresolvedPricing && ( - + Some pricing unresolved @@ -136,11 +125,7 @@ export function UserDetailClient({ userId, initial }: Props) { - + diff --git a/src/app/claude/users/page.tsx b/src/app/claude/users/page.tsx index beaf189e..4273a1dc 100644 --- a/src/app/claude/users/page.tsx +++ b/src/app/claude/users/page.tsx @@ -16,7 +16,7 @@ import { SyncButton } from "@/components/claude/sync-button"; import { SyncStatusPill } from "@/components/claude/sync-status-pill"; import { UsersMonthPicker } from "@/components/claude/users-month-picker"; import { UserKpiStrip } from "@/components/claude/user-kpi-strip"; -import { TopUsersBarChart } from "@/components/claude/top-users-bar-chart"; +import { TopUsersCard } from "@/components/claude/top-users-card"; import { CostDistributionHistogram } from "@/components/claude/cost-distribution-histogram"; import { DailyByUserChart } from "@/components/claude/daily-by-user-chart"; import { UsersTable } from "@/components/claude/users-table"; @@ -77,7 +77,7 @@ export default async function ClaudeUsersPage({
-

+

Claude API Spending

@@ -96,7 +96,7 @@ export default async function ClaudeUsersPage({

-

+

Claude API Spending

@@ -118,14 +118,7 @@ export default async function ClaudeUsersPage({ Top 10 moves below it. */} - - - Top 10 Users by Cost - - - - - +

diff --git a/src/app/claude/workspaces/[workspaceId]/page.tsx b/src/app/claude/workspaces/[workspaceId]/page.tsx index 5c831681..e1a1ebf3 100644 --- a/src/app/claude/workspaces/[workspaceId]/page.tsx +++ b/src/app/claude/workspaces/[workspaceId]/page.tsx @@ -54,7 +54,7 @@ export default async function WorkspaceDetailPage({ params }: PageProps) { }} aria-hidden /> -

{detail.workspace.name}

+

{detail.workspace.name}

{detail.workspace.isDefault && ( Default )} diff --git a/src/app/claude/workspaces/[workspaceId]/workspace-detail-client.tsx b/src/app/claude/workspaces/[workspaceId]/workspace-detail-client.tsx index dd9ac701..d94d002a 100644 --- a/src/app/claude/workspaces/[workspaceId]/workspace-detail-client.tsx +++ b/src/app/claude/workspaces/[workspaceId]/workspace-detail-client.tsx @@ -13,12 +13,13 @@ import { getWorkspaceDetail, setWorkspaceLimit, } from "@/actions/anthropic-global"; -import { toast } from "sonner"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import type { WorkspaceDetail } from "@/types"; import { AlertTriangle, TrendingDown, TrendingUp } from "lucide-react"; import { formatCurrency } from "@/lib/utils"; import { getDaysInMonth, parseISO } from "date-fns"; import { totalTileCaption } from "@/components/claude/today-estimate"; +import { InlineSpinner } from "@/components/ui/loading-state"; type Props = { workspaceIdParam: string; @@ -34,6 +35,7 @@ export function WorkspaceDetailClient({ workspaceIdParam, initial }: Props) { detail.limitCents != null ? String(detail.limitCents / 100) : "" ); const [savingLimit, savingTransition] = useTransition(); + const status = useInlineStatus(); function handleMonthChange(next: string) { setMonth(next); @@ -52,12 +54,12 @@ export function WorkspaceDetailClient({ workspaceIdParam, initial }: Props) { : Math.round(dollars * 100); const r = await setWorkspaceLimit(detail.workspace.id, cents); if (r.success) { - toast.success("Limit updated."); + status.ok("Limit updated"); setEditing(false); const refreshed = await getWorkspaceDetail(workspaceIdParam, month); if (refreshed) setDetail(refreshed); } else { - toast.error(`Failed: ${r.error}`); + status.error(r.error); } }); } @@ -75,11 +77,11 @@ export function WorkspaceDetailClient({ workspaceIdParam, initial }: Props) { detail.momDeltaPct === null ? ( — no spend last month ) : detail.momDeltaPct >= 0 ? ( - + +{detail.momDeltaPct}% vs prior month ) : ( - + {detail.momDeltaPct}% vs prior month ); @@ -165,11 +167,7 @@ export function WorkspaceDetailClient({ workspaceIdParam, initial }: Props) { onChange={handleMonthChange} months={detail.availableMonths.length > 0 ? detail.availableMonths : [month]} /> - {isPending && ( - - Loading… - - )} + {isPending && }
{editing ? (
@@ -190,6 +188,7 @@ export function WorkspaceDetailClient({ workspaceIdParam, initial }: Props) { +
{detail.workspace.isDefault && (

@@ -213,7 +212,6 @@ export function WorkspaceDetailClient({ workspaceIdParam, initial }: Props) { - {/* Language + Editor charts side by side */} -

- - - - - - - - - - - - - - - - - - -
- {/* Activity distribution chart */} - - - - - - - - - -
- ); + return ; } diff --git a/src/app/copilot/billing/loading.tsx b/src/app/copilot/billing/loading.tsx index b9f846e3..ec8c7fd6 100644 --- a/src/app/copilot/billing/loading.tsx +++ b/src/app/copilot/billing/loading.tsx @@ -1,42 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; +import { LoadingState } from "@/components/ui/loading-state"; export default function CopilotBillingLoading() { - return ( -
- {/* KPI cards skeleton */} -
- {Array.from({ length: 4 }).map((_, i) => ( - - - - - - - - - ))} -
- {/* Billing trend chart skeleton */} - - - - - - - - - - {/* Cost utilization chart skeleton */} - - - - - - - - - -
- ); + return ; } diff --git a/src/app/copilot/billing/page.tsx b/src/app/copilot/billing/page.tsx index a302ae16..feaf91df 100644 --- a/src/app/copilot/billing/page.tsx +++ b/src/app/copilot/billing/page.tsx @@ -42,7 +42,7 @@ export default async function CopilotBillingPage() {
{/* Header */}
-

Billing Overview

+

Billing Overview

{/* KPI Cards */} @@ -51,25 +51,25 @@ export default async function CopilotBillingPage() { Current Month Cost -
{formatCurrency(currentMonth.totalCostCents)}
+
{formatCurrency(currentMonth.totalCostCents)}
Cumulative Cost -
{formatCurrency(data.cumulativeCostCents)}
+
{formatCurrency(data.cumulativeCostCents)}
Cost / Active User -
{formatCurrency(currentMonth.costPerActiveUserCents)}
+
{formatCurrency(currentMonth.costPerActiveUserCents)}
Plan -
{currentMonth.planType}

{currentMonth.totalSeats} total / {currentMonth.activeSeats} active seats

+
{currentMonth.planType}

{currentMonth.totalSeats} total / {currentMonth.activeSeats} active seats

diff --git a/src/app/copilot/layout.tsx b/src/app/copilot/layout.tsx index b91f8993..e2c9baa6 100644 --- a/src/app/copilot/layout.tsx +++ b/src/app/copilot/layout.tsx @@ -15,7 +15,7 @@ export default async function CopilotLayout({ return (
-

Copilot

+

Copilot

GitHub Copilot usage analytics, seat management, and billing insights.

diff --git a/src/app/copilot/loading.tsx b/src/app/copilot/loading.tsx index 5787ef21..60d15c69 100644 --- a/src/app/copilot/loading.tsx +++ b/src/app/copilot/loading.tsx @@ -1,32 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; +import { LoadingState } from "@/components/ui/loading-state"; export default function CopilotOverviewLoading() { - return ( -
- {/* KPI cards skeleton */} -
- {Array.from({ length: 5 }).map((_, i) => ( - - - - - - - - - ))} -
- {/* Chart skeleton */} - - - - - - - - - -
- ); + return ; } diff --git a/src/app/copilot/seats/[userId]/page.tsx b/src/app/copilot/seats/[userId]/page.tsx index ec675c9d..27997674 100644 --- a/src/app/copilot/seats/[userId]/page.tsx +++ b/src/app/copilot/seats/[userId]/page.tsx @@ -62,7 +62,7 @@ export default async function CopilotSeatDetailPage({ {seat.matchedUserName} ) : ( - Unmatched + Unmatched )}
diff --git a/src/app/copilot/seats/loading.tsx b/src/app/copilot/seats/loading.tsx index 120db6fe..004f52b3 100644 --- a/src/app/copilot/seats/loading.tsx +++ b/src/app/copilot/seats/loading.tsx @@ -1,33 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; +import { LoadingState } from "@/components/ui/loading-state"; export default function CopilotSeatsLoading() { - return ( - - - - - - - {/* Table header */} -
- - - - - -
- {/* Table rows */} - {Array.from({ length: 5 }).map((_, i) => ( -
- - - - - -
- ))} -
-
- ); + return ; } diff --git a/src/app/globals.css b/src/app/globals.css index 9ed3eef4..685d5b92 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,6 +4,17 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* ============================================================================= + NOTHING DESIGN SYSTEM — token layer for AI Developer Hub (spec 028). + shadcn semantic var names are kept and re-pointed onto Nothing values so every + primitive keeps resolving. Light is the :root default (printed-manual canvas), + dark is the .dark override (instrument panel) — next-themes attribute="class". + + Collision note: shadcn's --accent is a NEUTRAL hover background, NOT the loud + Nothing red. The single red interrupt + all error/destructive states route + through --destructive (#d71921). Status greens/ambers are value-text-only. + ============================================================================= */ + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -12,6 +23,13 @@ --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); --radius-4xl: calc(var(--radius) + 16px); + + /* Three-family Nothing stack, chained to the next/font-injected variables + (font-specific names dodge a circular --font-sans: var(--font-sans)). */ + --font-sans: var(--font-space-grotesk), "DM Sans", system-ui, sans-serif; + --font-mono: var(--font-space-mono), "JetBrains Mono", "SF Mono", ui-monospace, monospace; + --font-display: var(--font-doto), var(--font-space-mono), monospace; + --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -27,6 +45,7 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-destructive-subtle: var(--destructive-subtle); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); @@ -43,75 +62,113 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + + /* Nothing extras shadcn lacks: 100% ink, 40% faint, status, segmented track. */ + --color-ink: var(--ink); + --color-faint: var(--faint); + --color-success: var(--success); + --color-warning: var(--warning); + --color-seg-empty: var(--seg-empty); + --color-border-strong: var(--input); + --color-interactive: var(--interactive); } +/* --------------------------------------------------------------------------- + LIGHT — :root default. Nothing "printed manual". + --------------------------------------------------------------------------- */ :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.78 0.19 120); - --primary-foreground: oklch(0.18 0 0); - --secondary: oklch(0.96 0 0); - --secondary-foreground: oklch(0.20 0 0); - --muted: oklch(0.96 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.95 0.03 120); - --accent-foreground: oklch(0.20 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.91 0 0); - --input: oklch(0.91 0 0); - --ring: oklch(0.78 0.19 120); - --chart-1: oklch(0.78 0.19 120); - --chart-2: oklch(0.55 0.15 250); - --chart-3: oklch(0.65 0.18 45); - --chart-4: oklch(0.50 0.15 300); - --chart-5: oklch(0.60 0.12 180); - --sidebar: oklch(0.98 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.78 0.19 120); - --sidebar-primary-foreground: oklch(0.18 0 0); - --sidebar-accent: oklch(0.96 0 0); - --sidebar-accent-foreground: oklch(0.20 0 0); - --sidebar-border: oklch(0.91 0 0); - --sidebar-ring: oklch(0.78 0.19 120); + --radius: 0.5rem; /* technical scale; cards override to 14px in card.tsx */ + + --background: #f5f5f5; /* Nothing --black (light canvas) */ + --foreground: #1a1a1a; /* --text-primary */ + --card: #ffffff; /* --surface */ + --card-foreground: #1a1a1a; + --popover: #f0f0f0; /* --surface-raised (dropdowns/popovers) */ + --popover-foreground: #1a1a1a; + --primary: #000000; /* --text-display — greyscale fill, NOT a hue */ + --primary-foreground: #f5f5f5; /* --black */ + --secondary: #f0f0f0; /* --surface-raised */ + --secondary-foreground: #1a1a1a; + --muted: #ffffff; /* --surface */ + --muted-foreground: #666666; /* --text-secondary */ + --accent: #f0f0f0; /* shadcn hover bg — NEUTRAL surface-raised */ + --accent-foreground: #1a1a1a; + --destructive: #d71921; /* THE one interrupt + all errors */ + --border: #e8e8e8; /* --border (dividers) */ + --input: #cccccc; /* --border-visible (interactive edges) */ + --ring: #d71921; /* focus = red, passes both modes */ + --chart-1: #1a1a1a; + --chart-2: #555555; + --chart-3: #888888; + --chart-4: #aaaaaa; + --chart-5: #c8c8c8; + --sidebar: #f5f5f5; /* sidebar == canvas */ + --sidebar-foreground: #1a1a1a; + --sidebar-primary: #000000; + --sidebar-primary-foreground: #f5f5f5; + --sidebar-accent: #f0f0f0; + --sidebar-accent-foreground: #000000; + --sidebar-border: #e8e8e8; + --sidebar-ring: #d71921; + + /* Nothing extras */ + --ink: #000000; /* --text-display (100%) */ + --faint: #999999; /* --text-disabled (40%) */ + --success: #2e7d32; /* darkened for ≥4.5:1 value text on light (see notes) */ + --warning: #835f00; /* darkened for ≥4.5:1 value text on light canvas (see notes) */ + --seg-empty: #e0e0e0; + --destructive-subtle: rgba(215, 25, 33, 0.10); + --accent-subtle: rgba(215, 25, 33, 0.10); + --interactive: #007aff; + + --font-ui: var(--font-sans); /* mockup-vocabulary alias */ } +/* --------------------------------------------------------------------------- + DARK — .dark override. Nothing "instrument panel" (OLED black). + --------------------------------------------------------------------------- */ .dark { - --background: oklch(0.16 0 0); - --foreground: oklch(0.97 0 0); - --card: oklch(0.21 0 0); - --card-foreground: oklch(0.98 0 0); - --popover: oklch(0.21 0 0); - --popover-foreground: oklch(0.98 0 0); - --primary: oklch(0.78 0.19 120); - --primary-foreground: oklch(0.18 0 0); - --secondary: oklch(0.25 0 0); - --secondary-foreground: oklch(0.98 0 0); - --muted: oklch(0.25 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.25 0.03 120); - --accent-foreground: oklch(0.98 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.78 0.19 120); - --chart-1: oklch(0.80 0.19 120); - --chart-2: oklch(0.65 0.17 250); - --chart-3: oklch(0.72 0.18 45); - --chart-4: oklch(0.62 0.16 300); - --chart-5: oklch(0.68 0.14 180); - --sidebar: oklch(0.21 0 0); - --sidebar-foreground: oklch(0.98 0 0); - --sidebar-primary: oklch(0.78 0.19 120); - --sidebar-primary-foreground: oklch(0.18 0 0); - --sidebar-accent: oklch(0.25 0 0); - --sidebar-accent-foreground: oklch(0.98 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.78 0.19 120); + --background: #000000; + --foreground: #e8e8e8; + --card: #111111; + --card-foreground: #e8e8e8; + --popover: #1a1a1a; + --popover-foreground: #e8e8e8; + --primary: #ffffff; + --primary-foreground: #000000; + --secondary: #1a1a1a; + --secondary-foreground: #e8e8e8; + --muted: #111111; + --muted-foreground: #999999; + --accent: #1a1a1a; /* neutral hover */ + --accent-foreground: #e8e8e8; + --destructive: #d71921; + --border: #222222; + --input: #333333; + --ring: #d71921; + --chart-1: #ffffff; + --chart-2: #cccccc; + --chart-3: #999999; + --chart-4: #666666; + --chart-5: #444444; + --sidebar: #000000; + --sidebar-foreground: #e8e8e8; + --sidebar-primary: #ffffff; + --sidebar-primary-foreground: #000000; + --sidebar-accent: #1a1a1a; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: #222222; + --sidebar-ring: #d71921; + + /* Nothing extras (spec values pass on black) */ + --ink: #ffffff; + --faint: #666666; + --success: #4a9e5c; + --warning: #d4a843; + --seg-empty: #222222; + --destructive-subtle: rgba(215, 25, 33, 0.15); + --accent-subtle: rgba(215, 25, 33, 0.15); + --interactive: #5b9bf6; } @layer base { @@ -119,10 +176,50 @@ @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground font-sans antialiased; } } +/* --------------------------------------------------------------------------- + Namespaced Nothing helpers (nd-*) — collision-safe globals for idioms that + are awkward as pure Tailwind utilities. Component classes live as React + components; only these few mechanical bits are global. + --------------------------------------------------------------------------- */ +@layer components { + /* Dot-matrix motif — subtle background texture for empty/auth/hero surfaces. */ + .nd-dot-grid { + background-image: radial-gradient( + circle, + var(--input) 1px, + transparent 1px + ); + background-size: 16px 16px; + } + + /* Hardware-style segmented spinner — replaces skeleton shimmer / animate-pulse. */ + .nd-seg-spinner { + display: inline-flex; + gap: 3px; + align-items: center; + } + .nd-seg-spinner > i { + width: 4px; + height: 12px; + background: var(--ink); + opacity: 0.2; + animation: nd-seg-pulse 1.1s cubic-bezier(0.25, 0.1, 0.25, 1) infinite; + } + .nd-seg-spinner > i:nth-child(2) { animation-delay: 0.12s; } + .nd-seg-spinner > i:nth-child(3) { animation-delay: 0.24s; } + .nd-seg-spinner > i:nth-child(4) { animation-delay: 0.36s; } + .nd-seg-spinner > i:nth-child(5) { animation-delay: 0.48s; } +} + +@keyframes nd-seg-pulse { + 0%, 100% { opacity: 0.18; } + 45% { opacity: 1; } +} + /* ── Reduced Motion Kill-Switch ── */ @media (prefers-reduced-motion: reduce) { diff --git a/src/app/invoices/bulk/bulk-upload-form.tsx b/src/app/invoices/bulk/bulk-upload-form.tsx index 5038015a..4016e056 100644 --- a/src/app/invoices/bulk/bulk-upload-form.tsx +++ b/src/app/invoices/bulk/bulk-upload-form.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useRef, useMemo, useCallback } from "react"; -import { toast } from "sonner"; import { Loader2, Upload, ArrowLeft, AlertTriangle } from "lucide-react"; import { useReactTable, @@ -27,6 +26,7 @@ import { type BulkSaveOutcome, } from "@/actions/invoices"; import { cn } from "@/lib/utils"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; // --------------------------------------------------------------------------- // Types @@ -121,6 +121,7 @@ export function BulkUploadForm() { const [rows, setRows] = useState([]); const [outcomes, setOutcomes] = useState([]); const [errorMessage, setErrorMessage] = useState(null); + const status = useInlineStatus(); // ---- row mutation helper ---- const updateRow = useCallback( @@ -154,7 +155,7 @@ export function BulkUploadForm() { const dup = row.original.duplicateType; if (dup === "db-duplicate") { return ( - + Duplicate — will be skipped @@ -162,7 +163,7 @@ export function BulkUploadForm() { } if (dup === "within-batch-duplicate") { return ( - + Within-batch duplicate — will be skipped @@ -183,7 +184,7 @@ export function BulkUploadForm() { onChange={(e) => updateRow(row.index, "invoiceNumber", e.target.value) } - className={cn(lowConf && "border-amber-500")} + className={cn(lowConf && "border-warning")} aria-label={`Invoice number for ${row.original.filename}`} disabled={isDuplicate} /> @@ -202,7 +203,7 @@ export function BulkUploadForm() { onChange={(e) => updateRow(row.index, "invoiceDate", e.target.value) } - className={cn(lowConf && "border-amber-500")} + className={cn(lowConf && "border-warning")} aria-label={`Invoice date for ${row.original.filename}`} disabled={isDuplicate} /> @@ -224,7 +225,7 @@ export function BulkUploadForm() { onChange={(e) => updateRow(row.index, "amountDollars", e.target.value) } - className={cn(lowConf && "border-amber-500")} + className={cn(lowConf && "border-warning")} aria-label={`Amount in dollars for ${row.original.filename}`} disabled={isDuplicate} /> @@ -242,7 +243,7 @@ export function BulkUploadForm() { updateRow(row.index, "vendor", e.target.value)} - className={cn(lowOrNull && "border-amber-500")} + className={cn(lowOrNull && "border-warning")} aria-label={`Vendor for ${row.original.filename}`} disabled={isDuplicate} /> @@ -270,7 +271,7 @@ export function BulkUploadForm() { // ---- upload handler ---- async function handleUpload(file: File) { if (file.size > MAX_FILE_SIZE) { - toast.error("File too large. Maximum size is 50 MB."); + status.error("File too large (max 50 MB)"); return; } @@ -342,14 +343,12 @@ export function BulkUploadForm() { skipped.length > 0 ? ` (${skipped.length} non-PDF files skipped)` : ""; - toast.success( - `Extracted ${drafts.length} invoice(s). Please review.${skippedMsg}`, - ); + status.ok(`Extracted ${drafts.length} — review${skippedMsg}`); } catch (err) { const message = err instanceof Error ? err.message : "Upload failed"; setErrorMessage(message); setState("error"); - toast.error(message); + status.error(message); } } @@ -366,9 +365,7 @@ export function BulkUploadForm() { (!r.invoiceNumber || !r.invoiceDate || !r.amountDollars), ); if (incomplete.length > 0) { - toast.error( - `${incomplete.length} row(s) have missing required fields.`, - ); + status.error(`${incomplete.length} row(s) missing fields`); setState("reviewing"); return; } @@ -394,12 +391,12 @@ export function BulkUploadForm() { setOutcomes(result.data); setState("done"); - toast.success("Invoices saved successfully."); + status.ok("Saved"); } catch (err) { const message = err instanceof Error ? err.message : "Save failed"; setErrorMessage(message); setState("error"); - toast.error(message); + status.error(message); } } @@ -442,6 +439,7 @@ export function BulkUploadForm() { Upload Zip +
)} @@ -507,7 +505,10 @@ export function BulkUploadForm() {
- +
+ + +
)} @@ -528,7 +529,7 @@ export function BulkUploadForm() { {savedOutcomes.length} invoice(s) saved {skippedOutcomes.length > 0 && ( - + {skippedOutcomes.length} invoice(s) skipped (duplicate) )} @@ -580,7 +581,7 @@ export function BulkUploadForm() { - + Skipped — {outcome.skipReason === "within-batch-duplicate" ? "within-batch duplicate" diff --git a/src/app/invoices/bulk/page.tsx b/src/app/invoices/bulk/page.tsx index b1461589..b31b9d22 100644 --- a/src/app/invoices/bulk/page.tsx +++ b/src/app/invoices/bulk/page.tsx @@ -8,7 +8,7 @@ export default async function BulkUploadPage() { return (
-

Bulk Upload

+

Bulk Upload

Upload a ZIP file containing PDF invoices. Each PDF will be extracted and parsed automatically so you can review the results before saving. diff --git a/src/app/invoices/loading.tsx b/src/app/invoices/loading.tsx index 9011da02..fc3e0c42 100644 --- a/src/app/invoices/loading.tsx +++ b/src/app/invoices/loading.tsx @@ -1,37 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; +import { LoadingState } from "@/components/ui/loading-state"; export default function InvoicesLoading() { - return ( -

-
- - -
- - -
- - - - - -
-
- -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - - - - -
- ))} -
-
-
-
- ); + return ; } diff --git a/src/app/invoices/new/invoice-upload-form.tsx b/src/app/invoices/new/invoice-upload-form.tsx index 2127e1d1..dbf64cd2 100644 --- a/src/app/invoices/new/invoice-upload-form.tsx +++ b/src/app/invoices/new/invoice-upload-form.tsx @@ -4,7 +4,6 @@ import { useState, useRef, useCallback } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { Upload, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -35,6 +34,7 @@ import { overwriteInvoice, } from "@/actions/invoices"; import { cn, formatCurrency } from "@/lib/utils"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import type { UseFormRegisterReturn } from "react-hook-form"; type UploadState = "idle" | "uploading" | "extracting" | "extracted" | "error"; @@ -118,7 +118,7 @@ function ConfidenceInput({ id={id} type={type} {...mergedRegisterProps} - className={cn(low ? "border-amber-400" : "")} + className={cn(low ? "border-warning" : "")} placeholder={placeholder} step={step} min={min} @@ -141,6 +141,8 @@ export function InvoiceUploadForm() { const [duplicateInfo, setDuplicateInfo] = useState(null); const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [isOverwriting, setIsOverwriting] = useState(false); + const status = useInlineStatus(); + const dupStatus = useInlineStatus(); const { register, @@ -177,7 +179,7 @@ export function InvoiceUploadForm() { if (!file) return; if (file.type !== "application/pdf") { - toast.error("Only PDF files are accepted."); + status.error("PDF only"); return; } @@ -207,7 +209,7 @@ export function InvoiceUploadForm() { const result = await extractInvoiceFieldsAction({ objectKey }); if (!result.success) { setUploadState("error"); - toast.error(result.error); + status.error(result.error); return; } @@ -233,7 +235,7 @@ export function InvoiceUploadForm() { } catch (err) { const message = err instanceof Error ? err.message : "Upload failed"; setUploadState("error"); - toast.error(message); + status.error(message); } }; @@ -249,7 +251,8 @@ export function InvoiceUploadForm() { setExtractionResult(null); setDuplicateInfo(null); setDuplicateDialogOpen(false); - toast.info("Upload skipped — duplicate invoice cancelled."); + dupStatus.clear(); + status.info("Skipped"); }; // T009: Overwrite existing action @@ -261,7 +264,7 @@ export function InvoiceUploadForm() { const formValues = watch(); const parsedAmount = parseFloat(amountDollars); if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { - toast.error("Please enter a valid positive amount before overwriting."); + dupStatus.error("Invalid amount"); setIsOverwriting(false); return; } @@ -278,20 +281,20 @@ export function InvoiceUploadForm() { }); if (!result.success) { - toast.error(result.error); + dupStatus.error(result.error); return; } + // Surface the budget-link caveat on the page-level status (the dialog is + // closing in `finally`) rather than losing it on navigation. if (result.linkWarning) { - toast.warning("Invoice overwritten.", { description: result.linkWarning }); - } else if (result.linkedPeriodLabel) { - toast.success(`Invoice overwritten. Linked to ${result.linkedPeriodLabel}.`); - } else { - toast.success("Invoice overwritten successfully."); + status.set("info", result.linkWarning, { autoClearMs: 0 }); + return; } + // Clean overwrite navigates to /invoices — navigation is the feedback. router.push("/invoices"); } catch { - toast.error("Failed to overwrite invoice."); + dupStatus.error("Overwrite failed"); } finally { setIsOverwriting(false); setDuplicateDialogOpen(false); @@ -313,15 +316,17 @@ export function InvoiceUploadForm() { const result = await saveInvoice(submitData); if (!result.success) { - toast.error(result.error); + status.error(result.error); return; } - if (result.linkWarning) { - toast.warning("Invoice saved.", { description: result.linkWarning }); - } else if (result.linkedPeriodLabel) { - toast.success(`Invoice saved. Linked to ${result.linkedPeriodLabel}.`); - } else { - toast.success("Invoice saved to archive."); + // Surface budget-link / filter caveats inline (the toast that used to carry + // these was removed in the redesign) and stay on the page so the notice is + // read instead of lost on navigation. A clean save navigates — navigation + // is the feedback. + const notice = result.linkWarning ?? result.filterWarning; + if (notice) { + status.set("info", notice, { autoClearMs: 0 }); + return; } router.push("/invoices"); }; @@ -365,6 +370,7 @@ export function InvoiceUploadForm() { className="hidden" onChange={handleFileChange} /> + {!showForm && }
@@ -419,7 +425,7 @@ export function InvoiceUploadForm() { confidence?.amountCents, amountDollars || undefined ) - ? "border-amber-400" + ? "border-warning" : "" )} /> @@ -447,16 +453,19 @@ export function InvoiceUploadForm() { error={errors.vendor?.message} /> - +
+ + +
)} @@ -489,7 +498,8 @@ export function InvoiceUploadForm() {
- + + Skip (Cancel Upload) diff --git a/src/app/invoices/new/page.tsx b/src/app/invoices/new/page.tsx index f1c948e4..1ad118f7 100644 --- a/src/app/invoices/new/page.tsx +++ b/src/app/invoices/new/page.tsx @@ -9,7 +9,7 @@ export default async function InvoicesNewPage() { return (
-

Upload Invoice

+

Upload Invoice

Upload a PDF invoice to auto-extract and archive it.

diff --git a/src/app/invoices/page.tsx b/src/app/invoices/page.tsx index 953e0fad..b3a463ec 100644 --- a/src/app/invoices/page.tsx +++ b/src/app/invoices/page.tsx @@ -30,7 +30,7 @@ export default async function InvoicesPage() { return (
-

Invoices

+

Invoices

diff --git a/src/app/requests/[id]/completion-dialog.tsx b/src/app/requests/[id]/completion-dialog.tsx index fcb7802d..19cd4c1f 100644 --- a/src/app/requests/[id]/completion-dialog.tsx +++ b/src/app/requests/[id]/completion-dialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, useTransition } from "react"; import { format } from "date-fns"; -import { toast } from "sonner"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { Dialog, DialogContent, @@ -57,6 +57,7 @@ export function CompletionDialog({ const [assignedAt, setAssignedAt] = useState(today); const [bodyMd, setBodyMd] = useState(""); const [pending, startTransition] = useTransition(); + const status = useInlineStatus(); // Reset when dialog opens useEffect(() => { @@ -73,7 +74,7 @@ export function CompletionDialog({ function handleAdvance() { if (!selectedTier) { - toast.error("Select a tier"); + status.error("Select a tier"); return; } // Render the template with the just-entered values bound. @@ -94,11 +95,10 @@ export function CompletionDialog({ bodyMd, }); if (result.success) { - toast.success("Completion sent — assignment created"); onOpenChange(false); onSuccess(); } else { - toast.error(result.error); + status.error(result.error); } }); } @@ -202,6 +202,7 @@ export function CompletionDialog({ ? "An assignment row will be created on send." : "Posts to channel + group chat. Completion is terminal."}

+ {step === 1 ? ( <>
+ diff --git a/src/app/requests/[id]/request-detail-client.tsx b/src/app/requests/[id]/request-detail-client.tsx index 5aa6d4e1..aaeede35 100644 --- a/src/app/requests/[id]/request-detail-client.tsx +++ b/src/app/requests/[id]/request-detail-client.tsx @@ -3,11 +3,20 @@ import { Fragment, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { formatDistanceToNow, format } from "date-fns"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Card, CardContent, @@ -58,22 +67,23 @@ export function RequestDetailClient({ approver, }: Props) { const router = useRouter(); + const status = useInlineStatus(); const [approveOpen, setApproveOpen] = useState(false); const [completeOpen, setCompleteOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false); + const [cancelOpen, setCancelOpen] = useState(false); const isPending = detail.status === "pending_review"; const isApproved = detail.status === "approved"; const canCancel = isPending || isApproved; async function handleCancel() { - if (!confirm("Cancel this request? Any pending approver activity will be discarded.")) return; const result = await cancelRequest({ requestId: detail.id }); if (result.success) { - toast.success("Request cancelled"); + setCancelOpen(false); router.refresh(); } else { - toast.error(result.error); + status.error(result.error); } } @@ -87,7 +97,7 @@ export function RequestDetailClient({ Back to requests
-

+

Request REQ-{String(detail.id).padStart(3, "0")}

@@ -199,9 +209,10 @@ export function RequestDetailClient({

Any admin can act on this request. First-write-wins.

-
+
+ {canCancel && ( - )} @@ -220,6 +231,24 @@ export function RequestDetailClient({
)} + + + + Cancel this request? + + Any pending approver activity will be discarded. + + + + + Keep request + + + + + {isPending && ( <>
-

License requests

+

License requests

Review and action license requests routed from Microsoft Forms via Power Automate. Any admin can claim any request — first to act wins. diff --git a/src/app/settings/api-preview/page.tsx b/src/app/settings/api-preview/page.tsx index 7763b5ad..606f3931 100644 --- a/src/app/settings/api-preview/page.tsx +++ b/src/app/settings/api-preview/page.tsx @@ -12,7 +12,7 @@ export default async function ApiPreviewPage() { return (

-

API Preview

+

API Preview

Test the profile API endpoint and inspect responses.

diff --git a/src/app/settings/appearance/page.tsx b/src/app/settings/appearance/page.tsx index e0ab1efc..0e43a57b 100644 --- a/src/app/settings/appearance/page.tsx +++ b/src/app/settings/appearance/page.tsx @@ -23,7 +23,7 @@ export default function AppearancePage() { return (
-

Appearance

+

Appearance

Customize the look and feel of the application.

diff --git a/src/app/settings/ingestion/ingestion-filters-section.tsx b/src/app/settings/ingestion/ingestion-filters-section.tsx index 186cec3b..577c6c01 100644 --- a/src/app/settings/ingestion/ingestion-filters-section.tsx +++ b/src/app/settings/ingestion/ingestion-filters-section.tsx @@ -2,8 +2,8 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { Plus, Trash2, Pencil, Filter } from "lucide-react"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -24,6 +24,15 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Table, TableBody, @@ -101,6 +110,11 @@ export function IngestionFiltersSection({ const [form, setForm] = useState(defaultForm); const [isPending, startTransition] = useTransition(); const [patternError, setPatternError] = useState(null); + const [deleteTarget, setDeleteTarget] = useState<{ + id: number; + name: string; + } | null>(null); + const status = useInlineStatus(); function openCreate() { setEditingId(null); @@ -154,11 +168,10 @@ export function IngestionFiltersSection({ }); if (result.success) { - toast.success(editingId ? "Filter updated" : "Filter created"); setDialogOpen(false); router.refresh(); } else { - toast.error(result.error); + status.error(result.error); } }); } @@ -169,22 +182,21 @@ export function IngestionFiltersSection({ if (result.success) { router.refresh(); } else { - toast.error(result.error); + status.error(result.error); } }); } - function handleDelete(id: number, name: string) { - if (!confirm(`Delete filter "${name}"? Previously filtered invoices will remain unchanged.`)) { - return; - } + function confirmDelete() { + if (!deleteTarget) return; startTransition(async () => { - const result = await deleteIngestionFilter(id); + const result = await deleteIngestionFilter(deleteTarget.id); if (result.success) { - toast.success("Filter deleted"); + status.ok("Deleted"); + setDeleteTarget(null); router.refresh(); } else { - toast.error(result.error); + status.error(result.error); } }); } @@ -198,10 +210,13 @@ export function IngestionFiltersSection({ Rules that control which invoices are linked to budget periods.

- +
+ + +
{filters.length === 0 ? ( @@ -274,7 +289,7 @@ export function IngestionFiltersSection({ variant="ghost" size="icon" className="size-8 text-destructive" - onClick={() => handleDelete(f.id, f.name)} + onClick={() => setDeleteTarget({ id: f.id, name: f.name })} disabled={isPending} > @@ -411,7 +426,8 @@ export function IngestionFiltersSection({
- + + + + +
); } diff --git a/src/app/settings/ingestion/page.tsx b/src/app/settings/ingestion/page.tsx index fe61b67c..1dd78126 100644 --- a/src/app/settings/ingestion/page.tsx +++ b/src/app/settings/ingestion/page.tsx @@ -21,7 +21,7 @@ export default async function IngestionSettingsPage() { return (
-

Ingestion

+

Ingestion

Manage filter rules and view the history of all ingested billing documents. diff --git a/src/app/settings/integrations/github-integration-client.tsx b/src/app/settings/integrations/github-integration-client.tsx index 84af13f5..aec290a7 100644 --- a/src/app/settings/integrations/github-integration-client.tsx +++ b/src/app/settings/integrations/github-integration-client.tsx @@ -2,8 +2,8 @@ import { useState, useTransition } from "react"; import { Github, Unplug, KeyRound } from "lucide-react"; -import { toast } from "sonner"; import { formatDate } from "@/lib/utils"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { Button } from "@/components/ui/button"; import { Card, @@ -57,6 +57,7 @@ interface Props { export function GitHubIntegrationClient({ initialConnection }: Props) { const [connection, setConnection] = useState(initialConnection); const [isPending, startTransition] = useTransition(); + const status = useInlineStatus(); // Token validation state const [token, setToken] = useState(""); @@ -79,9 +80,9 @@ export function GitHubIntegrationClient({ initialConnection }: Props) { if (result.data.organizations.length === 1) { setSelectedOrg(result.data.organizations[0].login); } - toast.success(`Token valid. Found ${result.data.organizations.length} organization(s).`); + status.ok(`Token valid · ${result.data.organizations.length} org(s)`); } else { - toast.error(result.error); + status.error(result.error); setIsValidated(false); setOrgs([]); } @@ -110,9 +111,9 @@ export function GitHubIntegrationClient({ initialConnection }: Props) { setToken(""); setIsValidated(false); setOrgs([]); - toast.success(`Connected to ${org.login}`); + status.ok(`Connected to ${org.login}`); } else { - toast.error(result.error); + status.error(result.error); } }); } @@ -122,9 +123,9 @@ export function GitHubIntegrationClient({ initialConnection }: Props) { const result = await disconnectGitHubOrg(); if (result.success) { setConnection(null); - toast.success("Disconnected. Enriched user data has been retained."); + status.ok("Disconnected"); } else { - toast.error(result.error); + status.error(result.error); } }); } @@ -135,9 +136,9 @@ export function GitHubIntegrationClient({ initialConnection }: Props) { if (result.success) { setShowUpdateToken(false); setUpdateTokenValue(""); - toast.success("Token updated successfully"); + status.ok("Token updated"); } else { - toast.error(result.error); + status.error(result.error); } }); } @@ -179,13 +180,16 @@ export function GitHubIntegrationClient({ initialConnection }: Props) {

- +
+ + +
{isValidated && orgs.length > 0 && (
@@ -281,6 +285,7 @@ export function GitHubIntegrationClient({ initialConnection }: Props) { +
{/* Update Token Form */} diff --git a/src/app/settings/integrations/page.tsx b/src/app/settings/integrations/page.tsx index 1a7e1287..5660cc8e 100644 --- a/src/app/settings/integrations/page.tsx +++ b/src/app/settings/integrations/page.tsx @@ -25,7 +25,7 @@ export default async function IntegrationsPage() { return (
-

Integrations

+

Integrations

Connect external services to enrich user data.

diff --git a/src/app/settings/license-templates/page.tsx b/src/app/settings/license-templates/page.tsx index a1e656eb..53b23b79 100644 --- a/src/app/settings/license-templates/page.tsx +++ b/src/app/settings/license-templates/page.tsx @@ -15,7 +15,7 @@ export default async function LicenseTemplatesPage() {
-

License Templates

+

License Templates

Approval and completion messages, customizable per tool and per tier. Tool defaults are inherited unless a tier override exists. diff --git a/src/app/settings/license-templates/template-editor-dialog.tsx b/src/app/settings/license-templates/template-editor-dialog.tsx index 5996dc43..384fc9df 100644 --- a/src/app/settings/license-templates/template-editor-dialog.tsx +++ b/src/app/settings/license-templates/template-editor-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useTransition, useMemo } from "react"; -import { toast } from "sonner"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { Dialog, DialogContent, @@ -59,6 +59,7 @@ const SAMPLE_CONTEXT: TemplateContext = { export function TemplateEditorDialog({ open, onOpenChange, state }: Props) { const [bodyMd, setBodyMd] = useState(state.bodyMd); const [pending, startTransition] = useTransition(); + const status = useInlineStatus(); // Build the actual sample context for THIS template (tool / tier names match). const ctx: TemplateContext = useMemo( @@ -83,10 +84,9 @@ export function TemplateEditorDialog({ open, onOpenChange, state }: Props) { bodyMd, }); if (result.success) { - toast.success("Template saved"); onOpenChange(false); } else { - toast.error(result.error); + status.error(result.error); } }); } @@ -96,10 +96,9 @@ export function TemplateEditorDialog({ open, onOpenChange, state }: Props) { startTransition(async () => { const result = await deleteMessageTemplate({ id: state.existingId! }); if (result.success) { - toast.success("Template deleted"); onOpenChange(false); } else { - toast.error(result.error); + status.error(result.error); } }); } @@ -169,10 +168,10 @@ export function TemplateEditorDialog({ open, onOpenChange, state }: Props) {

{missingVariables.length > 0 && ( -
- +
+
-

+

Unknown variable{missingVariables.length === 1 ? "" : "s"}

@@ -201,7 +200,8 @@ export function TemplateEditorDialog({ open, onOpenChange, state }: Props) { )}

-
+
+ diff --git a/src/app/settings/sync/github-member-sync-sheet.tsx b/src/app/settings/sync/github-member-sync-sheet.tsx index 0332397c..fcfa6133 100644 --- a/src/app/settings/sync/github-member-sync-sheet.tsx +++ b/src/app/settings/sync/github-member-sync-sheet.tsx @@ -34,7 +34,7 @@ import { UserPlus, SkipForward, } from "lucide-react"; -import { toast } from "sonner"; +import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { fetchGitHubSyncPreview, confirmGitHubSync, @@ -65,6 +65,7 @@ export function GitHubMemberSyncSheet({ const [loading, setLoading] = useState(false); const [confirming, setConfirming] = useState(false); const [error, setError] = useState(null); + const status = useInlineStatus(); // Resolution state: keyed by githubLogin const [resolutions, setResolutions] = useState< @@ -217,21 +218,13 @@ export function GitHubMemberSyncSheet({ newUsers, }); if (result.success) { - const d = result.data; - const parts: string[] = []; - if (d.enrichedCount > 0) parts.push(`${d.enrichedCount} enriched`); - if (d.importedCount > 0) parts.push(`${d.importedCount} imported`); - if (d.manuallyMatchedCount > 0) - parts.push(`${d.manuallyMatchedCount} matched`); - if (d.createdCount > 0) parts.push(`${d.createdCount} created`); - if (d.skippedCount > 0) parts.push(`${d.skippedCount} skipped`); - toast.success(`Sync complete: ${parts.join(", ")}`); + // Success closes the sheet — the navigation is the feedback. onOpenChange(false); } else { - toast.error(result.error); + status.error(result.error); } } catch { - toast.error("Failed to confirm sync"); + status.error("Sync failed"); } finally { setConfirming(false); } @@ -354,7 +347,8 @@ export function GitHubMemberSyncSheet({ )} {/* Confirm / Cancel */} -
+
+