One account menu, no scope disclosure, and a caret that only turns on click

3a49693988c3 · AtlantisPleb · · parent ba828e1990b6

One account menu, no scope disclosure, and a caret that only turns on click

There were two account menus with two sets of markup: the command bar built
one from Tailwind utilities against `bg-popover`, and the chat surface built
another from the design system, so the same control looked like two different
applications depending on which page you opened it from. Both now render the
same panel and the same rows, and the rows are sidebar rows -- a 32px line
with an icon, muted until hovered -- because that shape already exists and
had no reason to be reinvented at different metrics.

"Disconnect GitHub tools" is gone from both, along with the predicate that
gated it. The hero's scope disclosure is gone too, at the owner's direction;
GitHub's own consent screen still states the scope before any grant is made,
and the homepage test now asserts the paragraph is absent rather than present.

The section caret animated on navigation. `open` is re-rendered from the
server on every move between pages, so an unconditional transition played a
rotation the reader had not asked for. The hook now marks the element for the
length of one click, and the transition is scoped to that mark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

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/openagents.css
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/live/home_live.ex
  • modified test/openagents_web/account_chrome_test.exs
  • modified test/openagents_web/home_controller_test.exs

Diff

5 files changed, +156 -96

assets/css/openagents.css modified +87

@@ -1657,6 +1657,86 @@

1657 1657
    background: transparent;
1658 1658
  }
1659 1659
1660
  /* Menu rows are sidebar rows. Both are a 32px line with an icon, muted until
1661
     hovered, and there is no reason for the account menu to invent a second
1662
     set of metrics for the same shape. */
1663
  .menu__identity {
1664
    display: flex;
1665
    align-items: center;
1666
    gap: 10px;
1667
    border-bottom: 1px solid var(--line);
1668
    padding: 12px;
1669
  }
1670
1671
  .menu__identity span {
1672
    display: flex;
1673
    min-width: 0;
1674
    flex-direction: column;
1675
  }
1676
1677
  .menu__identity strong {
1678
    color: var(--text-primary);
1679
    font-size: 0.8125rem;
1680
    font-weight: 600;
1681
    line-height: 1.25rem;
1682
  }
1683
1684
  .menu__identity small {
1685
    color: var(--text-muted);
1686
    font-size: 0.75rem;
1687
    line-height: 1rem;
1688
  }
1689
1690
  .menu__form {
1691
    margin: 0;
1692
  }
1693
1694
  .menu__item {
1695
    display: flex;
1696
    width: 100%;
1697
    align-items: center;
1698
    gap: 8px;
1699
    min-height: 32px;
1700
    margin: 4px;
1701
    padding-inline: 8px;
1702
    border: 0;
1703
    border-radius: 6px;
1704
    background: transparent;
1705
    color: var(--text-muted);
1706
    cursor: pointer;
1707
    font-size: 0.8125rem;
1708
    font-weight: 500;
1709
    line-height: 1.25rem;
1710
    text-align: start;
1711
    text-decoration: none;
1712
    transition: color 150ms var(--ease), background 150ms var(--ease);
1713
  }
1714
1715
  /* The margin above would double between adjacent rows. */
1716
  .menu__form + .menu__form .menu__item,
1717
  .menu__item + .menu__item {
1718
    margin-block-start: 0;
1719
  }
1720
1721
  .menu__item .icon {
1722
    width: 16px;
1723
    height: 16px;
1724
    flex: none;
1725
    font-size: 16px;
1726
  }
1727
1728
  @media (hover: hover) {
1729
    .menu__item:hover {
1730
      background: var(--wash-hover);
1731
      color: var(--text-primary);
1732
    }
1733
  }
1734
1735
  .menu__item:focus-visible {
1736
    outline: 2px solid var(--ring);
1737
    outline-offset: -2px;
1738
  }
1739
1660 1740
  /* Menus ride the quick token (100ms), not the fast one: a picker should feel
1661 1741
     closer to instant than a state change. Progressive enhancement — guarded
1662 1742
     so a browser without :popover-open never inherits a stranded opacity: 0,

@@ -2143,6 +2223,13 @@

2143 2223
    height: 12px;
2144 2224
    flex: none;
2145 2225
    font-size: 12px;
2226
  }
2227
2228
  /* The caret animates only when a reader turned it. Navigation re-renders
2229
     `open` from the server, and an unconditional transition made every move
2230
     between pages play a rotation nobody asked for. The hook sets this
2231
     attribute for the length of one click and no longer. */
2232
  .sidebar-section[data-animate] .sidebar-section__caret {
2146 2233
    transition: transform 150ms var(--ease);
2147 2234
  }
2148 2235
lib/openagents_web/components/layouts.ex modified +61 -85

@@ -258,14 +258,26 @@ defmodule OpenAgentsWeb.Layouts do

258 258
            state[this.el.id] = this.el.open
259 259
            write(state)
260 260
          }
261
          // The caret rotates only for a turn the reader made. Navigation
262
          // re-renders `open`, so an always-on transition played an animation
263
          // on every move between pages. The attribute lives for one click.
264
          this.onClick = () => {
265
            this.el.dataset.animate = "true"
266
            clearTimeout(this.animateTimer)
267
            this.animateTimer = setTimeout(() => delete this.el.dataset.animate, 200)
268
          }
269
          this.summary = this.el.querySelector("summary")
261 270
          this.el.addEventListener("toggle", this.onToggle)
271
          if (this.summary) this.summary.addEventListener("click", this.onClick)
262 272
          this.restore()
263 273
        },
264 274
        updated() {
265 275
          this.restore()
266 276
        },
267 277
        destroyed() {
278
          clearTimeout(this.animateTimer)
268 279
          this.el.removeEventListener("toggle", this.onToggle)
280
          if (this.summary) this.summary.removeEventListener("click", this.onClick)
269 281
        },
270 282
        restore() {
271 283
          // A section holding the active page opens regardless of what the

@@ -395,7 +407,7 @@ defmodule OpenAgentsWeb.Layouts do

395 407
    </UI.button>
396 408
397 409
    <UI.menu id="account-menu" class="account-menu">
398
      <div class="account-menu__identity">
410
      <div class="menu__identity">
399 411
        <UI.avatar
400 412
          src={@current_user.github_avatar_url}
401 413
          alt={"GitHub avatar for @#{@current_user.github_login}"}

@@ -406,96 +418,65 @@ defmodule OpenAgentsWeb.Layouts do

406 418
          <small :if={@current_user.github_name}>@{@current_user.github_login}</small>
407 419
        </span>
408 420
      </div>
409
      <.form for={%{}} id="logout-form" action={~p"/logout"} method="delete">
410
        <UI.button
411
          id="logout"
412
          variant={:ghost}
413
          type="submit"
414
          role="menuitem"
415
          class="account-menu__logout"
416
        >
417
          <UI.icon name="logout" /> Log out
418
        </UI.button>
419
      </.form>
420
      <.link navigate={~p"/settings/api-tokens"} role="menuitem" class="account-menu__logout">
421
        API tokens
421
      <.link navigate={~p"/settings/api-tokens"} role="menuitem" class="menu__item">
422
        <UI.icon name="key" /> API tokens
422 423
      </.link>
423
      <.form
424
        :if={github_tools_connected?(@current_user)}
425
        for={%{}}
426
        id="github-disconnect-form"
427
        action={~p"/github/connection"}
428
        method="delete"
429
      >
430
        <UI.button
431
          id="github-disconnect"
432
          variant={:ghost}
433
          type="submit"
434
          role="menuitem"
435
          class="account-menu__logout"
436
        >
437
          Disconnect GitHub tools
438
        </UI.button>
424
      <.form for={%{}} id="logout-form" action={~p"/logout"} method="delete" class="menu__form">
425
        <button id="logout" type="submit" role="menuitem" class="menu__item">
426
          <UI.icon name="logout" /> Log out
427
        </button>
439 428
      </.form>
440 429
    </UI.menu>
441 430
    """
442 431
  end
443 432
433
  # The command bar's identity control. Renders the same panel and the same
434
  # rows as `account_control/1`. There were two account menus with two
435
  # different sets of markup -- one on Tailwind utilities against
436
  # `bg-popover`, one on the design system -- so the menu the chat surface
437
  # showed and the menu the command bar showed did not look like the same
438
  # application. There is one now.
439
  attr :current_scope, :map, required: true
440
444 441
  defp account_dropdown(assigns) do
445 442
    ~H"""
446
    <%!-- A native <details> disclosure rather than a JavaScript dropdown, for
447
    the same reason `UI.menu/1` uses the popover API: the account control
448
    has to work before any client script has run. --%>
449
    <details class="relative">
450
      <summary class="btn list-none cursor-pointer !p-1" data-variant="ghost">
451
        <img
443
    <UI.button
444
      id="account-bar-trigger"
445
      variant={:ghost}
446
      size={:sm}
447
      class="account-menu-trigger"
448
      popovertarget="account-bar-menu"
449
      popovertargetaction="toggle"
450
      aria-label={"Account menu for @#{@current_scope.github_login}"}
451
    >
452
      <UI.avatar
453
        src={@current_scope.github_avatar_url}
454
        alt={"GitHub avatar for @#{@current_scope.github_login}"}
455
        size={:sm}
456
      />
457
    </UI.button>
458
459
    <UI.menu id="account-bar-menu" class="account-menu">
460
      <div class="menu__identity">
461
        <UI.avatar
452 462
          src={@current_scope.github_avatar_url}
453 463
          alt={"GitHub avatar for @#{@current_scope.github_login}"}
454
          class="w-8 h-8 rounded-full"
464
          size={:lg}
455 465
        />
456
      </summary>
457
      <ul class="absolute end-0 z-10 mt-2 w-56 rounded-lg border border-border bg-popover p-2 shadow-lg">
458
        <li class="flex flex-col p-2">
459
          <span class="font-semibold">{account_display_name(@current_scope)}</span>
460
          <span :if={@current_scope.github_name} class="text-sm text-muted-foreground">
461
            @{@current_scope.github_login}
462
          </span>
463
        </li>
464
        <li>
465
          <.link
466
            navigate={~p"/settings/api-tokens"}
467
            class="btn w-full justify-start"
468
            data-variant="ghost"
469
          >
470
            API tokens
471
          </.link>
472
        </li>
473
        <li>
474
          <.form
475
            :if={github_tools_connected?(@current_scope)}
476
            for={%{}}
477
            action={~p"/github/connection"}
478
            method="delete"
479
            class="m-0 w-full"
480
          >
481
            <.button type="submit" variant={:ghost} class="w-full justify-start">
482
              Disconnect GitHub tools
483
            </.button>
484
          </.form>
485
        </li>
486
        <li>
487
          <.form for={%{}} as={:logout} action={~p"/logout"} method="post" class="m-0 w-full">
488
            <input type="hidden" name="_method" value="delete" />
489
            <button
490
              type="submit"
491
              class="w-full rounded-md px-2 py-1.5 text-left flex items-center gap-2 hover:bg-muted"
492
            >
493
              <.icon name="logout" class="size-4" /> Log out
494
            </button>
495
          </.form>
496
        </li>
497
      </ul>
498
    </details>
466
        <span>
467
          <strong>{account_display_name(@current_scope)}</strong>
468
          <small :if={@current_scope.github_name}>@{@current_scope.github_login}</small>
469
        </span>
470
      </div>
471
      <.link navigate={~p"/settings/api-tokens"} role="menuitem" class="menu__item">
472
        <UI.icon name="key" /> API tokens
473
      </.link>
474
      <.form for={%{}} as={:logout} action={~p"/logout"} method="delete" class="menu__form">
475
        <button type="submit" role="menuitem" class="menu__item">
476
          <UI.icon name="logout" /> Log out
477
        </button>
478
      </.form>
479
    </UI.menu>
499 480
    """
500 481
  end
501 482

@@ -655,11 +636,6 @@ defmodule OpenAgentsWeb.Layouts do

655 636
    )
656 637
  end
657 638
658
  defp github_tools_connected?(user) when is_map(user),
659
    do: is_binary(Map.get(user, :github_token_ciphertext))
660
661
  defp github_tools_connected?(_user), do: false
662
663 639
  defp hide(js \\ %JS{}, selector) do
664 640
    JS.hide(js,
665 641
      to: selector,
lib/openagents_web/live/home_live.ex modified +1 -7

@@ -38,14 +38,8 @@ defmodule OpenAgentsWeb.HomeLive do

38 38
                  as={:auth}
39 39
                  action={~p"/auth/github?github_tools=enabled"}
40 40
                  method="post"
41
                  class="m-0 max-w-xl space-y-3"
41
                  class="m-0"
42 42
                >
43
                  <p id="github-tools-disclosure" class="text-sm text-muted-foreground">
44
                    OpenAgents will retain an encrypted GitHub grant with the <code>repo</code>
45
                    scope. GitHub makes that scope read/write even though OpenAgents currently
46
                    exposes it only to bounded repository-reading tools. You can revoke the
47
                    grant from the account menu at any time.
48
                  </p>
49 43
                  <.button type="submit" variant={:primary} id="home-cta-signin">
50 44
                    Sign in and enable GitHub tools
51 45
                  </.button>
test/openagents_web/account_chrome_test.exs modified +1 -1

@@ -48,7 +48,7 @@ defmodule OpenAgentsWeb.AccountChromeTest do

48 48
      refute html =~ "<strong></strong>"
49 49
50 50
      identity =
51
        Regex.run(~r|account-menu__identity.{0,900}|s, html)
51
        Regex.run(~r|menu__identity.{0,900}|s, html)
52 52
        |> List.first()
53 53
54 54
      assert identity =~ "@#{user.github_login}"
test/openagents_web/home_controller_test.exs modified +6 -3

@@ -10,9 +10,12 @@ defmodule OpenAgentsWeb.HomeControllerTest do

10 10
    assert html =~ "The Agent Forge"
11 11
    assert html =~ ~s(action="/auth/github?github_tools=enabled")
12 12
    assert html =~ "Sign in and enable GitHub tools"
13
    assert html =~ ~s(id="github-tools-disclosure")
14
    assert html =~ "retain an encrypted GitHub grant"
15
    assert html =~ "read/write"
13
14
    # The scope disclosure was removed from the hero at the owner's direction.
15
    # GitHub's own consent screen still states the scope before the grant is
16
    # made, so this asserts the paragraph is gone rather than that it is there.
17
    refute html =~ ~s(id="github-tools-disclosure")
18
    refute html =~ "retain an encrypted GitHub grant"
16 19
17 20
    refute html =~ "One continuing conversation"
18 21
    refute html =~ ~s(href="/chat")

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