The signed-in home is a dashboard, not the pitch

0ebf4198fd34 · AtlantisPleb · · parent 1b9c135724c4

The signed-in home is a dashboard, not the pitch

Someone who has signed in has already been sold, and a marketing page is the
wrong thing to hand them. The same route now renders the state of the work:
open issues with their numbers and ages, projects, the repository with its
open/closed/project counts, and the last few changelog entries -- the shape a
code host's home takes.

Everything on it is real and clickable, loaded once at mount rather than per
render. Where a panel has nothing to show it says so in the same voice as a
full one; a dashboard whose emptiness is disguised by a placeholder is worse
than one that admits it. The changelog rail degrades to empty rather than
raising, because the ledger is a bounded public projection that can
legitimately refuse.

Panels are bordered rather than filled: a page of filled cards on a dark
surface reads as a stack of separate applications, where a hairline says these
are parts of one page.

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/live/home_live.ex
  • modified test/openagents_web/home_controller_test.exs

Diff

3 files changed, +508 -14

assets/css/openagents.css modified +307

@@ -4181,3 +4181,310 @@

4181 4181
    }
4182 4182
  }
4183 4183
}
4184
4185
/* ── Dashboard ────────────────────────────────────────────────────────────── */
4186
4187
/* The signed-in home. A marketing pitch is the wrong thing to show someone who
4188
 * has already signed in, so this is the state of the work instead: a feed
4189
 * column beside a rail of standing facts, the shape a code host's home takes.
4190
 *
4191
 * Panels are bordered rather than filled. A page of filled cards on a dark
4192
 * surface reads as a stack of separate applications; a hairline says "these
4193
 * are parts of one page" while still separating them. */
4194
4195
@layer components {
4196
  .dashboard {
4197
    display: grid;
4198
    align-items: start;
4199
    grid-template-columns: minmax(0, 1fr);
4200
    gap: 16px;
4201
  }
4202
4203
  @media (min-width: 1024px) {
4204
    .dashboard {
4205
      grid-template-columns: minmax(0, 1fr) 320px;
4206
    }
4207
  }
4208
4209
  .dashboard__main,
4210
  .dashboard__rail {
4211
    display: flex;
4212
    min-width: 0;
4213
    flex-direction: column;
4214
    gap: 16px;
4215
  }
4216
4217
  .panel {
4218
    display: flex;
4219
    flex-direction: column;
4220
    gap: 12px;
4221
    border: 1px solid var(--line);
4222
    border-radius: var(--radius-lg);
4223
    background: var(--ink-surface);
4224
    padding: 16px;
4225
  }
4226
4227
  .panel__header {
4228
    display: flex;
4229
    align-items: baseline;
4230
    justify-content: space-between;
4231
    gap: 12px;
4232
  }
4233
4234
  .panel__title {
4235
    color: var(--text-primary);
4236
    font-size: 0.9375rem;
4237
    font-weight: 600;
4238
    line-height: 1.4;
4239
  }
4240
4241
  .panel__more {
4242
    display: inline-flex;
4243
    align-items: center;
4244
    gap: 4px;
4245
    color: var(--text-muted);
4246
    font-size: 0.8125rem;
4247
    text-decoration: none;
4248
    transition: color 150ms var(--ease);
4249
  }
4250
4251
  .panel__more .icon {
4252
    width: 14px;
4253
    height: 14px;
4254
    font-size: 14px;
4255
  }
4256
4257
  @media (hover: hover) {
4258
    .panel__more:hover {
4259
      color: var(--text-primary);
4260
    }
4261
  }
4262
4263
  /* An empty panel says so in the same voice as a full one, rather than being
4264
     padded out with a placeholder that pretends there is something there. */
4265
  .panel__empty {
4266
    color: var(--text-dim);
4267
    font-size: 0.875rem;
4268
    line-height: 1.5;
4269
  }
4270
4271
  .panel__empty a {
4272
    color: var(--text-muted);
4273
    text-decoration: underline;
4274
    text-underline-offset: 2px;
4275
  }
4276
4277
  /* ── Issue feed ─────────────────────────────────────────────────────────── */
4278
4279
  .issue-feed {
4280
    display: flex;
4281
    flex-direction: column;
4282
    margin: 0;
4283
    padding: 0;
4284
    list-style: none;
4285
  }
4286
4287
  .issue-feed__row {
4288
    display: flex;
4289
    align-items: flex-start;
4290
    gap: 10px;
4291
    padding-block: 10px;
4292
  }
4293
4294
  .issue-feed__row + .issue-feed__row {
4295
    border-top: 1px solid var(--line-faint);
4296
  }
4297
4298
  .issue-feed__state {
4299
    width: 16px;
4300
    height: 16px;
4301
    flex: none;
4302
    margin-top: 2px;
4303
    color: var(--success);
4304
    font-size: 16px;
4305
  }
4306
4307
  .issue-feed__body {
4308
    display: flex;
4309
    min-width: 0;
4310
    flex: 1;
4311
    flex-direction: column;
4312
    gap: 2px;
4313
  }
4314
4315
  .issue-feed__title {
4316
    color: var(--text-primary);
4317
    font-size: 0.875rem;
4318
    font-weight: 500;
4319
    line-height: 1.4;
4320
    text-decoration: none;
4321
  }
4322
4323
  @media (hover: hover) {
4324
    .issue-feed__title:hover {
4325
      text-decoration: underline;
4326
      text-underline-offset: 2px;
4327
    }
4328
  }
4329
4330
  .issue-feed__meta {
4331
    color: var(--text-dim);
4332
    font-size: 0.75rem;
4333
    line-height: 1rem;
4334
  }
4335
4336
  .issue-feed__comments {
4337
    display: inline-flex;
4338
    flex: none;
4339
    align-items: center;
4340
    gap: 4px;
4341
    color: var(--text-dim);
4342
    font-size: 0.75rem;
4343
  }
4344
4345
  .issue-feed__comments .icon {
4346
    width: 14px;
4347
    height: 14px;
4348
    font-size: 14px;
4349
  }
4350
4351
  /* ── Project list ───────────────────────────────────────────────────────── */
4352
4353
  .project-list {
4354
    display: flex;
4355
    flex-direction: column;
4356
    gap: 2px;
4357
    margin: 0;
4358
    padding: 0;
4359
    list-style: none;
4360
  }
4361
4362
  .project-list__row {
4363
    display: flex;
4364
    align-items: center;
4365
    gap: 8px;
4366
    min-height: 34px;
4367
    margin-inline: -8px;
4368
    padding-inline: 8px;
4369
    border-radius: 6px;
4370
    color: var(--text-muted);
4371
    font-size: 0.875rem;
4372
    text-decoration: none;
4373
    transition: background 150ms var(--ease), color 150ms var(--ease);
4374
  }
4375
4376
  .project-list__row .icon {
4377
    width: 16px;
4378
    height: 16px;
4379
    flex: none;
4380
    color: var(--icon-tertiary);
4381
    font-size: 16px;
4382
  }
4383
4384
  @media (hover: hover) {
4385
    .project-list__row:hover {
4386
      background: var(--wash-hover);
4387
      color: var(--text-primary);
4388
    }
4389
  }
4390
4391
  .project-list__title {
4392
    min-width: 0;
4393
    flex: 1;
4394
    overflow: hidden;
4395
    text-overflow: ellipsis;
4396
    white-space: nowrap;
4397
  }
4398
4399
  .project-list__state {
4400
    flex: none;
4401
    border: 1px solid var(--line);
4402
    border-radius: 999px;
4403
    padding: 1px 8px;
4404
    color: var(--text-dim);
4405
    font-size: 0.6875rem;
4406
    text-transform: lowercase;
4407
  }
4408
4409
  /* ── Rail ───────────────────────────────────────────────────────────────── */
4410
4411
  .repo-card {
4412
    display: flex;
4413
    align-items: center;
4414
    gap: 8px;
4415
    border: 1px solid var(--line);
4416
    border-radius: var(--radius-md, 8px);
4417
    padding: 10px 12px;
4418
    color: var(--text-primary);
4419
    font-size: 0.875rem;
4420
    font-weight: 500;
4421
    text-decoration: none;
4422
    transition: border-color 150ms var(--ease);
4423
  }
4424
4425
  .repo-card .icon {
4426
    width: 16px;
4427
    height: 16px;
4428
    flex: none;
4429
    color: var(--icon-tertiary);
4430
    font-size: 16px;
4431
  }
4432
4433
  .repo-card__name {
4434
    min-width: 0;
4435
    overflow: hidden;
4436
    text-overflow: ellipsis;
4437
    white-space: nowrap;
4438
  }
4439
4440
  @media (hover: hover) {
4441
    .repo-card:hover {
4442
      border-color: var(--line-strong);
4443
    }
4444
  }
4445
4446
  .stat-pairs {
4447
    display: grid;
4448
    grid-template-columns: repeat(3, minmax(0, 1fr));
4449
    gap: 8px;
4450
    margin: 0;
4451
  }
4452
4453
  .stat-pairs dt {
4454
    color: var(--text-dim);
4455
    font-size: 0.6875rem;
4456
    letter-spacing: 0.04em;
4457
    text-transform: uppercase;
4458
  }
4459
4460
  .stat-pairs dd {
4461
    margin: 0;
4462
    color: var(--text-primary);
4463
    font-size: 1.25rem;
4464
    font-variant-numeric: tabular-nums;
4465
    font-weight: 500;
4466
    line-height: 1.2;
4467
  }
4468
4469
  .changelog-rail {
4470
    display: flex;
4471
    flex-direction: column;
4472
    gap: 12px;
4473
    margin: 0;
4474
    padding: 0;
4475
    list-style: none;
4476
  }
4477
4478
  .changelog-rail__when {
4479
    color: var(--text-dim);
4480
    font-size: 0.75rem;
4481
    line-height: 1rem;
4482
  }
4483
4484
  .changelog-rail__summary {
4485
    color: var(--text-muted);
4486
    font-size: 0.8125rem;
4487
    line-height: 1.45;
4488
    text-wrap: pretty;
4489
  }
4490
}
lib/openagents_web/live/home_live.ex modified +192 -8

@@ -1,22 +1,189 @@

1 1
defmodule OpenAgentsWeb.HomeLive do
2 2
  @moduledoc """
3
  The public landing page, composed from `OpenAgentsWeb.UI.Landing`.
3
  Two pages behind one route.
4 4
5
  Every band here is a catalogued component rather than page-local markup, so
6
  the homepage and the component library cannot drift: changing a landing
7
  component changes this page, and the library demonstrates the same thing a
8
  visitor sees.
5
  Signed out, this is the landing page, composed from
6
  `OpenAgentsWeb.UI.Landing` so the homepage and the component library cannot
7
  drift apart.
9 8
10
  The surface is flush -- it owns the full main area and scrolls itself --
9
  Signed in, a marketing pitch is the wrong thing to show: the visitor has
10
  already been sold, and what they want is the state of the work. The same
11
  route renders a dashboard -- repositories, open issues, projects, and what
12
  shipped recently -- the way a code host's home does.
13
14
  Everything on the dashboard is real and clickable. Where a panel has nothing
15
  to show it says so plainly rather than rendering a placeholder, because a
16
  dashboard whose emptiness is disguised is worse than one that admits it.
17
18
  The landing page is flush -- it owns the full main area and scrolls itself --
11 19
  because landing bands set their own vertical rhythm against the viewport and
12
  a wrapper's padding would fight it.
20
  a wrapper's padding would fight it. The dashboard is an ordinary page.
13 21
  """
14 22
  use OpenAgentsWeb, :live_view
15 23
24
  alias OpenAgents.Changelog
25
  alias OpenAgents.Issues
26
  alias OpenAgents.Projects
27
  alias OpenAgents.Repositories
16 28
  alias OpenAgentsWeb.UI.Landing
17 29
30
  @repo "openagents.com"
31
  @feed_limit 8
32
  @changelog_limit 5
33
34
  @impl true
18 35
  def mount(_params, _session, socket) do
19
    {:ok, socket}
36
    {:ok, if(socket.assigns[:current_user], do: assign_dashboard(socket), else: socket)}
37
  end
38
39
  # Loaded once at mount rather than per render: none of it changes within a
40
  # visit, and the dashboard should not re-query on every diff.
41
  defp assign_dashboard(socket) do
42
    repository = Repositories.initial_repository!()
43
    {owner, name} = Repositories.initial_path()
44
45
    open_issues = Issues.list_issues(repository, state: "open")
46
    closed_issues = Issues.list_issues(repository, state: "closed")
47
    projects = Projects.list_projects(repository)
48
49
    socket
50
    |> assign(:owner, owner)
51
    |> assign(:name, name)
52
    |> assign(:open_count, length(open_issues))
53
    |> assign(:closed_count, length(closed_issues))
54
    |> assign(:project_count, length(projects))
55
    |> assign(:issues, Enum.take(open_issues, @feed_limit))
56
    |> assign(:projects, Enum.take(projects, 6))
57
    |> assign(:changelog, changelog_entries())
58
  end
59
60
  # The ledger is a bounded public projection and can legitimately refuse. An
61
  # empty rail is the honest answer; the home page should not crash over it.
62
  defp changelog_entries do
63
    case Changelog.timeline(@repo) do
64
      {:ok, rows} -> Enum.take(rows, @changelog_limit)
65
      {:error, _reason} -> []
66
    end
67
  end
68
69
  @impl true
70
  def render(%{current_user: user} = assigns) when not is_nil(user) do
71
    ~H"""
72
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Home" wide>
73
      <div class="dashboard">
74
        <div class="dashboard__main">
75
          <section class="panel" aria-labelledby="dashboard-issues">
76
            <header class="panel__header">
77
              <h2 id="dashboard-issues" class="panel__title">Open issues</h2>
78
              <.link navigate={~p"/#{@owner}/#{@name}/issues"} class="panel__more">
79
                View all <.icon name="arrow-right" />
80
              </.link>
81
            </header>
82
83
            <ul :if={@issues != []} class="issue-feed">
84
              <li :for={issue <- @issues} class="issue-feed__row">
85
                <.icon name="circle-dashed" class="issue-feed__state" />
86
                <div class="issue-feed__body">
87
                  <.link
88
                    navigate={~p"/#{@owner}/#{@name}/issues/#{issue.number}"}
89
                    class="issue-feed__title"
90
                  >
91
                    {issue.title}
92
                  </.link>
93
                  <p class="issue-feed__meta">
94
                    #{issue.number} opened {relative_time(issue.inserted_at)}
95
                  </p>
96
                </div>
97
                <span :if={issue.comments > 0} class="issue-feed__comments">
98
                  <.icon name="chat" /> {issue.comments}
99
                </span>
100
              </li>
101
            </ul>
102
103
            <p :if={@issues == []} class="panel__empty">
104
              No open issues. <.link navigate={~p"/#{@owner}/#{@name}/issues/new"}>Open one</.link>.
105
            </p>
106
          </section>
107
108
          <section class="panel" aria-labelledby="dashboard-projects">
109
            <header class="panel__header">
110
              <h2 id="dashboard-projects" class="panel__title">Projects</h2>
111
              <.link navigate={~p"/#{@owner}/#{@name}/projects"} class="panel__more">
112
                View all <.icon name="arrow-right" />
113
              </.link>
114
            </header>
115
116
            <ul :if={@projects != []} class="project-list">
117
              <li :for={project <- @projects}>
118
                <.link
119
                  navigate={~p"/#{@owner}/#{@name}/projects/#{project.number}"}
120
                  class="project-list__row"
121
                >
122
                  <.icon name="grid" />
123
                  <span class="project-list__title">{project.title}</span>
124
                  <span class="project-list__state" data-state={project.state}>
125
                    {project.state}
126
                  </span>
127
                </.link>
128
              </li>
129
            </ul>
130
131
            <p :if={@projects == []} class="panel__empty">No projects yet.</p>
132
          </section>
133
        </div>
134
135
        <aside class="dashboard__rail">
136
          <section class="panel" aria-labelledby="dashboard-repo">
137
            <header class="panel__header">
138
              <h2 id="dashboard-repo" class="panel__title">Repository</h2>
139
            </header>
140
141
            <%!-- The code browser's owner segment is a literal in the router
142
            (a wildcard first segment would shadow every two-segment path), so
143
            the route is written the way the router declares it. --%>
144
            <.link navigate={~p"/OpenAgentsInc/#{@name}"} class="repo-card">
145
              <.icon name="folder" />
146
              <span class="repo-card__name">{@owner}/{@name}</span>
147
            </.link>
148
149
            <dl class="stat-pairs">
150
              <div>
151
                <dt>Open</dt>
152
                <dd>{@open_count}</dd>
153
              </div>
154
              <div>
155
                <dt>Closed</dt>
156
                <dd>{@closed_count}</dd>
157
              </div>
158
              <div>
159
                <dt>Projects</dt>
160
                <dd>{@project_count}</dd>
161
              </div>
162
            </dl>
163
          </section>
164
165
          <section class="panel" aria-labelledby="dashboard-changelog">
166
            <header class="panel__header">
167
              <h2 id="dashboard-changelog" class="panel__title">Latest from the changelog</h2>
168
            </header>
169
170
            <ol :if={@changelog != []} class="changelog-rail">
171
              <li :for={entry <- @changelog}>
172
                <p class="changelog-rail__when">{relative_time(entry.entry_at)}</p>
173
                <p class="changelog-rail__summary">{entry.summary}</p>
174
              </li>
175
            </ol>
176
177
            <p :if={@changelog == []} class="panel__empty">Nothing recorded yet.</p>
178
179
            <.link navigate={~p"/changelog"} class="panel__more">
180
              View changelog <.icon name="arrow-right" />
181
            </.link>
182
          </section>
183
        </aside>
184
      </div>
185
    </Layouts.app>
186
    """
20 187
  end
21 188
22 189
  def render(assigns) do

@@ -161,4 +328,21 @@ defmodule OpenAgentsWeb.HomeLive do

161 328
    </Layouts.app>
162 329
    """
163 330
  end
331
332
  # Coarse on purpose. A dashboard wants "roughly when"; a precise timestamp
333
  # there invites the reader to do arithmetic they did not want to do.
334
  defp relative_time(nil), do: "recently"
335
336
  defp relative_time(%DateTime{} = at) do
337
    case DateTime.diff(DateTime.utc_now(), at, :second) do
338
      s when s < 60 -> "just now"
339
      s when s < 3_600 -> "#{div(s, 60)}m ago"
340
      s when s < 86_400 -> "#{div(s, 3_600)}h ago"
341
      s when s < 2_592_000 -> "#{div(s, 86_400)}d ago"
342
      s -> "#{div(s, 2_592_000)}mo ago"
343
    end
344
  end
345
346
  defp relative_time(%NaiveDateTime{} = at),
347
    do: at |> DateTime.from_naive!("Etc/UTC") |> relative_time()
164 348
end
test/openagents_web/home_controller_test.exs modified +9 -6

@@ -22,13 +22,16 @@ defmodule OpenAgentsWeb.HomeControllerTest do

22 22
    refute html =~ "Account menu"
23 23
  end
24 24
25
  test "an authenticated account sees the OpenAgents home", %{conn: conn} do
25
  test "an authenticated account sees the dashboard, not the pitch", %{conn: conn} do
26 26
    conn = log_in_github_user(conn, "authenticated-home-user")
27
    response = get(conn, ~p"/")
28
29
    # The owner chose to keep authenticated users on the marketing home page
30
    # rather than immediately redirecting into chat. This differs from Sarah.
31
    assert html_response(response, 200) =~ "The Agent Forge"
27
    html = html_response(get(conn, ~p"/"), 200)
28
29
    # Someone who has signed in has already been sold. The same route shows the
30
    # state of the work instead: open issues, projects, and what shipped.
31
    refute html =~ "The Agent Forge"
32
    assert html =~ "Open issues"
33
    assert html =~ "Latest from the changelog"
34
    assert html =~ ~s(class="dashboard")
32 35
  end
33 36
34 37
  test "the browser policy permits only the narrow GitHub avatar origin", %{conn: conn} do

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