Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .cursor/rules/dark-theme-no-banding.mdc
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,83 @@ 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.
- **Sound design** — five subtle synthesized cues (wake, sleep, task sent, task done, approval needed). No audio files; pure tuned Web Audio tones. Toggle in Settings.
- **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.

---


Expand Down
18 changes: 17 additions & 1 deletion electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
11 changes: 6 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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}
>
<div className="hud-nebula" />
<div className="hud-glow" />
<div className="hud-vignette" />

<TopBar
geminiDot={dotState(geminiStatus, ["connected"])}
hermesDot={dotState(hermesStatus, ["ready"])}
Expand Down
7 changes: 5 additions & 2 deletions src/components/BootSequence.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState, type CSSProperties } from "react";
import ReactorCore from "./ReactorCore";
import ReactorCore, { ORB_ACCENT } from "./ReactorCore";

const BOOT_LINES = [
"initializing neural core",
Expand Down Expand Up @@ -40,7 +40,10 @@ export default function BootSequence({
return (
<div className={`boot ${compact ? "compact" : ""} ${closing ? "closing" : ""}`}>
{!compact ? (
<div className="orb-stage boot-orb" style={{ "--orb-accent": "18, 163, 148" } as CSSProperties}>
<div
className="orb-stage boot-orb"
style={{ "--orb-accent": ORB_ACCENT.online } as CSSProperties}
>
<span className="orb-ring" />
<span className="orb-radar" />
<ReactorCore state="online" />
Expand Down
14 changes: 2 additions & 12 deletions src/components/CenterStage.tsx
Original file line number Diff line number Diff line change
@@ -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<ReactorState, string> = {
idle: "120, 170, 150",
online: "18, 163, 148",
listening: "40, 205, 170",
speaking: "238, 122, 92",
working: "120, 180, 120",
};

function Telemetry({
awake,
gemini,
Expand Down Expand Up @@ -158,7 +148,7 @@ export default function CenterStage({
{/* Zoom-style split control: mute toggles, the caret picks the mic. */}
<span className="t-split">
<button
className={`t-btn small ${muted ? "muted" : ""}`}
className={`t-btn small ${muted ? "muted" : "primary"}`}
onClick={onToggleMute}
title={muted ? "Unmute microphone" : "Mute microphone"}
>
Expand Down
6 changes: 5 additions & 1 deletion src/components/CommsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ export default function CommsPanel({
<div className="comms-scroll" ref={scrollRef}>
{transcript.length === 0 ? (
<div className="empty">
<p>No conversation yet. Wake Iris and start talking.</p>
<span className="empty-icon">
<MessageSquare size={19} />
</span>
<p>No conversation yet</p>
<small>Wake Iris and start talking — everything you say lands here.</small>
{testDataEnabled ? (
<button className="demo-load" onClick={onLoadDemo}>
Load demo comms
Expand Down
30 changes: 16 additions & 14 deletions src/components/HudShell.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
import { BrainCircuit, ChevronDown, Hand, Maximize2, MessageSquare, Mic, MicOff, Power, Terminal } from "lucide-react";
import ReactorCore from "./ReactorCore";
import { BrainCircuit, ChevronDown, Hand, MessageSquare, Mic, MicOff, Minimize2, Power, Terminal } from "lucide-react";
import ReactorCore, { ORB_ACCENT } from "./ReactorCore";
import WorkCard from "./WorkCard";
import { HandSkeleton } from "./CameraDock";
import type { HandoffTone, ReactorState, TaskCard, TranscriptLine } from "../types";
import type { HandState } from "../hooks/useHandControl";
import { acceptedKey } from "../lib/tasks";

const ORB_ACCENT: Record<ReactorState, string> = {
idle: "120, 170, 150",
online: "18, 163, 148",
listening: "40, 205, 170",
speaking: "238, 122, 92",
working: "120, 180, 120",
};

function HudCamera({
stream,
hand,
Expand Down Expand Up @@ -183,7 +175,11 @@ export default function HudShell({
})();

return (
<div className={`hud-shell ${awake ? "awake" : "asleep"}`}>
<div
className={`hud-shell ${awake ? "awake" : "asleep"}`}
/* Lights every HUD island from the reactor's current state (inherited). */
style={{ "--orb-accent": ORB_ACCENT[reactorState] } as CSSProperties}
>
{/* Slim work stream, top-right — collapsible like Comms */}
{visibleTasks.length > 0 ? (
<div className="hud-right">
Expand Down Expand Up @@ -263,7 +259,10 @@ export default function HudShell({
{/* One source of truth: App's caption already covers awake states,
asleep hints, and the token-saving nap (with/without Hermes). */}
<div className={`hud-caption ${captionDim ? "dim" : ""} ${!awake || captionCompact ? "hint" : ""}`}>
{caption}
{/* State light, matched to the orb's current color. Suppressed on
hints — those are instructions, not Iris speaking. */}
{awake && !captionCompact ? <i className="hud-caption-dot" aria-hidden="true" /> : null}
<span className="hud-caption-text">{caption}</span>
</div>
<div
className={`orb-stage hud-orb ${autoSlept && !awake ? "napping" : ""}`}
Expand Down Expand Up @@ -326,8 +325,11 @@ export default function HudShell({
>
<Hand size={14} />
</button>
<button className="hud-btn" onClick={onExitHud} title="Back to deck (⌥H)">
<Maximize2 size={14} />
{/* Inward arrows are the conventional "exit fullscreen" glyph;
Maximize2's outward arrows read as "make this bigger", the
opposite of what this does. */}
<button className="hud-btn" onClick={onExitHud} title="Exit Glass HUD — back to the window (⌥H)">
<Minimize2 size={14} />
</button>
</div>
</div>
Expand Down
Loading
Loading