Retheme on Linear's neutral palette and restore light mode

af183c5f97c7 · AtlantisPleb · · parent d72282fcb203

Retheme on Linear's neutral palette and restore light mode

The palette was tinted -- chroma ~.03-.07 around hue 263-266 -- so every
surface, border and muted text read blue. Replaced with Linear's ramp, taken
from a saved copy of their docs: #08090a page, #0f1011 canvas, #141516
raised, #f7f8f8 ink, #23252a/#34343a borders. Neutral to faintly cool,
never blue.

Light mode returns. Linear publishes no light palette, so it is derived in
the same language rather than copied: same neutral discipline, same
surface-ladder direction (raised stays lighter than void, which inverts to
white cards on a grey page), ink flipped to their darkest value.

Only the primitives and four literal exceptions are restated for light. The
icon tiers, washes, hover surface and inverse control are all
color-mix(... var(--text-ink) N% ...) and invert on their own -- the ladder
was built for this even though nothing had used it yet. The inverse control
becomes a dark fill with light glyph in light mode without being told.

Theme selection is a three-rung segmented control in both command bars.
The active rung cannot be rendered server-side because the choice lives in
localStorage, so the head script records it as data-theme-choice and the CSS
expresses the selection. That script is deliberately not deferred: resolving
the theme after the stylesheet paints produces a flash of the wrong theme.
"System" stores nothing and clears data-theme so the prefers-color-scheme
fallback governs; an explicit choice always wins.

Built on basecoat's button-group, whose inline-flex/w-fit shrink-wrap is
what the previous toggle lacked when it stretched across its container.

Two fixes found on the way:

- .audio-player hardcoded color-scheme: dark, which would have put a dark
  native scrubber on a white card in light mode. Now inherits.
- Documented a real name collision: basecoat's base.css also defines
  --accent, but as shadcn does -- a subtle hover background, not a brand
  color. Ours wins on cascade order, and only combobox/dropdown-menu/select
  consume basecoat's meaning, none of which are imported. Importing one
  would give it an indigo hover surface.

The CSS contract test asserted the cascade contained no data-theme or
prefers-color-scheme at all. That was the right guard for proving DaisyUI's
theme plugin was gone, but it forbade all theming including ours. Replaced
the prohibition with a positive contract: both themes must compile, the
no-JS fallback must compile, and no third theme may appear.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/app.css
  • modified assets/css/openagents.css
  • modified assets/test/css_contract_test.mjs
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/components/layouts/root.html.heex

Diff

5 files changed, +195 -28

assets/css/app.css modified +120 -26

@@ -26,6 +26,7 @@

26 26
 * exact structure consumed by `OpenAgentsWeb.UI`. */
27 27
@import "../vendor/basecoat/base/base.css";
28 28
@import "../vendor/basecoat/components/button.css";
29
@import "../vendor/basecoat/components/button-group.css";
29 30
@import "../vendor/basecoat/components/input.css";
30 31
@import "../vendor/basecoat/components/textarea.css";
31 32
@import "../vendor/basecoat/components/label.css";

@@ -69,31 +70,50 @@

69 70
 * (--ink-surface), elevated components another step up (--ink-raised). New
70 71
 * surfaces take a rung; they do not invent a fourth.
71 72
 *
72
 * The palette is dark-only, as OpenAgents is (`color-scheme: dark`). A light
73
 * palette requires a separate owner-approved design project.
73
 * Two themes. `:root` carries dark; `:root[data-theme="light"]` overrides only
74
 * the primitives and the few literal exceptions. Everything else in the ladder
75
 * is `color-mix(... var(--text-ink) N% ...)`, so the icon tiers, washes, hover
76
 * surface and inverse control invert on their own when the ink flips — that is
77
 * the whole point of deriving them rather than listing them twice.
78
 *
79
 * Values are Linear's, taken from a saved copy of their docs: neutral to
80
 * faintly cool, never blue. The previous palette was tinted (chroma ~.03-.07
81
 * around hue 263) which read as blue on every surface.
74 82
 */
75 83
:root {
76 84
  color-scheme: dark;
77 85
78
  /* PRIMITIVES — the Aiur palette, verbatim. */
79
  --ink-void: oklch(12.901% 0.01498 266.378); /* darkest: page, sidebar */
80
  --ink-surface: oklch(18.311% 0.03089 263.383); /* +1: canvas, chrome */
81
  --ink-raised: oklch(24.185% 0.04699 263.85); /* +2: elevated components */
82
  --text-ink: oklch(96.386% 0.01695 267.792); /* the one text/icon/wash ink */
83
  --accent: oklch(62.308% 0.18801 259.815);
84
  --accent-bright: oklch(71.374% 0.14338 254.624);
85
  --info: oklch(75.351% 0.13899 232.661);
86
  --success: oklch(72.275% 0.19201 149.579);
87
  --warning: oklch(76.859% 0.16466 70.08);
88
  --danger: oklch(71.063% 0.16615 22.216);
89
90
  /* DERIVATIONS — text tiers as alpha steps of the ink. The literals are the
91
     Aiur values; the percentages beside them are the measured best-fit alpha
92
     steps, recorded so a later theme can choose the ladder deliberately. */
86
  /* PRIMITIVES — Linear's dark ramp, verbatim.
87
     void #08090a is their --color-bg-primary, surface #0f1011 their level-1
88
     panel, raised #141516 their level-2. Raised is LIGHTER than void; light
89
     mode keeps that direction rather than flipping it. */
90
  --ink-void: #08090a; /* darkest: page, sidebar */
91
  --ink-surface: #0f1011; /* +1: canvas, chrome */
92
  --ink-raised: #141516; /* +2: elevated components */
93
  --text-ink: #f7f8f8; /* the one text/icon/wash ink (their --color-fg-primary) */
94
95
  /* The single accent. Linear's brand indigo, used only for the primary action
96
     and links; every other surface is neutral.
97
     NAME COLLISION, deliberate: Basecoat's base.css also defines --accent, but
98
     as shadcn does — a subtle hover background paired with --accent-foreground,
99
     not a brand color. Ours is defined later at equal specificity so it wins.
100
     Only combobox.css, dropdown-menu.css and select.css consume Basecoat's
101
     meaning and none of the three is imported; importing one would give it an
102
     indigo hover surface. Rename ours before importing them. */
103
  --accent: #5e6ad2;
104
  --accent-bright: #828fff;
105
  --info: #4ea7fc;
106
  --success: #27a644;
107
  --warning: #d4b144;
108
  --danger: #eb5757;
109
110
  /* DERIVATIONS — text tiers. Literals are Linear's fg ramp; the percentages
111
     beside them are the equivalent alpha step, kept so the ladder stays legible
112
     if a later theme wants to derive rather than list. */
93 113
  --text-primary: color-mix(in oklab, var(--text-ink) 100%, transparent); /* 100% */
94
  --text-body: oklch(90.551% 0.0307 265.325); /* exception; best fit 91% */
95
  --text-muted: oklch(71.613% 0.04926 262.439); /* exception; best fit 65% */
96
  --text-dim: oklch(65.775% 0.04389 274.477); /* exception; best fit 57% */
114
  --text-body: #d0d6e0; /* their fg-secondary */
115
  --text-muted: #8a8f98; /* their fg-tertiary */
116
  --text-dim: #62666d; /* their fg-quaternary */
97 117
98 118
  /* Icon tiers. Glyphs read lighter than text at the same alpha, so they get
99 119
     their own ladder rather than borrowing the text one. */

@@ -114,19 +134,72 @@

114 134
  --ink-hover: color-mix(in oklab, var(--text-ink) 4%, var(--ink-raised)); /* 4% over raised */
115 135
116 136
  /* The one inverted control: the strongest ink as ground, the darkest rung as
117
     glyph. State changes ride opacity so the fill never shifts hue. */
137
     glyph. Flips by itself in light mode — dark fill, light glyph. */
118 138
  --control-inverse-bg: var(--text-primary);
119 139
  --control-inverse-fg: var(--ink-void);
120 140
121
  /* Borders. --line and --line-strong are the 12% and 20% rungs by role but
122
     keep their literals (same chroma accounting as the text exceptions); the
123
     soft and faint rungs are true ladder steps. */
124
  --line: oklch(29.154% 0.0506 264.607); /* exception; best fit 11% */
141
  /* Borders. Linear's border ramp. */
142
  --line: #23252a; /* their --color-border-primary */
125 143
  --line-soft: color-mix(in oklab, var(--text-ink) 8%, transparent); /* 8% */
126 144
  --line-faint: color-mix(in oklab, var(--text-ink) 4%, transparent); /* 4% */
127
  --line-strong: oklch(36.455% 0.0696 264.888); /* exception; best fit 20% */
145
  --line-strong: #34343a; /* their --color-border-secondary */
146
}
147
148
/* LIGHT.
149
 *
150
 * Linear publishes no light palette (their site is dark-only), so this is
151
 * derived in the same language rather than copied: the same neutral hue
152
 * discipline, the same surface-ladder direction (raised is lighter than void),
153
 * and the same ink flipped to their darkest value.
154
 *
155
 * Only the primitives and the four literal exceptions are restated. The icon
156
 * tiers, washes, hover surface and inverse control are all derived from
157
 * --text-ink above and invert without being repeated here.
158
 */
159
:root[data-theme="light"] {
160
  color-scheme: light;
161
162
  --ink-void: #f7f8f8; /* page, sidebar — the most recessive surface */
163
  --ink-surface: #fbfbfc; /* +1: canvas */
164
  --ink-raised: #ffffff; /* +2: elevated components read as paper */
165
  --text-ink: #08090a; /* the ink is now their darkest value */
166
167
  /* Indigo darkens for contrast against white; the bright rung becomes the
168
     hover/visited state rather than the resting one. */
169
  --accent: #5e6ad2;
170
  --accent-bright: #4a55b8;
171
172
  --text-body: #3c4149;
173
  --text-muted: #62666d;
174
  --text-dim: #8a8f98;
175
176
  --line: #e6e7e9;
177
  --line-strong: #d0d2d6;
178
}
179
180
/* No-JS / no-stored-preference fallback. The head script sets data-theme
181
 * before first paint, so this only governs the window before it runs and the
182
 * case where scripting is off. It is scoped :not([data-theme]) so an explicit
183
 * choice always wins over the OS preference. */
184
@media (prefers-color-scheme: light) {
185
  :root:not([data-theme]) {
186
    color-scheme: light;
187
188
    --ink-void: #f7f8f8;
189
    --ink-surface: #fbfbfc;
190
    --ink-raised: #ffffff;
191
    --text-ink: #08090a;
192
    --accent: #5e6ad2;
193
    --accent-bright: #4a55b8;
194
    --text-body: #3c4149;
195
    --text-muted: #62666d;
196
    --text-dim: #8a8f98;
197
    --line: #e6e7e9;
198
    --line-strong: #d0d2d6;
199
  }
128 200
}
129 201
202
130 203
/* The semantic four are OpenAgents, not DaisyUI's, and have no Basecoat
131 204
 * equivalent — `--color-destructive` covers danger, but there is no success,
132 205
 * warning, or info in the Basecoat base. */

@@ -511,3 +584,24 @@

511 584
.send-action:active:not(:disabled) {
512 585
  transform: scale(0.97);
513 586
}
587
588
/* Theme toggle.
589
 *
590
 * The active rung cannot be rendered server-side: the choice lives in
591
 * localStorage, so the head script records it on the root element and the
592
 * selection is expressed here. `button-group` supplies the shrink-wrap
593
 * (inline-flex w-fit) and the joined borders; without a width the segments
594
 * would stretch to whatever container they land in.
595
 */
596
@layer components {
597
  .theme-toggle .btn[data-theme-option] {
598
    color: var(--icon-tertiary);
599
  }
600
601
  :root[data-theme-choice="system"] .theme-toggle .btn[data-theme-option="system"],
602
  :root[data-theme-choice="light"] .theme-toggle .btn[data-theme-option="light"],
603
  :root[data-theme-choice="dark"] .theme-toggle .btn[data-theme-option="dark"] {
604
    background: var(--wash-selected);
605
    color: var(--icon-primary);
606
  }
607
}
assets/css/openagents.css modified +4 -1

@@ -1603,7 +1603,10 @@

1603 1603
    height: 34px;
1604 1604
    border-radius: var(--radius);
1605 1605
    background: var(--ink-raised);
1606
    color-scheme: dark;
1606
    /* Follow the document theme so the native control chrome matches the
1607
       surface it sits on. Forcing dark here left light mode with a dark
1608
       scrubber on a white card. */
1609
    color-scheme: inherit;
1607 1610
  }
1608 1611
1609 1612
  .audio-player:focus-visible {
assets/test/css_contract_test.mjs modified +11 -1

@@ -43,7 +43,17 @@ test("the compiled cascade preserves every governed button variant", () => {

43 43
44 44
    assert.doesNotMatch(css, /\.btn-(primary|secondary|ghost|error|success|warning|info)\b/)
45 45
    assert.doesNotMatch(css, /--color-base-(100|200|300|content)\b/)
46
    assert.doesNotMatch(css, /data-theme|prefers-color-scheme/)
46
47
    // This previously asserted the cascade contained no `data-theme` or
48
    // `prefers-color-scheme` at all. That was the right guard for its moment --
49
    // it proved DaisyUI's theme plugin was gone -- but it forbade ALL theming,
50
    // including ours. The owner has since asked for a light mode, so the
51
    // prohibition is replaced by a positive contract: the app ships exactly two
52
    // themes, keyed off data-theme with a prefers-color-scheme fallback, and
53
    // neither may reintroduce a third-party theme plugin's tokens.
54
    assert.match(css, /\[data-theme=["']?light["']?\]/, "the light theme must be compiled")
55
    assert.match(css, /prefers-color-scheme:\s*light/, "the no-JS light fallback must be compiled")
56
    assert.doesNotMatch(css, /\[data-theme=["']?(?!light|dark)[a-z]+["']?\]/, "only light and dark are governed themes")
47 57
  } finally {
48 58
    rmSync(output, {force: true})
49 59
  }
lib/openagents_web/components/layouts.ex modified +38

@@ -96,6 +96,7 @@ defmodule OpenAgentsWeb.Layouts do

96 96
      </div>
97 97
98 98
      <div class="flex items-center gap-2">
99
        <.theme_toggle />
99 100
        <%= if @current_scope do %>
100 101
          <.account_dropdown current_scope={@current_scope} />
101 102
        <% else %>

@@ -127,12 +128,49 @@ defmodule OpenAgentsWeb.Layouts do

127 128
      <div class="command-controls">
128 129
        {render_slot(@controls)}
129 130
131
        <.theme_toggle />
130 132
        <.account_control :if={@current_user} current_user={@current_user} />
131 133
      </div>
132 134
    </header>
133 135
    """
134 136
  end
135 137
138
  @doc """
139
  System / light / dark, as a three-rung segmented control.
140
141
  The active rung is not rendered here. The choice lives in `localStorage`, so
142
  the head script in `root.html.heex` records it as `data-theme-choice` on the
143
  root element and `app.css` expresses the selection from that. Rendering it
144
  server-side would be wrong twice over: the server does not know the visitor's
145
  choice, and a LiveView re-render would fight the script.
146
147
  "System" stores nothing and clears `data-theme`, which lets the
148
  `prefers-color-scheme` fallback in `app.css` govern.
149
  """
150
  def theme_toggle(assigns) do
151
    ~H"""
152
    <div class="button-group theme-toggle" role="group" aria-label="Color theme">
153
      <UI.button
154
        :for={
155
          {choice, icon, label} <- [
156
            {"system", "desktop", "Match system theme"},
157
            {"light", "sun", "Light theme"},
158
            {"dark", "moon", "Dark theme"}
159
          ]
160
        }
161
        variant={:secondary}
162
        size={:sm}
163
        data-theme-option={choice}
164
        aria-label={label}
165
        title={label}
166
        phx-click={JS.dispatch("phx:set-theme", detail: %{theme: choice})}
167
      >
168
        <UI.icon name={icon} />
169
      </UI.button>
170
    </div>
171
    """
172
  end
173
136 174
  @doc """
137 175
  The one authenticated identity control: an avatar trigger opening a bounded
138 176
  native popover with the same identity and a labeled `LOG OUT` action.
lib/openagents_web/components/layouts/root.html.heex modified +22

@@ -10,6 +10,28 @@

10 10
    <link rel="apple-touch-icon" href={~p"/apple-touch-icon.png"} sizes="180x180" />
11 11
    <.live_title default="OpenAgents" suffix=" · OpenAgents" phx-no-format>{assigns[:page_title]}</.live_title>
12 12
    <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
13
    <%!-- Runs before first paint, and deliberately not deferred: resolving the
14
    theme after the stylesheet has painted produces a flash of the wrong theme.
15
    "system" stores nothing and leaves data-theme unset so the CSS
16
    prefers-color-scheme fallback governs; an explicit choice sets the
17
    attribute, which always wins. --%>
18
    <script phx-no-format>(() => {
19
      const stored = localStorage.getItem("phx:theme");
20
      const apply = (choice) => {
21
        if (choice === "system" || choice === null) {
22
          localStorage.removeItem("phx:theme");
23
          document.documentElement.removeAttribute("data-theme");
24
          document.documentElement.setAttribute("data-theme-choice", "system");
25
        } else {
26
          localStorage.setItem("phx:theme", choice);
27
          document.documentElement.setAttribute("data-theme", choice);
28
          document.documentElement.setAttribute("data-theme-choice", choice);
29
        }
30
      };
31
      apply(stored);
32
      window.addEventListener("phx:set-theme", (e) => apply(e.detail?.theme ?? e.target?.dataset?.theme));
33
      window.addEventListener("storage", (e) => e.key === "phx:theme" && apply(e.newValue));
34
    })();</script>
13 35
    <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
14 36
    </script>
15 37
  </head>

This page updates live while a promote is in flight · changelog