Lead the homepage with Coder and the one command that installs it

445b7ae32b5e · AtlantisPleb · · parent 8f282e9f8b27

Lead the homepage with Coder and the one command that installs it

The band above the fold sold the platform. It named the forge, described
what it is for, and offered two ways in -- sign in, or read the docs --
which asks a reader to choose before they know enough to choose well.
What the product actually is, and the single line that gets it onto their
machine, appeared nowhere.

So the hero states the product and hands over the command. An
announcement pill above the headline, a two-tone headline, one line of
prose, the install command in a frame that copies itself when pressed, a
note on what an account starts with, and quiet links to the docs, the
changelog, and the source. The band closes with a rule, so the claim and
the way to act on it read as one thing rather than running into the
feature grid.

Three pieces of that are new in `Landing` and catalogued with the rest:
`announce/1` for the pill, `install_command/1` for the frame, and
`title_muted` plus `command`, `note`, and `links` slots on `hero/1`. The
type scale comes down -- 5.5rem was set for a headline with nothing under
it, and there are now four rows beneath this one -- and the lede takes a
48rem measure so the description does not run wider than the headline.

The command is the same one `priv/docs/install-cli.md` documents and the
same script `priv/static/install.sh` serves, held in one module attribute
so the page cannot advertise an installer this site does not hand out.

`page_controller_test.exs` asserted the opposite: that the homepage is
not where the CLI gets sold. That was the right call while the install
command pointed at a binary release that had been withdrawn and then at
an npm package that does not exist. Neither is true of `install.sh`, so
the test now states the current contract and keeps the refutation of
`npm i -g`, which is still wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wPUM1Fr44hwBGPP42iMxP
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.

pushed
by user · WAL seq 454 · 2026-08-26T17:16:55.187869Z

Changed files

  • modified assets/css/openagents.css
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/landing.ex
  • modified lib/openagents_web/live/components_live.ex
  • modified lib/openagents_web/live/home_live.ex
  • modified test/openagents_web/controllers/page_controller_test.exs
  • modified test/openagents_web/home_controller_test.exs

Diff

7 files changed, +545 -36

assets/css/openagents.css modified +255 -8

@@ -3417,8 +3417,28 @@

3417 3417
3418 3418
  /* ── Hero ───────────────────────────────────────────────────────────────── */
3419 3419
3420
  /* Weighted to the top. A landing page opens on nothing but the claim, and
3421
     equal padding above and below sets it mid-band, where it reads as one item
3422
     in a list rather than as the first thing on the page. */
3423
  .hero {
3424
    padding-block: 128px 64px;
3425
  }
3426
3427
  @media (min-width: 640px) {
3428
    .hero {
3429
      padding-block: 160px 96px;
3430
    }
3431
  }
3432
3433
  @media (min-width: 1024px) {
3434
    .hero {
3435
      padding-block-start: 192px;
3436
    }
3437
  }
3438
3420 3439
  .hero__lede {
3421 3440
    display: flex;
3441
    max-width: 48rem;
3422 3442
    flex-direction: column;
3423 3443
    align-items: center;
3424 3444
    gap: 24px;

@@ -3426,20 +3446,27 @@

3426 3446
  }
3427 3447
3428 3448
  .hero__title {
3429
    max-width: 20ch;
3430 3449
    color: var(--text-primary);
3431
    font-size: clamp(2.5rem, 1.4rem + 5vw, 5.5rem);
3432
    font-weight: 600;
3433
    letter-spacing: -0.035em;
3434
    line-height: 1.05;
3450
    font-size: clamp(2.25rem, 1.45rem + 2.6vw, 3.75rem);
3451
    font-weight: 500;
3452
    letter-spacing: -0.025em;
3453
    line-height: 1.1;
3435 3454
    text-wrap: balance;
3436 3455
  }
3437 3456
3457
  /* Its own line, always. The second tone is the object of the sentence, and a
3458
     headline that breaks it mid-phrase on one viewport and not another reads
3459
     as a wrap rather than as a decision. */
3460
  .hero__title-muted {
3461
    display: block;
3462
    color: var(--text-muted);
3463
  }
3464
3438 3465
  .hero__description {
3439
    max-width: 46rem;
3466
    max-width: 36rem;
3440 3467
    color: var(--text-muted);
3441
    font-size: clamp(1rem, 0.94rem + 0.3vw, 1.25rem);
3442
    line-height: 1.55;
3468
    font-size: clamp(1rem, 0.94rem + 0.3vw, 1.125rem);
3469
    line-height: 1.625;
3443 3470
    text-wrap: balance;
3444 3471
  }
3445 3472

@@ -3450,6 +3477,226 @@

3450 3477
    gap: 12px;
3451 3478
  }
3452 3479
3480
  /* The frame sets its own width from the command inside it, so it ends where
3481
     the command does rather than spanning the measure. It still has to be able
3482
     to shrink: on a narrow viewport the command scrolls inside the frame. */
3483
  .hero__command {
3484
    max-width: 100%;
3485
  }
3486
3487
  /* Tight to the command it qualifies rather than on the lede's 24px rhythm:
3488
     it is a footnote to the frame above it, not the next thing in the band. */
3489
  .hero__command + .hero__note {
3490
    margin-block-start: -12px;
3491
  }
3492
3493
  .hero__note {
3494
    color: var(--text-dim);
3495
    font-size: 0.75rem;
3496
  }
3497
3498
  .hero__links {
3499
    display: flex;
3500
    flex-wrap: wrap;
3501
    justify-content: center;
3502
    gap: 4px;
3503
  }
3504
3505
  /* Quieter than a secondary action and quieter than body text: these are for
3506
     a reader the band did not answer, and at full contrast they compete with
3507
     the command, which is the one thing the band is asking for. */
3508
  .hero__link.btn {
3509
    gap: 2px;
3510
    border-radius: 999px;
3511
    color: var(--text-muted);
3512
    font-weight: 500;
3513
  }
3514
3515
  .hero__link.btn:hover {
3516
    color: var(--text-primary);
3517
  }
3518
3519
  .hero__link .icon {
3520
    color: inherit;
3521
    opacity: 0.7;
3522
  }
3523
3524
  /* ── Announcement pill ──────────────────────────────────────────────────── */
3525
3526
  .announce {
3527
    display: inline-flex;
3528
    align-items: center;
3529
    gap: 10px;
3530
    border: 1px solid var(--line-soft);
3531
    border-radius: 999px;
3532
    padding: 5px 5px 5px 10px;
3533
    background: color-mix(in oklab, var(--ink-surface) 60%, transparent);
3534
    backdrop-filter: blur(6px);
3535
    text-decoration: none;
3536
    transition:
3537
      border-color var(--motion-fast) var(--ease),
3538
      background-color var(--motion-fast) var(--ease);
3539
  }
3540
3541
  .announce:hover {
3542
    border-color: var(--line-strong);
3543
    background: var(--ink-surface);
3544
  }
3545
3546
  /* The band's only accent. It is what makes the pill findable before it is
3547
     read, which is the whole reason an announcement sits above a headline. */
3548
  .announce__tag {
3549
    display: inline-flex;
3550
    align-items: center;
3551
    border: 1px solid color-mix(in oklab, var(--accent-bright) 45%, transparent);
3552
    border-radius: 999px;
3553
    padding: 2px 7px;
3554
    color: var(--accent-bright);
3555
    font-size: 0.625rem;
3556
    font-weight: 600;
3557
    letter-spacing: 0.06em;
3558
    line-height: 1;
3559
    text-transform: uppercase;
3560
  }
3561
3562
  .announce__text {
3563
    font-size: 0.8125rem;
3564
    letter-spacing: -0.01em;
3565
    white-space: nowrap;
3566
  }
3567
3568
  .announce__lead {
3569
    color: var(--text-primary);
3570
    font-weight: 600;
3571
  }
3572
3573
  .announce__separator {
3574
    color: var(--text-dim);
3575
  }
3576
3577
  .announce__detail {
3578
    color: var(--text-muted);
3579
    transition: color var(--motion-fast) var(--ease);
3580
  }
3581
3582
  .announce:hover .announce__detail {
3583
    color: var(--text-body);
3584
  }
3585
3586
  .announce__glyph {
3587
    display: inline-flex;
3588
    width: 24px;
3589
    height: 24px;
3590
    flex-shrink: 0;
3591
    align-items: center;
3592
    justify-content: center;
3593
    border-radius: 999px;
3594
    background: var(--wash-hover);
3595
    color: var(--icon-tertiary);
3596
    font-size: 0.625rem;
3597
    transition:
3598
      background-color var(--motion-fast) var(--ease),
3599
      color var(--motion-fast) var(--ease);
3600
  }
3601
3602
  .announce:hover .announce__glyph {
3603
    background: var(--wash-selected);
3604
    color: var(--icon-primary);
3605
  }
3606
3607
  /* On a narrow viewport the detail is the half that goes: the tag and the
3608
     claim are the announcement, and the rest is elaboration that would
3609
     otherwise push the pill wider than the screen. */
3610
  @media (max-width: 480px) {
3611
    .announce__separator,
3612
    .announce__detail {
3613
      display: none;
3614
    }
3615
  }
3616
3617
  /* ── Install command ────────────────────────────────────────────────────── */
3618
3619
  .install-command {
3620
    max-width: 100%;
3621
    overflow: hidden;
3622
    border: 1px solid var(--line);
3623
    border-radius: var(--radius-xl);
3624
    transition: border-color var(--motion-fast) var(--ease);
3625
  }
3626
3627
  .install-command:hover {
3628
    border-color: var(--line-strong);
3629
  }
3630
3631
  .install-command__button {
3632
    position: relative;
3633
    display: flex;
3634
    width: 100%;
3635
    min-width: 0;
3636
    align-items: center;
3637
    padding: 12px 16px;
3638
    cursor: pointer;
3639
    font-family: var(--font-mono);
3640
    font-size: 0.8125rem;
3641
    text-align: left;
3642
  }
3643
3644
  @media (min-width: 640px) {
3645
    .install-command__button {
3646
      font-size: 0.875rem;
3647
    }
3648
  }
3649
3650
  /* Scrolls rather than wraps, and fades out under the glyph, so a command too
3651
     long for the viewport reads as continuing rather than as truncated. */
3652
  .install-command__text {
3653
    width: 100%;
3654
    min-width: 0;
3655
    overflow-x: auto;
3656
    /* Wider than the fade, so a command that fits ends before the gradient
3657
       starts. At 28px the tail of every command was dimmed as though it had
3658
       been truncated, when nothing was being cut off at all. */
3659
    padding-inline-end: 56px;
3660
    color: var(--text-body);
3661
    scrollbar-width: none;
3662
    white-space: nowrap;
3663
    mask-image: linear-gradient(to right, black calc(100% - 48px), transparent calc(100% - 32px));
3664
  }
3665
3666
  .install-command__text::-webkit-scrollbar {
3667
    display: none;
3668
  }
3669
3670
  .install-command__prompt {
3671
    margin-inline-end: 0.5ch;
3672
    color: var(--text-dim);
3673
  }
3674
3675
  .install-command__glyph {
3676
    position: absolute;
3677
    inset-inline-end: 16px;
3678
    display: inline-flex;
3679
    color: var(--icon-tertiary);
3680
    transition: color var(--motion-fast) var(--ease);
3681
  }
3682
3683
  .install-command__button:hover .install-command__glyph {
3684
    color: var(--icon-primary);
3685
  }
3686
3687
  .install-command__button[data-copied="true"] .install-command__glyph {
3688
    color: var(--success);
3689
  }
3690
3691
  .install-command__done,
3692
  .install-command__button[data-copied="true"] .install-command__idle {
3693
    display: none;
3694
  }
3695
3696
  .install-command__button[data-copied="true"] .install-command__done {
3697
    display: inline-flex;
3698
  }
3699
3453 3700
  .hero__figure {
3454 3701
    position: relative;
3455 3702
    width: 100%;
lib/openagents_web/component_catalog.ex modified +14

@@ -349,6 +349,20 @@ defmodule OpenAgentsWeb.ComponentCatalog do

349 349
          source: "OpenAgentsWeb.UI.Landing.hero/1",
350 350
          summary: "Eyebrow, headline, prose, actions, and a lit figure."
351 351
        },
352
        %{
353
          slug: "landing-announce",
354
          title: "Announcement",
355
          icon: "notification-bell",
356
          source: "OpenAgentsWeb.UI.Landing.announce/1",
357
          summary: "Tag, claim, and detail in one pill above a headline."
358
        },
359
        %{
360
          slug: "landing-install-command",
361
          title: "Install command",
362
          icon: "terminal",
363
          source: "OpenAgentsWeb.UI.Landing.install_command/1",
364
          summary: "One shell command, framed, copied when pressed."
365
        },
352 366
        %{
353 367
          slug: "landing-glow",
354 368
          title: "Glow",
lib/openagents_web/components/landing.ex modified +132 -2

@@ -120,17 +120,36 @@ defmodule OpenAgentsWeb.UI.Landing do

120 120
121 121
  The figure is a slot rather than an image attribute, so a page can put a live
122 122
  surface in the frame instead of a screenshot of one.
123
124
  `title_muted` is a second headline line in the muted tier. Two tones over two
125
  lines is what separates the claim from its object -- the setup is not the
126
  point, the name is -- and doing it with an attribute keeps the break out of
127
  the caller's markup, where a `<br>` would fight `text-wrap: balance`.
128
129
  Below the actions sit three quieter rows, in the order a reader needs them: a
130
  `command` to run, a `note` qualifying it, and `links` to what the band did
131
  not say. Each is a slot rather than an attribute because all three carry
132
  markup -- a copy control, an emphasis, a set of destinations.
133
134
  `rule` passes through, because whether a hero closes with a hairline depends
135
  on what follows it: a band that opens straight into a section of its own
136
  needs the division, and one whose next band is a figure does not.
123 137
  """
124 138
  attr :title, :string, required: true
139
  attr :title_muted, :string, default: nil, doc: "a second headline line, in the muted tier"
125 140
  attr :description, :string, default: nil
141
  attr :rule, :boolean, default: false, doc: "draw the closing hairline"
126 142
  attr :class, :any, default: nil
127 143
  slot :eyebrow
128 144
  slot :actions
145
  slot :command, doc: "one command to run, usually install_command/1"
146
  slot :note, doc: "a line qualifying the command"
147
  slot :links, doc: "quiet destinations, set beneath everything else"
129 148
  slot :figure
130 149
131 150
  def hero(assigns) do
132 151
    ~H"""
133
    <.section class={["hero", @class]} rule={false}>
152
    <.section class={["hero", @class]} rule={@rule}>
134 153
      <%!-- The lede's own light. This used to live inside the figure block, so
135 154
      commenting the figure out took the glow with it and left the headline
136 155
      sitting on flat ink. It belongs to the words. --%>

@@ -138,13 +157,22 @@ defmodule OpenAgentsWeb.UI.Landing do

138 157
139 158
      <div class="hero__lede">
140 159
        <div :if={@eyebrow != []} class="hero__eyebrow appear">{render_slot(@eyebrow)}</div>
141
        <h1 class="hero__title appear">{@title}</h1>
160
        <h1 class="hero__title appear">
161
          {@title}<span :if={@title_muted} class="hero__title-muted">{@title_muted}</span>
162
        </h1>
142 163
        <p :if={@description} class="hero__description appear appear--delay-1">
143 164
          {@description}
144 165
        </p>
145 166
        <div :if={@actions != []} class="hero__actions appear appear--delay-2">
146 167
          {render_slot(@actions)}
147 168
        </div>
169
        <div :if={@command != []} class="hero__command appear appear--delay-2">
170
          {render_slot(@command)}
171
        </div>
172
        <p :if={@note != []} class="hero__note appear appear--delay-3">{render_slot(@note)}</p>
173
        <div :if={@links != []} class="hero__links appear appear--delay-3">
174
          {render_slot(@links)}
175
        </div>
148 176
      </div>
149 177
150 178
      <div :if={@figure != []} class="hero__figure">

@@ -155,6 +183,108 @@ defmodule OpenAgentsWeb.UI.Landing do

155 183
    """
156 184
  end
157 185
186
  @doc """
187
  A pill announcing one thing, above a hero's headline.
188
189
  Three registers on one line -- a tag, the claim, a detail -- because an
190
  announcement set at a single weight is indistinguishable from a caption. The
191
  tag carries the only accent colour in the band, which is what makes the pill
192
  findable before it is read.
193
194
  It is a link. A pill that announces something and then does nothing when
195
  pressed spends a reader's attention and returns none of it, so `navigate`,
196
  `patch`, or `href` is required.
197
  """
198
  attr :tag, :any, default: "New", doc: "the leading badge; nil to omit"
199
  attr :lead, :string, required: true, doc: "what is being announced"
200
  attr :detail, :string, default: nil, doc: "the quieter half of the line"
201
  attr :glyph, :any, default: "play-sm", doc: "the trailing glyph; nil to omit"
202
  attr :class, :any, default: nil
203
  attr :rest, :global, include: ~w(navigate patch href target rel)
204
205
  def announce(assigns) do
206
    ~H"""
207
    <.link class={["announce", @class]} {@rest}>
208
      <span :if={@tag} class="announce__tag">{@tag}</span>
209
      <span class="announce__text">
210
        <span class="announce__lead">{@lead}</span><span
211
          :if={@detail}
212
          class="announce__separator"
213
          aria-hidden="true"
214
        >&nbsp;&bull;&nbsp;</span><span :if={@detail} class="announce__detail">{@detail}</span>
215
      </span>
216
      <span :if={@glyph} class="announce__glyph" aria-hidden="true">
217
        <UI.icon name={@glyph} />
218
      </span>
219
    </.link>
220
    """
221
  end
222
223
  @doc """
224
  One shell command, framed, and copied to the clipboard when pressed.
225
226
  The whole frame is the control rather than a button beside it: the command is
227
  not there to be read character by character, it is there to be taken, and a
228
  small target next to a long piece of text asks the reader to aim at the
229
  smaller of the two.
230
231
  A command too long for the viewport scrolls inside the frame and fades out
232
  under the glyph rather than wrapping, so the band keeps one line of monospace
233
  at every width. The confirmation lives on the element as `data-copied`,
234
  because it is presentational and per-visitor and the server has no stake in
235
  it.
236
  """
237
  attr :id, :string, required: true
238
  attr :command, :string, required: true
239
  attr :prompt, :string, default: "$", doc: "the shell prompt drawn before the command"
240
  attr :label, :string, default: "Copy the install command"
241
  attr :class, :any, default: nil
242
243
  def install_command(assigns) do
244
    ~H"""
245
    <div class={["install-command", @class]}>
246
      <button
247
        id={@id}
248
        type="button"
249
        class="install-command__button"
250
        data-copied="false"
251
        data-copy-text={@command}
252
        aria-label={@label}
253
        phx-hook=".CopyCommand"
254
      >
255
        <code class="install-command__text">
256
          <span class="install-command__prompt" aria-hidden="true">{@prompt}</span>{@command}
257
        </code>
258
        <span class="install-command__glyph" aria-hidden="true">
259
          <UI.icon name="copy" class="install-command__idle" />
260
          <UI.icon name="check" class="install-command__done" />
261
        </span>
262
      </button>
263
    </div>
264
    <script :type={Phoenix.LiveView.ColocatedHook} name=".CopyCommand">
265
      export default {
266
        mounted() {
267
          this.el.addEventListener("click", async () => {
268
            try {
269
              await navigator.clipboard.writeText(this.el.dataset.copyText)
270
            } catch (_error) {
271
              return
272
            }
273
            this.el.dataset.copied = "true"
274
            clearTimeout(this.resetTimer)
275
            this.resetTimer = setTimeout(() => {
276
              this.el.dataset.copied = "false"
277
            }, 1600)
278
          })
279
        },
280
        destroyed() {
281
          clearTimeout(this.resetTimer)
282
        }
283
      }
284
    </script>
285
    """
286
  end
287
158 288
  @doc """
159 289
  A grid of short capability statements.
160 290
lib/openagents_web/live/components_live.ex modified +69

@@ -1658,6 +1658,75 @@ defmodule OpenAgentsWeb.ComponentsLive do

1658 1658
          </:figure>
1659 1659
        </Landing.hero>
1660 1660
      </div>
1661
1662
      <p class="text-sm text-base-content/60">
1663
        A second headline tone, a command to run, a note qualifying it, and quiet links
1664
        under everything else. The muted line always breaks on its own, so the split
1665
        between the claim and its object is a decision rather than a wrap.
1666
      </p>
1667
      <div class="demo-frame">
1668
        <Landing.hero
1669
          title="Introducing"
1670
          title_muted="Coder."
1671
          description="Your all-in-one coding agent."
1672
        >
1673
          <:eyebrow>
1674
            <Landing.announce lead="Coder is here" detail="Install it in one command" href="#" />
1675
          </:eyebrow>
1676
          <:command>
1677
            <Landing.install_command
1678
              id="demo-hero-install-command"
1679
              command="curl -fsSL https://openagents.com/install.sh | sh"
1680
            />
1681
          </:command>
1682
          <:note>Every new account starts with $20 of credit.</:note>
1683
          <:links>
1684
            <UI.button href="#" variant={:ghost} size={:sm} class="hero__link">
1685
              Read the docs <UI.icon name="chevron-right" />
1686
            </UI.button>
1687
            <UI.button href="#" variant={:ghost} size={:sm} class="hero__link">
1688
              Changelog <UI.icon name="chevron-right" />
1689
            </UI.button>
1690
          </:links>
1691
        </Landing.hero>
1692
      </div>
1693
    </div>
1694
    """
1695
  end
1696
1697
  defp component_demo(%{item: %{slug: "landing-announce"}} = assigns) do
1698
    ~H"""
1699
    <div class="space-y-3">
1700
      <p class="text-sm text-base-content/60">
1701
        Three registers on one line — a tag, the claim, a detail — because an
1702
        announcement set at a single weight is indistinguishable from a caption. The tag
1703
        carries the band's only accent, which is what makes the pill findable before it
1704
        is read. Below 480px the detail drops rather than widening the pill past the
1705
        screen.
1706
      </p>
1707
      <div class="demo-frame flex flex-wrap items-center justify-center gap-3">
1708
        <Landing.announce lead="Coder is here" detail="Install it in one command" href="#" />
1709
        <Landing.announce tag="Beta" lead="Computers" glyph={nil} href="#" />
1710
      </div>
1711
    </div>
1712
    """
1713
  end
1714
1715
  defp component_demo(%{item: %{slug: "landing-install-command"}} = assigns) do
1716
    ~H"""
1717
    <div class="space-y-3">
1718
      <p class="text-sm text-base-content/60">
1719
        The whole frame is the control, not a button beside it: the command is there to
1720
        be taken rather than read, and a small target next to a long piece of text asks
1721
        the reader to aim at the smaller of the two. A command wider than the frame
1722
        scrolls and fades out under the glyph instead of wrapping.
1723
      </p>
1724
      <div class="demo-frame flex justify-center">
1725
        <Landing.install_command
1726
          id="demo-install-command"
1727
          command="curl -fsSL https://openagents.com/install.sh | sh"
1728
        />
1729
      </div>
1661 1730
    </div>
1662 1731
    """
1663 1732
  end
lib/openagents_web/live/home_live.ex modified +52 -12

@@ -47,6 +47,13 @@ defmodule OpenAgentsWeb.HomeLive do

47 47
  alias OpenAgentsWeb.UI.Landing
48 48
49 49
  @repo "openagents.com"
50
51
  # The one line the landing page asks a reader to run. It is the command
52
  # `priv/docs/install-cli.md` documents and the script
53
  # `priv/static/install.sh` serves, so the page cannot advertise an installer
54
  # this site does not hand out.
55
  @install_command "curl -fsSL https://openagents.com/install.sh | sh"
56
50 57
  @feed_limit 8
51 58
  @project_limit 6
52 59
  @post_limit 6

@@ -63,7 +70,10 @@ defmodule OpenAgentsWeb.HomeLive do

63 70
       |> assign(:changed_repositories, MapSet.new())
64 71
       |> assign_dashboard()}
65 72
    else
66
      {:ok, assign(socket, :device_user_code, device_user_code(params))}
73
      {:ok,
74
       socket
75
       |> assign(:device_user_code, device_user_code(params))
76
       |> assign(:install_command, @install_command)}
67 77
    end
68 78
  end
69 79

@@ -526,20 +536,50 @@ defmodule OpenAgentsWeb.HomeLive do

526 536
      <div class="landing-page">
527 537
        <Landing.layout_lines />
528 538
539
        <%!-- The band leads with the product a reader can have in one command,
540
        not with the platform behind it. The install line is the only action
541
        here on purpose: signing in is what the command bar is for, and a hero
542
        that offers two ways to start makes the reader choose before they know
543
        enough to choose well.
544
545
        It closes with a rule rather than running straight into the feature
546
        grid, so the claim and the way to act on it read as one band. --%>
529 547
        <Landing.hero
530
          title="The Agent Forge"
531
          description="Purpose-built for planning and shipping issues. Designed for the agent era."
548
          title="Introducing"
549
          title_muted="Coder."
550
          description="Your all-in-one coding agent."
551
          rule
532 552
        >
533
          <:actions>
534
            <.github_login id="home-cta-signin" size={:lg} />
535
            <%!-- Quieter than the action beside it. `variant` defaults to
536
            `:primary`, so two filled buttons sat side by side stating that
537
            both were the thing to do, which leaves a reader picking rather
538
            than proceeding. --%>
539
            <.button navigate={~p"/docs"} variant={:secondary} size={:lg}>
540
              Read the docs
553
          <:eyebrow>
554
            <Landing.announce
555
              lead="Coder is here"
556
              detail="Install it in one command"
557
              navigate={~p"/coder"}
558
            />
559
          </:eyebrow>
560
561
          <:command>
562
            <Landing.install_command id="home-install-command" command={@install_command} />
563
          </:command>
564
565
          <:note>Every new account starts with $20 of credit.</:note>
566
567
          <:links>
568
            <.button navigate={~p"/docs"} variant={:ghost} size={:sm} class="hero__link">
569
              Read the docs <.icon name="chevron-right" />
541 570
            </.button>
542
          </:actions>
571
            <.button navigate={~p"/changelog"} variant={:ghost} size={:sm} class="hero__link">
572
              Changelog <.icon name="chevron-right" />
573
            </.button>
574
            <.button
575
              navigate={~p"/OpenAgentsInc/openagents.com"}
576
              variant={:ghost}
577
              size={:sm}
578
              class="hero__link"
579
            >
580
              Open source <.icon name="chevron-right" />
581
            </.button>
582
          </:links>
543 583
544 584
          <%!-- Commented out rather than deleted: the frame is right, what goes
545 585
          in it is not. An empty browser chrome captioned with the domain shows
test/openagents_web/controllers/page_controller_test.exs modified +17 -10

@@ -2,20 +2,27 @@ defmodule OpenAgentsWeb.PageControllerTest do

2 2
  use OpenAgentsWeb.ConnCase
3 3
4 4
  test "GET /", %{conn: conn} do
5
    conn = get(conn, ~p"/")
6
    assert html_response(conn, 200) =~ "The Agent Forge"
5
    html = conn |> get(~p"/") |> html_response(200)
6
7
    assert html =~ "Introducing"
8
    assert html =~ "Coder."
9
    assert html =~ "Your all-in-one coding agent."
7 10
  end
8 11
9
  test "the landing page sells the forge, not a CLI install", %{conn: conn} do
12
  test "the landing page leads with the install command", %{conn: conn} do
10 13
    html = conn |> get(~p"/") |> html_response(200)
11 14
12
    # The landing page carried an install command under the hero. It went
13
    # through two wrong answers before this one: `curl … | bash`, withdrawn
14
    # along with the binary release it installed, and then `npm i -g …`, which
15
    # was a substitution when the instruction had been to remove it. The
16
    # homepage is not where the CLI gets sold.
17
    refute html =~ ~s(id="home-install")
18
    refute html =~ "install.sh"
15
    # The homepage sells Coder, and the whole of the pitch a reader has to act
16
    # on is one line they can paste. It is the same command
17
    # `priv/docs/install-cli.md` documents and the same script this site
18
    # serves, so a reader who copies it from the hero and a reader who copies
19
    # it from the docs run the same installer.
20
    assert html =~ ~s(id="home-install-command")
21
    assert html =~ "curl -fsSL https://openagents.com/install.sh | sh"
22
23
    # `npm i -g @openagentsinc/cli` was a previous answer and is not one now:
24
    # there is no npm package, and a homepage that prints an install command
25
    # nothing publishes is worse than one that prints none.
19 26
    refute html =~ "npm i -g"
20 27
  end
21 28
end
test/openagents_web/home_controller_test.exs modified +6 -4

@@ -7,9 +7,11 @@ defmodule OpenAgentsWeb.HomeControllerTest do

7 7
    response = get(conn, ~p"/")
8 8
    html = html_response(response, 200)
9 9
10
    # OpenAgents deliberately ships its own landing page ("The Agent Forge"),
11
    # not Sarah's. These assertions match the current product identity.
12
    assert html =~ "The Agent Forge"
10
    # OpenAgents deliberately ships its own landing page, not Sarah's. The
11
    # band above the fold is the Coder pitch; these assertions match the
12
    # current product identity.
13
    assert html =~ "Introducing"
14
    assert html =~ "Your all-in-one coding agent."
13 15
    assert html =~ ~s(action="/auth/github?github_tools=enabled")
14 16
    assert html =~ "Log in with GitHub"
15 17

@@ -30,7 +32,7 @@ defmodule OpenAgentsWeb.HomeControllerTest do

30 32
31 33
    # Someone who has signed in has already been sold. The same route shows the
32 34
    # state of the work instead: open issues, projects, and what shipped.
33
    refute html =~ "The Agent Forge"
35
    refute html =~ "Your all-in-one coding agent."
34 36
    assert html =~ "Open issues"
35 37
    assert html =~ "Latest from the changelog"
36 38
    assert html =~ ~s(class="dashboard")

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