Left-align the sidebar footer, and stop navigation collapsing open sections

cf15a330b73a · AtlantisPleb · · parent 31f3ef588a2a

Left-align the sidebar footer, and stop navigation collapsing open sections

Two `.sidebar-footer` rules existed: a stale unlayered one in app.css left
over from when the footer held an avatar, and the current one inside
`@layer components`. Unlayered declarations beat layered ones whatever their
specificity or order, and the cascade resolves per property, so the layered
`flex-direction: column` survived while the stale `align-items: center` won --
which on a column container centres the children horizontally. That is why the
links stacked correctly but sat indented. The stale rule is gone.

Sections were `open` purely as a function of the active page, so every
navigation re-derived the whole set and closed everything the reader had
opened but was not currently inside. The server value is now a seed: it still
decides first paint, and still forces open any section holding the active page
so a row can never be hidden, but between those the reader's own toggles are
the state and persist across navigation. With no JavaScript the behaviour is
exactly what it was before.

The id is computed with `assign`, not `assign_new`. The attr declaration
already puts `:id` in assigns as nil, so `assign_new` considered it present
and never ran -- and a `phx-hook` on an element with no DOM id does not run at
all, which made the first version of this a silent no-op.

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/app.css
  • modified lib/openagents_web/components/layouts.ex

Diff

2 files changed, +79 -11

assets/css/app.css modified -10

@@ -395,16 +395,6 @@

395 395
  line-height: 1.25rem;
396 396
}
397 397
398
.sidebar-footer {
399
  display: flex;
400
  flex: none;
401
  align-items: center;
402
  gap: 10px;
403
  min-height: 52px;
404
  padding: 8px;
405
  border-top: 1px solid var(--ink-raised);
406
}
407
408 398
/* Docs layout */
409 399
410 400
.docs-layout {
lib/openagents_web/components/layouts.ex modified +79 -1

@@ -192,11 +192,24 @@ defmodule OpenAgentsWeb.Layouts do

192 192
  """
193 193
  attr :title, :string, required: true
194 194
  attr :open, :boolean, default: false
195
  attr :id, :string, default: nil, doc: "defaults to a slug of the title"
195 196
  slot :inner_block, required: true
196 197
197 198
  def sidebar_section(assigns) do
199
    # Not `assign_new`: the `id` attr declaration already puts the key in
200
    # assigns as nil, so `assign_new` would consider it present and never
201
    # compute. A hook without a DOM id silently does not run.
202
    assigns =
203
      assign(assigns, :id, assigns.id || "sidebar-section-" <> section_slug(assigns.title))
204
198 205
    ~H"""
199
    <details class="docs-sidebar__section sidebar-section" open={@open}>
206
    <details
207
      id={@id}
208
      class="docs-sidebar__section sidebar-section"
209
      open={@open}
210
      phx-hook=".SidebarSection"
211
      data-holds-active={to_string(@open)}
212
    >
200 213
      <summary class="sidebar-section-label sidebar-section__summary">
201 214
        <UI.icon name="chevron-right" class="sidebar-section__caret" />
202 215
        <span>{@title}</span>

@@ -205,9 +218,74 @@ defmodule OpenAgentsWeb.Layouts do

205 218
        {render_slot(@inner_block)}
206 219
      </div>
207 220
    </details>
221
    <%!-- `open` above is a seed, not the truth. The server can only derive it
222
    from the active page, so re-deriving it on every navigation collapses every
223
    section the reader opened but is not currently inside. The hook makes the
224
    reader's own choices the state and keeps them in sessionStorage; the server
225
    still decides on first paint and whenever a section holds the active page,
226
    so a page can never be hidden inside a section the reader had closed, and
227
    the sidebar works with no JavaScript at all. --%>
228
    <script :type={Phoenix.LiveView.ColocatedHook} name=".SidebarSection">
229
      const KEY = "sidebar-sections"
230
231
      const read = () => {
232
        try {
233
          return JSON.parse(sessionStorage.getItem(KEY)) || {}
234
        } catch (_error) {
235
          return {}
236
        }
237
      }
238
239
      const write = (state) => {
240
        try {
241
          sessionStorage.setItem(KEY, JSON.stringify(state))
242
        } catch (_error) {
243
          // A full or unavailable store costs the reader their open sections
244
          // on the next navigation, which is the behaviour without the hook.
245
        }
246
      }
247
248
      export default {
249
        mounted() {
250
          this.onToggle = () => {
251
            const state = read()
252
            state[this.el.id] = this.el.open
253
            write(state)
254
          }
255
          this.el.addEventListener("toggle", this.onToggle)
256
          this.restore()
257
        },
258
        updated() {
259
          this.restore()
260
        },
261
        destroyed() {
262
          this.el.removeEventListener("toggle", this.onToggle)
263
        },
264
        restore() {
265
          // A section holding the active page opens regardless of what the
266
          // reader last did, so navigation can never land on a hidden row.
267
          if (this.el.dataset.holdsActive === "true") {
268
            this.el.open = true
269
            this.onToggle()
270
            return
271
          }
272
          const stored = read()[this.el.id]
273
          if (stored !== undefined) this.el.open = stored
274
        },
275
      }
276
    </script>
208 277
    """
209 278
  end
210 279
280
  # Ids have to survive navigation for the reader's open sections to be found
281
  # again, so they come from the section name rather than a render-time counter.
282
  defp section_slug(title) do
283
    title
284
    |> String.downcase()
285
    |> String.replace(~r/[^a-z0-9]+/u, "-")
286
    |> String.trim("-")
287
  end
288
211 289
  @doc """
212 290
  A sidebar row that navigates without throwing the sidebar away.
213 291

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