Catalog the 19 SarahUI components; fix the theme toggle stretching

8178af7a5c5c · AtlantisPleb · · parent 1ab73022ad07

Catalog the 19 SarahUI components; fix the theme toggle stretching

The component library was documenting 9 components while the repo shipped
30. Every one of SarahUI's 19 primitives -- the Sarah design system that
landed in Step 6 -- was missing, as were Layouts.command_bar/1 and
account_control/1.

Theme toggle: the container had no width constraint while its three
segments are w-1/3, so it shrink-wrapped correctly as a flex item in the
navbar but stretched to fill the demo card, spreading the icons across
~800px. Pinned to w-24, which is the natural content width (3 buttons x
(p-2 + size-4)), so the navbar rendering is unchanged.

SarahUI is imported via sarah_html_helpers rather than :live_view, and
button/input/icon collide with CoreComponents, so the catalog calls it
through an explicit UI alias and prefixes those slugs sarah-.

Adds ComponentCatalogTest, which walks __components__/0 for every
documented module and fails when a public component has no catalog entry.
It intersects with the module's exports so private components with attrs
(Layouts.openagents_command_bar/1) are not reported. This is the drift
that let 19 components ship undocumented; it is now loud.

Two real defects surfaced by writing the demos:
- SarahUI.event_header/1 requires a DateTime for :timestamp and raises on
  an ISO string.
- Layouts.account_control/1 hard-codes the ids account-menu,
  account-menu-trigger, logout-form, and logout, so it cannot be rendered
  twice on a page. The catalog shows one instance and documents why.

mix precommit green: 70 passed (was 63).

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 lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/live/components_live.ex
  • added test/openagents_web/component_catalog_test.exs

Diff

4 files changed, +592 -7

lib/openagents_web/component_catalog.ex modified +177 -1

@@ -7,7 +7,16 @@ defmodule OpenAgentsWeb.ComponentCatalog do

7 7
  cannot appear in one and be missing from the other. Adding a component means
8 8
  adding an entry here and a matching `component_demo/1` clause in
9 9
  `ComponentsLive`; `ComponentsLive` has a test that asserts every slug here
10
  resolves to a page.
10
  resolves to a page, and `ComponentCatalogTest` asserts the catalog covers
11
  every public function component in the modules it claims to document.
12
13
  Two component sets ship in this repo and they are catalogued separately:
14
15
    * `OpenAgentsWeb.CoreComponents` — the Phoenix-generated set, styled with
16
      DaisyUI. Imported by `use OpenAgentsWeb, :live_view`.
17
    * `OpenAgentsWeb.SarahUI` — the Sarah interface primitives, imported
18
      separately via `sarah_html_helpers`. `button`, `input`, and `icon` exist
19
      in both sets, which is why the SarahUI slugs are prefixed `sarah-`.
11 20
12 21
  Icon names are drawn from the vendored Apps SDK set (`OpenAgentsWeb.Icons`).
13 22
  """

@@ -67,6 +76,144 @@ defmodule OpenAgentsWeb.ComponentCatalog do

67 76
        }
68 77
      ]
69 78
    },
79
    %{
80
      title: "Sarah UI",
81
      items: [
82
        %{
83
          slug: "sarah-button",
84
          title: "Button",
85
          icon: "cube",
86
          source: "OpenAgentsWeb.SarahUI.button/1",
87
          summary: "Eight variants, four sizes, and a danger tone."
88
        },
89
        %{
90
          slug: "sarah-text-button",
91
          title: "Text button",
92
          icon: "text",
93
          source: "OpenAgentsWeb.SarahUI.text_button/1",
94
          summary: "Borderless action for inline and secondary affordances."
95
        },
96
        %{
97
          slug: "sarah-input",
98
          title: "Input",
99
          icon: "square-text",
100
          source: "OpenAgentsWeb.SarahUI.input/1",
101
          summary: "Bare text input primitive, unwrapped by a form field."
102
        },
103
        %{
104
          slug: "sarah-textarea",
105
          title: "Textarea",
106
          icon: "text",
107
          source: "OpenAgentsWeb.SarahUI.textarea/1",
108
          summary: "Multi-line text primitive."
109
        },
110
        %{
111
          slug: "sarah-label",
112
          title: "Label",
113
          icon: "tag",
114
          source: "OpenAgentsWeb.SarahUI.label/1",
115
          summary: "Form label bound to a control by id."
116
        },
117
        %{
118
          slug: "sarah-field",
119
          title: "Field",
120
          icon: "file-document",
121
          source: "OpenAgentsWeb.SarahUI.field/1",
122
          summary: "Wrapper that stacks a label and its control."
123
        },
124
        %{
125
          slug: "sarah-alert",
126
          title: "Alert",
127
          icon: "warning",
128
          source: "OpenAgentsWeb.SarahUI.alert/1",
129
          summary: "Four variants across box, row, and notice appearances."
130
        },
131
        %{
132
          slug: "sarah-badge",
133
          title: "Badge",
134
          icon: "tag",
135
          source: "OpenAgentsWeb.SarahUI.badge/1",
136
          summary: "Status pill in six variants."
137
        },
138
        %{
139
          slug: "sarah-card",
140
          title: "Card",
141
          icon: "square-image",
142
          source: "OpenAgentsWeb.SarahUI.card/1",
143
          summary: "Content container with an optional corner frame and danger variant."
144
        },
145
        %{
146
          slug: "sarah-avatar",
147
          title: "Avatar",
148
          icon: "user",
149
          source: "OpenAgentsWeb.SarahUI.avatar/1",
150
          summary: "Image or initials fallback in three sizes."
151
        },
152
        %{
153
          slug: "sarah-item",
154
          title: "Item",
155
          icon: "dot",
156
          source: "OpenAgentsWeb.SarahUI.item/1",
157
          summary: "Status, label, and detail row for activity lists."
158
        },
159
        %{
160
          slug: "sarah-event-header",
161
          title: "Event header",
162
          icon: "info",
163
          source: "OpenAgentsWeb.SarahUI.event_header/1",
164
          summary: "Titled event row with status, timestamp, and chip slot."
165
        },
166
        %{
167
          slug: "sarah-empty",
168
          title: "Empty state",
169
          icon: "circle",
170
          source: "OpenAgentsWeb.SarahUI.empty/1",
171
          summary: "Placeholder for lists and panels with nothing to show."
172
        },
173
        %{
174
          slug: "sarah-kbd",
175
          title: "Keyboard key",
176
          icon: "keyboard",
177
          source: "OpenAgentsWeb.SarahUI.kbd/1",
178
          summary: "Rendered keycap for shortcut documentation."
179
        },
180
        %{
181
          slug: "sarah-menu",
182
          title: "Menu",
183
          icon: "menu",
184
          source: "OpenAgentsWeb.SarahUI.menu/1",
185
          summary: "Popover menu surface used by the account control."
186
        },
187
        %{
188
          slug: "sarah-frame",
189
          title: "Frame",
190
          icon: "grid",
191
          source: "OpenAgentsWeb.SarahUI.frame/1",
192
          summary: "Corner-bracket decoration around arbitrary content."
193
        },
194
        %{
195
          slug: "sarah-status-indicator",
196
          title: "Status indicator",
197
          icon: "check-circle",
198
          source: "OpenAgentsWeb.SarahUI.status_indicator/1",
199
          summary: "Labelled state dot, optionally decorative."
200
        },
201
        %{
202
          slug: "sarah-audio-player",
203
          title: "Audio player",
204
          icon: "play",
205
          source: "OpenAgentsWeb.SarahUI.audio_player/1",
206
          summary: "Labelled audio element for recordings."
207
        },
208
        %{
209
          slug: "sarah-icon",
210
          title: "Icon",
211
          icon: "sparkle",
212
          source: "OpenAgentsWeb.SarahUI.icon/1",
213
          summary: "Apps SDK glyph with an optional accessible label."
214
        }
215
      ]
216
    },
70 217
    %{
71 218
      title: "Layout",
72 219
      items: [

@@ -76,6 +223,20 @@ defmodule OpenAgentsWeb.ComponentCatalog do

76 223
          icon: "moon",
77 224
          source: "OpenAgentsWeb.Layouts.theme_toggle/1",
78 225
          summary: "System, light, and dark. The same control sits in the site header."
226
        },
227
        %{
228
          slug: "command-bar",
229
          title: "Command bar",
230
          icon: "compass",
231
          source: "OpenAgentsWeb.Layouts.command_bar/1",
232
          summary: "Top bar with brand lockup, control slot, and account menu."
233
        },
234
        %{
235
          slug: "account-control",
236
          title: "Account control",
237
          icon: "user",
238
          source: "OpenAgentsWeb.Layouts.account_control/1",
239
          summary: "Avatar trigger and popover menu for the signed-in user."
79 240
        }
80 241
      ]
81 242
    },

@@ -104,4 +265,19 @@ defmodule OpenAgentsWeb.ComponentCatalog do

104 265
105 266
  @doc "Look up one item by slug. Returns nil when the slug is unknown."
106 267
  def fetch(slug), do: Enum.find(items(), &(&1.slug == slug))
268
269
  @doc """
270
  The modules this catalog claims to document, and the components in each that
271
  are deliberately not given a page.
272
273
  `OpenAgentsWeb.Layouts.app/1` and `flash_group/1` wrap the catalog page
274
  itself, so they cannot be demoed inside it.
275
  """
276
  def documented_modules do
277
    %{
278
      OpenAgentsWeb.CoreComponents => [],
279
      OpenAgentsWeb.SarahUI => [],
280
      OpenAgentsWeb.Layouts => [:app, :flash_group]
281
    }
282
  end
107 283
end
lib/openagents_web/components/layouts.ex modified +4 -1

@@ -372,7 +372,10 @@ defmodule OpenAgentsWeb.Layouts do

372 372
  """
373 373
  def theme_toggle(assigns) do
374 374
    ~H"""
375
    <div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
375
    <%!-- w-24 is load-bearing: the three buttons are w-1/3, so without an explicit
376
    width this stretches to fill any block-level parent and the segments stretch
377
    with it. 24 (6rem) is the natural content width: 3 buttons x (p-2 + size-4). --%>
378
    <div class="card relative flex flex-row items-center w-24 shrink-0 border-2 border-base-300 bg-base-300 rounded-full">
376 379
      <div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 [[data-theme-source=system]_&]:!left-0 transition-[left]" />
377 380
378 381
      <button
lib/openagents_web/live/components_live.ex modified +316 -5

@@ -12,6 +12,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

12 12
  use OpenAgentsWeb, :live_view
13 13
14 14
  alias OpenAgentsWeb.ComponentCatalog
15
  alias OpenAgentsWeb.SarahUI, as: UI
15 16
16 17
  @sample_rows [
17 18
    %{id: 1, owner: "OpenAgentsInc", repo: "openagents.com", state: "open"},

@@ -24,6 +25,17 @@ defmodule OpenAgentsWeb.ComponentsLive do

24 25
    hero-arrow-path hero-sun-micro hero-moon-micro hero-computer-desktop-micro
25 26
  )
26 27
28
  # SarahUI.icon/1 renders the vendored Apps SDK set, not heroicons.
29
  @sarah_icons ~w(sparkle compass folder document user bell play star)
30
31
  # account_control/1 and command_bar/1 read three fields off the current user.
32
  # A plain map is enough and keeps the catalog page free of database access.
33
  @demo_user %{
34
    github_login: "openagents-demo",
35
    github_name: "Demo Account",
36
    github_avatar_url: nil
37
  }
38
27 39
  @impl true
28 40
  def mount(_params, _session, socket) do
29 41
    form =

@@ -41,7 +53,9 @@ defmodule OpenAgentsWeb.ComponentsLive do

41 53
     socket
42 54
     |> assign(:form, form)
43 55
     |> assign(:rows, @sample_rows)
44
     |> assign(:icons, @icons)}
56
     |> assign(:icons, @icons)
57
     |> assign(:sarah_icons, @sarah_icons)
58
     |> assign(:demo_user, @demo_user)}
45 59
  end
46 60
47 61
  @impl true

@@ -93,10 +107,14 @@ defmodule OpenAgentsWeb.ComponentsLive do

93 107
    <div id="components-index" class="max-w-3xl">
94 108
      <h1 class="text-3xl font-semibold mb-4">Component library</h1>
95 109
      <p class="text-base-content/70 mb-8 text-pretty max-w-[68ch]">
96
        Live examples of every reusable function component in this repository.
97
        These controls come from <code>OpenAgentsWeb.CoreComponents</code>
98
        and <code>OpenAgentsWeb.Layouts</code>, styled with DaisyUI. Planned
99
        GitHub-shaped components are listed in <code>docs/component-library.md</code>.
110
        Live examples of every reusable function component in this repository, drawn from
111
        <code>OpenAgentsWeb.CoreComponents</code>
112
        (the Phoenix set, styled with DaisyUI), <code>OpenAgentsWeb.SarahUI</code>
113
        (the Sarah interface primitives), and <code>OpenAgentsWeb.Layouts</code>. <code>button</code>, <code>input</code>, and
114
        <code>icon</code>
115
        exist in both component sets, so the SarahUI entries are listed separately.
116
        A test asserts this page covers every public component in those modules.
117
        Planned GitHub-shaped components are listed in <code>docs/component-library.md</code>.
100 118
      </p>
101 119
102 120
      <section :for={section <- ComponentCatalog.sections()} class="mb-10">

@@ -138,6 +156,8 @@ defmodule OpenAgentsWeb.ComponentsLive do

138 156
  attr :form, :any, default: nil
139 157
  attr :rows, :list, default: []
140 158
  attr :icons, :list, default: []
159
  attr :sarah_icons, :list, default: []
160
  attr :demo_user, :map, default: nil
141 161
142 162
  defp component_demo(%{item: %{slug: "button"}} = assigns) do
143 163
    ~H"""

@@ -261,4 +281,295 @@ defmodule OpenAgentsWeb.ComponentsLive do

261 281
    />
262 282
    """
263 283
  end
284
285
  # --- Sarah UI -------------------------------------------------------------
286
  # SarahUI is imported by `sarah_html_helpers`, not by `:live_view`, so these
287
  # are called with the full module prefix. That also documents provenance on a
288
  # page whose whole job is showing where a component comes from.
289
290
  defp component_demo(%{item: %{slug: "sarah-button"}} = assigns) do
291
    ~H"""
292
    <div class="space-y-4">
293
      <div class="flex flex-wrap items-center gap-3">
294
        <UI.button
295
          :for={v <- ~w(primary secondary outline ghost destructive chip notched link)a}
296
          variant={v}
297
        >
298
          {v |> Atom.to_string() |> String.capitalize()}
299
        </UI.button>
300
      </div>
301
      <div class="flex flex-wrap items-center gap-3">
302
        <UI.button :for={sz <- ~w(xs sm default lg)a} size={sz}>{sz}</UI.button>
303
      </div>
304
      <div class="flex flex-wrap items-center gap-3">
305
        <UI.button tone={:danger}>Danger tone</UI.button>
306
        <UI.button disabled>Disabled</UI.button>
307
      </div>
308
    </div>
309
    """
310
  end
311
312
  defp component_demo(%{item: %{slug: "sarah-text-button"}} = assigns) do
313
    ~H"""
314
    <div class="flex flex-wrap items-center gap-4">
315
      <UI.text_button>Default</UI.text_button>
316
      <UI.text_button tone={:danger}>Danger</UI.text_button>
317
      <UI.text_button disabled>Disabled</UI.text_button>
318
    </div>
319
    """
320
  end
321
322
  defp component_demo(%{item: %{slug: "sarah-input"}} = assigns) do
323
    ~H"""
324
    <div class="space-y-3 max-w-sm">
325
      <UI.input id="sarah-input-demo" name="demo" value="Ship the catalog" />
326
      <UI.input id="sarah-input-demo-empty" name="demo_empty" placeholder="Placeholder text" />
327
      <UI.input id="sarah-input-demo-disabled" name="demo_disabled" value="Disabled" disabled />
328
    </div>
329
    """
330
  end
331
332
  defp component_demo(%{item: %{slug: "sarah-textarea"}} = assigns) do
333
    ~H"""
334
    <div class="max-w-sm">
335
      <UI.textarea
336
        id="sarah-textarea-demo"
337
        name="demo_body"
338
        value="Multi-line text primitive."
339
        rows="4"
340
      />
341
    </div>
342
    """
343
  end
344
345
  defp component_demo(%{item: %{slug: "sarah-label"}} = assigns) do
346
    ~H"""
347
    <div class="space-y-2 max-w-sm">
348
      <UI.label for="sarah-label-target">Repository name</UI.label>
349
      <UI.input id="sarah-label-target" name="repo" value="openagents.com" />
350
    </div>
351
    """
352
  end
353
354
  defp component_demo(%{item: %{slug: "sarah-field"}} = assigns) do
355
    ~H"""
356
    <div class="max-w-sm space-y-4">
357
      <UI.field>
358
        <UI.label for="sarah-field-title">Title</UI.label>
359
        <UI.input id="sarah-field-title" name="title" value="Ship the component catalog" />
360
      </UI.field>
361
      <UI.field>
362
        <UI.label for="sarah-field-body">Body</UI.label>
363
        <UI.textarea id="sarah-field-body" name="body" rows="3" />
364
      </UI.field>
365
    </div>
366
    """
367
  end
368
369
  defp component_demo(%{item: %{slug: "sarah-alert"}} = assigns) do
370
    ~H"""
371
    <div class="space-y-6">
372
      <div class="space-y-3">
373
        <p class="text-sm text-base-content/60">Variants (box appearance)</p>
374
        <UI.alert :for={v <- ~w(info success warning danger)a} variant={v} label={Atom.to_string(v)}>
375
          A {v} alert in the default box appearance.
376
        </UI.alert>
377
      </div>
378
      <div class="space-y-3">
379
        <p class="text-sm text-base-content/60">Appearances</p>
380
        <UI.alert
381
          :for={a <- ~w(box row notice)a}
382
          appearance={a}
383
          variant={:info}
384
          label={Atom.to_string(a)}
385
        >
386
          The {a} appearance.
387
        </UI.alert>
388
      </div>
389
    </div>
390
    """
391
  end
392
393
  defp component_demo(%{item: %{slug: "sarah-badge"}} = assigns) do
394
    ~H"""
395
    <div class="flex flex-wrap items-center gap-3">
396
      <UI.badge :for={v <- ~w(default info success warning danger dim)a} variant={v}>
397
        {v}
398
      </UI.badge>
399
    </div>
400
    """
401
  end
402
403
  defp component_demo(%{item: %{slug: "sarah-card"}} = assigns) do
404
    ~H"""
405
    <div class="grid gap-4 sm:grid-cols-2">
406
      <UI.card id="sarah-card-default">
407
        <p class="font-medium">Default</p>
408
        <p class="text-sm text-base-content/70">A plain content container.</p>
409
      </UI.card>
410
      <UI.card id="sarah-card-corners" frame={:corners}>
411
        <p class="font-medium">Corner frame</p>
412
        <p class="text-sm text-base-content/70">With bracket decoration.</p>
413
      </UI.card>
414
      <UI.card id="sarah-card-danger" variant={:danger}>
415
        <p class="font-medium">Danger</p>
416
        <p class="text-sm text-base-content/70">For destructive context.</p>
417
      </UI.card>
418
    </div>
419
    """
420
  end
421
422
  defp component_demo(%{item: %{slug: "sarah-avatar"}} = assigns) do
423
    ~H"""
424
    <div class="flex flex-wrap items-end gap-6">
425
      <UI.avatar :for={sz <- ~w(sm default lg)a} size={sz} fallback="OA" label={"size #{sz}"} />
426
      <UI.avatar tone={:accent} fallback="AC" label="accent tone" />
427
    </div>
428
    """
429
  end
430
431
  defp component_demo(%{item: %{slug: "sarah-item"}} = assigns) do
432
    ~H"""
433
    <div class="space-y-2">
434
      <UI.item status="ok" label="Fleet node 1" detail="converged" />
435
      <UI.item status="pending" label="Fleet node 2" detail="rolling" />
436
      <UI.item status="error" label="Fleet node 3" detail="unreachable" />
437
    </div>
438
    """
439
  end
440
441
  defp component_demo(%{item: %{slug: "sarah-event-header"}} = assigns) do
442
    ~H"""
443
    <UI.event_header
444
      id="sarah-event-header-demo"
445
      status="ok"
446
      title="repo_commit_push"
447
      status_note="completed"
448
      timestamp={~U[2026-08-19 21:51:00Z]}
449
    >
450
      <:chips>
451
        <UI.badge variant={:dim}>tool</UI.badge>
452
      </:chips>
453
      <p class="text-sm text-base-content/70">Pushed 3 files to the forge.</p>
454
    </UI.event_header>
455
    """
456
  end
457
458
  defp component_demo(%{item: %{slug: "sarah-empty"}} = assigns) do
459
    ~H"""
460
    <UI.empty id="sarah-empty-demo" title="No delegations yet">
461
      Work you delegate to a paired machine will appear here.
462
    </UI.empty>
463
    """
464
  end
465
466
  defp component_demo(%{item: %{slug: "sarah-kbd"}} = assigns) do
467
    ~H"""
468
    <p class="flex flex-wrap items-center gap-2 text-sm">
469
      Press
470
      <UI.kbd>⌘</UI.kbd>
471
472
      <UI.kbd>K</UI.kbd>
473
      to open the command bar, or
474
      <UI.kbd>Esc</UI.kbd>
475
      to dismiss it.
476
    </p>
477
    """
478
  end
479
480
  defp component_demo(%{item: %{slug: "sarah-menu"}} = assigns) do
481
    ~H"""
482
    <div class="space-y-3">
483
      <UI.button popovertarget="sarah-demo-menu" popovertargetaction="toggle">
484
        Open menu
485
      </UI.button>
486
      <UI.menu id="sarah-demo-menu" label="Demo menu">
487
        <UI.text_button>Profile</UI.text_button>
488
        <UI.text_button>Settings</UI.text_button>
489
        <UI.text_button tone={:danger}>Sign out</UI.text_button>
490
      </UI.menu>
491
    </div>
492
    """
493
  end
494
495
  defp component_demo(%{item: %{slug: "sarah-frame"}} = assigns) do
496
    ~H"""
497
    <UI.frame>
498
      <div class="p-6">
499
        <p class="font-medium">Framed content</p>
500
        <p class="text-sm text-base-content/70">Corner brackets wrap arbitrary children.</p>
501
      </div>
502
    </UI.frame>
503
    """
504
  end
505
506
  defp component_demo(%{item: %{slug: "sarah-status-indicator"}} = assigns) do
507
    ~H"""
508
    <div class="flex flex-wrap items-center gap-6">
509
      <UI.status_indicator state="ok" label="Healthy" />
510
      <UI.status_indicator state="pending" label="Converging" />
511
      <UI.status_indicator state="error" label="Degraded" />
512
      <UI.status_indicator state="ok" label="Decorative" decorative />
513
    </div>
514
    """
515
  end
516
517
  defp component_demo(%{item: %{slug: "sarah-audio-player"}} = assigns) do
518
    ~H"""
519
    <div class="space-y-2">
520
      <UI.audio_player
521
        id="sarah-audio-demo"
522
        src="/audio/does-not-exist.wav"
523
        label="Voice recording (demo source, nothing to play)"
524
      />
525
      <p class="text-sm text-base-content/60">
526
        The src is intentionally a dead path; this page has no recording to serve.
527
      </p>
528
    </div>
529
    """
530
  end
531
532
  defp component_demo(%{item: %{slug: "sarah-icon"}} = assigns) do
533
    ~H"""
534
    <ul id="sarah-demo-icons" role="list" class="flex flex-wrap gap-4">
535
      <li :for={name <- @sarah_icons} class="flex flex-col items-center gap-2 w-28">
536
        <UI.icon name={name} class="size-6" />
537
        <p class="text-center text-sm text-base-content/70">{name}</p>
538
      </li>
539
    </ul>
540
    """
541
  end
542
543
  # --- Layout ---------------------------------------------------------------
544
545
  defp component_demo(%{item: %{slug: "command-bar"}} = assigns) do
546
    ~H"""
547
    <Layouts.command_bar aria_label="Demo command bar" current_user={@demo_user}>
548
      <:lockup>
549
        <UI.badge variant={:dim}>demo</UI.badge>
550
      </:lockup>
551
      <:controls>
552
        <UI.text_button>Controls slot</UI.text_button>
553
      </:controls>
554
    </Layouts.command_bar>
555
    """
556
  end
557
558
  defp component_demo(%{item: %{slug: "account-control"}} = assigns) do
559
    ~H"""
560
    <div class="space-y-4">
561
      <div class="space-y-2">
562
        <p class="text-sm text-base-content/60">Bar context</p>
563
        <Layouts.account_control current_user={@demo_user} context={:bar} />
564
      </div>
565
      <p class="text-sm text-base-content/60 max-w-[68ch]">
566
        Only one instance is shown. <code>account_control/1</code>
567
        hard-codes the ids <code>account-menu</code>, <code>account-menu-trigger</code>, <code>logout-form</code>, and <code>logout</code>, so it can be rendered at most
568
        once per page — rendering the <code>:row</code>
569
        context alongside this one produces duplicate ids. That is fine for a layout,
570
        which has a single account control, but it is a real constraint on reuse.
571
      </p>
572
    </div>
573
    """
574
  end
264 575
end
test/openagents_web/component_catalog_test.exs added +95

@@ -0,0 +1,95 @@

1
defmodule OpenAgentsWeb.ComponentCatalogTest do
2
  @moduledoc """
3
  Guards the catalog against silently falling behind the code.
4
5
  The catalog was originally hand-written and drifted: nineteen SarahUI
6
  components shipped without ever appearing on the components page. This test
7
  makes that failure mode loud — adding a public function component to a
8
  documented module fails the suite until it is catalogued or explicitly
9
  excluded.
10
  """
11
12
  use ExUnit.Case, async: true
13
14
  alias OpenAgentsWeb.ComponentCatalog
15
16
  test "slugs are unique" do
17
    slugs = ComponentCatalog.slugs()
18
    assert length(slugs) == length(Enum.uniq(slugs))
19
  end
20
21
  test "every catalog entry names a module and function that actually exist" do
22
    for item <- ComponentCatalog.items() do
23
      {module, function} = parse_source(item.source)
24
25
      assert Code.ensure_loaded?(module),
26
             "#{item.slug} names a module that does not exist: #{inspect(module)}"
27
28
      assert function_exported?(module, function, 1),
29
             "#{item.slug} names #{inspect(module)}.#{function}/1, which is not exported"
30
    end
31
  end
32
33
  test "the catalog covers every public function component in the documented modules" do
34
    for {module, excluded} <- ComponentCatalog.documented_modules() do
35
      catalogued =
36
        ComponentCatalog.items()
37
        |> Enum.map(&parse_source(&1.source))
38
        |> Enum.filter(fn {m, _f} -> m == module end)
39
        |> Enum.map(fn {_m, f} -> f end)
40
        |> MapSet.new()
41
42
      missing =
43
        module
44
        |> function_components()
45
        |> Enum.reject(&(&1 in excluded))
46
        |> Enum.reject(&(&1 in catalogued))
47
48
      assert missing == [],
49
             """
50
             #{inspect(module)} has public function components with no catalog entry:
51
             #{inspect(missing)}
52
53
             Add them to OpenAgentsWeb.ComponentCatalog with a matching
54
             component_demo/1 clause in OpenAgentsWeb.ComponentsLive, or add them
55
             to the exclusion list in ComponentCatalog.documented_modules/0 with a
56
             reason.
57
             """
58
    end
59
  end
60
61
  # `__components__/0` registers every component the compiler saw attrs for,
62
  # including `defp` ones (Layouts.openagents_command_bar/1 is private). A
63
  # private component cannot be called from a catalog page, so intersect with
64
  # the module's actual exports rather than carrying dead exclusion entries.
65
  defp function_components(module) do
66
    Code.ensure_loaded!(module)
67
68
    exported =
69
      module.__info__(:functions)
70
      |> Enum.filter(fn {_name, arity} -> arity == 1 end)
71
      |> Enum.map(&elem(&1, 0))
72
      |> MapSet.new()
73
74
    if function_exported?(module, :__components__, 0) do
75
      module.__components__()
76
      |> Map.keys()
77
      |> Enum.filter(&(&1 in exported))
78
      |> Enum.sort()
79
    else
80
      exported
81
      |> Enum.reject(&String.starts_with?(Atom.to_string(&1), "__"))
82
      |> Enum.sort()
83
    end
84
  end
85
86
  defp parse_source(source) do
87
    [path, arity_part] = String.split(source, "/", parts: 2)
88
    ^arity_part = "1"
89
90
    segments = String.split(path, ".")
91
    {function, module_segments} = List.pop_at(segments, -1)
92
93
    {Module.concat(module_segments), String.to_atom(function)}
94
  end
95
end

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