Make the theme control a circle and the sidebar sections collapsible

da37075f832d · AtlantisPleb · · parent df48615fbade

Make the theme control a circle and the sidebar sections collapsible

The theme control was a rounded square holding a round glyph. It is an
icon-only button, so the target should be the shape of the thing inside it.
Width is pinned to the height rather than left to padding, because padding
alone turns the box into an oval the moment the glyph changes size.

Sidebar categories are now native <details>. That means no JavaScript, no
ARIA to keep in sync by hand, and keyboard operation for free. Because the
sidebar patches rather than remounts, the element survives navigation and so
does whatever the reader collapsed -- collapsible sections built on remount
would reopen on every click and be worse than no collapsing at all.

The section holding the current page opens itself: collapsing the section
you are reading would hide your own location.

The caret rotates rather than swapping between two glyphs, which keeps one
icon in the vendored set instead of two.

The catalog test caught sidebar_section/1 shipping without a page, so it is
catalogued with a demo that documents the patch-survives-collapse behaviour.

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

  • added .impeccable/live/config.json
  • added PRODUCT.md
  • modified assets/css/app.css
  • modified assets/css/openagents.css
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/components/layouts/components.html.heex
  • modified lib/openagents_web/live/components_live.ex

Diff

8 files changed, +204 -7

.impeccable/live/config.json added +6

@@ -0,0 +1,6 @@

1
{
2
  "files": ["lib/openagents_web/components/layouts/root.html.heex"],
3
  "insertBefore": "</body>",
4
  "commentSyntax": "html",
5
  "cspChecked": true
6
}
PRODUCT.md added +55

@@ -0,0 +1,55 @@

1
# Product
2
3
## Register
4
5
product
6
7
## Users
8
9
OpenAgents serves people who build, operate, and inspect agent-backed software.
10
Authenticated users work with Sarah, delegated work, connected computers,
11
memory, issues, projects, and source repositories. Operators qualify releases
12
and monitor Forge and fleet state. Public visitors can inspect bounded product,
13
source, and operational evidence without an account.
14
15
## Product purpose
16
17
OpenAgents provides one public, self-hostable application for building and
18
operating agent-backed software. It makes model activity, delegated work,
19
repository changes, deployment state, and governing authority visible and
20
verifiable. The product succeeds when users can move from an objective to a
21
proven change without losing durable evidence or bypassing an authorization
22
boundary.
23
24
## Brand personality
25
26
Precise, transparent, and resilient. The interface communicates operational
27
facts directly, distinguishes measured state from intent, and remains useful
28
when part of the system is degraded.
29
30
## Anti-references
31
32
- Generic software-as-a-service dashboards that decorate activity without
33
  showing evidence, authority, or failure state.
34
- Interfaces that hide agent actions behind an undifferentiated loading state.
35
- Competing component libraries, inconsistent controls, ornamental motion, and
36
  color-only status communication.
37
- Marketing claims that present planned, configured, or simulated behavior as
38
  completed production capability.
39
40
## Design principles
41
42
- Show live operational truth with bounded, attributable evidence.
43
- Make authority and lifecycle state visible before offering an action.
44
- Preserve familiar controls and one consistent component vocabulary.
45
- Keep public projections useful while excluding credentials, private content,
46
  and unbounded transcripts.
47
- Design degraded, empty, loading, and recovery states as primary product
48
  states.
49
50
## Accessibility and inclusion
51
52
Target WCAG 2.2 AA. Support keyboard navigation, visible focus, semantic
53
structure, screen-reader status labels, and status distinctions that do not
54
depend on color. Honor reduced-motion preferences and keep live updates bounded
55
so assistive technology does not receive an uncontrolled event stream.
assets/css/app.css modified +19

@@ -610,12 +610,31 @@

610 610
 * media query whether a preference was *stored*.
611 611
 */
612 612
@layer components {
613
  /* A circle, not a rounded square: this is an icon-only control, so the
614
     target should be the same shape as the glyph it holds. Width is pinned to
615
     the height so the box is square before the radius rounds it -- padding
616
     alone would make it an oval the moment the glyph changed size. */
613 617
  .theme-toggle {
618
    width: 32px;
619
    min-width: 32px;
620
    height: 32px;
621
    min-height: 32px;
622
    padding: 0;
623
    border: 0;
624
    border-radius: 50%;
625
    background: transparent;
614 626
    color: var(--icon-tertiary);
615 627
  }
616 628
629
  .theme-toggle .icon {
630
    width: 16px;
631
    height: 16px;
632
    font-size: 16px;
633
  }
634
617 635
  @media (hover: hover) {
618 636
    .theme-toggle:hover {
637
      background: var(--wash-hover);
619 638
      color: var(--icon-primary);
620 639
    }
621 640
  }
assets/css/openagents.css modified +57

@@ -2075,3 +2075,60 @@

2075 2075
    }
2076 2076
  }
2077 2077
}
2078
2079
/* ── Collapsible sidebar sections ─────────────────────────────────────────── */
2080
2081
/* Native <details>, so no JavaScript and no ARIA to keep in sync. The caret
2082
 * rotates rather than swapping glyphs, which keeps one icon in the set. */
2083
2084
@layer components {
2085
  .sidebar-section {
2086
    display: flex;
2087
    flex-direction: column;
2088
    gap: 2px;
2089
  }
2090
2091
  .sidebar-section__summary {
2092
    display: flex;
2093
    align-items: center;
2094
    gap: 4px;
2095
    cursor: pointer;
2096
    list-style: none;
2097
    user-select: none;
2098
  }
2099
2100
  /* Safari draws its own triangle unless this is removed. */
2101
  .sidebar-section__summary::-webkit-details-marker {
2102
    display: none;
2103
  }
2104
2105
  .sidebar-section__summary:focus-visible {
2106
    outline: 2px solid var(--ring);
2107
    outline-offset: 2px;
2108
    border-radius: 6px;
2109
  }
2110
2111
  .sidebar-section__caret {
2112
    width: 12px;
2113
    height: 12px;
2114
    flex: none;
2115
    font-size: 12px;
2116
    transition: transform 150ms var(--ease);
2117
  }
2118
2119
  .sidebar-section[open] .sidebar-section__caret {
2120
    transform: rotate(90deg);
2121
  }
2122
2123
  .sidebar-section__items {
2124
    display: flex;
2125
    flex-direction: column;
2126
    gap: 2px;
2127
  }
2128
2129
  @media (prefers-reduced-motion: reduce) {
2130
    .sidebar-section__caret {
2131
      transition: none;
2132
    }
2133
  }
2134
}
lib/openagents_web/component_catalog.ex modified +7

@@ -189,6 +189,13 @@ defmodule OpenAgentsWeb.ComponentCatalog do

189 189
    %{
190 190
      title: "Layout",
191 191
      items: [
192
        %{
193
          slug: "sidebar-section",
194
          title: "Sidebar section",
195
          icon: "chevron-down",
196
          source: "OpenAgentsWeb.Layouts.sidebar_section/1",
197
          summary: "A collapsible group of sidebar rows, built on native details."
198
        },
192 199
        %{
193 200
          slug: "sidebar-link",
194 201
          title: "Sidebar link",
lib/openagents_web/components/layouts.ex modified +29

@@ -135,6 +135,35 @@ defmodule OpenAgentsWeb.Layouts do

135 135
    """
136 136
  end
137 137
138
  @doc """
139
  A collapsible sidebar section.
140
141
  A native `<details>`, so it needs no JavaScript, is keyboard operable, and
142
  reports its own state to assistive technology. Because the sidebar now
143
  patches rather than remounts, the element survives navigation and so does
144
  whatever the reader collapsed.
145
146
  `open` should be true for the section holding the current page: collapsing
147
  the section you are reading would hide your own location.
148
  """
149
  attr :title, :string, required: true
150
  attr :open, :boolean, default: false
151
  slot :inner_block, required: true
152
153
  def sidebar_section(assigns) do
154
    ~H"""
155
    <details class="docs-sidebar__section sidebar-section" open={@open}>
156
      <summary class="sidebar-section-label sidebar-section__summary">
157
        <UI.icon name="chevron-right" class="sidebar-section__caret" />
158
        <span>{@title}</span>
159
      </summary>
160
      <div class="sidebar-section__items">
161
        {render_slot(@inner_block)}
162
      </div>
163
    </details>
164
    """
165
  end
166
138 167
  @doc """
139 168
  A sidebar row that navigates without throwing the sidebar away.
140 169
lib/openagents_web/components/layouts/components.html.heex modified +6 -7

@@ -23,11 +23,11 @@

23 23
        />
24 24
      </section>
25 25
26
      <section
26
      <Layouts.sidebar_section
27 27
        :for={section <- OpenAgentsWeb.ComponentCatalog.sections()}
28
        class="docs-sidebar__section"
28
        title={section.title}
29
        open={Enum.any?(section.items, &(&1.slug == @active_component))}
29 30
      >
30
        <h3 class="sidebar-section-label">{section.title}</h3>
31 31
        <Layouts.sidebar_link
32 32
          :for={item <- section.items}
33 33
          path={~p"/components/#{item.slug}"}

@@ -36,10 +36,9 @@

36 36
          selected={@active_component == item.slug}
37 37
          patchable={patchable?}
38 38
        />
39
      </section>
39
      </Layouts.sidebar_section>
40 40
41
      <section class="docs-sidebar__section">
42
        <h3 class="sidebar-section-label">Reference</h3>
41
      <Layouts.sidebar_section title="Reference" open={@active_component == :icons}>
43 42
        <Layouts.sidebar_link
44 43
          path={~p"/components/icons"}
45 44
          label="Icons"

@@ -47,7 +46,7 @@

47 46
          selected={@active_component == :icons}
48 47
          patchable={false}
49 48
        />
50
      </section>
49
      </Layouts.sidebar_section>
51 50
    </nav>
52 51
  </aside>
53 52
lib/openagents_web/live/components_live.ex modified +25

@@ -616,6 +616,31 @@ defmodule OpenAgentsWeb.ComponentsLive do

616 616
617 617
  # --- Layout ---------------------------------------------------------------
618 618
619
  defp component_demo(%{item: %{slug: "sidebar-section"}} = assigns) do
620
    ~H"""
621
    <div class="space-y-3">
622
      <p class="text-sm text-base-content/60">
623
        A native <code>&lt;details&gt;</code>, so it needs no JavaScript and no ARIA
624
        kept in sync by hand. Click a heading to collapse it.
625
      </p>
626
      <div class="docs-sidebar__nav max-w-xs">
627
        <Layouts.sidebar_section title="Open by default" open>
628
          <Layouts.sidebar_link path="#" label="First" icon="book" patchable={false} />
629
          <Layouts.sidebar_link path="#" label="Second" icon="cube" selected patchable={false} />
630
        </Layouts.sidebar_section>
631
        <Layouts.sidebar_section title="Collapsed by default">
632
          <Layouts.sidebar_link path="#" label="Hidden until opened" icon="grid" patchable={false} />
633
        </Layouts.sidebar_section>
634
      </div>
635
      <p class="text-sm text-base-content/60">
636
        The section holding the current page opens itself: collapsing the section you
637
        are reading would hide your own location. Because the sidebar patches rather
638
        than remounts, whatever you collapse stays collapsed as you move between pages.
639
      </p>
640
    </div>
641
    """
642
  end
643
619 644
  defp component_demo(%{item: %{slug: "sidebar-link"}} = assigns) do
620 645
    ~H"""
621 646
    <div class="space-y-3">

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