Unify the sidebars, and fix popover menus pinning to the corner

bd4584091516 · AtlantisPleb · · parent 3ed85628b17c

Unify the sidebars, and fix popover menus pinning to the corner

One sidebar treatment across the application. The main sidebar sat on
--ink-surface with an --ink-raised divider while the docs sidebars sat on
--ink-void with a --line divider; two sidebars in one application that
differ by a surface rung read as two applications. Both now use the same
container, and the main sidebar's hand-written rows go through
sidebar_link/1, so its rows gain the shared hover and focus behaviour and
lose forty lines of duplicated markup.

The brand is a wordmark, a rule, and the section name: two destinations,
because they answer different questions. The wordmark leaves for the
application; the section name returns to this section's index. That also
retires the "All components" and "Overview" rows, which listed a
destination the reader was already looking at.

The footer is its own block behind a rule, with colour-only hover. Giving
secondary links the nav's background wash made a footer link look as
important as the section being worked in.

Hover now goes to the brightest ink rather than one step up. The subtler
lift was correct in isolation and unreadable in practice: the background
wash arrives at the same moment, so a small text change reads as "the
background moved" and nothing else.

Two real defects fixed along the way:

- The theme control's glyph sat against its right edge. `.btn[data-size=sm]`
  sets `padding: 8px 15px` at (0,2,0) and outranked a bare `.theme-toggle`,
  so the padding survived inside a 32px box. The selector now carries the
  size attribute.
- Every native popover menu pinned itself to the viewport's top-left
  corner, the account menu included. `.menu` set `margin: 0`, which defeats
  the user agent's `margin: auto` centring, and nothing replaced it with
  anchor positioning. Centring is restored as the fallback, with the panel
  anchored under its trigger where the browser supports it.

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 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/components/layouts/docs.html.heex
  • modified lib/openagents_web/live/components_live.ex

Diff

7 files changed, +221 -106

assets/css/app.css modified +28 -12

@@ -229,8 +229,10 @@

229 229
  width: 280px;
230 230
  height: 100%;
231 231
  overflow: hidden;
232
  border-right: 1px solid var(--ink-raised);
233
  background: var(--ink-surface);
232
  /* Identical to .docs-sidebar. Two sidebars in one application that differ by
233
     a surface rung and a border colour read as two applications. */
234
  border-right: 1px solid var(--line);
235
  background: var(--ink-void);
234 236
}
235 237
236 238
.sidebar-header {

@@ -238,8 +240,10 @@

238 240
  flex: none;
239 241
  align-items: center;
240 242
  gap: 10px;
243
  /* Matches .docs-header, so the brand and the breadcrumb sit on one line
244
     across the two surfaces. */
241 245
  min-height: 52px;
242
  padding: 8px 8px 8px 16px;
246
  padding: 8px 20px;
243 247
}
244 248
245 249
.brand-name {

@@ -254,7 +258,7 @@

254 258
  flex: none;
255 259
  flex-direction: column;
256 260
  gap: 1px;
257
  padding-block: 6px;
261
  padding-block: 2px;
258 262
}
259 263
260 264
.sidebar-nav[aria-label="OpenAgents tools"] {

@@ -289,18 +293,23 @@

289 293
@media (hover: hover) {
290 294
  .sidebar-row:hover {
291 295
    background: var(--wash-hover);
292
    /* One step up from rest, not all the way to primary. Jumping straight to
293
       the brightest ink made every hover look like a selection. */
294
    color: var(--text-body);
296
    /* All the way to the brightest ink. A one-step lift to --text-body was
297
       there but unreadable: the wash arrives at the same moment, so a small
298
       text change reads as "the background moved" and nothing else. Hover and
299
       the current row now share a text colour and are told apart by the
300
       background -- selected carries a stronger, persistent wash. */
301
    color: var(--text-primary);
295 302
  }
296 303
}
297 304
298 305
.sidebar-row:has(.sidebar-row__hit:active) {
299
  background: var(--ink-raised);
306
  background: var(--wash-active);
300 307
}
301 308
302 309
.sidebar-row[data-selected] {
303
  background: var(--ink-raised);
310
  /* A wash like hover, one step stronger. Using an opaque rung made the current
311
     row a different surface floating on the sidebar instead of a state of it. */
312
  background: var(--wash-selected);
304 313
  /* The place you are is the one row that stays bright without being hovered. */
305 314
  color: var(--text-primary);
306 315
}

@@ -376,7 +385,10 @@

376 385
  min-height: 36px;
377 386
  margin-inline: 12px;
378 387
  padding-inline: 8px;
379
  background: var(--ink-surface);
388
  /* Sticky, so it needs an opaque fill for rows to scroll under -- but it must
389
     be the SIDEBAR's fill. Left on --ink-surface it drew a lighter bar across
390
     a darker sidebar, which read as a panel rather than a heading. */
391
  background: var(--ink-void);
380 392
  color: var(--text-muted);
381 393
  font-size: 0.875rem;
382 394
  font-weight: 400;

@@ -457,7 +469,7 @@

457 469
458 470
@media (hover: hover) {
459 471
  .docs-sidebar__link:hover {
460
    background: var(--ink-raised);
472
    background: var(--wash-hover);
461 473
    color: var(--text-primary);
462 474
  }
463 475
}

@@ -623,7 +635,11 @@

623 635
     target should be the same shape as the glyph it holds. Width is pinned to
624 636
     the height so the box is square before the radius rounds it -- padding
625 637
     alone would make it an oval the moment the glyph changed size. */
626
  .theme-toggle {
638
  /* Selector carries the size attribute deliberately: `.btn[data-size="sm"]`
639
     sets `padding: 8px 15px` at (0,2,0) and beats a bare `.theme-toggle`, so
640
     the padding survived and pushed the glyph off centre inside a 32px box. */
641
  .theme-toggle,
642
  .theme-toggle[data-size="sm"] {
627 643
    width: 32px;
628 644
    min-width: 32px;
629 645
    height: 32px;
assets/css/openagents.css modified +122 -2

@@ -1628,7 +1628,12 @@

1628 1628
   * and pins the menu open. Declare no `display` here so the user agent keeps
1629 1629
   * control of open and closed state. Position is app-shell layout. */
1630 1630
  .menu {
1631
    margin: 0;
1631
    /* A native popover is centred by the user agent's `margin: auto`. Setting
1632
       `margin: 0` defeated that and pinned every menu to the viewport's
1633
       top-left corner -- the account menu included, not just this demo. Keep
1634
       the UA's centring as the fallback and anchor to the trigger where the
1635
       browser can. */
1636
    margin: auto;
1632 1637
    padding: 0;
1633 1638
    overflow: hidden;
1634 1639
    border: 1px solid var(--line-strong);

@@ -1638,6 +1643,16 @@

1638 1643
    color: var(--text-body);
1639 1644
  }
1640 1645
1646
  /* Chrome makes a `popovertarget` invoker the popover's implicit anchor, so
1647
     the panel can sit under the control that opened it. Progressive: browsers
1648
     without this keep the centred fallback above. */
1649
  @supports (position-area: bottom) {
1650
    .menu {
1651
      position-area: bottom span-right;
1652
      margin: 6px 0 0;
1653
    }
1654
  }
1655
1641 1656
  .menu::backdrop {
1642 1657
    background: transparent;
1643 1658
  }

@@ -2101,7 +2116,7 @@

2101 2116
2102 2117
  @media (hover: hover) {
2103 2118
    .sidebar-section__summary:hover {
2104
      color: var(--text-body);
2119
      color: var(--text-primary);
2105 2120
    }
2106 2121
  }
2107 2122

@@ -2357,3 +2372,108 @@

2357 2372
    font-weight: 600;
2358 2373
  }
2359 2374
}
2375
2376
/* ── Sidebar brand ────────────────────────────────────────────────────────── */
2377
2378
/* Wordmark, rule, section name. Two targets because they answer two questions:
2379
 * the wordmark leaves for the application, the section name returns to this
2380
 * section's index. The rule between them says they are separate controls --
2381
 * without it, adjacent links at the same weight read as one. */
2382
2383
@layer components {
2384
  .sidebar-brand {
2385
    display: flex;
2386
    flex: none;
2387
    align-items: center;
2388
    gap: 10px;
2389
    min-height: 52px;
2390
    padding-inline: 20px;
2391
  }
2392
2393
  .sidebar-brand__mark {
2394
    flex: none;
2395
    color: var(--text-primary);
2396
    font-size: 0.9375rem;
2397
    font-weight: 700;
2398
    letter-spacing: -0.01em;
2399
    text-decoration: none;
2400
    transition: color 150ms var(--ease);
2401
  }
2402
2403
  .sidebar-brand__divider {
2404
    width: 1px;
2405
    height: 16px;
2406
    flex: none;
2407
    background: var(--line-strong);
2408
  }
2409
2410
  .sidebar-brand__title {
2411
    min-width: 0;
2412
    overflow: hidden;
2413
    color: var(--text-body);
2414
    font-size: 0.9375rem;
2415
    font-weight: 500;
2416
    text-decoration: none;
2417
    text-overflow: ellipsis;
2418
    white-space: nowrap;
2419
    transition: color 150ms var(--ease);
2420
  }
2421
2422
  @media (hover: hover) {
2423
    .sidebar-brand__mark:hover,
2424
    .sidebar-brand__title:hover {
2425
      color: var(--text-primary);
2426
    }
2427
  }
2428
}
2429
2430
/* ── Sidebar footer ───────────────────────────────────────────────────────── */
2431
2432
/* Secondary destinations, so they are quieter than the nav above: a rule
2433
 * separates them, and hover moves colour only. Giving them the nav's background
2434
 * wash would make a footer link look as important as a section you are working
2435
 * in. */
2436
2437
@layer components {
2438
  .sidebar-footer {
2439
    display: flex;
2440
    flex: none;
2441
    flex-direction: column;
2442
    margin-top: auto;
2443
    border-top: 1px solid var(--line);
2444
    padding: 20px;
2445
  }
2446
2447
  .sidebar-footer__link {
2448
    display: flex;
2449
    align-items: center;
2450
    gap: 8px;
2451
    min-height: 36px;
2452
    padding-inline: 5px;
2453
    color: var(--text-muted);
2454
    font-size: 0.8125rem;
2455
    font-weight: 500;
2456
    line-height: 1.25rem;
2457
    text-decoration: none;
2458
    transition: color 150ms var(--ease);
2459
  }
2460
2461
  .sidebar-footer__link .icon {
2462
    width: 16px;
2463
    height: 16px;
2464
    flex: none;
2465
    font-size: 16px;
2466
  }
2467
2468
  @media (hover: hover) {
2469
    .sidebar-footer__link:hover {
2470
      color: var(--text-primary);
2471
    }
2472
  }
2473
2474
  .sidebar-footer__link:focus-visible {
2475
    outline: 2px solid var(--ring);
2476
    outline-offset: 2px;
2477
    border-radius: 6px;
2478
  }
2479
}
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-brand",
194
          title: "Sidebar brand",
195
          icon: "book",
196
          source: "OpenAgentsWeb.Layouts.sidebar_brand/1",
197
          summary: "Wordmark and section name as two separate destinations."
198
        },
192 199
        %{
193 200
          slug: "sidebar-section",
194 201
          title: "Sidebar section",
lib/openagents_web/components/layouts.ex modified +46 -55

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

135 135
    """
136 136
  end
137 137
138
  @doc """
139
  The brand lockup at the top of a documentation sidebar.
140
141
  Two targets, because they answer two different questions. The mark returns to
142
  the application, for a reader who arrived from a search result and wants the
143
  product. The title returns to this section's own index, for a reader who is
144
  already in the docs and wants the contents.
145
146
  Collapsing them into one link would cost one of those, and giving the index
147
  its own sidebar row would list a destination the reader is already looking at.
148
  """
149
  attr :title, :string, required: true
150
  attr :path, :string, required: true, doc: "this section's index"
151
152
  def sidebar_brand(assigns) do
153
    ~H"""
154
    <header class="sidebar-brand">
155
      <.link navigate={~p"/"} class="sidebar-brand__mark">OpenAgents</.link>
156
      <span class="sidebar-brand__divider" aria-hidden="true"></span>
157
      <.link patch={@path} class="sidebar-brand__title">{@title}</.link>
158
    </header>
159
    """
160
  end
161
138 162
  @doc """
139 163
  A collapsible sidebar section.
140 164

@@ -379,45 +403,20 @@ defmodule OpenAgentsWeb.Layouts do

379 403
      </header>
380 404
381 405
      <nav class="sidebar-nav" aria-label="OpenAgents surfaces">
382
        <div class="sidebar-row">
383
          <.link navigate={~p"/"} class="sidebar-row__hit" aria-label="Home"></.link>
384
          <span class="sidebar-row__content">
385
            <span class="sidebar-row__icon"><.icon name="home" /></span>
386
            <span class="sidebar-row__label">Home</span>
387
          </span>
388
        </div>
389
390
        <div class="sidebar-row">
391
          <.link navigate={~p"/chat"} class="sidebar-row__hit" aria-label="Chat"></.link>
392
          <span class="sidebar-row__content">
393
            <span class="sidebar-row__icon"><.icon name="chat" /></span>
394
            <span class="sidebar-row__label">Chat</span>
395
          </span>
396
        </div>
397
398
        <div class="sidebar-row">
399
          <.link
400
            navigate={~p"/OpenAgentsInc/openagents.com/issues"}
401
            class="sidebar-row__hit"
402
            aria-label="Issues"
403
          ></.link>
404
          <span class="sidebar-row__content">
405
            <span class="sidebar-row__icon"><.icon name="bug" /></span>
406
            <span class="sidebar-row__label">Issues</span>
407
          </span>
408
        </div>
409
410
        <div class="sidebar-row">
411
          <.link
412
            navigate={~p"/OpenAgentsInc/openagents.com/projects"}
413
            class="sidebar-row__hit"
414
            aria-label="Projects"
415
          ></.link>
416
          <span class="sidebar-row__content">
417
            <span class="sidebar-row__icon"><.icon name="folder" /></span>
418
            <span class="sidebar-row__label">Projects</span>
419
          </span>
420
        </div>
406
        <Layouts.sidebar_link path={~p"/"} label="Home" icon="home" patchable={false} />
407
        <Layouts.sidebar_link path={~p"/chat"} label="Chat" icon="chat" patchable={false} />
408
        <Layouts.sidebar_link
409
          path={~p"/OpenAgentsInc/openagents.com/issues"}
410
          label="Issues"
411
          icon="bug"
412
          patchable={false}
413
        />
414
        <Layouts.sidebar_link
415
          path={~p"/OpenAgentsInc/openagents.com/projects"}
416
          label="Projects"
417
          icon="folder"
418
          patchable={false}
419
        />
421 420
      </nav>
422 421
423 422
      <%!--

@@ -446,22 +445,14 @@ defmodule OpenAgentsWeb.Layouts do

446 445
      </div>
447 446
      --%>
448 447
449
      <nav class="sidebar-nav" aria-label="OpenAgents tools">
450
        <div class="sidebar-row">
451
          <.link navigate={~p"/components"} class="sidebar-row__hit" aria-label="Components"></.link>
452
          <span class="sidebar-row__content">
453
            <span class="sidebar-row__icon"><.icon name="widget" /></span>
454
            <span class="sidebar-row__label">Components</span>
455
          </span>
456
        </div>
457
        <div class="sidebar-row">
458
          <.link navigate={~p"/docs"} class="sidebar-row__hit" aria-label="Documentation"></.link>
459
          <span class="sidebar-row__content">
460
            <span class="sidebar-row__icon"><.icon name="book" /></span>
461
            <span class="sidebar-row__label">Documentation</span>
462
          </span>
463
        </div>
464
      </nav>
448
      <footer class="sidebar-footer">
449
        <.link navigate={~p"/components"} class="sidebar-footer__link">
450
          <UI.icon name="widget" /> Components
451
        </.link>
452
        <.link navigate={~p"/docs"} class="sidebar-footer__link">
453
          <UI.icon name="book" /> Documentation
454
        </.link>
455
      </footer>
465 456
    </aside>
466 457
    """
467 458
  end
lib/openagents_web/components/layouts/components.html.heex modified +1 -20

@@ -1,28 +1,9 @@

1 1
<% patchable? = @active_component != :icons %>
2 2
<div class="docs-layout">
3 3
  <aside class="docs-sidebar">
4
    <nav class="flex flex-col gap-1" aria-label="Components back">
5
      <div class="sidebar-row">
6
        <.link navigate={~p"/"} class="sidebar-row__hit" aria-label="Back to app"></.link>
7
        <span class="sidebar-row__content">
8
          <span class="sidebar-row__icon"><.icon name="arrow-left" /></span>
9
          <span class="sidebar-row__label">Back to app</span>
10
        </span>
11
      </div>
12
    </nav>
4
    <Layouts.sidebar_brand title="Components" path={~p"/components"} />
13 5
14 6
    <nav class="docs-sidebar__nav" aria-label="Component library">
15
      <section class="docs-sidebar__section">
16
        <h3 class="sidebar-section-label">Overview</h3>
17
        <Layouts.sidebar_link
18
          path={~p"/components"}
19
          label="All components"
20
          icon="book"
21
          selected={@active_component == :index}
22
          patchable={patchable?}
23
        />
24
      </section>
25
26 7
      <Layouts.sidebar_section
27 8
        :for={section <- OpenAgentsWeb.ComponentCatalog.sections()}
28 9
        title={section.title}
lib/openagents_web/components/layouts/docs.html.heex modified +1 -17

@@ -1,24 +1,8 @@

1 1
<div class="docs-layout">
2 2
  <aside class="docs-sidebar">
3
    <nav class="flex flex-col gap-1" aria-label="Docs back">
4
      <div class="sidebar-row">
5
        <.link navigate={~p"/"} class="sidebar-row__hit" aria-label="Back to app"></.link>
6
        <span class="sidebar-row__content">
7
          <span class="sidebar-row__icon"><.icon name="arrow-left" /></span>
8
          <span class="sidebar-row__label">Back to app</span>
9
        </span>
10
      </div>
11
    </nav>
3
    <Layouts.sidebar_brand title="Docs" path={~p"/docs"} />
12 4
13 5
    <nav class="docs-sidebar__nav" aria-label="Documentation">
14
      <Layouts.sidebar_link
15
        path={~p"/docs"}
16
        label="Overview"
17
        icon="book"
18
        selected={@active_page == :index}
19
        patchable={true}
20
      />
21
22 6
      <Layouts.sidebar_section
23 7
        :for={section <- OpenAgentsWeb.DocsCatalog.sections()}
24 8
        title={section.title}
lib/openagents_web/live/components_live.ex modified +16

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

616 616
617 617
  # --- Layout ---------------------------------------------------------------
618 618
619
  defp component_demo(%{item: %{slug: "sidebar-brand"}} = assigns) do
620
    ~H"""
621
    <div class="space-y-3">
622
      <p class="text-sm text-base-content/60">
623
        Two destinations, not one. The wordmark leaves for the application; the
624
        section name returns to this section's index. The rule between them says
625
        they are separate controls — without it, two links at the same weight read
626
        as a single label.
627
      </p>
628
      <div class="max-w-xs" style="border: 1px solid var(--line); border-radius: 8px;">
629
        <Layouts.sidebar_brand title="Components" path={~p"/components"} />
630
      </div>
631
    </div>
632
    """
633
  end
634
619 635
  defp component_demo(%{item: %{slug: "sidebar-section"}} = assigns) do
620 636
    ~H"""
621 637
    <div class="space-y-3">

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