Loading files
710fbeaaeThis file is pinned to the selected release. Download the original to open it in your design tool.
import 'package:flutter/material.dart';
import 'package:harness/terminal/terminal_text.dart';
import 'app_type.dart';
export 'app_type.dart';
import 'color_palette.dart';
/// The app's live brightness — the single source of truth the color tokens below
/// resolve against. Harness Desktop is dark-only: `_GridTokenScope` (in
/// `main.dart`) pins this to [Brightness.dark] once, at the top of the tree.
///
/// Every `AppPalette`/`AppSurface`/`AppGlass`/`AppCard` member is a getter that
/// switches on this, so a call site like `color: AppPalette.windowBg` follows the
/// theme with no change to the call site.
abstract final class AppTheme {
static final BrightnessNotifier brightness = BrightnessNotifier(
Brightness.dark,
);
static final palette = ValueNotifier(HarnessPalette.graphite);
static bool get isDark => brightness.value == Brightness.dark;
/// Pick between a light and a dark value for the current brightness.
static T pick<T>(T light, T dark) => isDark ? dark : light;
/// Read tokens as some *other* brightness would resolve them.
///
/// The tokens resolve against one global ([brightness]), which is what makes a
/// call site like `AppPalette.windowBg` follow the theme with no plumbing — but
/// it also means the dark palette is unreadable while the app is light. The
/// theme preview needs exactly that: three swatches, each showing a palette the
/// app is *not* currently wearing.
///
/// So: swap the global, read, put it back. Safe because it's synchronous and
/// restores in a `finally` — nothing can observe the swapped value, and the
/// swap is [BrightnessNotifier.muted] so it doesn't dirty every widget
/// watching the theme on the way out and back.
///
/// Do not `await` inside [read]: that would hand the swapped brightness to the
/// rest of the frame, and the app would paint half a palette.
static T as<T>(Brightness other, T Function() read) {
final previous = brightness.value;
if (previous == other) return read();
return brightness.muted(() {
try {
brightness.value = other;
return read();
} finally {
brightness.value = previous;
}
});
}
/// Registers the calling widget to rebuild whenever [brightness] flips, and
/// returns the current value.
///
/// The color tokens read [brightness] `.value` directly — a plain field read
/// the element tree can't see — so a widget that only reads tokens has no
/// tracked reason to rebuild when the theme changes. Worse, the app is full of
/// `const` chrome (`const AppSidebar()`, `const _MainShellBody()`), and a
/// `const` child is reference-identical across a parent's rebuild, so Flutter
/// short-circuits it: rebuilding from the top never reaches the sidebar, and it
/// stays on the old palette until something *else* (a Riverpod change from
/// clicking a row) happens to rebuild it.
///
/// Calling this at the top of a chrome widget's `build` fixes that at the root:
/// it depends on the [_BrightnessScope] inherited widget, whose notifier is
/// [brightness]. An `InheritedNotifier` marks its dependents dirty *directly*
/// when the notifier fires — it doesn't rebuild through the widget tree — so
/// every `const` boundary in between is irrelevant. This is exactly how
/// `Theme.of(context)` makes a widget follow the theme.
/// It also registers for type changes, via [TerminalFontScope]. The two travel
/// together deliberately: `AppTheme.watch(context)` is the one line a widget
/// adds to follow the app's appearance, and splitting it into two calls would
/// mean every widget that already follows the theme still silently ignores the
/// font — with the exact same symptom (stuck on the value it first built with)
/// that this method exists to fix.
static Brightness watch(BuildContext context) {
TerminalFontScope.watch(context);
context.dependOnInheritedWidgetOfExactType<_BrightnessScope>();
context.dependOnInheritedWidgetOfExactType<_PaletteScope>();
return brightness.value;
}
}
/// The brightness global, with one extra power: it can be moved *silently*.
///
/// [AppTheme.as] swaps the brightness to read the other palette and swaps it
/// straight back. A plain `ValueNotifier` broadcasts both of those moves, so
/// every widget watching the theme would be marked dirty twice per swatch — six
/// spurious rebuilds to draw three previews, all to end up back where we
/// started. [muted] suppresses the broadcast for a change that is, from the
/// outside, not a change at all.
class BrightnessNotifier extends ValueNotifier<Brightness> {
BrightnessNotifier(super.value);
bool _muted = false;
/// Run [body] with notifications suppressed. Only for a swap that restores the
/// original value before anything can observe it — see [AppTheme.as].
T muted<T>(T Function() body) {
final previous = _muted;
_muted = true;
try {
return body();
} finally {
_muted = previous;
}
}
@override
void notifyListeners() {
if (_muted) return;
super.notifyListeners();
}
}
/// Wraps the app so any descendant that calls [AppTheme.watch] rebuilds when the
/// brightness flips or the user's type settings change — regardless of the
/// `const` widgets in between. Mount it once, high in the tree (see
/// `grid_app.dart`).
///
/// The name predates the font settings and is kept: it's mounted in one place,
/// and every call site in the app already knows this as the widget that makes
/// [AppTheme.watch] work.
class BrightnessScope extends StatelessWidget {
const BrightnessScope({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return TerminalFontScope(
child: _BrightnessScope(
notifier: AppTheme.brightness,
child: _PaletteScope(notifier: AppTheme.palette, child: child),
),
);
}
}
class _BrightnessScope extends InheritedNotifier<ValueNotifier<Brightness>> {
const _BrightnessScope({required super.notifier, required super.child});
}
class _PaletteScope extends InheritedNotifier<ValueNotifier<HarnessPalette>> {
const _PaletteScope({required super.notifier, required super.child});
}
/// The app's palette — a warm paper white with near-black ink in light, a deep
/// charcoal with off-white ink in dark (the design-system "Codex" direction).
/// Centralized so every pane reads the same surfaces/accents instead of re-typing
/// hex literals, and resolves per [AppTheme.brightness].
abstract final class AppPalette {
// Approved Swarms canvas and native tab-strip palette.
static Color get swarmField => AppTheme.palette.value.workspace;
// Empty tabs join the selected native tab as one continuous surface.
static Color get swarmWelcome => AppTheme.palette.value.workspace;
static Color get swarmTabBar => AppTheme.palette.value.tabBar;
static Color get swarmAccent => AppTheme.palette.value.accent;
// Shared with the native search field for a continuous input/results surface.
static Color get swarmSearchSurface => AppTheme.palette.value.search;
static const agentEntrySurface = Color(0xff101113);
static const agentEntryField = Color(0xff1d1f22);
// The command field deliberately stays light, like a browser's new-tab omnibox.
static const commandField = Color(0xFFF7F8FA);
static const commandInk = Color(0xFF202124);
static const commandMuted = Color(0xFF646971);
static const commandChip = Color(0xFFE9EBEF);
// the conversation / content area — pure white in light, like Codex.
//
// Dark is a charcoal page rather than the near-black it was (#0A0A0A). That
// moves every surface above it closer: a card lands at 1.065:1 where it had
// 1.188, a dialog at 1.090 where it had 1.215, and [AppPalette.panelBg]
// (#141414) is now *darker* than the page it sits on. Depth in dark is
// therefore carried by the rim and the shadow, not by the fill — §2's stack
// still holds, it just has less room to say it in.
static Color get windowBg =>
AppTheme.pick(const Color(0xFFFFFFFF), AppTheme.palette.value.background);
// sidebar column — a barely-there cool grey (Codex keeps the rail almost white,
// set apart by a hairline, not a tone) / charcoal panel in dark.
static Color get panelBg =>
AppTheme.pick(const Color(0xFFF9F9F8), AppTheme.palette.value.panel);
// input fills, quiet cards
static Color get cardBg =>
AppTheme.pick(const Color(0xFFF3F3F2), AppTheme.palette.value.card);
static Color get cardBgHover =>
AppTheme.pick(const Color(0xFFECECEA), AppTheme.palette.value.hover);
// A hairline separator. Light: a faint cool black; dark: a faint white — a
// black divider would vanish on charcoal.
static Color get divider =>
AppTheme.pick(const Color(0x0F000000), const Color(0x14FFFFFF));
/// A hairline that has to hold a *shape* rather than just part two blocks —
/// the tree guide threading the sidebar's projects and their chats.
///
/// Stronger than [divider], and deliberately so. A separator only has to be
/// findable at the seam the eye is already looking at; a guide is a long thin
/// run the eye has to follow, and at the divider's weight it stops reading as
/// one line and breaks into stray ticks wherever anything crosses it.
/// Measured against the rail it is drawn on (`AppGlass.sidebarFill`, over
/// [windowBg] in light): divider lands at **1.14:1 light / 1.27:1 dark**,
/// this at **1.45:1 light / 1.47:1 dark**.
///
/// The two alphas differ because the rails differ — `#F9F9F8` light against
/// `#242424` dark — and matching the *numbers* rather than the alphas is what
/// keeps the guide equally present in both themes instead of a step fainter
/// in light, which is how the divider's pair behaved.
static Color get guide =>
AppTheme.pick(const Color(0x29000000), const Color(0x20FFFFFF));
// primary action — a solid fill with white text on it, so it has to stay dark
// enough to carry that text (white on this is 5.5:1). Same value in both
// themes: as a *fill* it reads on either surface.
static const accent = Color(0xFF2F5BEA);
/// The fill under a destructive **filled** button — Remove, Delete.
///
/// Not `colorScheme.error`, and the reason is what that colour is *for*: dark's
/// `#F2544B` is tuned to read as **ink on a dark surface**, which is why
/// `AppIconButton` needs one lighter still. As a *fill* it leaves white
/// lettering at 3.42:1, under the 4.5:1 floor — and Material's own `onError`
/// substitute reaches only 3.83:1, so no foreground rescues it. Darkened until
/// white clears the bar with the room the app's own accent button already has:
///
/// ```
/// white on fill fill vs dialog
/// accent #2F5BEA 5.52 3.02
/// this #C92E26 (dark) 5.38 3.10
/// this #B3261E (light) 6.54 6.54
/// ```
static Color get dangerFill =>
AppTheme.pick(const Color(0xFFB3261E), const Color(0xFFC92E26));
/// The accent as a *mark on a surface* — a selected row's icon, an accent
/// rail — rather than a fill behind white text.
///
/// It has to part from [accent] in dark. A selected rail row composites to
/// #353535, and #2F5BEA on that is 2.2:1 — under the 3.0 WCAG 1.4.11 asks of
/// a UI element, which is why the selected icon read as flat charcoal-on-
/// charcoal there while light (4.6:1) was fine. Lightened to 3.97:1 on that
/// row, still plainly the same indigo. [accent] can't simply take this value:
/// it is the fill under white text in ~100 places, and lightening it there
/// would drop that text to ~3.1:1 — fixing the icon by breaking the buttons.
static Color get accentOnSurface =>
AppTheme.pick(const Color(0xFF2F5BEA), AppTheme.palette.value.accent);
// avatar fill (white text on it); a touch brighter in dark for contrast.
static Color get accentMuted =>
AppTheme.pick(const Color(0xFF3550C8), const Color(0xFF4E6BF0));
/// The initial tile beside a name in a roster — the accent's hue with most of
/// its saturation taken out, so a column of ten of them reads as a list rather
/// than as ten buttons.
///
/// **Not [accent] or [accentMuted].** Both are made to be *loud*: one is the
/// fill under a primary button, the other the base of the account avatar that
/// marks **you**. A saturated indigo is right for one mark on a screen and
/// wrong for a mark on every row — a whole panel of them pulled the eye off
/// the names they were meant to introduce.
///
/// Still a solid fill under white text, and still measured for it: 5.26:1 in
/// light, 6.19:1 in dark. Softening a mark must not cost the letter inside it,
/// which is the only part carrying information — a tint at 14% alpha would
/// have taken the glyph to 4.5:1 at rest and under it on a hovered row.
///
/// Slightly deeper in dark, where the tile stands on charcoal (2.63:1 against
/// the panel) rather than on white (5.26:1).
static Color get avatarFill =>
AppTheme.pick(const Color(0xFF5369AC), const Color(0xFF4B5F9B));
/// The colours a member's circle is drawn in, one per person — see
/// `memberAvatarSlot`, which decides which of them an address takes.
///
/// **The same list in both themes, deliberately.** Every other colour here
/// resolves per brightness; this one must not, because here the colour *is*
/// the identity. A person who is teal on the dark theme and green on the light
/// one is two people to the eye, and the whole reason for colouring a roster
/// is that a face can be found before it is read.
///
/// Eight hues rather than a generated spread: a hash over a continuous wheel
/// puts neighbouring people two degrees apart as often as not, and a palette
/// picked by hand is the only way the marks stay *told apart*. All eight carry
/// white at ≥5:1 (measured: 5.02 green → 7.10 violet), which is what the
/// letter needs — the disc itself is a container, and lands at 2.3–3.5:1
/// against the panels it sits on, the same band [avatarFill] occupies today.
static const avatarPalette = <Color>[
Color(0xFF1D5BD6), // blue
Color(0xFF0F766E), // teal
Color(0xFF15803D), // green
Color(0xFFA65A08), // amber
Color(0xFFBE123C), // rose
Color(0xFF6D28D9), // violet
Color(0xFFA21CAF), // fuchsia
Color(0xFF4E5D78), // slate
];
/// The primary action's fill under the pointer — the top bar's Invite button.
///
/// **Deepens in light, brightens in dark**, rather than one shift applied to
/// both: hover should raise the control's contrast against the page it sits
/// on, and the page runs the other way in the other theme. The numbers force
/// it too — the obvious "lift toward [AppCard.accentStrong]" puts dark on
/// `#5C7CFF`, where the button's own white label falls to **3.63:1**. These
/// two hold it at 6.50:1 (light) and 4.76:1 (dark).
static Color get accentHover =>
AppTheme.pick(const Color(0xFF2850D8), const Color(0xFF4166F2));
// "Owner" badge — a teal that stays legible on either surface.
static Color get teal =>
AppTheme.pick(const Color(0xFF0F766E), const Color(0xFF2DD4BF));
// green "connected" dot
static Color get online =>
AppTheme.pick(const Color(0xFF15803D), const Color(0xFF3FB950));
// expiring soon
static Color get warn =>
AppTheme.pick(const Color(0xFFB45309), const Color(0xFFFFB020));
// grey dot
static Color get offline =>
AppTheme.pick(const Color(0xFFA3A29C), const Color(0xFF6E6E6E));
// Grid brand lightning gold — the live/active ⚡ mark, matching the tray bolt.
static Color get brandBolt =>
AppTheme.pick(const Color(0xFFC98A00), const Color(0xFFE0A93B));
static Color get textPrimary =>
AppTheme.pick(const Color(0xFF1A1A18), const Color(0xFFF5F5F5));
static Color get textSecondary =>
AppTheme.pick(const Color(0xFF62615B), const Color(0xFFA8A8A2));
static Color get textFaint =>
AppTheme.pick(const Color(0xFF8E8D86), const Color(0xFF6E6E68));
/// The page a document is drawn on, and the ink on it.
///
/// The one pair here that does **not** follow the app's theme, deliberately: a
/// page is the *document*, not the app's chrome. Word keeps dark chrome around
/// white paper for the reason that decides it — what you edit has to look like
/// what you send, and a page that turned charcoal in dark mode would show the
/// author a document nobody else will ever see. Named here rather than typed
/// into the editor so the rule is stated once and the exception is visible
/// next to the tokens it breaks with.
/// Both are `const`, and that is the enforcement rather than a note: a
/// `AppTheme.pick` here would make the page follow Light/Dark, and a value that
/// cannot be picked cannot drift. Pure black ink, not the app's near-black
/// [textPrimary] — Word's "automatic" text colour is #000000, and this text is
/// going to print.
static const paper = Color(0xFFFFFFFF);
static const paperInk = Color(0xFF000000);
/// The furniture *around* the page — the formatting bar and the two rulers.
///
/// Light in both themes, like [paper] above, and for a reason one step on
/// from it. The page stays white because it is what you send; these stay light
/// because they are what you set it with, and a ruler mixed for charcoal
/// while the paper beside it is white belongs to neither. Word and Google Docs
/// both keep this band light against a dark desk, which is what makes the page
/// read as a page rather than as a white hole.
///
/// `const` for the same reason [paper] is: a value that cannot be
/// [AppTheme.pick]ed cannot quietly start following the theme again.
///
/// Measured on [paperChrome]: [paperChromeInk] 11.3:1, [paperChromeInkSoft]
/// 5.1:1 — both clear §11 with room, which the app's own text tokens would not
/// have on this surface in dark.
static const paperChrome = Color(0xFFF2F1ED);
/// Hairlines, and the ruler's margin band.
static const paperChromeLine = Color(0xFFD6D5CF);
/// A pressed toggle, and the lift under a hovered glyph.
static const paperChromeFill = Color(0xFFE3E2DC);
/// Labels, glyphs and the ruler's numbers.
static const paperChromeInk = Color(0xFF33332F);
/// The resting ink of a control that isn't the one in force.
static const paperChromeInkSoft = Color(0xFF63625C);
/// The desk the page sits on.
///
/// It used to follow the theme, and in dark that made it `#141414` — a page
/// of white paper laid on near-black, which is the harshest edge the app had
/// and the thing that made this screen tiring to look at. Now that the bar and
/// the rulers around the page are fixed light, a desk that flipped was also
/// the one surface still changing theme in the middle of the document, with
/// chrome above it that didn't.
///
/// So: one warm grey, in both. Deeper than [paperChromeLine] so the desk reads
/// as *behind* the rulers rather than continuous with them, and far enough
/// from [paper] that the page keeps its edges — which was the reason the light
/// value could never be white either.
static const paperDesk = Color(0xFFC4C3BD);
}
/// The scrollbar thumb, per brightness.
///
/// Kept as plain constants rather than only as [AppSurface] getters because
/// [buildAppTheme] needs the values *before* the global brightness is the one
/// being built for — see the note at its `scrollbarTheme`. The getters below read
/// these same four, so there is one set of numbers rather than two that can drift.
const Color _scrollThumbLight = Color(0xFF8C8C8C);
const Color _scrollThumbDark = Color(0xFF686868);
const Color _scrollThumbHoverLight = Color(0xFF6E6E6E);
const Color _scrollThumbHoverDark = Color(0xFF8A8A8A);
/// Surface tokens for the app's chrome — the sidebar's rows, the composer card,
/// a recessed list column. Depth comes from a hairline rim and a soft shadow; the
/// overlays flip from black (on light) to white (on dark) so a hover/selection is
/// visible on either surface.
abstract final class AppSurface {
/// The sidebar row you're on.
static Color get selectedFill =>
AppTheme.pick(const Color(0x0D000000), const Color(0x14FFFFFF));
/// The sidebar row under the pointer — lighter than [selectedFill], so hover
/// never reads as "selected".
static Color get hoverFill =>
AppTheme.pick(const Color(0x07000000), const Color(0x0DFFFFFF));
/// A whisper of the accent, washed under the rail's primary action ("New
/// chat") so it invites the click without hardening into a button. Kept faint
/// (~8% in light, a touch stronger in dark so it reads on charcoal).
static Color get accentWash =>
AppTheme.pick(const Color(0x142F5BEA), const Color(0x242F5BEA));
/// The same wash a step stronger, for the primary action under the pointer —
/// so hovering it still reads as a change without ever looking "selected".
static Color get accentWashHover =>
AppTheme.pick(const Color(0x1F2F5BEA), const Color(0x332F5BEA));
/// The icon well inside a list row.
///
/// Translucent on purpose. An opaque fill would be picked against the row's
/// *resting* colour and then near-vanish the moment the row lifted on hover —
/// measured at 1.041:1 against the hovered light row. An overlay rides whatever
/// it sits on, so the well keeps its edge in both states:
///
/// ```
/// rest hover
/// light 1.168 1.173
/// dark 1.183 1.200
/// ```
///
/// The two themes take different alphas because they start from different
/// grounds — light needs more to separate from `#F3F3F2` than dark does from
/// `#202020`. The glyph inside keeps 4.79:1 (light) and 5.76:1 (dark).
static Color get wellFill =>
AppTheme.pick(const Color(0x12000000), const Color(0x0FFFFFFF));
/// A recessed well inside a panel.
static Color get recess =>
AppTheme.pick(const Color(0x08000000), const Color(0x0FFFFFFF));
/// The recessed well under the pointer — a step lighter than [recess], so a
/// hoverable surface (the account pill) lifts a touch to say it's clickable.
static Color get recessHover =>
AppTheme.pick(const Color(0x12000000), const Color(0x1AFFFFFF));
/// A scrollbar thumb at rest.
///
/// Opaque rather than a wash, and per-brightness rather than one alpha, because
/// Material's default is neither and lands under the 3:1 that WCAG 1.4.11 asks
/// of a UI element — `onSurface` at 10% is **1.23:1** on the light page, and at
/// 30% is **2.46:1** on the dark one. Both were measured; both read as "no
/// scrollbar" until you look for it.
///
/// One alpha can't fix it either: white lifts a dark page fast while black
/// barely dents a white one, so reaching 3:1 needs 0.40 in dark and 0.50 in
/// light. These are those two values resolved — 3.55:1 dark, 3.36:1 light —
/// stated as colours so a list on the panel measures the same as one on the page.
static Color get scrollThumb =>
AppTheme.pick(_scrollThumbLight, _scrollThumbDark);
/// The thumb under the pointer or mid-drag: the same hue, plainly grabbed.
static Color get scrollThumbHover =>
AppTheme.pick(_scrollThumbHoverLight, _scrollThumbHoverDark);
/// Soft drop shadow that lifts a floating surface (the composer) off the page.
/// Deeper/darker in dark mode where a light lift would look like a glow.
static List<BoxShadow> get shadow => AppTheme.pick(
const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 24,
offset: Offset(0, 10),
spreadRadius: -10,
),
BoxShadow(color: Color(0x0A000000), blurRadius: 4, offset: Offset(0, 1)),
],
const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 24,
offset: Offset(0, 10),
spreadRadius: -10,
),
BoxShadow(color: Color(0x40000000), blurRadius: 4, offset: Offset(0, 1)),
],
);
/// The composer's lift — Codex gives its input a soft shadow that spreads wide
/// and low so the box clearly floats over the transcript. Two layers: a broad
/// ambient pool plus a tighter contact shadow right under the rim.
static List<BoxShadow> get composerShadow => AppTheme.pick(
const [
BoxShadow(
color: Color(0x1F000000),
blurRadius: 28,
offset: Offset(0, 10),
spreadRadius: -6,
),
BoxShadow(
color: Color(0x14000000),
blurRadius: 8,
offset: Offset(0, 2),
spreadRadius: -2,
),
],
const [
BoxShadow(
color: Color(0x80000000),
blurRadius: 30,
offset: Offset(0, 12),
spreadRadius: -6,
),
BoxShadow(
color: Color(0x4D000000),
blurRadius: 10,
offset: Offset(0, 3),
spreadRadius: -3,
),
],
);
}
/// Translucent Codex-like chrome surfaces used behind a backdrop blur (the
/// sidebar, the top bar, floating pills). Fills are semi-transparent so the blur
/// shows through; kept separate from [AppCard] because these surfaces are chrome,
/// not dense content cards.
abstract final class AppGlass {
// Near-opaque so the rail reads as a calm flat surface, not a frosted panel.
//
// Dark is fully opaque and says the value it means. Nothing is blurred behind
// the rail (see [AppSidebar]), so the alpha did one thing: pull the fill 6%
// back towards the page under it and land the rail on #191919 rather than the
// number written here — a token that could only be read by compositing it.
// At #242424 the rail is 1.27:1 against the page, so it reads as a surface
// the content lies beside rather than the same charcoal with a hairline on it.
static Color get sidebarFill =>
AppTheme.pick(const Color(0xF7F9F9F8), const Color(0xFF242424));
// Pills/menus are solid white in Codex (their softness comes from the rim and
// a whisper of shadow, not from translucency).
static Color get surfaceFill =>
AppTheme.pick(const Color(0xFFFFFFFF), const Color(0xFF202020));
static Color get surfaceHoverFill =>
AppTheme.pick(const Color(0xFFF7F7F6), const Color(0xFF272727));
static Color get hair =>
AppTheme.pick(const Color(0x14000000), const Color(0x1FFFFFFF));
/// A more present rim for the surfaces that should read as *lifted* — the
/// composer, above all. The plain [hair] disappears into a white pane; this one
/// is a clear soft grey (~#DADADA on white) so the input keeps a visible edge
/// like Codex, without hardening into a boxy outline.
static Color get lift =>
AppTheme.pick(const Color(0x2E000000), const Color(0x2EFFFFFF));
static Color get bubbleFill =>
AppTheme.pick(const Color(0xFFF3F3F1), const Color(0xFF242424));
static List<BoxShadow> get shadow => AppTheme.pick(
const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 16,
offset: Offset(0, 6),
spreadRadius: -6,
),
BoxShadow(color: Color(0x0A000000), blurRadius: 2, offset: Offset(0, 1)),
],
const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 18,
offset: Offset(0, 7),
spreadRadius: -7,
),
BoxShadow(color: Color(0x33000000), blurRadius: 3, offset: Offset(0, 1)),
],
);
// A whisper — the soft, tight lift Codex gives its floating pills and menus,
// not a big ambient drop.
/// A list row sitting directly on the page, at rest.
///
/// **Not [surfaceFill], and light is the whole reason.** `windowBg` in light is
/// pure `#FFFFFF` and so is `surfaceFill`, which put the row and the page it
/// sits on at **1.000:1** — the same colour. The list had no rows in light at
/// all until the pointer moved over one, because `surfaceHoverFill` (`#F7F7F6`,
/// 1.072:1) was the first fill that differed from the page.
///
/// A raised block cannot be raised by fill on a pure-white page: lighter than
/// white does not exist. So light recesses instead — a grey card on white, the
/// ordinary light-UI idiom — while dark keeps lifting. Measured, and shaped to
/// match dark's separation rather than guessed at:
///
/// ```
/// rest hover hover vs rest
/// dark 1.090 1.189 1.091
/// light 1.110 1.205 1.086
/// ```
///
/// Light's rest is [AppPalette.cardBg] rather than a new colour; only the hover
/// step needed one. Text keeps its room on both: `textSecondary` reads 5.60:1
/// at rest and 5.15:1 hovered.
static Color get rowFill =>
AppTheme.pick(const Color(0xFFF3F3F2), const Color(0xFF202020));
/// The same row under the pointer.
///
/// Moves *away* from the page in both themes — darker in light, lighter in dark
/// — because a hover that drifts toward the background erases the row it is
/// meant to be highlighting.
static Color get rowHoverFill =>
AppTheme.pick(const Color(0xFFEAEAE7), const Color(0xFF272727));
static List<BoxShadow> get cardShadow => AppTheme.pick(
const [
BoxShadow(
color: Color(0x0F000000),
blurRadius: 8,
offset: Offset(0, 3),
spreadRadius: -3,
),
BoxShadow(color: Color(0x08000000), blurRadius: 1, offset: Offset(0, 1)),
],
const [
BoxShadow(
color: Color(0x59000000),
blurRadius: 10,
offset: Offset(0, 4),
spreadRadius: -4,
),
],
);
}
/// The floating-panel recipe — **one** source for every menu, popover and
/// tooltip in the app, so the three cannot drift apart.
///
/// They already had. Before this the app carried *four* recipes for the same
/// surface, and no two agreed:
///
/// ```
/// fill (dark) elevation radius rim
/// menuTheme / popupMenuTheme #1E1E1E 8 6 no
/// appMenuStyle() #2A2A2A 12 10 yes
/// tooltipTheme #1E1E1E — 10 yes
/// the account footer, inline cardBg 18 8 yes
/// ```
///
/// The cost was exactly what a second recipe always costs: the account footer's
/// `MenuAnchor` (since removed with the machine rail) passed no style at all, so
/// it opened the rimless themed default — the surface `appMenuStyle` had been
/// written to replace.
///
/// ⚠️ The fill is deliberately **not** the themed default. `#1E1E1E` sits within
/// 1.02:1 of a raised block ([AppGlass.surfaceFill], `#202020`), and in light
/// both are pure white — a menu opened over a dialog then has no edge at all and
/// its rows appear to float loose on the page. These lift clear of *both*
/// grounds a menu can open over: the page (`#181818` / `#FFFFFF`) and that
/// block. Measured with `tool/contrast.py`, 2026-09-03:
///
/// ```
/// dark panel #2A2A2A vs page #181818 = 1.237 : 1
/// dark panel #2A2A2A vs block #202020 = 1.135 : 1
/// light panel #FFFFFF vs page #FFFFFF = 1.000 : 1 ← the rim does it
/// light panel #FFFFFF vs block #FFFFFF = 1.000 : 1 ← and here
/// ```
///
/// Light is why the rim exists. On a white page the panel and the ground under
/// it are *the same colour*, so the rim is the only thing drawing the edge —
/// which is what earns it the one exception §1 allows. Fill alone cannot
/// separate two surfaces (§9.1); the rim and the shadow do it together, which is
/// also why the rim is not held to §16's 3.0 — see the note in `contrast.py`.
///
/// ⚠️ These are measurements with a date. Move `windowBg` or `surfaceFill` and
/// all four are stale — re-run the script, don't trust the comment.
///
/// ⚠️ The `*Light`/`*Dark` const twins exist for the same reason `_scrollThumb*`
/// do: [buildAppTheme] builds a theme for a brightness it has been *handed*,
/// while a getter answers for the brightness the app is *wearing*, and the two
/// differ in exactly the call that produces `darkTheme`. Inside that function
/// use [styleFor] and the consts; everywhere else use [style] and the getters.
abstract final class AppMenu {
static const Color fillLight = Color(0xFFFFFFFF);
static const Color fillDark = Color(0xFF2A2A2A);
static Color get fill => AppTheme.pick(fillLight, fillDark);
/// The panel's rim — the same hairline [AppGlass.hair] resolves to, stated as
/// consts so [buildAppTheme] can read it.
static const Color rimLight = Color(0x14000000);
static const Color rimDark = Color(0x1FFFFFFF);
static Color get rim => AppTheme.pick(rimLight, rimDark);
/// A menu panel's rounding. On the §3 ladder this is the *panel* step (10),
/// one below a content card (12) and above the rows inside it (8) — a child is
/// never rounder than its parent.
static const double panelRadius = 10;
/// Deeper than Material's menu default (8): this panel opens over chrome that
/// already carries a lift of its own, and at 8 it reads as lying *on* that
/// chrome rather than over it.
static const double elevation = 12;
/// ⚠️ Vertical only. Rows carry their own horizontal gutter so their hover
/// highlight reads as an inset pill; side padding here would double it.
static const EdgeInsets panelPadding = EdgeInsets.symmetric(vertical: 5);
/// The style every menu panel in the app takes.
///
/// [minWidth]/[maxWidth] are for a panel whose width is part of its meaning —
/// the account menu holds an email address and must not resize as the address
/// changes. [maxHeight] defaults to [AppControl.menuMaxHeight]; override it
/// only for a panel that is taller by design, and read that token's note
/// first: a menu that opens *upward* places itself by summing the height it is
/// about to take.
static MenuStyle style({
double? minWidth,
double? maxWidth,
double? maxHeight,
}) => _style(
isDark: AppTheme.isDark,
minWidth: minWidth,
maxWidth: maxWidth,
maxHeight: maxHeight,
);
/// [style] resolved against a brightness handed in rather than the one the app
/// is wearing. Only [buildAppTheme] needs this — see the ⚠️ above.
static MenuStyle styleFor({required bool isDark}) => _style(isDark: isDark);
static MenuStyle _style({
required bool isDark,
double? minWidth,
double? maxWidth,
double? maxHeight,
}) => MenuStyle(
backgroundColor: WidgetStatePropertyAll(isDark ? fillDark : fillLight),
surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent),
elevation: const WidgetStatePropertyAll(elevation),
padding: const WidgetStatePropertyAll(panelPadding),
minimumSize: minWidth == null
? null
: WidgetStatePropertyAll(Size(minWidth, 0)),
maximumSize: WidgetStatePropertyAll(
Size(maxWidth ?? double.infinity, maxHeight ?? AppControl.menuMaxHeight),
),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(panelRadius),
side: BorderSide(color: isDark ? rimDark : rimLight),
),
),
);
}
/// Content-card recipe — a surface with a hairline rim and a soft lift. Distinct
/// from [AppSurface] (the chrome): cards carry dense content, so they stay quiet
/// and let the text do the work. Applied via [GlassCard].
abstract final class AppCard {
static const accent = AppPalette.accent;
static Color get accentStrong =>
AppTheme.pick(const Color(0xFF1E40AF), const Color(0xFF5C7CFF));
// card surface
static Color get base =>
AppTheme.pick(const Color(0xFFFFFFFF), const Color(0xFF1E1E1E));
// recessed inner box / list tile
static Color get inset =>
AppTheme.pick(const Color(0xFFF7F7F5), const Color(0xFF181818));
static Color get hair => AppPalette.divider; // card rim
static Color get insetHair =>
AppTheme.pick(const Color(0x0F000000), const Color(0x14FFFFFF));
// Accent tints — used sparingly, for the focal card's wash and rim. Slightly
// stronger in dark so the wash reads on charcoal.
static Color get tint10 =>
AppTheme.pick(const Color(0x0A2F5BEA), const Color(0x1A2F5BEA));
static Color get tint18 =>
AppTheme.pick(const Color(0x142F5BEA), const Color(0x282F5BEA));
static Color get tint25 =>
AppTheme.pick(const Color(0x332F5BEA), const Color(0x422F5BEA));
static Color get highlightEdge =>
AppTheme.pick(const Color(0x0A000000), const Color(0x1FFFFFFF));
/// Corner rounding, on macOS's scale rather than iOS's.
///
/// A Mac window is ~10 and a sheet/popover ~12; iOS and the web round far
/// harder, and at 18 a card read as an iOS sheet that had wandered onto a
/// desktop. It also disagreed with the buttons *inside* it ([AppControl.radius]
/// is 8), so a card and its own action were speaking two shape languages.
///
/// The inset tile drops to 8 to match those buttons: an inset sits inside a
/// card, and nesting a rounder box inside a less-round one is what makes a
/// tile look pasted on rather than set in.
static const double radius = 12;
static const double insetRadius = 8;
/// Soft ambient drop that lifts a card off the page.
static List<BoxShadow> get shadow => AppTheme.pick(
const [
BoxShadow(
color: Color(0x12000000),
blurRadius: 20,
offset: Offset(0, 8),
spreadRadius: -8,
),
],
const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 20,
offset: Offset(0, 8),
spreadRadius: -8,
),
],
);
/// The focal (hero) card's stronger lift.
static List<BoxShadow> get heroShadow => AppTheme.pick(
[
const BoxShadow(
color: Color(0x1A000000),
blurRadius: 28,
offset: Offset(0, 12),
spreadRadius: -8,
),
BoxShadow(
color: tint18,
blurRadius: 32,
offset: const Offset(0, 10),
spreadRadius: -8,
),
],
[
const BoxShadow(
color: Color(0x73000000),
blurRadius: 28,
offset: Offset(0, 12),
spreadRadius: -8,
),
BoxShadow(
color: tint18,
blurRadius: 32,
offset: const Offset(0, 10),
spreadRadius: -8,
),
],
);
}
/// The app's theme for a given [brightness]. Both the light and dark themes are
/// built from this one function so the two never drift; the color tokens above
/// resolve against [AppTheme.brightness] at paint time.
ThemeData buildAppTheme({Brightness brightness = Brightness.light}) {
final isDark = brightness == Brightness.dark;
final scheme = isDark
? ColorScheme.dark(
primary: AppPalette.accent,
onPrimary: Colors.white,
secondary: AppPalette.accent,
surface: AppTheme.palette.value.background,
onSurface: const Color(0xFFF5F5F5),
onSurfaceVariant: const Color(0xFFA8A8A2),
surfaceContainerHighest: AppTheme.palette.value.card,
outline: const Color(0x14FFFFFF),
outlineVariant: const Color(0x14FFFFFF),
error: const Color(0xFFF2544B),
)
: const ColorScheme.light(
primary: AppPalette.accent,
onPrimary: Colors.white,
secondary: AppPalette.accent,
// Pure white — matches AppPalette.windowBg so the chat pane (which
// paints scheme.surface) reads as clean white, letting the composer's
// rim and shadow stand out instead of blending into an off-white wash.
surface: Color(0xFFFFFFFF),
onSurface: Color(0xFF1A1A18),
onSurfaceVariant: Color(0xFF62615B),
surfaceContainerHighest: Color(0xFFF3F3F2),
outline: Color(0x0F000000),
outlineVariant: Color(0x0F000000),
error: Color(0xFFB3261E),
);
// The chrome fills used by menus, dialogs and toasts. A getter-backed token
// can't be a compile-time const, so these are resolved here per-brightness —
// and, more importantly, from `isDark` rather than off a token, for the reason
// spelled out at `scrollbarTheme` below.
//
// Two fills, not one. A *floating panel* (menu, popover, tooltip) opens over
// chrome that may itself be raised, so it takes [AppMenu]'s lifted fill; a
// *dialog* is the raised block, so it takes the block's own fill. Before this
// both were `#1E1E1E`, which is [AppCard.base] — a content card's colour, one
// step *below* the block it was supposed to be.
final panelFill = isDark ? AppMenu.fillDark : AppMenu.fillLight;
final dialogFill = isDark
? AppTheme.palette.value.card
: const Color(0xFFFFFFFF);
final textTheme = _appTextTheme(scheme.onSurface, scheme.onSurfaceVariant);
return ThemeData(
filledButtonTheme: FilledButtonThemeData(style: _filledButtonStyle()),
outlinedButtonTheme: OutlinedButtonThemeData(
style: _outlinedButtonStyle(scheme),
),
textButtonTheme: TextButtonThemeData(style: _textButtonStyle()),
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
// Also stated at ThemeData level, not only inside the ramp: Material builds
// text of its own (a dialog's semantics label, a field's error line) that
// never passes through `textTheme`, and without this those fall through to
// Roboto instead of the system face.
fontFamily: AppFont.sans,
fontFamilyFallback: AppFont.sansFallback,
scaffoldBackgroundColor: scheme.surface,
canvasColor: scheme.surface,
dividerColor: scheme.outline,
dividerTheme: DividerThemeData(
color: scheme.outline,
thickness: 1,
space: 1,
),
// Set once here rather than per-Scrollbar: the three call sites that predate
// this each accepted Material's default, which measures 1.23:1 on the light
// page and 2.46:1 on the dark one — under the 3:1 floor for a UI element.
// See `AppSurface.scrollThumb` for the numbers.
scrollbarTheme: ScrollbarThemeData(
// Resolved from `isDark`, not read off `AppSurface` — this function is
// called twice up front, and the dark pass runs while the global
// brightness still says light, so a getter-backed token would bake the
// light thumb into `darkTheme`. The `AppPalette.accent` reads above never
// exposed this: that one is a const with the same value in both themes.
thumbColor: WidgetStateProperty.resolveWith(
(states) =>
states.contains(WidgetState.hovered) ||
states.contains(WidgetState.dragged)
? (isDark ? _scrollThumbHoverDark : _scrollThumbHoverLight)
: (isDark ? _scrollThumbDark : _scrollThumbLight),
),
// No track: a filled channel down the edge of every pane is a border by
// another name, and §2 allows exactly one of those.
trackColor: WidgetStateProperty.all(Colors.transparent),
trackBorderColor: WidgetStateProperty.all(Colors.transparent),
// 6px, radius 3 — a thin capsule rather than the 8px slab, which at this
// contrast would read as a chrome element competing with the rows.
thickness: WidgetStateProperty.all(6),
radius: const Radius.circular(3),
// The thumb is only meaningful while there is more list than pane, and
// fading it in on scroll keeps a short list from wearing furniture.
thumbVisibility: WidgetStateProperty.all(false),
interactive: true,
),
// Material opens a tooltip the instant the pointer enters. In a transcript
// that scrolls under a still pointer that means tooltips popping open on
// rows flying past — and worse: a tooltip shown mid-scroll gets its overlay
// hit-tested before it has been laid out, which throws inside the mouse
// tracker and then re-throws every frame after (see ErrorBurstFilter). A
// delay is the fix for both, set once here rather than at the nine call
// sites that each reached for their own number.
//
// Everything below the delay is the app's floating-panel recipe, the same
// one `appMenuStyle` uses, because a tooltip is one: the menu fill, the
// hairline rim, the panel radius and the lift. Material's own is an
// *inverse* surface — deliberately the opposite of the window — so on a dark
// build it opened a white slab in the middle of a near-black app.
//
// The rim earns its place here for the reason it does on a menu: in light
// the fill is the same white as the surface underneath, so the rim is the
// only thing drawing the edge, and in dark it firms up a lift that a shadow
// alone renders too softly against near-black.
tooltipTheme: TooltipThemeData(
waitDuration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
color: panelFill,
borderRadius: BorderRadius.circular(AppMenu.panelRadius),
// Resolved from `isDark` rather than read off the `AppMenu` getters:
// those answer for the brightness the app is *showing*, and this function
// builds the theme for a brightness it has been handed.
border: Border.all(color: isDark ? AppMenu.rimDark : AppMenu.rimLight),
boxShadow: isDark
? const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 24,
offset: Offset(0, 10),
spreadRadius: -10,
),
BoxShadow(
color: Color(0x40000000),
blurRadius: 4,
offset: Offset(0, 1),
),
]
: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 24,
offset: Offset(0, 10),
spreadRadius: -10,
),
BoxShadow(
color: Color(0x0A000000),
blurRadius: 4,
offset: Offset(0, 1),
),
],
),
// Room for multiline text.
textStyle: AppType.caption(height: 1.45, color: scheme.onSurface),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
// Long quoted lines would otherwise take the tooltip out to the window's
// full width — a panel wider than the conversation it is explaining.
constraints: const BoxConstraints(maxWidth: 420),
),
// macOS controls do not ripple. §10.1 lists the ink ripple among the four
// things a raw `MenuItemButton` gets wrong, and §11 names it again for
// `IconButton` and `SegmentedButton` — so turning it off once here is the
// same rule applied at the root instead of at every call site.
splashFactory: NoSplash.splashFactory,
textTheme: textTheme,
primaryTextTheme: textTheme,
iconTheme: IconThemeData(color: scheme.onSurfaceVariant, size: 18),
iconButtonTheme: IconButtonThemeData(
style: ButtonStyle(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),
),
overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) return Colors.transparent;
if (states.contains(WidgetState.pressed)) {
return isDark ? const Color(0x1AFFFFFF) : const Color(0x0D000000);
}
if (states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return isDark ? const Color(0x0DFFFFFF) : const Color(0x07000000);
}
return Colors.transparent;
}),
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.all(Colors.white),
trackColor: WidgetStateProperty.resolveWith(
(s) => s.contains(WidgetState.selected)
? AppPalette.accent
: (isDark ? const Color(0xFF3A3A3A) : const Color(0xFFA3A29C)),
),
trackOutlineColor: WidgetStateProperty.all(Colors.transparent),
),
// A text field must show where the keyboard is going. This used to set
// BorderSide.none on all three states, so every field that took the theme
// default — most of the app — had no focus ring at all: you could not tell
// a focused field from an idle one. macOS rings the focused control in the
// accent; that's what these borders restore.
inputDecorationTheme: InputDecorationTheme(
isDense: true,
filled: true,
fillColor: scheme.surfaceContainerHighest,
// A field hint is set like the field's own text.
// (The typed text is set on the field via
// [kFieldTextStyle]; `InputDecorationTheme` has no `style` of its own, so
// a field's own text can't be themed globally here.)
hintStyle: _fieldTextStyle(
scheme.onSurfaceVariant.withValues(alpha: 0.7),
),
// Material builds a field for a phone: its default padding, plus the 48px
// touch target it gives a prefixIcon, rendered this 48 tall next to a 32px
// button. That's what these constraints exist to hold back.
//
// The height is [AppControl.heightField], not [AppControl.height]: a field
// is typed in, not clicked, and at a button's 32 it reads cramped — see
// that token. Derived here rather than typed as a number that happens to
// land near it.
//
// The arithmetic: the box is padding + one line of AppControl.fontSize.
// `isDense` already tightens Material's own vertical slack, so the padding
// is what's left over once the line has taken its share.
constraints: BoxConstraints(minHeight: AppControl.heightFieldScaled),
contentPadding: EdgeInsets.symmetric(
horizontal: 10,
vertical:
(AppControl.heightFieldScaled - AppControl.fontSize * 1.35) / 2,
),
// The glyph sits on the text's line, not in a tap target of its own. A
// step above [AppControl.iconSize]: that size is tuned to a button's cap
// height, and this field is taller, so the button's glyph looks
// undersized in it.
prefixIconConstraints: BoxConstraints(
minWidth: 32 * AppFont.uiScale,
minHeight: kFieldIconSize * AppFont.uiScale,
),
border: _fieldBorder(scheme.outline),
enabledBorder: _fieldBorder(scheme.outline),
focusedBorder: _fieldBorder(AppPalette.accent, width: 1.5),
errorBorder: _fieldBorder(scheme.error),
focusedErrorBorder: _fieldBorder(scheme.error, width: 1.5),
),
// Fill, rim and elevation come from [AppMenu] so a Material popup cannot
// disagree with a MenuAnchor about what a menu looks like.
//
// The radius does not: [AppControl.menuRadius] (6) is the §3 step for
// *Material's own* popup, which draws tighter than the app's panel (10).
popupMenuTheme: PopupMenuThemeData(
color: panelFill,
surfaceTintColor: Colors.transparent,
elevation: AppMenu.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppControl.menuRadius),
side: BorderSide(color: isDark ? AppMenu.rimDark : AppMenu.rimLight),
),
),
// The default every `MenuAnchor` gets, identical to what [AppMenu.style]
// hands a call site that asks for one explicitly. That identity is the whole
// point: a MenuAnchor that forgets to pass a style used to fall back to a
// rimless panel, which is the bug the hand-written style existed to fix.
menuTheme: MenuThemeData(style: AppMenu.styleFor(isDark: isDark)),
// An ExpansionTile defaults `backgroundColor` (expanded) and
// `collapsedBackgroundColor` to *different* values and cross-fades between
// them, so opening one flashes a tint over whatever surface it sits on. Our
// tiles are always laid into a card that has already painted its own
// background, so both states must simply be transparent — then expanding
// moves the disclosure and nothing else.
expansionTileTheme: const ExpansionTileThemeData(
backgroundColor: Colors.transparent,
collapsedBackgroundColor: Colors.transparent,
// Same reason for the divider lines each tile was clearing by hand.
shape: Border(),
collapsedShape: Border(),
),
dialogTheme: DialogThemeData(
// The raised block's own fill, not a menu's and not a card's. §9.3: a
// dialog that takes `windowBg` merges with the page, and one that takes
// [AppCard.base] sits a step under the block it is supposed to *be* —
// both invisible in light, both plain in dark.
backgroundColor: dialogFill,
surfaceTintColor: Colors.transparent,
// The card radius, not a number of its own. This was 18 — the iOS
// action-sheet curve, and the app's cards had already come down to 12 for
// macOS without the dialogs following.
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppCard.radius),
),
titleTextStyle: AppType.heading(color: scheme.onSurface),
contentTextStyle: AppType.body(color: scheme.onSurface),
),
// ⚠️ [AppPalette.accentOnSurface], NOT `colorScheme.primary`.
//
// A spinner is the textbook case that token exists for: a MARK ON A SURFACE,
// not a fill carrying white text. Left to `primary` the arc draws #2F5BEA on
// the dark page, which measures 3.218:1 — over §16's 3.0 floor for a UI
// element, but only just, and it is the one thing on screen saying the app
// is still working. The lifted tint reaches 5.750:1.
//
// Resolved from `isDark` rather than read off the getter — see the note at
// `scrollbarTheme`.
progressIndicatorTheme: ProgressIndicatorThemeData(
color: isDark ? const Color(0xFF6E8BFF) : const Color(0xFF2F5BEA),
linearTrackColor: scheme.outline,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
width: 520,
// A snackbar floats over everything, so it takes the panel fill.
backgroundColor: panelFill,
contentTextStyle: AppType.body(color: scheme.onSurface),
actionTextColor: AppPalette.accent,
closeIconColor: scheme.onSurfaceVariant,
elevation: 10,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(13)),
),
);
}
/// The one set of numbers every button in the app is built from.
///
/// macOS controls are compact, squarish and quiet: a 13pt semibold label in a
/// ~32px capsule with a small radius — not the tall, wide, fully-round buttons
/// Material defaults to. Before this existed each call site invented its own,
/// which is how the app ended up with seven button heights, three shape systems
/// and six label sizes. Change a number here, not at a call site.
abstract final class AppControl {
/// Standard control height. macOS's regular push button sits at 32; the app's
/// own most-used value was 34, and 32 reads correctly next to a 13pt label.
static const double height = 32;
/// A compact control (inline actions inside a dense row or a card header).
static const double heightSmall = 28;
/// A search field at the head of a list column.
///
/// Taller than [height], and deliberately: a push button is sized to be hit,
/// but this field is sized to be *typed in* and to anchor the column under it.
/// Finder and Mail both give their sidebar search more room than a button in
/// the same window for exactly that reason — at [height] it reads as cramped,
/// which is what a control set to a button's scale looks like when it isn't
/// one.
static const double heightField = 36;
/// Corner radius. Apple's push buttons are gently rounded, not stadium — a
/// pill reads as a "chip" on macOS, not as a button.
static const double radius = 8;
/// A menu/popover's rounding. Tighter than a button's: macOS menus are nearly
/// square-cornered, and rounding one like an iOS action sheet is one of the
/// louder tells that a desktop app was drawn to phone conventions.
static const double menuRadius = 6;
/// The air between a menu panel and the control it hangs off.
///
/// One number for every menu in the app, in both directions: a downward menu
/// passes `Offset(0, menuGap)`, an upward one `Offset(0, -menuGap)`. It was
/// three numbers before — 6 nearly everywhere, 8 on the composer's agent and
/// approval pills, and none at all on its model pill, so the three menus that
/// open off the *same* toolbar each sat at a different height.
static const double menuGap = 6;
/// The tallest a menu panel ever draws, whatever it lists — `appMenuStyle`
/// caps every menu here.
///
/// A token rather than a number inside that style, because the menus that
/// open *upward* have to place themselves by summing the height they are
/// about to take: a caller that predicts 310 for a panel the style then draws
/// at 240 lifts it 70px clear of its button. That is what floated the chat
/// model list off the composer and over the conversation.
/// [anchoredMenuPosition] clamps to this by default.
static const double menuMaxHeight = 240;
/// A button's label: [AppType.label].
static const double fontSize = AppType.bodySize;
static const FontWeight fontWeight = AppFont.medium;
/// A leading glyph inside a button, sized to sit on the cap height of a 13pt
/// label rather than tower over it.
static const double iconSize = 16;
/// A compact glyph for an inline chip.
static const double iconSizeChip = 13;
/// Horizontal breathing room. Apple pads a push button generously sideways and
/// barely at all vertically — the height is what sets the touch target.
static const EdgeInsets padding = EdgeInsets.symmetric(horizontal: 14);
static const EdgeInsets paddingSmall = EdgeInsets.symmetric(horizontal: 10);
/// A button that leads with a glyph, at the compact scale.
///
/// Wider on the leading edge than the trailing one, and the asymmetry is the
/// point: [paddingSmall] is even, which measures as centred and *reads* as
/// pushed left. A glyph is a solid shape that fills its box to the edge, while
/// a label's first letter carries its own sidebearing — so at an equal 10px
/// the glyph sits visibly tighter to the rim than the text does at the other
/// end. Extra leading pad buys back what the glyph lacks.
///
/// Only for `.icon` constructors; a text-only button stays on [paddingSmall].
static const EdgeInsets paddingSmallIcon = EdgeInsets.only(
left: 12,
right: 10,
);
// — the same numbers, grown with the user's UI size —
//
// The constants above stay constants on purpose. They appear in ~40 `const`
// expressions across the app (`const Size(0, AppControl.height)`, `const
// Icon(…, size: AppControl.iconSize)`), and turning them into getters would
// break every one of those for no gain: a `const Icon` inside a themed button
// is already re-laid-out when the button's own box grows.
//
// What actually has to grow is the *box*, and every box in the app comes from
// one of the button/field styles built below in [buildAppTheme] — so these
// scaled forms are read there, in one place, rather than at the call sites.
/// [height], grown for the current UI size.
static double get heightScaled => height * AppFont.uiScale;
/// [heightField], grown for the current UI size.
static double get heightFieldScaled => heightField * AppFont.uiScale;
/// [padding], grown for the current UI size — a wider label needs the
/// sidebearing to grow with it, or the text crowds the capsule's ends.
static EdgeInsets get paddingScaled =>
EdgeInsets.symmetric(horizontal: 14 * AppFont.uiScale);
static EdgeInsets get paddingSmallScaled =>
EdgeInsets.symmetric(horizontal: 10 * AppFont.uiScale);
}
/// Desktop feedback is visible on the next frame. Keep these shared names so
/// hover, selection, panels, and meters cannot grow independent animation delays.
/// Progress indicators may still animate while real work is in flight.
abstract final class AppMotion {
static const Duration hover = Duration.zero;
static const Duration swap = Duration.zero;
static const Duration fold = Duration.zero;
static const Duration meter = Duration.zero;
static const Duration press = Duration.zero;
static const Curve curve = Curves.easeOut;
}
/// The app's two faces and its weight ladder. Sizes live in [AppType].
abstract final class AppFont {
/// The system UI face. See [AppType] for the rule on which face text gets.
static String get sans => AppType.sansFamily;
static List<String> get sansFallback => AppType.sansFallback;
/// The terminal's face, used for terminal chrome and copyable strings.
static String get mono => AppType.monoFamily;
static List<String> get monoFallback => AppType.monoFallback;
/// Digits that hold a fixed width, so a stat doesn't reflow as its value
/// changes and a column of numbers lines up.
static const List<FontFeature> tabularFigures = [
FontFeature.tabularFigures(),
];
// — the weight ladder —
//
// Three steps, and the app should not need a fourth. Named rather than typed
// as `FontWeight.w500` at the call site, because the numbers moved once
// already: the app was drawn at w600 for every label and w700 for every
// heading, which is a step heavier than macOS itself sets the same things.
// Finder's sidebar, System Settings' rows and a push button's label are all
// *medium*; semibold is what Apple reserves for a window title or a section
// header that has to out-rank the rows under it.
//
// At 13pt on a Retina panel that one step is the difference between a UI that
// reads as crisp and one that reads as shouted — every label competing with
// every other, and nothing left to promote a heading with.
/// Body copy, and anything that is simply being read.
static const FontWeight regular = FontWeight.w400;
/// The app's workhorse: control labels, sidebar items, row titles, table
/// headers — anything that names something without being a heading.
///
/// This is the weight that used to be [semibold] in 133 places.
static const FontWeight medium = FontWeight.w500;
/// Reserved for type that has to out-rank the medium text beside it: a screen
/// or section heading, the selected row in a menu.
///
/// If everything on a screen is semibold, nothing is — which is the state the
/// ladder was introduced to fix. Reach for [medium] first.
static const FontWeight semibold = FontWeight.w600;
/// Tracking for the sans face; see [AppType.trackingFor].
static double trackingFor(double size) => AppType.trackingFor(size);
/// The UI no longer scales with the terminal's size setting: ⌘+ and ⌘−
/// resize the terminal alone, so control geometry stays as drawn.
static const double uiScale = 1;
/// The terminal's size, for a surface that shows terminal output verbatim.
static double get codeSize => terminalFontStore.size;
/// A block of code or a log: the terminal face at the UI's mono size.
static TextStyle codeStyle({
Color? color,
double? height,
FontWeight? fontWeight,
}) => AppType.mono(color: color, height: height, fontWeight: fontWeight);
}
/// A text field's rim at one state. Radius matches [AppControl.radius] so a
/// field and the button next to it are cut from the same shape language.
OutlineInputBorder _fieldBorder(Color color, {double width = 1}) =>
OutlineInputBorder(
borderRadius: BorderRadius.circular(AppControl.radius),
borderSide: BorderSide(color: color, width: width),
);
/// The label style shared by every button: a `ButtonStyle.textStyle` does
/// **not** inherit `fontFamily` from the text theme, so a button must receive
/// the UI font explicitly.
TextStyle get _buttonTextStyle =>
AppType.label(fontWeight: AppControl.fontWeight);
/// A text field's own text: [AppType.mono] — what the user types is set the
/// way a terminal sets it, at the scale of the button beside it.
/// `InputDecorationTheme` has no `style` slot (it themes the *decoration*, not
/// the editable text), so a field must be handed this explicitly:
/// `TextField(style: kFieldTextStyle, ...)`.
TextStyle get kFieldTextStyle => _fieldTextStyle(AppPalette.textPrimary);
TextStyle _fieldTextStyle(Color color) => AppType.mono(color: color);
/// A field's leading glyph — the magnifier on a search box, and its kind.
///
/// A step above [AppControl.iconSize], which is tuned to sit on the cap height
/// of a button's 13pt label. A field is [AppControl.heightField] tall, so the
/// button's glyph reads undersized in it — the icon has to follow the box it
/// sits in, not the token next to it. Themed via `prefixIconConstraints`, but
/// the `Icon` itself still has to be handed this size at the call site.
const double kFieldIconSize = 18;
RoundedRectangleBorder get _buttonShape => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppControl.radius),
);
/// The primary action: a solid accent capsule.
/// A filled button that destroys something.
///
/// Exists so the three that do — the two connector confirms and the skill delete
/// — cannot each pick their own red and their own label colour. Before this they
/// had three answers between them, and all three failed in dark: two set white
/// on [AppPalette.dangerFill]'s predecessor (3.42:1) and one left the label to
/// Material (3.83:1).
///
/// `foregroundColor` is named rather than left to `colorScheme.onError`, which
/// this app's scheme never sets — so Material fills in its own and lands under
/// the floor.
ButtonStyle dangerButtonStyle() => FilledButton.styleFrom(
backgroundColor: AppPalette.dangerFill,
foregroundColor: Colors.white,
);
ButtonStyle _filledButtonStyle() => FilledButton.styleFrom(
animationDuration: Duration.zero,
minimumSize: Size(0, AppControl.heightScaled),
padding: AppControl.paddingScaled,
shape: _buttonShape,
textStyle: _buttonTextStyle,
// Material pads every button out to a 48px tap target — on a desktop app
// that leaves a 32px button floating in a 48px box and wrecks every row it
// sits in.
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.standard,
// Its own wash, not [AppSurface.hoverFill]: this button already carries the
// accent as a FILL, so the hover has to read against that rather than against
// the page. White at 12% lifts the accent a step without turning it into a
// second colour. See [_textButtonStyle] for why any of these are needed at
// all — `NoSplash` took the ripple away and left nothing behind it.
overlayColor: const Color(0x1FFFFFFF),
);
/// The secondary action: a hairline rim, no fill — Apple's "bordered" button.
ButtonStyle _outlinedButtonStyle(ColorScheme scheme) =>
OutlinedButton.styleFrom(
animationDuration: Duration.zero,
minimumSize: Size(0, AppControl.heightScaled),
padding: AppControl.paddingScaled,
shape: _buttonShape,
textStyle: _buttonTextStyle,
side: BorderSide(color: scheme.outline),
foregroundColor: scheme.onSurface,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.standard,
);
/// The tertiary action: text only, for the quiet way out of a dialog.
ButtonStyle _textButtonStyle() => TextButton.styleFrom(
animationDuration: Duration.zero,
minimumSize: Size(0, AppControl.heightScaled),
padding: AppControl.paddingSmallScaled,
shape: _buttonShape,
textStyle: _buttonTextStyle,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.standard,
// ⚠️ Without this a text button has NO hover state at all.
//
// `splashFactory: NoSplash` turns Material's ripple off app-wide — right, it
// is an Android idiom — but the ripple was also the only thing this theme
// left drawing a response to the pointer. M3 derives its own overlay from
// `foregroundColor`, and `styleFrom` here passes none, so the resolved
// overlay came back **null**: the button lit up on press and on focus and did
// nothing whatsoever on hover. On a desktop app, where the pointer is how you
// find out what is clickable, that is a control that reads as a label.
//
// [AppSurface.hoverFill] is the same wash the rows and menu items already
// use, so a button now answers the pointer the way everything around it does.
overlayColor: AppSurface.hoverFill,
);
TextTheme _appTextTheme(Color primary, Color secondary) {
// Material's fifteen roles folded onto [AppType]'s steps: headings and
// labels in mono, body in sans. A heading keeps semibold; a role that names a
// control drops to medium.
final display = AppType.display(color: primary);
final title = AppType.title(color: primary);
final heading = AppType.heading(color: primary);
final body = AppType.body(color: primary);
final label = AppType.label(color: primary);
return TextTheme(
displayLarge: display,
displayMedium: display,
displaySmall: display,
headlineLarge: title,
headlineMedium: title,
headlineSmall: title,
titleLarge: title,
titleMedium: heading,
titleSmall: label,
bodyLarge: body,
bodyMedium: body,
bodySmall: body.copyWith(color: secondary),
labelLarge: label,
labelMedium: label,
labelSmall: AppType.monoMeta(color: primary, fontWeight: AppFont.medium),
);
}