Publish a first release, keep internal links in the tab, and stop the hero stuttering

d76965ffe33f · AtlantisPleb · · parent 7a2e8fe52060

Publish a first release, keep internal links in the tab, and stop the hero stuttering

Four fixes to what a visitor sees first.

**The changelog was empty because its seed never ran.** The backfill was gated
behind `:changelog_backfill_on_boot`, and nothing in any config file ever set
it, so the curated entries sat in the code unused from the day they were
written. Enabled for dev and prod, and deliberately not for test: it runs off
the boot path, and a write that escapes the sandbox is a flaky suite.

What it seeds is narrower than what it holds. Every curated entry is anchored
to a commit from before this repository's clean-room rewrite, and none of those
commits are here -- publishing them would have given the page thirty links
resolving to nothing, on a page that tells the reader each entry links to its
commit and the diff is readable from there. So the launch is stated once, as
v0.0.1, from a commit that exists. The rest are retained for the day that
history is grafted in.

Found while writing it: `summary` validated to 500 characters into a
`varchar(255)` column, so anything between the two passed the changeset and
then raised `string_data_right_truncation` from Postgres. That is the only path
that writes this table, and it would have failed silently inside the boot task.

**Internal links opened new tabs.** The sanitizer gives every link the same
isolation attributes, which is right for a link that leaves the site and wrong
for one that does not: a reader following our own docs collected a tab per
link. Links that stay on the site now stay in the tab. `//host` is still
treated as leaving, because it reads as a path and is not one.

**The hero animation stuttered.** Two causes, both paint cost. Eight font faces
are `font-display: swap` and none were preloaded, so faces arrived after first
paint and re-laid-out hero text mid-animation. And the keyframes animated
`filter: blur()`, which no compositor can do -- every frame repainted a 5.5rem
headline spanning the viewport. The three faces the first paint uses are
preloaded, and the animation is opacity and translate only.

**Component demos were capped at a reading measure.** The page is what fills
the column now and the prose caps itself; a composed surface squeezed into 768px
stops demonstrating the proportions it exists to show.

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 config/dev.exs
  • modified config/runtime.exs
  • modified lib/openagents/changelog/backfill.ex
  • modified lib/openagents/changelog/entry.ex
  • modified lib/openagents/markdown.ex
  • modified lib/openagents_web/components/layouts/root.html.heex
  • modified lib/openagents_web/live/components_live.ex
  • modified test/openagents/changelog_test.exs
  • modified test/openagents/markdown_test.exs

Diff

10 files changed, +185 -14

assets/css/openagents.css modified +11 -5

@@ -3967,6 +3967,12 @@

3967 3967
    animation-fill-mode: forwards;
3968 3968
    animation-timing-function: var(--ease, cubic-bezier(0.16, 1, 0.3, 1));
3969 3969
    opacity: 0;
3970
    /* These animate opacity, translate and scale, which a compositor can run
3971
       on its own. Saying so gets each one its own layer up front, so the hero
3972
       glow -- a very large gradient being scaled -- composites instead of
3973
       repainting its whole area every frame. Four elements, all above the
3974
       fold, so the layers are cheap and short-lived. */
3975
    will-change: opacity, translate, scale;
3970 3976
  }
3971 3977
3972 3978
  .appear { animation-name: landing-appear; }

@@ -3977,19 +3983,18 @@

3977 3983
  .appear--delay-3 { animation-delay: 500ms; }
3978 3984
  .appear--delay-4 { animation-delay: 700ms; }
3979 3985
3986
  /* Opacity and translate only. This also animated `filter: blur()`, which no
3987
     compositor can do: every frame repainted the element at its full size, and
3988
     the largest element here is a 5.5rem headline spanning the viewport. The
3989
     dropped frames read as the animation stuttering and starting over. */
3980 3990
  @keyframes landing-appear {
3981 3991
    from {
3982 3992
      opacity: 0;
3983 3993
      translate: 0 1rem;
3984
      filter: blur(0.5rem);
3985
    }
3986
    50% {
3987
      filter: blur(0);
3988 3994
    }
3989 3995
    to {
3990 3996
      opacity: 1;
3991 3997
      translate: 0 0;
3992
      filter: blur(0);
3993 3998
    }
3994 3999
  }
3995 4000

@@ -4009,6 +4014,7 @@

4009 4014
    .appear-zoom {
4010 4015
      animation: none;
4011 4016
      opacity: 1;
4017
      will-change: auto;
4012 4018
    }
4013 4019
4014 4020
    .cta__glow,
config/dev.exs modified +4

@@ -2,6 +2,10 @@ import Config

2 2
3 3
config :openagents, :runtime_environment, :development
4 4
5
# The changelog seed. Deliberately not enabled in `test`: it runs off the boot
6
# path, and a write that escapes the sandbox is a flaky suite.
7
config :openagents, :changelog_backfill_on_boot, true
8
5 9
# Configure your database
6 10
config :openagents, OpenAgents.Repo,
7 11
  username: System.get_env("USER") || "christopherdavid",
config/runtime.exs modified +7

@@ -96,6 +96,13 @@ if config_env() == :dev do

96 96
  config :openagents, :openai_api_key, optional_text.("OPENAI_API_KEY")
97 97
end
98 98
99
# The changelog seed. Idempotent and off the boot path. It was gated behind a
100
# flag that nothing ever set, so the seed sat unused and the public page said
101
# "Nothing yet" from the day it shipped.
102
if config_env() == :prod do
103
  config :openagents, :changelog_backfill_on_boot, true
104
end
105
99 106
if config_env() == :prod and runtime_role == :web do
100 107
  runtime_environment =
101 108
    case required_text.("OPENAGENTS_ENVIRONMENT") do
lib/openagents/changelog/backfill.ex modified +39 -3

@@ -33,7 +33,7 @@ defmodule OpenAgents.Changelog.Backfill do

33 33
34 34
  @doc "Insert every seed entry idempotently. Returns the inserted count."
35 35
  def run do
36
    Enum.count(entries(), fn attrs ->
36
    Enum.count(seeded_entries(), fn attrs ->
37 37
      case Changelog.record(Map.merge(attrs, %{repo: @repo, source: "backfill"})) do
38 38
        {:ok, %{id: id}} when not is_nil(id) -> true
39 39
        _ -> false

@@ -41,8 +41,44 @@ defmodule OpenAgents.Changelog.Backfill do

41 41
    end)
42 42
  end
43 43
44
  @doc "The curated seed, lead-commit-anchored, from CHANGELOG.md."
45
  def entries do
44
  @doc """
45
  What actually gets seeded.
46
47
  Every entry links to its commit, and the page says so, so an entry may only
48
  be published if its commit is in this repository's history. The curated
49
  entries in `pre_public_entries/0` are anchored to the history that preceded
50
  this repository's clean-room rewrite: those commits are not here, and
51
  publishing them would give the page thirty links that resolve to nothing.
52
53
  So the launch is stated once, as a release, from a commit that exists. If
54
  the earlier history is ever grafted in, move those entries back.
55
  """
56
  def seeded_entries, do: [launch_entry()]
57
58
  @doc """
59
  The first public release.
60
61
  Anchored to this repository's initial commit, which is where its history --
62
  and everything the changelog can show a diff for -- begins.
63
  """
64
  def launch_entry do
65
    e(
66
      ~U[2026-08-19 19:47:28Z],
67
      "a352f78",
68
      "feature",
69
      "v0.0.1 — the first public release: issues, projects and milestones over a GitHub-shaped API; repositories to create, import from GitHub, clone and push; code and diffs beside the issues that changed them; an agent to talk to; receipts under all of it."
70
    )
71
  end
72
73
  @doc """
74
  The curated seed, lead-commit-anchored, from CHANGELOG.md.
75
76
  Retained, not seeded: see `seeded_entries/0`.
77
  """
78
  def entries, do: pre_public_entries()
79
80
  @doc "Curated entries from before this repository's history begins."
81
  def pre_public_entries do
46 82
    [
47 83
      # ── 2026-08-19 (later) — the transparency surfaces themselves
48 84
      e(
lib/openagents/changelog/entry.ex modified +5 -1

@@ -62,7 +62,11 @@ defmodule OpenAgents.Changelog.Entry do

62 62
    ])
63 63
    |> validate_required([:repo, :sha, :summary, :category, :source, :entry_at])
64 64
    |> validate_format(:sha, ~r/^[0-9a-f]{7,40}$/)
65
    |> validate_length(:summary, max: 500)
65
    # 255, because that is what the column is. This said 500, so a summary
66
    # between the two passed validation and then raised
67
    # `string_data_right_truncation` from Postgres -- a changeset error turned
68
    # into a crash, on the one path that writes this table.
69
    |> validate_length(:summary, max: 255)
66 70
    |> validate_inclusion(:category, @categories)
67 71
    |> validate_inclusion(:source, @sources)
68 72
    |> validate_inclusion(:visibility, @visibilities)
lib/openagents/markdown.ex modified +13

@@ -58,6 +58,18 @@ defmodule OpenAgents.Markdown do

58 58
59 59
  @empty_link ~s(<a href="" target="_blank" rel="noopener noreferrer nofollow">)
60 60
61
  # The sanitizer gives every link the same isolation attributes, which is
62
  # right for a link that leaves the site and wrong for one that does not: a
63
  # link to our own page opened a second tab, and the reader ends up with a
64
  # trail of tabs for what is one visit.
65
  #
66
  # `safe_url/1` has already rewritten `//host` to `https://host`, so at this
67
  # point a leading single `/` means same-origin, and `#` means this very page.
68
  # Everything else -- any scheme, any bare host -- is still treated as leaving.
69
  @internal_link ~r{<a href="(/(?!/)[^"]*|#[^"]*)" target="_blank" rel="noopener noreferrer nofollow">}
70
71
  @same_tab ~S(<a href="\1">)
72
61 73
  @emphasis_markers ~w(*** ___ ** __ ~~ * _)
62 74
63 75
  @doc """

@@ -86,6 +98,7 @@ defmodule OpenAgents.Markdown do

86 98
         document <- normalize_links(document),
87 99
         {:ok, rendered} <- MDEx.to_html(document, mdex_options),
88 100
         rendered <- String.replace(rendered, @empty_link, "<a>"),
101
         rendered <- String.replace(rendered, @internal_link, @same_tab),
89 102
         :ok <- validate_output(rendered) do
90 103
      {:safe, rendered}
91 104
    else
lib/openagents_web/components/layouts/root.html.heex modified +31

@@ -12,6 +12,37 @@

12 12
    which applies it to `default` as well: a page with no title of its own
13 13
    rendered the brand twice. --%>
14 14
    <.live_title default="OpenAgents" phx-no-format>{page_title(assigns)}</.live_title>
15
    <%!-- The faces the first paint actually uses, fetched in parallel with the
16
    stylesheet instead of after it. Every face is `font-display: swap`, so one
17
    arriving late re-lays-out text that is already on screen -- and the hero
18
    animates while that happens, which reads as the animation stuttering and
19
    restarting rather than as text reflowing.
20
21
    Plain paths, not `~p`: these must be byte-identical to the `src` in
22
    `@font-face`, or the browser fetches the file twice and preloads nothing.
23
    `crossorigin` is required even same-origin -- fonts are fetched in CORS
24
    mode, and a preload without it is discarded and re-fetched. --%>
25
    <link
26
      rel="preload"
27
      as="font"
28
      type="font/woff2"
29
      href="/fonts/geist-sans-latin-400-normal.woff2"
30
      crossorigin="anonymous"
31
    />
32
    <link
33
      rel="preload"
34
      as="font"
35
      type="font/woff2"
36
      href="/fonts/geist-sans-latin-500-normal.woff2"
37
      crossorigin="anonymous"
38
    />
39
    <link
40
      rel="preload"
41
      as="font"
42
      type="font/woff2"
43
      href="/fonts/geist-sans-latin-600-normal.woff2"
44
      crossorigin="anonymous"
45
    />
15 46
    <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
16 47
    <%!-- Runs before first paint, and deliberately not deferred: resolving the
17 48
    theme after the stylesheet has painted produces a flash of the wrong theme.
lib/openagents_web/live/components_live.ex modified +7 -3

@@ -410,7 +410,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

410 410
  @impl true
411 411
  def render(%{live_action: :index} = assigns) do
412 412
    ~H"""
413
    <div id="components-index" class="max-w-3xl">
413
    <div id="components-index">
414 414
      <h1 class="text-3xl font-semibold mb-4">Component library</h1>
415 415
      <p class="text-muted-foreground mb-8 text-pretty max-w-[68ch]">
416 416
        Live examples of every supported function component in <code>OpenAgentsWeb.UI</code>, <code>OpenAgentsWeb.Layouts</code>, and the

@@ -423,7 +423,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

423 423
        <h2 class="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
424 424
          {section.title}
425 425
        </h2>
426
        <div class="grid gap-4 sm:grid-cols-2">
426
        <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
427 427
          <.link
428 428
            :for={item <- section.items}
429 429
            patch={~p"/components/#{item.slug}"}

@@ -440,7 +440,11 @@ defmodule OpenAgentsWeb.ComponentsLive do

440 440
441 441
  def render(assigns) do
442 442
    ~H"""
443
    <div id={"component-#{@item.slug}"} class="max-w-3xl space-y-6">
443
    <%!-- The page fills its column and the prose caps itself. Capping the page
444
    capped the demo too, which is the one thing on it that wants the room: a
445
    composed surface squeezed into a reading measure stops demonstrating the
446
    proportions it exists to show. --%>
447
    <div id={"component-#{@item.slug}"} class="space-y-6">
444 448
      <header class="space-y-1">
445 449
        <h1 class="text-3xl font-semibold">{@item.title}</h1>
446 450
        <p class="text-sm text-muted-foreground"><code>{@item.source}</code></p>
test/openagents/changelog_test.exs modified +45 -2

@@ -225,11 +225,11 @@ defmodule OpenAgents.ChangelogTest do

225 225
  end
226 226
227 227
  describe "Backfill.run/0" do
228
    test "seeds every curated entry and is idempotent on re-run" do
228
    test "seeds what it can prove and is idempotent on re-run" do
229 229
      Backfill.run()
230 230
231 231
      seeded = Repo.aggregate(Entry, :count)
232
      assert seeded == length(Backfill.entries())
232
      assert seeded == length(Backfill.seeded_entries())
233 233
234 234
      # Re-running inserts nothing: the {repo, sha, source} conflict target
235 235
      # makes every duplicate a DB-level no-op.

@@ -239,5 +239,48 @@ defmodule OpenAgents.ChangelogTest do

239 239
      assert {:ok, rows} = Changelog.timeline("openagents.com", refresh: true)
240 240
      assert Enum.any?(rows, &(&1.source == "backfill"))
241 241
    end
242
243
    test "the launch entry is the release, and its commit is in this history" do
244
      # The page tells a reader that every entry links to its commit and that
245
      # the diff is readable from there. An entry anchored to a commit this
246
      # repository does not contain is a link to nothing, so the seed may only
247
      # publish entries whose sha is really here.
248
      assert [launch] = Backfill.seeded_entries()
249
      assert launch.summary =~ "v0.0.1"
250
251
      for %{sha: sha} <- Backfill.seeded_entries() do
252
        assert {_output, 0} = System.cmd("git", ["cat-file", "-e", sha <> "^{commit}"]),
253
               "seeded entry #{sha} is not a commit in this repository"
254
      end
255
    end
256
257
    test "the curated pre-public entries are retained but not published" do
258
      # They are anchored to the history that preceded this repository's
259
      # clean-room rewrite. Kept so they can be restored if that history is
260
      # ever grafted in; not seeded, because today they would be dead links.
261
      refute Backfill.pre_public_entries() == []
262
263
      seeded = MapSet.new(Backfill.seeded_entries(), & &1.sha)
264
265
      for %{sha: sha} <- Backfill.pre_public_entries() do
266
        refute MapSet.member?(seeded, sha)
267
      end
268
    end
269
270
    test "a summary the column cannot hold is a changeset error, not a crash" do
271
      # `changelog_entries.summary` is varchar(255) and the validation allowed
272
      # 500, so anything between the two raised from Postgres instead.
273
      attrs = %{
274
        repo: "openagents.com",
275
        sha: "abcdef1",
276
        summary: String.duplicate("x", 256),
277
        category: "feature",
278
        source: "backfill",
279
        entry_at: DateTime.utc_now()
280
      }
281
282
      assert {:error, changeset} = Changelog.record(attrs)
283
      assert %{summary: [_message]} = errors_on(changeset)
284
    end
242 285
  end
243 286
end
test/openagents/markdown_test.exs modified +23

@@ -109,6 +109,29 @@ defmodule OpenAgents.MarkdownTest do

109 109
      assert html("[cdn](//example.com/x)") =~ ~s(href="https://example.com/x")
110 110
    end
111 111
112
    test "a link that stays on the site opens in the same tab" do
113
      # A second tab per internal link leaves the reader with a trail of them
114
      # for what is one visit.
115
      for markdown <- ["[path](/docs)", "[root](/)", "[section](#part)"] do
116
        rendered = html(markdown)
117
118
        refute rendered =~ ~s(target="_blank"), "opened a new tab: #{markdown}"
119
120
        refute rendered =~ ~s(rel="noopener noreferrer nofollow"),
121
               "isolated a same-origin link: #{markdown}"
122
      end
123
    end
124
125
    test "a link that only looks internal still leaves" do
126
      # `//host` is a protocol-relative URL: it reads as a path and is not one.
127
      # It is rewritten to https earlier, and must still be treated as leaving.
128
      rendered = html("[cdn](//example.com/x)")
129
130
      assert rendered =~ ~s(href="https://example.com/x")
131
      assert rendered =~ ~s(target="_blank")
132
      assert rendered =~ ~s(rel="noopener noreferrer nofollow")
133
    end
134
112 135
    test "a bare domain is resolved as https rather than relative to Sarah" do
113 136
      assert html("[site](example.com)") =~ ~s(href="https://example.com")
114 137
    end

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