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/CHANGELOG.md b/CHANGELOG.md index 3317d96..13a5edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,53 @@ and the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. Iris is pre-1.0, so minor versions may still contain breaking changes to configuration and IPC surfaces. +## [0.4.0] — 2026-07-25 + +The Luminous Instrument redesign. The interface is rebuilt on flat surfaces and a +real token system, which also eliminates the colour banding that dark UIs +normally suffer from. + +### Changed + +- **The UI is rebuilt on flat surfaces.** The deck, HUD, and overlays no longer + use stacked gradient washes. Depth now comes from one step of fill tone plus + spacing rather than from washes and outlines, and nested 1px borders are gone + from panels, bubbles, and cards — edges are kept only for floating menus. +- **The palette is lifted off near-black**, from `#030509` to `#0b111c`. This is + the core fix for contour lines: below the sRGB toe, brightness is proportional + to code value, so one code step at value 7 is a ~14% brightness jump while at + value 21 it is ~5% and stops reading as an edge. +- **Tokens are retuned** into proper type, space, radius, and elevation scales, + and the reactive accent now tracks the orb state so ambient light follows what + Iris is doing. +- **Light only exists while the reactor runs.** The canvas halo carried a fixed + alpha floor, so a sleeping orb still emitted a glow disc wider than its drawn + ring, which the asleep filter turned into a grey smudge. The halo is now gated + on how far energy sits above rest, keyed off a single shared constant, with + stops rebalanced so the awake look is unchanged. +- **View → Toggle Full Screen** is replaced by **Toggle Glass HUD**. The old item + was a no-op on this frameless window; the HUD now has a menu-bar exit. + +### Fixed + +- **Colour banding in dark areas.** The visible contour lines were 8-bit + quantization, not a rendering bug. A soft gradient spread across ~900px has + fewer code levels than pixels to cross, so it renders as wide flat plateaus + with hard 1-level edges. It only showed in dim states because the aurora and + glow were dimmed there, leaving the near-flat base ramp as the only structure + on screen. The six-wash drifting aurora, the full-window vignette, and the + window-sized deck and boot gradients are now flat fills. +- **Phantom depth around the orb.** The dark radial scrim behind the HUD orb and + the plinth under every orb stage are gone. Both were soft washes standing in + for depth, and on a flat dark surface they read as shadow rather than light. + +### Added + +- **"Dark UI without colour banding"** in the README — a full writeup of the + cause and the five rules Iris follows, since the problem is general to dark + interfaces and the analysis transfers to any project. +- A Cursor rule scoped to `src/styles` that enforces those rules for future work. + ## [0.3.0] — 2026-07-25 The all-day release. Iris gains a searchable knowledge graph, survives a full @@ -144,5 +191,6 @@ First public release. - macOS packaging and production launch support. - README showcase with demo video and screenshots. +[0.4.0]: https://github.com/ASHR12/iris/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/ASHR12/iris/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/ASHR12/iris/releases/tag/v0.2.0 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/package-lock.json b/package-lock.json index 8747a85..c35cd60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "iris-hermes-voice", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iris-hermes-voice", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@fontsource-variable/inter": "^5.2.8", diff --git a/package.json b/package.json index 16e9d6c..8a69839 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iris-hermes-voice", "productName": "Iris", - "version": "0.3.0", + "version": "0.4.0", "private": true, "license": "MIT", "description": "A futuristic Gemini Live voice companion that delegates real work to Hermes.", 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 ? ( -
+
diff --git a/src/components/CenterStage.tsx b/src/components/CenterStage.tsx index e7894b8..f3fa149 100644 --- a/src/components/CenterStage.tsx +++ b/src/components/CenterStage.tsx @@ -1,19 +1,9 @@ import { useEffect, useState, type CSSProperties, type RefObject } from "react"; import { Mic, MicOff, Power } from "lucide-react"; -import ReactorCore from "./ReactorCore"; +import ReactorCore, { ORB_ACCENT } from "./ReactorCore"; import DevicePicker from "./DevicePicker"; import type { HandoffTone, ReactorState } from "../types"; -// Arc-reactor accent color per state (matches ReactorCore palettes) — drives the -// surrounding ring/radar so it stays the same color as the orb. -const ORB_ACCENT: Record = { - idle: "120, 170, 150", - online: "18, 163, 148", - listening: "40, 205, 170", - speaking: "238, 122, 92", - working: "120, 180, 120", -}; - function Telemetry({ awake, gemini, @@ -158,7 +148,7 @@ export default function CenterStage({ {/* Zoom-style split control: mute toggles, the caret picks the mic. */}