Make the theme control one toggle instead of a three-rung picker

a0a2dff2e163 · AtlantisPleb · · parent 53f4989e38ea

Make the theme control one toggle instead of a three-rung picker

Storing nothing IS system, and it is the default until someone chooses, so
an explicit "system" button made the common case -- never touching this --
look like an unmade decision. One control now flips between light and dark
and leaves the untouched state alone.

The glyph names the destination rather than the current state, because the
control is an action, not a status readout: sun while dark, moon while
light. Which glyph shows cannot be decided in CSS -- with nothing stored
the answer lives in the OS -- so the head script resolves the effective
theme onto data-theme-effective and the stylesheet reads that. Both glyphs
stay in the layout so the button does not resize as it flips and shove the
breadcrumb beside it.

The script now also stops following the OS once a preference exists, which
the previous version got wrong: it repainted on every prefers-color-scheme
change regardless of what the visitor had chosen.

Two guards did their job while rewriting this. The CSP test caught the
inline bootstrap losing its nonce -- the policy admits no unsafe-inline, so
a nonce-less script is silently dead. And the script needs
phx-no-curly-interpolation, since HEEx reads braces in a script body and
this one is nothing but braces.

The component test asserted the three rungs; it now asserts one control and
that no rung survives, with a note explaining the change.

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 lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/components/layouts/root.html.heex
  • modified test/openagents_web/live/components_live_test.exs

Diff

4 files changed, +104 -77

assets/css/app.css modified +27 -11

@@ -588,21 +588,37 @@

588 588
589 589
/* Theme toggle.
590 590
 *
591
 * The active rung cannot be rendered server-side: the choice lives in
592
 * localStorage, so the head script records it on the root element and the
593
 * selection is expressed here. `button-group` supplies the shrink-wrap
594
 * (inline-flex w-fit) and the joined borders; without a width the segments
595
 * would stretch to whatever container they land in.
591
 * The glyph names the destination, not the current state: this is an action,
592
 * not a status readout. Both glyphs stay in the layout so the control does not
593
 * resize as it flips and shove the breadcrumb beside it.
594
 *
595
 * `data-theme-effective` is set by the head script rather than derived here,
596
 * because with nothing stored the answer lives in the OS, and CSS cannot ask a
597
 * media query whether a preference was *stored*.
596 598
 */
597 599
@layer components {
598
  .theme-toggle .btn[data-theme-option] {
600
  .theme-toggle {
599 601
    color: var(--icon-tertiary);
600 602
  }
601 603
602
  :root[data-theme-choice="system"] .theme-toggle .btn[data-theme-option="system"],
603
  :root[data-theme-choice="light"] .theme-toggle .btn[data-theme-option="light"],
604
  :root[data-theme-choice="dark"] .theme-toggle .btn[data-theme-option="dark"] {
605
    background: var(--wash-selected);
606
    color: var(--icon-primary);
604
  @media (hover: hover) {
605
    .theme-toggle:hover {
606
      color: var(--icon-primary);
607
    }
608
  }
609
610
  .theme-toggle__sun,
611
  .theme-toggle__moon {
612
    display: none;
613
  }
614
615
  /* Dark now, so the control offers light. */
616
  :root[data-theme-effective="dark"] .theme-toggle__sun {
617
    display: inline-flex;
618
  }
619
620
  /* Light now, so the control offers dark. */
621
  :root[data-theme-effective="light"] .theme-toggle__moon {
622
    display: inline-flex;
607 623
  }
608 624
}
lib/openagents_web/components/layouts.ex modified +24 -29

@@ -136,38 +136,33 @@ defmodule OpenAgentsWeb.Layouts do

136 136
  end
137 137
138 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.
139
  One control that flips between light and dark.
140
141
  There is no explicit "system" rung. Storing nothing IS system, and that is the
142
  default until someone chooses: the head script leaves `data-theme` unset and
143
  the `prefers-color-scheme` fallback in `app.css` governs. A third button would
144
  make the common case — never touching this at all — look like an unmade
145
  decision.
146
147
  The glyph shows the theme you would move to, not the one you are in, because
148
  the control is an action rather than a status. Which glyph is visible cannot
149
  be decided here: the effective theme depends on the visitor's OS when nothing
150
  is stored, so the head script resolves it and the CSS picks the glyph.
149 151
  """
150 152
  def theme_toggle(assigns) do
151 153
    ~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>
154
    <button
155
      type="button"
156
      class="btn theme-toggle"
157
      data-variant="ghost"
158
      data-size="sm"
159
      aria-label="Toggle theme"
160
      title="Toggle theme"
161
      phx-click={JS.dispatch("phx:toggle-theme")}
162
    >
163
      <UI.icon name="sun" class="theme-toggle__sun" />
164
      <UI.icon name="moon" class="theme-toggle__moon" />
165
    </button>
171 166
    """
172 167
  end
173 168
lib/openagents_web/components/layouts/root.html.heex modified +37 -19

@@ -12,27 +12,45 @@

12 12
    <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
13 13
    <%!-- Runs before first paint, and deliberately not deferred: resolving the
14 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. --%>
15
16
    Storing nothing IS "system", so an untouched visitor gets their OS setting
17
    through the prefers-color-scheme fallback and `data-theme` stays unset. The
18
    script also records the EFFECTIVE theme, because the toggle needs to know
19
    which way to flip and the CSS needs to know which glyph to show -- and when
20
    nothing is stored, only the browser knows the answer. --%>
18 21
    <script nonce={assigns[:csp_nonce]} phx-no-curly-interpolation>
19 22
      (() => {
20
        const stored = localStorage.getItem("phx:theme");
21
        const apply = (choice) => {
22
          if (choice === "system" || choice === null) {
23
            localStorage.removeItem("phx:theme");
24
            document.documentElement.removeAttribute("data-theme");
25
            document.documentElement.setAttribute("data-theme-choice", "system");
26
          } else {
27
            localStorage.setItem("phx:theme", choice);
28
            document.documentElement.setAttribute("data-theme", choice);
29
            document.documentElement.setAttribute("data-theme-choice", choice);
30
          }
31
        };
32
        apply(stored);
33
        window.addEventListener("phx:set-theme", (e) => apply(e.detail?.theme ?? e.target?.dataset?.theme));
34
        window.addEventListener("storage", (e) => e.key === "phx:theme" && apply(e.newValue));
35
      })();
23
            const media = window.matchMedia("(prefers-color-scheme: dark)");
24
            const effective = () => {
25
              const stored = localStorage.getItem("phx:theme");
26
              if (stored === "light" || stored === "dark") return stored;
27
              return media.matches ? "dark" : "light";
28
            };
29
            const paint = () => {
30
              const stored = localStorage.getItem("phx:theme");
31
              const root = document.documentElement;
32
              if (stored === "light" || stored === "dark") {
33
                root.setAttribute("data-theme", stored);
34
              } else {
35
                root.removeAttribute("data-theme");
36
              }
37
              root.setAttribute("data-theme-effective", effective());
38
            };
39
            paint();
40
            window.addEventListener("phx:toggle-theme", () => {
41
              localStorage.setItem("phx:theme", effective() === "dark" ? "light" : "dark");
42
              paint();
43
            });
44
            window.addEventListener("phx:set-theme", (e) => {
45
              const next = e.detail?.theme ?? e.target?.dataset?.theme;
46
              if (next === "system") localStorage.removeItem("phx:theme");
47
              else if (next) localStorage.setItem("phx:theme", next);
48
              paint();
49
            });
50
            // Following the OS only matters while nothing is stored.
51
            media.addEventListener("change", () => localStorage.getItem("phx:theme") || paint());
52
            window.addEventListener("storage", (e) => e.key === "phx:theme" && paint());
53
          })();
36 54
    </script>
37 55
    <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
38 56
    </script>
test/openagents_web/live/components_live_test.exs modified +16 -18

@@ -79,33 +79,31 @@ defmodule OpenAgentsWeb.ComponentsLiveTest do

79 79
    refute has_element?(view, ~s{a[href="/components/theme-toggle"]})
80 80
  end
81 81
82
  test "the command bar exposes exactly the governed theme preferences" do
82
  test "the theme control is one action, not a three-rung picker" do
83
    # This previously asserted three buttons -- system, light, dark. The owner
84
    # replaced that with a single toggle: storing nothing IS system, and it is
85
    # the default until someone chooses, so a third button made the common case
86
    # (never touching this) look like an unmade decision.
83 87
    document =
84 88
      OpenAgentsWeb.Layouts.theme_toggle(%{})
85 89
      |> rendered_to_string()
86 90
      |> LazyHTML.from_fragment()
87 91
88
    assert document
89
           |> LazyHTML.query(~s{.theme-toggle[role="group"][aria-label="Color theme"]})
90
           |> LazyHTML.to_tree() != []
91
92
    assert document
93
           |> LazyHTML.query(
94
             ~s{button[data-theme-option="system"][aria-label="Match system theme"]}
95
           )
96
           |> LazyHTML.to_tree() != []
92
    buttons = document |> LazyHTML.query(~s{button.theme-toggle}) |> LazyHTML.to_tree()
93
    assert length(buttons) == 1
97 94
98 95
    assert document
99
           |> LazyHTML.query(~s{button[data-theme-option="light"][aria-label="Light theme"]})
96
           |> LazyHTML.query(~s{button.theme-toggle[aria-label="Toggle theme"]})
100 97
           |> LazyHTML.to_tree() != []
101 98
102
    assert document
103
           |> LazyHTML.query(~s{button[data-theme-option="dark"][aria-label="Dark theme"]})
104
           |> LazyHTML.to_tree() != []
99
    # Both glyphs ship; which one shows is decided at runtime from the effective
100
    # theme, because with nothing stored only the browser knows it.
101
    for glyph <- ~w(theme-toggle__sun theme-toggle__moon) do
102
      assert document |> LazyHTML.query(~s{.#{glyph}}) |> LazyHTML.to_tree() != [],
103
             "the #{glyph} glyph is missing, so the control cannot name its destination"
104
    end
105 105
106
    assert document
107
           |> LazyHTML.query(~s{button[data-theme-option]})
108
           |> LazyHTML.to_tree()
109
           |> length() == 3
106
    # No rung survives from the old picker.
107
    assert document |> LazyHTML.query(~s{[data-theme-option]}) |> LazyHTML.to_tree() == []
110 108
  end
111 109
end

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