From a4a81d42397fba17fc39b562b23c4eb9c506877f Mon Sep 17 00:00:00 2001 From: Ashutosh Shrivastava Date: Sat, 25 Jul 2026 12:19:24 +0530 Subject: [PATCH 1/3] Rebuild the UI on flat surfaces and end dark-mode banding Redesigns the deck, HUD and overlays on a proper token system, and removes the stacked gradient washes that made large dark areas show contour lines. The banding was 8-bit quantization, not a rendering bug: sRGB keeps very few code levels near black, so a soft gradient spread across ~900px has nothing to put between its steps and renders as wide flat plateaus with hard 1-level edges. It only showed in the dim states because the aurora and glow were dimmed there, leaving the undimmed near-flat base ramp as the only structure on screen. - Lift the palette off near-black (#030509 -> #0b111c). One code step at value 7 is a ~14% brightness jump; at value 21 it is ~5% and stops reading as a line. - Replace the six-wash drifting aurora, the full-window vignette and the window-sized deck and boot gradients with flat fills. A gradient that crosses more pixels than it has code levels is not a gradient, it is plateaus. - Drop the nested 1px borders on panels, bubbles and cards. Depth now comes from one step of fill tone plus spacing; edges are kept only for floating menus. - Retune tokens into type, space, radius and elevation scales, and fix the reactive accent so ambient light tracks the orb state. Also replaces the dead View > Toggle Full Screen menu item (a no-op on this frameless window) with Toggle Glass HUD, so the HUD has a menu-bar way out. Records the approach in the README and enforces it for future work via a Cursor rule scoped to src/styles. Co-authored-by: Cursor --- .cursor/rules/dark-theme-no-banding.mdc | 49 ++ README.md | 71 ++- electron/main.mjs | 18 +- src/App.tsx | 11 +- src/components/BootSequence.tsx | 8 +- src/components/CenterStage.tsx | 15 +- src/components/CommsPanel.tsx | 6 +- src/components/HudShell.tsx | 31 +- src/components/ReactorCore.tsx | 37 +- src/components/WorkCard.tsx | 4 + src/components/WorkStream.tsx | 6 +- src/styles/base.css | 210 +++---- src/styles/brain.css | 143 +++-- src/styles/deck.css | 746 +++++++++++++++--------- src/styles/fx.css | 33 +- src/styles/hud.css | 371 ++++++++---- src/styles/overlays.css | 558 +++++++++--------- src/styles/tokens.css | 196 ++++++- 18 files changed, 1602 insertions(+), 911 deletions(-) create mode 100644 .cursor/rules/dark-theme-no-banding.mdc diff --git a/.cursor/rules/dark-theme-no-banding.mdc b/.cursor/rules/dark-theme-no-banding.mdc new file mode 100644 index 0000000..b5e8d95 --- /dev/null +++ b/.cursor/rules/dark-theme-no-banding.mdc @@ -0,0 +1,49 @@ +--- +description: Dark-theme rules that prevent 8-bit colour banding (contour lines) +globs: src/styles/**/*.css +alwaysApply: false +--- + +# Dark surfaces must not band + +Contour lines in a dark UI come from 8-bit quantization: sRGB has very few code +levels near black, so a wide soft gradient has nothing to put between its steps +and renders as flat plateaus with hard edges. Below the sRGB toe brightness is +proportional to code value, so **one step at value 7 is a ~14% brightness jump; +at value 21 it is ~5%** and stops reading as a line. Full explanation in +`README.md` → "Dark UI without colour banding". + +## Rules + +1. **A gradient that crosses more pixels than it has code levels is not a + gradient — it is plateaus with visible edges.** Use a flat fill. +2. **Never take a surface below `--bg-0` (`#0b111c`).** Vignettes, scrims and + overlays must land above that floor too, or they drag their region back into + the crush zone. +3. **Gradients on dark surfaces must be small and steep** — a 40px button fade is + fine, a 900px wash is not. +4. **Depth = one step of fill tone + spacing.** Not washes, not outlines. Surfaces + nest by tone (background → panel → card) and carry no border; borders are only + for things that float (menus, popovers). +5. **One light source.** Only the reactor canvas glows. Note the trap: on a flat + background a lone soft halo becomes the only brightness variation, which is + exactly what vignetting looks like. + +```css +/* ❌ BAD — 2 levels of green stretched over 860px, plus window-sized washes */ +background: + radial-gradient(92% 78% at 50% 30%, rgba(18, 36, 68, 0.55), transparent 74%), + linear-gradient(180deg, #05070d 0%, #030509 100%); + +/* ✅ GOOD */ +background: var(--bg-1); +``` + +## Do not "fix" banding with these + +- **Noise/dither overlays.** Cannot remove a staircase already baked into a + rasterized layer, and `backdrop-filter` averages any dither beneath it away, so + banding returns inside blurred glass. +- **`mix-blend-mode: overlay`.** Resolves to `2 × backdrop × source` below + mid-grey, so on dark surfaces its effect is a fraction of one code level. +- **More gradient stops.** The limit is quantization, not stop count. diff --git a/README.md b/README.md index 0521e51..52d5352 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ When Hermes finishes a background task, Iris **proactively speaks up**: *"Quick ### Design -- **Deep-space design system** — electric cyan + violet on near-black, glass panels, Inter / Space Grotesk / JetBrains Mono +- **Deep-space design system** — electric cyan + violet on flat deep navy (deliberately *not* near-black — see [below](#dark-ui-without-colour-banding)), glass panels, Inter / Space Grotesk / JetBrains Mono - **The orb** — a canvas-drawn arc reactor that breathes with the actual audio level, changes palette per state (listening / speaking / working), and flashes when work is handed off - **Orb micro-expressions** — a double-pulse on wake, a soft ripple when your words are locked in, and an orbiting "thinking" swirl in the gap before Iris speaks - **Two voice signatures** — your voice renders as sharp radial bars around the orb; Iris's voice as a smooth breathing wave. You can *see* who's talking. @@ -106,6 +106,75 @@ When Hermes finishes a background task, Iris **proactively speaks up**: *"Quick - **Comet handoff** — a particle streaks from the orb to the task card when Gemini delegates, and back when Hermes returns - **Cinematic boot sequence**, animated transitions, and a custom app icon rendered from the orb itself +### Dark UI without colour banding + +If you have ever built a dark interface and seen faint **contour lines** — concentric +rings or horizontal bands across large dark areas — this section is why Iris does +not have them. It is the most transferable thing in this design system, so it is +written up in full. + +**The cause.** A display gives you 256 steps per channel, and sRGB spends very few +of them near black. A soft gradient stretched across 900px has only a handful of +values available to cross that distance, so it cannot render as a gradient at all: +it becomes a few wide flat plateaus with a hard 1-level edge between each. Those +edges are the contour lines. It gets worse the darker you go, because below the +sRGB toe brightness is proportional to code value — **one code step at value 7 is a +~14% jump in brightness; at value 21 the same step is ~5%**, which is under the +threshold where the eye reads it as a line. + +This is not a bug anyone has fixed. You can see it in Cursor, in native macOS +windows, and in dark video. Polished apps don't solve it — they avoid causing it. +Look closely at Linear, Raycast, Xcode or Things: almost no large soft gradients +anywhere. Flat surfaces, hairlines, small shadows, and gradients only where they +are small and steep. + +**The rules Iris follows.** + +1. **If a gradient crosses more pixels than it has code levels to cross them with, + it is not a gradient — it is a stack of plateaus with visible edges.** Make it a + flat fill. The deck background was two window-sized radial washes plus a + `--bg-1 → --bg-0` ramp; that ramp spanned **2 levels of green over 860px**. + Invisible as shading, glaring as edges. +2. **Keep the whole palette out of the crush zone.** `--bg-0` is `#0b111c`, not the + `#030509` it started as. Everything painted later — vignettes, scrims, overlays — + has to respect the same floor, or it drags its own region back down. +3. **Gradients on dark surfaces must be small and steep.** A 40px button fade has + plenty of levels for its range. A 900px wash does not. +4. **Depth comes from a step of fill tone plus spacing**, not from washes or + outlines. Surfaces here nest by tone: background → panel → card. None of them + carry a border, because a surface a step lighter than its parent already reads as + separate, and saying it twice means the lines are what you notice. +5. **One light source, and let it be real.** The reactor canvas is the only thing + that glows. Beware the trap: once the background is flat, a single soft halo + becomes the *only* brightness variation, so a bright centre falling off to dark + corners is exactly what vignetting looks like. + +**What does not work**, in case you are tempted: + +- **A noise/dither overlay.** The standard advice, and it is a band-aid. It cannot + remove a staircase that is already baked into a rasterized layer — it only + textures over it, so the low-frequency steps the eye integrates survive. Worse, + `backdrop-filter` **averages** its backdrop, so any dither underneath a blurred + glass panel is smoothed away and the banding comes back inside the glass. +- **Blend modes.** `mix-blend-mode: overlay` resolves to `2 × backdrop × source` + below mid-grey, so on a near-black surface its effect is a fraction of one code + level. Invisible. +- **More gradient stops.** The steps are a quantization limit, not a stop-count + problem. + +**If you need to measure it.** Run-length checks will lie to you: dither makes runs +look short while the banding is untouched. The eye integrates *along* a long +straight edge, so average each row across a wide strip of background first (which +cancels per-pixel noise), then look for jumps in that row-average. Max local slope +is the number that tracks visibility — a smooth 30-level ramp over 600px climbs at +~0.05 levels/px, and anything spiking well above that is a line you can see. + +> These rules are enforced for future work, not just documented: they live as a +> Cursor rule in [`.cursor/rules/dark-theme-no-banding.mdc`](.cursor/rules/dark-theme-no-banding.mdc), +> scoped to `src/styles/**/*.css`, so anyone (or any agent) editing the styles gets +> them automatically. Copy that file into another project to carry the approach +> over. + --- diff --git a/electron/main.mjs b/electron/main.mjs index 324e1d9..2176562 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -3784,7 +3784,23 @@ function installAppMenu() { { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, - { role: "togglefullscreen" }, + // NOT `role: "togglefullscreen"`. The window is `fullscreenable: false` + // (it is transparent and frameless, and the Glass HUD is this app's + // real full-screen mode), so that item was a silent no-op. Worse, it + // implied a full-screen state the user would then look to undo. + // This exposes the actual mode switch, and gives a way back out of the + // HUD from the menu bar as well as the tray and ⌥H. + // The hotkey is shown in the label rather than set as `accelerator`: + // ⌥H is already claimed by globalShortcut, and registering the same + // chord in both places risks firing twice — which would toggle the HUD + // out and straight back in, making the item look broken. + { + label: `Toggle Glass HUD (${hudHotkey().replace("Alt+", "⌥")})`, + click: () => { + toggleHud(); + updateTrayMenu(); + }, + }, ], }, { role: "windowMenu" }, diff --git a/src/App.tsx b/src/App.tsx index b37ea11..4b59464 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import type { ReactorState, TaskCard, LogLine, TranscriptLine } from "./types"; import { TERMINAL, @@ -19,6 +19,7 @@ import TopBar from "./components/TopBar"; import CommsPanel from "./components/CommsPanel"; import CameraDock from "./components/CameraDock"; import CenterStage from "./components/CenterStage"; +import { ORB_ACCENT } from "./components/ReactorCore"; import WorkStream from "./components/WorkStream"; import ReaderOverlay from "./components/ReaderOverlay"; import HistoryDrawer from "./components/HistoryDrawer"; @@ -1499,11 +1500,11 @@ export default function App() { className={`deck ${sidecarRunning ? "awake" : "asleep"} ${ modeTransition === "to-hud" ? "deck-leaving" : "" } ${modeTransition === "to-deck" ? "deck-entering" : ""}`} + /* The reactor is the lamp of the room: this inherits down so the + ambient aurora, glass edge-light and hairlines are all tinted by + whatever Iris is doing right now. */ + style={{ "--orb-accent": ORB_ACCENT[reactorState] } as CSSProperties} > -
-
-
- {!compact ? ( -
+
+