Give each component its own page; drop the docs search bar

02d1214475fd · AtlantisPleb · · parent 7e3d7fc80e12

Give each component its own page; drop the docs search bar

/components was one long scrolling gallery. It now uses the same
sidebar+main structure as /docs: an index at /components and a page per
component at /components/:slug.

- OpenAgentsWeb.ComponentCatalog is the single source of truth. The
  sidebar and the pages are both generated from it, so a component
  cannot appear in one and be missing from the other. A data-driven test
  walks every slug and asserts it resolves, so adding a catalog entry
  without a demo clause fails the suite.
- New :components layout mirrors layouts/docs.html.heex. Active rows use
  the existing .sidebar-row[data-selected] convention rather than a new
  modifier class.
- /components/icons stays a literal route ahead of /components/:slug and
  joins the same layout, so the sidebar is consistent across the section.
  IconIndexLive drops its own Layouts.app wrapper.
- Removed the docs sidebar search input. It was non-functional markup,
  so the .docs-sidebar__search rules went with it.
- Home hero reads "The Agent Forge", matching README.md.

mix precommit green: 63 passed.

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
  • added lib/openagents_web/component_catalog.ex
  • added 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
  • modified lib/openagents_web/live/home_live.ex
  • modified lib/openagents_web/live/icon_index_live.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents_web/controllers/page_controller_test.exs
  • modified test/openagents_web/live/components_live_test.exs
  • added test/openagents_web/live/docs_live_test.exs

Diff

11 files changed, +471 -224

assets/css/app.css modified -21

@@ -287,27 +287,6 @@

287 287
  min-height: 44px;
288 288
}
289 289
290
.docs-sidebar__search {
291
  display: flex;
292
  align-items: center;
293
  gap: 8px;
294
  margin-inline: 6px;
295
  padding: 8px 10px;
296
  border: 1px solid var(--color-base-300);
297
  border-radius: 8px;
298
  background: var(--color-base-100);
299
}
300
301
.docs-sidebar__search input {
302
  background: transparent;
303
  color: var(--color-base-content);
304
  outline: none;
305
}
306
307
.docs-sidebar__search input::placeholder {
308
  color: var(--color-neutral);
309
}
310
311 290
.docs-sidebar__nav {
312 291
  display: flex;
313 292
  flex: 1;
lib/openagents_web/component_catalog.ex added +107

@@ -0,0 +1,107 @@

1
defmodule OpenAgentsWeb.ComponentCatalog do
2
  @moduledoc """
3
  The component catalog's table of contents.
4
5
  Both the sidebar in `layouts/components.html.heex` and the pages rendered by
6
  `OpenAgentsWeb.ComponentsLive` are generated from this list, so a component
7
  cannot appear in one and be missing from the other. Adding a component means
8
  adding an entry here and a matching `component_demo/1` clause in
9
  `ComponentsLive`; `ComponentsLive` has a test that asserts every slug here
10
  resolves to a page.
11
12
  Icon names are drawn from the vendored Apps SDK set (`OpenAgentsWeb.Icons`).
13
  """
14
15
  @sections [
16
    %{
17
      title: "Core components",
18
      items: [
19
        %{
20
          slug: "button",
21
          title: "Button",
22
          icon: "cube",
23
          source: "OpenAgentsWeb.CoreComponents.button/1",
24
          summary: "Default, primary, navigation, and disabled variants."
25
        },
26
        %{
27
          slug: "input",
28
          title: "Input",
29
          icon: "square-text",
30
          source: "OpenAgentsWeb.CoreComponents.input/1",
31
          summary: "Text, select, textarea, and checkbox fields inside a form."
32
        },
33
        %{
34
          slug: "header",
35
          title: "Header",
36
          icon: "book",
37
          source: "OpenAgentsWeb.CoreComponents.header/1",
38
          summary: "Page title with an optional subtitle and action slot."
39
        },
40
        %{
41
          slug: "table",
42
          title: "Table",
43
          icon: "table-cells-filled",
44
          source: "OpenAgentsWeb.CoreComponents.table/1",
45
          summary: "Row listing with column and action slots."
46
        },
47
        %{
48
          slug: "list",
49
          title: "List",
50
          icon: "file-document",
51
          source: "OpenAgentsWeb.CoreComponents.list/1",
52
          summary: "Title and description pairs in a definition list."
53
        },
54
        %{
55
          slug: "icon",
56
          title: "Icon",
57
          icon: "grid",
58
          source: "OpenAgentsWeb.CoreComponents.icon/1",
59
          summary: "Inline glyphs from the vendored icon set."
60
        },
61
        %{
62
          slug: "flash",
63
          title: "Flash",
64
          icon: "bell",
65
          source: "OpenAgentsWeb.CoreComponents.flash/1",
66
          summary: "Info and error toasts rendered by the layout's flash group."
67
        }
68
      ]
69
    },
70
    %{
71
      title: "Layout",
72
      items: [
73
        %{
74
          slug: "theme-toggle",
75
          title: "Theme toggle",
76
          icon: "moon",
77
          source: "OpenAgentsWeb.Layouts.theme_toggle/1",
78
          summary: "System, light, and dark. The same control sits in the site header."
79
        }
80
      ]
81
    },
82
    %{
83
      title: "Forge",
84
      items: [
85
        %{
86
          slug: "repo-header",
87
          title: "Repo header",
88
          icon: "folder",
89
          source: "OpenAgentsWeb.Components.RepoHeader.repo_header/1",
90
          summary: "Repository breadcrumb and tab bar with issue counts."
91
        }
92
      ]
93
    }
94
  ]
95
96
  @doc "Sidebar sections, in display order."
97
  def sections, do: @sections
98
99
  @doc "Every catalog item, flattened, in display order."
100
  def items, do: Enum.flat_map(@sections, & &1.items)
101
102
  @doc "Every slug in the catalog."
103
  def slugs, do: Enum.map(items(), & &1.slug)
104
105
  @doc "Look up one item by slug. Returns nil when the slug is unknown."
106
  def fetch(slug), do: Enum.find(items(), &(&1.slug == slug))
107
end
lib/openagents_web/components/layouts/components.html.heex added +64

@@ -0,0 +1,64 @@

1
<div class="docs-layout">
2
  <aside class="docs-sidebar">
3
    <nav class="flex flex-col gap-1" aria-label="Components 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>
12
13
    <nav class="docs-sidebar__nav" aria-label="Component library">
14
      <section class="docs-sidebar__section">
15
        <h3 class="sidebar-section-label">Overview</h3>
16
        <div class="sidebar-row" data-selected={@active_component == :index}>
17
          <.link navigate={~p"/components"} class="sidebar-row__hit" aria-label="All components"></.link>
18
          <span class="sidebar-row__content">
19
            <span class="sidebar-row__icon"><.icon name="book" /></span>
20
            <span class="sidebar-row__label">All components</span>
21
          </span>
22
        </div>
23
      </section>
24
25
      <section
26
        :for={section <- OpenAgentsWeb.ComponentCatalog.sections()}
27
        class="docs-sidebar__section"
28
      >
29
        <h3 class="sidebar-section-label">{section.title}</h3>
30
        <div
31
          :for={item <- section.items}
32
          class="sidebar-row"
33
          data-selected={@active_component == item.slug}
34
        >
35
          <.link
36
            navigate={~p"/components/#{item.slug}"}
37
            class="sidebar-row__hit"
38
            aria-label={item.title}
39
          ></.link>
40
          <span class="sidebar-row__content">
41
            <span class="sidebar-row__icon"><.icon name={item.icon} /></span>
42
            <span class="sidebar-row__label">{item.title}</span>
43
          </span>
44
        </div>
45
      </section>
46
47
      <section class="docs-sidebar__section">
48
        <h3 class="sidebar-section-label">Reference</h3>
49
        <div class="sidebar-row" data-selected={@active_component == :icons}>
50
          <.link navigate={~p"/components/icons"} class="sidebar-row__hit" aria-label="Icons"></.link>
51
          <span class="sidebar-row__content">
52
            <span class="sidebar-row__icon"><.icon name="book-open" /></span>
53
            <span class="sidebar-row__label">Icons</span>
54
          </span>
55
        </div>
56
      </section>
57
    </nav>
58
  </aside>
59
60
  <main class="docs-main">
61
    <.flash_group flash={@flash} />
62
    {@inner_content}
63
  </main>
64
</div>
lib/openagents_web/components/layouts/docs.html.heex modified -9

@@ -10,15 +10,6 @@

10 10
      </div>
11 11
    </nav>
12 12
13
    <div class="docs-sidebar__search">
14
      <.icon name="hero-magnifying-glass" class="w-4 h-4 text-base-content/50" />
15
      <input
16
        type="text"
17
        placeholder="Search docs..."
18
        class="input input-ghost input-sm w-full h-auto px-0 border-0 focus:outline-none"
19
      />
20
    </div>
21
22 13
    <nav class="docs-sidebar__nav" aria-label="Documentation">
23 14
      <section class="docs-sidebar__section">
24 15
        <h3 class="sidebar-section-label">Get started</h3>
lib/openagents_web/live/components_live.ex modified +194 -173

@@ -2,14 +2,17 @@ defmodule OpenAgentsWeb.ComponentsLive do

2 2
  @moduledoc """
3 3
  Public catalog of reusable HEEx components that ship in this codebase.
4 4
5
  Renders every function component in `OpenAgentsWeb.CoreComponents` and
6
  `OpenAgentsWeb.Layouts` (except `Layouts.app/1` and `flash_group/1`, which
7
  wrap this page). Planned forge and issues components live in
8
  `docs/component-library.md`.
5
  `/components` is an index; each component gets its own page at
6
  `/components/:slug`. The sidebar and the set of valid slugs both come from
7
  `OpenAgentsWeb.ComponentCatalog`, so navigation and pages cannot drift.
8
9
  Planned forge and issues components are listed in `docs/component-library.md`.
9 10
  """
10 11
11 12
  use OpenAgentsWeb, :live_view
12 13
14
  alias OpenAgentsWeb.ComponentCatalog
15
13 16
  @sample_rows [
14 17
    %{id: 1, owner: "OpenAgentsInc", repo: "openagents.com", state: "open"},
15 18
    %{id: 2, owner: "OpenAgentsInc", repo: "sarah", state: "open"},

@@ -36,12 +39,37 @@ defmodule OpenAgentsWeb.ComponentsLive do

36 39
37 40
    {:ok,
38 41
     socket
39
     |> assign(:page_title, "Components")
40 42
     |> assign(:form, form)
41 43
     |> assign(:rows, @sample_rows)
42 44
     |> assign(:icons, @icons)}
43 45
  end
44 46
47
  @impl true
48
  def handle_params(_params, _uri, %{assigns: %{live_action: :index}} = socket) do
49
    {:noreply,
50
     socket
51
     |> assign(:page_title, "Components")
52
     |> assign(:active_component, :index)
53
     |> assign(:item, nil)}
54
  end
55
56
  def handle_params(%{"slug" => slug}, _uri, socket) do
57
    case ComponentCatalog.fetch(slug) do
58
      nil ->
59
        {:noreply,
60
         socket
61
         |> put_flash(:error, "Unknown component: #{slug}")
62
         |> push_navigate(to: ~p"/components")}
63
64
      item ->
65
        {:noreply,
66
         socket
67
         |> assign(:page_title, item.title)
68
         |> assign(:active_component, item.slug)
69
         |> assign(:item, item)}
70
    end
71
  end
72
45 73
  @impl true
46 74
  def handle_event("validate", %{"demo" => params}, socket) do
47 75
    {:noreply, assign(socket, :form, to_form(params, as: :demo))}

@@ -60,184 +88,177 @@ defmodule OpenAgentsWeb.ComponentsLive do

60 88
  end
61 89
62 90
  @impl true
63
  def render(assigns) do
91
  def render(%{live_action: :index} = assigns) do
64 92
    ~H"""
65
    <Layouts.app flash={@flash} current_scope={@current_scope} wide>
66
      <div id="components-gallery" class="space-y-12">
67
        <header class="space-y-3">
68
          <.header>
69
            Component library
70
            <:subtitle>
71
              Live examples of every reusable function component in this
72
              repository. Planned GitHub-shaped components are listed in
73
              docs/component-library.md.
74
            </:subtitle>
75
          </.header>
76
          <p class="text-pretty text-base text-base-content/70 max-w-[68ch]">
77
            These controls come from <code>OpenAgentsWeb.CoreComponents</code>
78
            and <code>OpenAgentsWeb.Layouts</code>. DaisyUI classes style them.
79
            New forge and issues components should land in a dedicated module
80
            and appear on this page when they ship.
81
          </p>
82
        </header>
83
84
        <.catalog_section
85
          id="section-button"
86
          title="Button"
87
          source="OpenAgentsWeb.CoreComponents.button/1"
88
        >
89
          <div class="flex flex-wrap items-center gap-3">
90
            <.button id="demo-button-default">Default</.button>
91
            <.button id="demo-button-primary" variant="primary">Primary</.button>
92
            <.button id="demo-button-navigate" navigate={~p"/"}>Navigate home</.button>
93
            <.button id="demo-button-disabled" disabled>Disabled</.button>
94
          </div>
95
        </.catalog_section>
96
97
        <.catalog_section
98
          id="section-input"
99
          title="Input"
100
          source="OpenAgentsWeb.CoreComponents.input/1"
101
        >
102
          <.form
103
            for={@form}
104
            id="component-form"
105
            phx-change="validate"
106
            phx-submit="save"
107
            class="grid gap-4 sm:grid-cols-2"
93
    <div id="components-index" class="max-w-3xl">
94
      <h1 class="text-3xl font-semibold mb-4">Component library</h1>
95
      <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>.
100
      </p>
101
102
      <section :for={section <- ComponentCatalog.sections()} class="mb-10">
103
        <h2 class="text-sm font-semibold uppercase tracking-wide text-base-content/50 mb-3">
104
          {section.title}
105
        </h2>
106
        <div class="grid gap-4 sm:grid-cols-2">
107
          <.link
108
            :for={item <- section.items}
109
            navigate={~p"/components/#{item.slug}"}
110
            class="card bg-base-200 border border-base-300 p-4 hover:border-base-content/30"
108 111
          >
109
            <div>
110
              <.input field={@form[:title]} label="Title" />
111
            </div>
112
            <div>
113
              <.input
114
                field={@form[:state]}
115
                type="select"
116
                label="State"
117
                options={[{"Open", "open"}, {"Closed", "closed"}]}
118
              />
119
            </div>
120
            <div class="sm:col-span-2">
121
              <.input field={@form[:body]} type="textarea" label="Body" />
122
            </div>
123
            <div>
124
              <.input field={@form[:public]} type="checkbox" label="Public repository" />
125
            </div>
126
            <div class="flex items-end">
127
              <.button type="submit" variant="primary">Save demo</.button>
128
            </div>
129
          </.form>
130
        </.catalog_section>
131
132
        <.catalog_section
133
          id="section-header"
134
          title="Header"
135
          source="OpenAgentsWeb.CoreComponents.header/1"
136
        >
137
          <.header>
138
            Repository issues
139
            <:subtitle>Open and closed issues for this repository.</:subtitle>
140
            <:actions>
141
              <.button variant="primary">New issue</.button>
142
            </:actions>
143
          </.header>
144
        </.catalog_section>
145
146
        <.catalog_section
147
          id="section-table"
148
          title="Table"
149
          source="OpenAgentsWeb.CoreComponents.table/1"
150
        >
151
          <.table id="demo-table" rows={@rows}>
152
            <:col :let={row} label="Owner">{row.owner}</:col>
153
            <:col :let={row} label="Repository">{row.repo}</:col>
154
            <:col :let={row} label="State">{row.state}</:col>
155
            <:action :let={row}>
156
              <.link navigate={~p"/"} class="link link-hover">View {row.repo}</.link>
157
            </:action>
158
          </.table>
159
        </.catalog_section>
160
161
        <.catalog_section
162
          id="section-list"
163
          title="List"
164
          source="OpenAgentsWeb.CoreComponents.list/1"
165
        >
166
          <.list>
167
            <:item title="Flash">Toast alerts for info and error.</:item>
168
            <:item title="Button">Primary and soft variants, plus navigation.</:item>
169
            <:item title="Input">Text, select, textarea, and checkbox.</:item>
170
          </.list>
171
        </.catalog_section>
172
173
        <.catalog_section
174
          id="section-icon"
175
          title="Icon"
176
          source="OpenAgentsWeb.CoreComponents.icon/1"
177
        >
178
          <ul id="demo-icons" role="list" class="flex flex-wrap gap-4">
179
            <li :for={name <- @icons} class="flex flex-col items-center gap-2 w-28">
180
              <.icon name={name} class="size-6" />
181
              <p class="text-center text-sm text-base-content/70">{name}</p>
182
            </li>
183
          </ul>
184
        </.catalog_section>
185
186
        <.catalog_section
187
          id="section-flash"
188
          title="Flash"
189
          source="OpenAgentsWeb.CoreComponents.flash/1"
190
        >
191
          <p class="text-pretty text-base text-base-content/70 max-w-[68ch]">
192
            Flash renders through <code>Layouts.flash_group/1</code> at the
193
            corner of the page. Trigger a sample message:
194
          </p>
195
          <div class="flex flex-wrap gap-3">
196
            <.button id="demo-flash-info" phx-click="flash-info">Show info flash</.button>
197
            <.button id="demo-flash-error" phx-click="flash-error">Show error flash</.button>
198
          </div>
199
        </.catalog_section>
200
201
        <.catalog_section
202
          id="section-theme-toggle"
203
          title="Theme toggle"
204
          source="OpenAgentsWeb.Layouts.theme_toggle/1"
205
        >
206
          <p class="text-pretty text-base text-base-content/70 max-w-[68ch]">
207
            System, light, and dark. The same control is in the site header.
208
          </p>
209
          <div id="demo-theme-toggle">
210
            <Layouts.theme_toggle />
211
          </div>
212
        </.catalog_section>
213
214
        <.catalog_section
215
          id="section-repo-header"
216
          title="Repo header"
217
          source="OpenAgentsWeb.Components.RepoHeader.repo_header/1"
218
        >
219
        </.catalog_section>
112
            <h3 class="text-lg font-medium mb-1">{item.title}</h3>
113
            <p class="text-sm text-base-content/70">{item.summary}</p>
114
          </.link>
115
        </div>
116
      </section>
117
    </div>
118
    """
119
  end
120
121
  def render(assigns) do
122
    ~H"""
123
    <div id={"component-#{@item.slug}"} class="max-w-3xl space-y-6">
124
      <header class="space-y-1">
125
        <h1 class="text-3xl font-semibold">{@item.title}</h1>
126
        <p class="text-sm text-base-content/70"><code>{@item.source}</code></p>
127
        <p class="text-base text-base-content/70 text-pretty max-w-[68ch]">{@item.summary}</p>
128
      </header>
129
130
      <div class="rounded-box border border-base-300 bg-base-100 p-6">
131
        <.component_demo {assigns} />
220 132
      </div>
221
    </Layouts.app>
133
    </div>
222 134
    """
223 135
  end
224 136
225
  attr :id, :string, required: true
226
  attr :title, :string, required: true
227
  attr :source, :string, required: true
228
  slot :inner_block, required: true
137
  attr :item, :map, required: true
138
  attr :form, :any, default: nil
139
  attr :rows, :list, default: []
140
  attr :icons, :list, default: []
141
142
  defp component_demo(%{item: %{slug: "button"}} = assigns) do
143
    ~H"""
144
    <div class="flex flex-wrap items-center gap-3">
145
      <.button id="demo-button-default">Default</.button>
146
      <.button id="demo-button-primary" variant="primary">Primary</.button>
147
      <.button id="demo-button-navigate" navigate={~p"/"}>Navigate home</.button>
148
      <.button id="demo-button-disabled" disabled>Disabled</.button>
149
    </div>
150
    """
151
  end
229 152
230
  defp catalog_section(assigns) do
153
  defp component_demo(%{item: %{slug: "input"}} = assigns) do
231 154
    ~H"""
232
    <section id={@id} class="space-y-4">
233
      <div class="space-y-1">
234
        <h2 class="text-xl font-semibold tracking-tight text-balance">{@title}</h2>
235
        <p class="text-sm text-base-content/70"><code>{@source}</code></p>
155
    <.form
156
      for={@form}
157
      id="component-form"
158
      phx-change="validate"
159
      phx-submit="save"
160
      class="grid gap-4 sm:grid-cols-2"
161
    >
162
      <div>
163
        <.input field={@form[:title]} label="Title" />
236 164
      </div>
237
      <div class="rounded-box border border-base-300 bg-base-100 p-6">
238
        {render_slot(@inner_block)}
165
      <div>
166
        <.input
167
          field={@form[:state]}
168
          type="select"
169
          label="State"
170
          options={[{"Open", "open"}, {"Closed", "closed"}]}
171
        />
172
      </div>
173
      <div class="sm:col-span-2">
174
        <.input field={@form[:body]} type="textarea" label="Body" />
175
      </div>
176
      <div>
177
        <.input field={@form[:public]} type="checkbox" label="Public repository" />
178
      </div>
179
      <div class="flex items-end">
180
        <.button type="submit" variant="primary">Save demo</.button>
239 181
      </div>
240
    </section>
182
    </.form>
183
    """
184
  end
185
186
  defp component_demo(%{item: %{slug: "header"}} = assigns) do
187
    ~H"""
188
    <.header>
189
      Repository issues
190
      <:subtitle>Open and closed issues for this repository.</:subtitle>
191
      <:actions>
192
        <.button variant="primary">New issue</.button>
193
      </:actions>
194
    </.header>
195
    """
196
  end
197
198
  defp component_demo(%{item: %{slug: "table"}} = assigns) do
199
    ~H"""
200
    <.table id="demo-table" rows={@rows}>
201
      <:col :let={row} label="Owner">{row.owner}</:col>
202
      <:col :let={row} label="Repository">{row.repo}</:col>
203
      <:col :let={row} label="State">{row.state}</:col>
204
      <:action :let={row}>
205
        <.link navigate={~p"/"} class="link link-hover">View {row.repo}</.link>
206
      </:action>
207
    </.table>
208
    """
209
  end
210
211
  defp component_demo(%{item: %{slug: "list"}} = assigns) do
212
    ~H"""
213
    <.list>
214
      <:item title="Flash">Toast alerts for info and error.</:item>
215
      <:item title="Button">Primary and soft variants, plus navigation.</:item>
216
      <:item title="Input">Text, select, textarea, and checkbox.</:item>
217
    </.list>
218
    """
219
  end
220
221
  defp component_demo(%{item: %{slug: "icon"}} = assigns) do
222
    ~H"""
223
    <ul id="demo-icons" role="list" class="flex flex-wrap gap-4">
224
      <li :for={name <- @icons} class="flex flex-col items-center gap-2 w-28">
225
        <.icon name={name} class="size-6" />
226
        <p class="text-center text-sm text-base-content/70">{name}</p>
227
      </li>
228
    </ul>
229
    """
230
  end
231
232
  defp component_demo(%{item: %{slug: "flash"}} = assigns) do
233
    ~H"""
234
    <p class="text-pretty text-base text-base-content/70 max-w-[68ch] mb-4">
235
      Flash renders through <code>Layouts.flash_group/1</code>
236
      at the corner of the page. Trigger a sample message:
237
    </p>
238
    <div class="flex flex-wrap gap-3">
239
      <.button id="demo-flash-info" phx-click="flash-info">Show info flash</.button>
240
      <.button id="demo-flash-error" phx-click="flash-error">Show error flash</.button>
241
    </div>
242
    """
243
  end
244
245
  defp component_demo(%{item: %{slug: "theme-toggle"}} = assigns) do
246
    ~H"""
247
    <div id="demo-theme-toggle">
248
      <Layouts.theme_toggle />
249
    </div>
250
    """
251
  end
252
253
  defp component_demo(%{item: %{slug: "repo-header"}} = assigns) do
254
    ~H"""
255
    <OpenAgentsWeb.Components.RepoHeader.repo_header
256
      owner="OpenAgentsInc"
257
      repo="openagents.com"
258
      active="issues"
259
      open_count={12}
260
      closed_count={148}
261
    />
241 262
    """
242 263
  end
243 264
end
lib/openagents_web/live/home_live.ex modified +1 -1

@@ -15,7 +15,7 @@ defmodule OpenAgentsWeb.HomeLive do

15 15
        <div class="hero-content text-center">
16 16
          <div class="max-w-4xl space-y-6">
17 17
            <h1 class="text-5xl md:text-7xl font-bold tracking-tight">
18
              The agent forge
18
              The Agent Forge
19 19
            </h1>
20 20
            <p class="text-xl md:text-2xl text-base-content/70">
21 21
              Purpose-built for planning and shipping issues. Designed for the agent era.
lib/openagents_web/live/icon_index_live.ex modified +12 -12

@@ -9,6 +9,8 @@ defmodule OpenAgentsWeb.IconIndexLive do

9 9
  def mount(_params, _session, socket) do
10 10
    {:ok,
11 11
     socket
12
     |> assign(:page_title, "Icons")
13
     |> assign(:active_component, :icons)
12 14
     |> assign(:current_scope, socket.assigns[:current_scope])
13 15
     |> assign(:icons, OpenAgentsWeb.Icons.names())}
14 16
  end

@@ -16,20 +18,18 @@ defmodule OpenAgentsWeb.IconIndexLive do

16 18
  @impl true
17 19
  def render(assigns) do
18 20
    ~H"""
19
    <Layouts.app flash={@flash} current_scope={@current_scope} wide>
20
      <div class="max-w-7xl mx-auto">
21
        <h1 class="text-2xl font-bold mb-4">Icons ({length(@icons)} glyphs)</h1>
21
    <div>
22
      <h1 class="text-3xl font-semibold mb-4">Icons ({length(@icons)} glyphs)</h1>
22 23
23
        <div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">
24
          <%= for name <- @icons do %>
25
            <div class="flex flex-col items-center gap-2 p-3 border border-base-300 rounded-lg">
26
              <.icon name={name} class="size-8" />
27
              <span class="text-xs text-center break-all text-base-content/70">{name}</span>
28
            </div>
29
          <% end %>
30
        </div>
24
      <div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">
25
        <%= for name <- @icons do %>
26
          <div class="flex flex-col items-center gap-2 p-3 border border-base-300 rounded-lg">
27
            <.icon name={name} class="size-8" />
28
            <span class="text-xs text-center break-all text-base-content/70">{name}</span>
29
          </div>
30
        <% end %>
31 31
      </div>
32
    </Layouts.app>
32
    </div>
33 33
    """
34 34
  end
35 35
end
lib/openagents_web/router.ex modified +8

@@ -27,8 +27,16 @@ defmodule OpenAgentsWeb.Router do

27 27
    live_session :public,
28 28
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
29 29
      live "/", HomeLive, :index
30
    end
31
32
    # `/components/icons` must stay ahead of `/components/:slug` so the literal
33
    # route wins; the catalog's own slugs never include "icons".
34
    live_session :components,
35
      layout: {OpenAgentsWeb.Layouts, :components},
36
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
30 37
      live "/components", ComponentsLive, :index
31 38
      live "/components/icons", IconIndexLive, :index
39
      live "/components/:slug", ComponentsLive, :show
32 40
    end
33 41
34 42
    live_session :docs,
test/openagents_web/controllers/page_controller_test.exs modified +1 -1

@@ -3,6 +3,6 @@ defmodule OpenAgentsWeb.PageControllerTest do

3 3
4 4
  test "GET /", %{conn: conn} do
5 5
    conn = get(conn, ~p"/")
6
    assert html_response(conn, 200) =~ "The agent forge"
6
    assert html_response(conn, 200) =~ "The Agent Forge"
7 7
  end
8 8
end
test/openagents_web/live/components_live_test.exs modified +64 -7

@@ -3,21 +3,78 @@ defmodule OpenAgentsWeb.ComponentsLiveTest do

3 3
4 4
  import Phoenix.LiveViewTest
5 5
6
  test "renders every reusable component on /components", %{conn: conn} do
6
  alias OpenAgentsWeb.ComponentCatalog
7
8
  test "the index lists every component in the catalog", %{conn: conn} do
7 9
    {:ok, view, html} = live(conn, ~p"/components")
8 10
9 11
    assert html =~ "Component library"
10
    assert has_element?(view, "#components-gallery")
12
    assert has_element?(view, "#components-index")
13
14
    for item <- ComponentCatalog.items() do
15
      assert has_element?(view, ~s{a[href="/components/#{item.slug}"]}),
16
             "index is missing a link to #{item.slug}"
17
    end
18
  end
19
20
  test "every catalog slug renders its own page", %{conn: conn} do
21
    for item <- ComponentCatalog.items() do
22
      {:ok, view, html} = live(conn, ~p"/components/#{item.slug}")
23
24
      assert has_element?(view, "#component-#{item.slug}"),
25
             "#{item.slug} did not render a component page"
26
27
      assert html =~ item.title
28
      assert html =~ item.source
29
    end
30
  end
31
32
  test "each page marks its own sidebar row as selected", %{conn: conn} do
33
    for item <- ComponentCatalog.items() do
34
      {:ok, view, _html} = live(conn, ~p"/components/#{item.slug}")
35
36
      assert has_element?(
37
               view,
38
               ~s{.sidebar-row[data-selected] a[href="/components/#{item.slug}"]}
39
             ),
40
             "#{item.slug} did not mark its sidebar row selected"
41
    end
42
  end
43
44
  test "the sidebar is present on component pages", %{conn: conn} do
45
    {:ok, view, _html} = live(conn, ~p"/components/button")
46
47
    assert has_element?(view, ~s{nav[aria-label="Component library"]})
48
    assert has_element?(view, ~s{a[href="/components/icons"]})
49
  end
50
51
  test "an unknown slug redirects back to the index", %{conn: conn} do
52
    assert {:error, {:live_redirect, %{to: "/components"}}} =
53
             live(conn, ~p"/components/not-a-component")
54
  end
55
56
  test "the icons page keeps its own route ahead of the slug route", %{conn: conn} do
57
    {:ok, view, html} = live(conn, ~p"/components/icons")
58
59
    assert html =~ "glyphs"
60
    refute has_element?(view, "#component-icons")
61
  end
62
63
  test "the button page renders the button variants", %{conn: conn} do
64
    {:ok, view, _html} = live(conn, ~p"/components/button")
65
11 66
    assert has_element?(view, "#demo-button-primary", "Primary")
67
    assert has_element?(view, "#demo-button-disabled")
68
  end
69
70
  test "the input page renders the demo form", %{conn: conn} do
71
    {:ok, view, _html} = live(conn, ~p"/components/input")
72
12 73
    assert has_element?(view, "#component-form")
13
    assert has_element?(view, "#demo-table")
14
    assert has_element?(view, "#demo-icons")
15
    assert has_element?(view, "#demo-theme-toggle")
16
    assert has_element?(view, "#section-flash")
17 74
  end
18 75
19 76
  test "info flash action shows the flash", %{conn: conn} do
20
    {:ok, view, _html} = live(conn, ~p"/components")
77
    {:ok, view, _html} = live(conn, ~p"/components/flash")
21 78
22 79
    view
23 80
    |> element("#demo-flash-info")
test/openagents_web/live/docs_live_test.exs added +20

@@ -0,0 +1,20 @@

1
defmodule OpenAgentsWeb.DocsLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  import Phoenix.LiveViewTest
5
6
  test "the docs page renders with its sidebar", %{conn: conn} do
7
    {:ok, view, html} = live(conn, ~p"/docs")
8
9
    assert html =~ "Documentation"
10
    assert has_element?(view, ~s{nav[aria-label="Documentation"]})
11
  end
12
13
  test "the docs sidebar has no search bar", %{conn: conn} do
14
    {:ok, view, html} = live(conn, ~p"/docs")
15
16
    refute has_element?(view, ".docs-sidebar__search")
17
    refute has_element?(view, ~s{input[placeholder="Search docs..."]})
18
    refute html =~ "Search docs"
19
  end
20
end

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