Bound every atom attribute, and stop docs rendering at their source width

e6b21acc8bc7 · AtlantisPleb · · parent 3c78ebc7b9e1

Bound every atom attribute, and stop docs rendering at their source width

`UI.github_login/1` declared `attr :size, :atom, default: :md`. Phoenix checks
what a call site passes only when the declaration carries a `values:` list, so
`:md` went through unchecked, `button/1` has no `md` size, and the control
rendered `data-size="md"`, matched no size rule, and arrived with no height,
no padding and no type scale. Nothing warned and no test failed.

Every `:atom` attribute now declares its values, and a test holds that: an
atom attribute is an enumeration, so it must say what it enumerates, which
hands the check to the compiler. The rule earned itself immediately —
`graph_node/1` was declared with only the SCV statuses while the swarm and the
demo both legitimately pass work-item statuses, and the compiler named every
call site. It accepts both vocabularies now.

The guard was verified by reinstating the original defect and watching it fail
with the file, line and attribute named. Worth saying: my first attempt to
reinstate it silently did not apply, so the test appeared to pass while never
having been exercised. A guard that has not fired is not known to work.

Documentation pages rendered at the width of their source files. The Markdown
renderer sets `hardbreaks`, which is right for a person's message — the line
breaks they typed are part of what they said — and wrong for authored prose
wrapped at an editing width, where every wrap became a `<br>`. No CSS could
widen that. Hard breaks are now a caller's choice and the docs opt out; chat
is unchanged, and a check confirms it still breaks where a person did.

Sidebar icons state their own distance from their labels: 16px, 12px to the
right. The nav rows had been taking 6px from an ancestor's gap and the footer
links 12px from their own, so two columns that look like one set sat at
different distances.

The OpenAgents UI catalog section is alphabetised.

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
  • modified assets/css/openagents.css
  • modified lib/openagents/markdown.ex
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/graph.ex
  • modified lib/openagents_web/components/ui.ex
  • modified lib/openagents_web/docs_catalog.ex
  • added test/openagents_web/component_attrs_test.exs

Diff

8 files changed, +234 -123

assets/css/app.css modified +8 -1

@@ -350,7 +350,7 @@

350 350
  display: flex;
351 351
  min-width: 0;
352 352
  align-items: center;
353
  gap: 6px;
353
  gap: 0;
354 354
  pointer-events: none;
355 355
}
356 356

@@ -360,8 +360,15 @@

360 360
.sidebar-row__icon {
361 361
  display: inline-flex;
362 362
  width: 16px;
363
  height: 16px;
363 364
  flex: none;
365
  align-items: center;
364 366
  justify-content: center;
367
  /* Stated here rather than as a gap on an ancestor. The nav rows took their
368
     spacing from `.sidebar-row__content`'s 6px gap and the footer links from
369
     their own 12px, so the two columns sat at different distances from their
370
     labels while looking like the same control. */
371
  margin-right: 12px;
365 372
  color: currentColor;
366 373
  font-size: 16px;
367 374
}
assets/css/openagents.css modified +3 -1

@@ -2581,7 +2581,7 @@

2581 2581
    display: flex;
2582 2582
    align-items: center;
2583 2583
    justify-content: flex-start;
2584
    gap: 12px;
2584
    gap: 0;
2585 2585
    min-height: 32px;
2586 2586
    margin-inline: 12px;
2587 2587
    padding-inline: 8px;

@@ -2598,6 +2598,8 @@

2598 2598
    width: 16px;
2599 2599
    height: 16px;
2600 2600
    flex: none;
2601
    /* The same 12px the nav rows use, so the two columns read as one set. */
2602
    margin-right: 12px;
2601 2603
    font-size: 16px;
2602 2604
  }
2603 2605
lib/openagents/markdown.ex modified +20 -4

@@ -74,15 +74,17 @@ defmodule OpenAgents.Markdown do

74 74
75 75
      true ->
76 76
        source = if options[:streaming], do: complete(text), else: text
77
        render(source)
77
        render(source, options)
78 78
    end
79 79
  end
80 80
81
  defp render(text) do
82
    with {:ok, document} <- MDEx.parse_document(text, @mdex_options),
81
  defp render(text, options) do
82
    mdex_options = mdex_options(options)
83
84
    with {:ok, document} <- MDEx.parse_document(text, mdex_options),
83 85
         :ok <- validate_nesting(document),
84 86
         document <- normalize_links(document),
85
         {:ok, rendered} <- MDEx.to_html(document),
87
         {:ok, rendered} <- MDEx.to_html(document, mdex_options),
86 88
         rendered <- String.replace(rendered, @empty_link, "<a>"),
87 89
         :ok <- validate_output(rendered) do
88 90
      {:safe, rendered}

@@ -142,6 +144,20 @@ defmodule OpenAgents.Markdown do

142 144
    end
143 145
  end
144 146
147
  # Hard breaks are correct for a person's message, where the line breaks they
148
  # typed are part of what they said. They are wrong for authored documents,
149
  # whose source is wrapped at a comfortable editing width: every one of those
150
  # wraps became a `<br>`, so a docs page rendered at the width of its source
151
  # file rather than the width of its column, and no amount of CSS could widen
152
  # it.
153
  defp mdex_options(options) do
154
    if Keyword.get(options, :hardbreaks, true) do
155
      @mdex_options
156
    else
157
      put_in(@mdex_options, [:render, :hardbreaks], false)
158
    end
159
  end
160
145 161
  defp limited(text, reason) do
146 162
    excerpt = truncate_utf8(text, @maximum_fallback_bytes)
147 163
lib/openagents_web/component_catalog.ex modified +113 -113

@@ -16,6 +16,41 @@ defmodule OpenAgentsWeb.ComponentCatalog do

16 16
    %{
17 17
      title: "OpenAgents UI",
18 18
      items: [
19
        %{
20
          slug: "openagents-alert",
21
          title: "Alert",
22
          icon: "warning",
23
          source: "OpenAgentsWeb.UI.alert/1",
24
          summary: "Four variants across box, row, and notice appearances."
25
        },
26
        %{
27
          slug: "openagents-audio-player",
28
          title: "Audio player",
29
          icon: "play",
30
          source: "OpenAgentsWeb.UI.audio_player/1",
31
          summary: "Labeled audio element for recordings."
32
        },
33
        %{
34
          slug: "openagents-avatar",
35
          title: "Avatar",
36
          icon: "user",
37
          source: "OpenAgentsWeb.UI.avatar/1",
38
          summary: "Image or initials fallback in three sizes."
39
        },
40
        %{
41
          slug: "openagents-badge",
42
          title: "Badge",
43
          icon: "tag",
44
          source: "OpenAgentsWeb.UI.badge/1",
45
          summary: "Status pill in six variants."
46
        },
47
        %{
48
          slug: "openagents-breadcrumb",
49
          title: "Breadcrumb",
50
          icon: "compass",
51
          source: "OpenAgentsWeb.UI.breadcrumb/1",
52
          summary: "Ancestor trail ending in the current page, which is not a link."
53
        },
19 54
        %{
20 55
          slug: "openagents-button",
21 56
          title: "Button",

@@ -24,32 +59,39 @@ defmodule OpenAgentsWeb.ComponentCatalog do

24 59
          summary: "Eight variants, four sizes, navigation, and a danger tone."
25 60
        },
26 61
        %{
27
          slug: "openagents-text-button",
28
          title: "Text button",
29
          icon: "text",
30
          source: "OpenAgentsWeb.UI.text_button/1",
31
          summary: "Borderless action for inline and secondary affordances."
62
          slug: "openagents-card",
63
          title: "Card",
64
          icon: "square-image",
65
          source: "OpenAgentsWeb.UI.card/1",
66
          summary: "Content container with an optional corner frame and danger variant."
32 67
        },
33 68
        %{
34
          slug: "openagents-input",
35
          title: "Input",
36
          icon: "square-text",
37
          source: "OpenAgentsWeb.UI.input/1",
38
          summary: "Form-aware text, select, textarea, checkbox, and raw inputs."
69
          slug: "openagents-copy-button",
70
          title: "Copy button",
71
          icon: "copy",
72
          source: "OpenAgentsWeb.UI.copy_button/1",
73
          summary: "Copies text and confirms it, so the reader is not left guessing."
39 74
        },
40 75
        %{
41
          slug: "openagents-textarea",
42
          title: "Textarea",
43
          icon: "text",
44
          source: "OpenAgentsWeb.UI.textarea/1",
45
          summary: "Unwrapped multiline text primitive."
76
          slug: "openagents-diff-file",
77
          title: "Diff",
78
          icon: "code",
79
          source: "OpenAgentsWeb.UI.diff_file/1",
80
          summary: "One file's diff: hunks, both line numbers, addressable lines."
46 81
        },
47 82
        %{
48
          slug: "openagents-label",
49
          title: "Label",
50
          icon: "tag",
51
          source: "OpenAgentsWeb.UI.label/1",
52
          summary: "Form label bound to a control by ID."
83
          slug: "openagents-empty",
84
          title: "Empty state",
85
          icon: "circle",
86
          source: "OpenAgentsWeb.UI.empty/1",
87
          summary: "Placeholder for lists and panels with nothing to show."
88
        },
89
        %{
90
          slug: "openagents-event-header",
91
          title: "Event header",
92
          icon: "info",
93
          source: "OpenAgentsWeb.UI.event_header/1",
94
          summary: "Titled event row with status, timestamp, and chip slot."
53 95
        },
54 96
        %{
55 97
          slug: "openagents-field",

@@ -58,13 +100,6 @@ defmodule OpenAgentsWeb.ComponentCatalog do

58 100
          source: "OpenAgentsWeb.UI.field/1",
59 101
          summary: "Wrapper that stacks a label, control, and validation message."
60 102
        },
61
        %{
62
          slug: "openagents-breadcrumb",
63
          title: "Breadcrumb",
64
          icon: "compass",
65
          source: "OpenAgentsWeb.UI.breadcrumb/1",
66
          summary: "Ancestor trail ending in the current page, which is not a link."
67
        },
68 103
        %{
69 104
          slug: "openagents-file-table",
70 105
          title: "File table",

@@ -73,18 +108,11 @@ defmodule OpenAgentsWeb.ComponentCatalog do

73 108
          summary: "A repository tree: ref bar, latest commit, and entries."
74 109
        },
75 110
        %{
76
          slug: "openagents-repo-about",
77
          title: "Repo about",
78
          icon: "info",
79
          source: "OpenAgentsWeb.UI.repo_about/1",
80
          summary: "The rail beside a repository: description, licence, languages."
81
        },
82
        %{
83
          slug: "openagents-diff-file",
84
          title: "Diff",
85
          icon: "code",
86
          source: "OpenAgentsWeb.UI.diff_file/1",
87
          summary: "One file's diff: hunks, both line numbers, addressable lines."
111
          slug: "openagents-frame",
112
          title: "Frame",
113
          icon: "grid",
114
          source: "OpenAgentsWeb.UI.frame/1",
115
          summary: "Corner-bracket decoration around arbitrary content."
88 116
        },
89 117
        %{
90 118
          slug: "openagents-github-login",

@@ -93,13 +121,6 @@ defmodule OpenAgentsWeb.ComponentCatalog do

93 121
          source: "OpenAgentsWeb.UI.github_login/1",
94 122
          summary: "Sign-in form that goes pending on submit."
95 123
        },
96
        %{
97
          slug: "openagents-copy-button",
98
          title: "Copy button",
99
          icon: "copy",
100
          source: "OpenAgentsWeb.UI.copy_button/1",
101
          summary: "Copies text and confirms it, so the reader is not left guessing."
102
        },
103 124
        %{
104 125
          slug: "openagents-header",
105 126
          title: "Header",

@@ -108,46 +129,18 @@ defmodule OpenAgentsWeb.ComponentCatalog do

108 129
          summary: "Page heading with supporting text and an action slot."
109 130
        },
110 131
        %{
111
          slug: "openagents-table",
112
          title: "Table",
113
          icon: "table-cells-filled",
114
          source: "OpenAgentsWeb.UI.table/1",
115
          summary: "Responsive rows with regular-list and LiveView stream support."
116
        },
117
        %{
118
          slug: "openagents-list",
119
          title: "List",
120
          icon: "file-document",
121
          source: "OpenAgentsWeb.UI.list/1",
122
          summary: "Title and description pairs."
123
        },
124
        %{
125
          slug: "openagents-alert",
126
          title: "Alert",
127
          icon: "warning",
128
          source: "OpenAgentsWeb.UI.alert/1",
129
          summary: "Four variants across box, row, and notice appearances."
130
        },
131
        %{
132
          slug: "openagents-badge",
133
          title: "Badge",
134
          icon: "tag",
135
          source: "OpenAgentsWeb.UI.badge/1",
136
          summary: "Status pill in six variants."
137
        },
138
        %{
139
          slug: "openagents-card",
140
          title: "Card",
141
          icon: "square-image",
142
          source: "OpenAgentsWeb.UI.card/1",
143
          summary: "Content container with an optional corner frame and danger variant."
132
          slug: "openagents-icon",
133
          title: "Icon",
134
          icon: "sparkle",
135
          source: "OpenAgentsWeb.UI.icon/1",
136
          summary: "Apps SDK glyph with an optional accessible label."
144 137
        },
145 138
        %{
146
          slug: "openagents-avatar",
147
          title: "Avatar",
148
          icon: "user",
149
          source: "OpenAgentsWeb.UI.avatar/1",
150
          summary: "Image or initials fallback in three sizes."
139
          slug: "openagents-input",
140
          title: "Input",
141
          icon: "square-text",
142
          source: "OpenAgentsWeb.UI.input/1",
143
          summary: "Form-aware text, select, textarea, checkbox, and raw inputs."
151 144
        },
152 145
        %{
153 146
          slug: "openagents-item",

@@ -156,20 +149,6 @@ defmodule OpenAgentsWeb.ComponentCatalog do

156 149
          source: "OpenAgentsWeb.UI.item/1",
157 150
          summary: "Status, label, and detail row for activity lists."
158 151
        },
159
        %{
160
          slug: "openagents-event-header",
161
          title: "Event header",
162
          icon: "info",
163
          source: "OpenAgentsWeb.UI.event_header/1",
164
          summary: "Titled event row with status, timestamp, and chip slot."
165
        },
166
        %{
167
          slug: "openagents-empty",
168
          title: "Empty state",
169
          icon: "circle",
170
          source: "OpenAgentsWeb.UI.empty/1",
171
          summary: "Placeholder for lists and panels with nothing to show."
172
        },
173 152
        %{
174 153
          slug: "openagents-kbd",
175 154
          title: "Keyboard key",

@@ -177,6 +156,20 @@ defmodule OpenAgentsWeb.ComponentCatalog do

177 156
          source: "OpenAgentsWeb.UI.kbd/1",
178 157
          summary: "Rendered keycap for shortcut documentation."
179 158
        },
159
        %{
160
          slug: "openagents-label",
161
          title: "Label",
162
          icon: "tag",
163
          source: "OpenAgentsWeb.UI.label/1",
164
          summary: "Form label bound to a control by ID."
165
        },
166
        %{
167
          slug: "openagents-list",
168
          title: "List",
169
          icon: "file-document",
170
          source: "OpenAgentsWeb.UI.list/1",
171
          summary: "Title and description pairs."
172
        },
180 173
        %{
181 174
          slug: "openagents-menu",
182 175
          title: "Menu",

@@ -185,11 +178,11 @@ defmodule OpenAgentsWeb.ComponentCatalog do

185 178
          summary: "Native popover menu surface used by the account control."
186 179
        },
187 180
        %{
188
          slug: "openagents-frame",
189
          title: "Frame",
190
          icon: "grid",
191
          source: "OpenAgentsWeb.UI.frame/1",
192
          summary: "Corner-bracket decoration around arbitrary content."
181
          slug: "openagents-repo-about",
182
          title: "Repo about",
183
          icon: "info",
184
          source: "OpenAgentsWeb.UI.repo_about/1",
185
          summary: "The rail beside a repository: description, licence, languages."
193 186
        },
194 187
        %{
195 188
          slug: "openagents-status-indicator",

@@ -199,18 +192,25 @@ defmodule OpenAgentsWeb.ComponentCatalog do

199 192
          summary: "Labeled state dot, optionally decorative."
200 193
        },
201 194
        %{
202
          slug: "openagents-audio-player",
203
          title: "Audio player",
204
          icon: "play",
205
          source: "OpenAgentsWeb.UI.audio_player/1",
206
          summary: "Labeled audio element for recordings."
195
          slug: "openagents-table",
196
          title: "Table",
197
          icon: "table-cells-filled",
198
          source: "OpenAgentsWeb.UI.table/1",
199
          summary: "Responsive rows with regular-list and LiveView stream support."
207 200
        },
208 201
        %{
209
          slug: "openagents-icon",
210
          title: "Icon",
211
          icon: "sparkle",
212
          source: "OpenAgentsWeb.UI.icon/1",
213
          summary: "Apps SDK glyph with an optional accessible label."
202
          slug: "openagents-text-button",
203
          title: "Text button",
204
          icon: "text",
205
          source: "OpenAgentsWeb.UI.text_button/1",
206
          summary: "Borderless action for inline and secondary affordances."
207
        },
208
        %{
209
          slug: "openagents-textarea",
210
          title: "Textarea",
211
          icon: "text",
212
          source: "OpenAgentsWeb.UI.textarea/1",
213
          summary: "Unwrapped multiline text primitive."
214 214
        }
215 215
      ]
216 216
    },
lib/openagents_web/components/graph.ex modified +4 -1

@@ -146,7 +146,10 @@ defmodule OpenAgentsWeb.UI.Graph do

146 146
  attr :r, :float, default: 22.0, doc: "radius for a circle node"
147 147
  attr :width, :float, default: 44.0, doc: "width for a rect node"
148 148
  attr :height, :float, default: 32.0, doc: "height for a rect node"
149
  attr :status, :atom, default: :idle
149
  # Both vocabularies: a node draws an SCV or a work item, and the two
150
  # lifecycles are distinct sets. Naming only the SCV ones made the compiler
151
  # reject the item statuses the demo and the swarm both legitimately pass.
152
  attr :status, :atom, values: @statuses ++ @item_statuses, default: :idle
150 153
  attr :label, :string, default: nil
151 154
  attr :selected, :boolean, default: false
152 155
  attr :class, :any, default: nil
lib/openagents_web/components/ui.ex modified +10 -2

@@ -1113,8 +1113,16 @@ defmodule OpenAgentsWeb.UI do

1113 1113
  """
1114 1114
  attr :id, :string, required: true
1115 1115
  attr :label, :string, default: "Log in with GitHub"
1116
  attr :variant, :atom, default: :primary
1117
  attr :size, :atom, default: :md
1116
1117
  attr :variant, :atom,
1118
    values: [:primary, :secondary, :outline, :ghost, :destructive, :chip, :notched, :link],
1119
    default: :primary
1120
1121
  # Every atom attr states its values. Without a `values:` list Phoenix cannot
1122
  # check call sites, and this one defaulted to `:md` -- not one of
1123
  # `button/1`'s sizes -- so it rendered `data-size="md"`, matched no size
1124
  # rule, and left the control with no height, padding or type scale at all.
1125
  attr :size, :atom, values: [:default, :xs, :sm, :lg], default: :default
1118 1126
1119 1127
  attr :action, :string,
1120 1128
    default: "/auth/github?github_tools=enabled",
lib/openagents_web/docs_catalog.ex modified +6 -1

@@ -127,7 +127,12 @@ defmodule OpenAgentsWeb.DocsCatalog do

127 127
         path = Path.join(source_dir(), "#{slug}.md"),
128 128
         {:ok, markdown} <- File.read(path) do
129 129
      toc = headings(markdown)
130
      html = markdown |> OpenAgents.Markdown.to_html() |> anchor_headings(toc)
130
      # Authored prose, not a message: the source is wrapped for editing, and
131
      # those wraps are not line breaks the reader should see.
132
      html =
133
        markdown
134
        |> OpenAgents.Markdown.to_html(hardbreaks: false)
135
        |> anchor_headings(toc)
131 136
132 137
      {:ok, %{item: item, html: html, toc: toc, markdown: markdown}}
133 138
    else
test/openagents_web/component_attrs_test.exs added +70

@@ -0,0 +1,70 @@

1
defmodule OpenAgentsWeb.ComponentAttrsTest do
2
  @moduledoc """
3
  Every enumerated component attribute states the values it accepts.
4
5
  Phoenix checks a literal attribute at a call site only when the declaration
6
  carries a `values:` list. Without one it accepts anything, and the failure is
7
  silent all the way to the browser: `UI.github_login/1` declared
8
  `attr :size, :atom, default: :md`, `:md` is not one of `button/1`'s sizes, so
9
  the button rendered `data-size="md"`, matched no size rule, and arrived with
10
  no height, no padding and no type scale. Nothing warned, no test failed, and
11
  the control simply looked wrong.
12
13
  The rule is narrow and mechanical: an `:atom` attribute is an enumeration, so
14
  it must say what it enumerates. That is enough to hand the problem to the
15
  compiler, which then rejects a bad call site by name — and it did
16
  immediately, catching `graph_node/1` being passed work-item statuses its
17
  declaration did not admit.
18
19
  `:string`, `:boolean`, `:map`, `:list`, `:any` and `:global` are unbounded by
20
  nature and are not checked.
21
  """
22
23
  use ExUnit.Case, async: true
24
25
  # `attr` declarations wrap, so the options are read up to the next `attr`,
26
  # `slot`, `def`, or blank-line boundary rather than to the end of the line.
27
  @declaration ~r/^[ \t]*attr :(\w+), :atom\b(?<options>(?:.|\n)*?)(?=\n[ \t]*(?:attr |slot |def |defp |@doc|\n))/m
28
29
  test "every atom attribute declares the values it accepts" do
30
    offenders =
31
      for path <- component_sources(),
32
          source = File.read!(path),
33
          match <- Regex.scan(@declaration, source, capture: :all_but_first, return: :index),
34
          {offender, line} <- offense(source, match),
35
          do: {relative(path), line, offender}
36
37
    assert offenders == [], """
38
    These `:atom` attributes do not declare a `values:` list, so Phoenix cannot
39
    check what call sites pass them and a wrong value fails silently in the
40
    browser rather than loudly at compile time:
41
42
    #{Enum.map_join(offenders, "\n", fn {file, line, name} -> "  #{file}:#{line}  attr :#{name}" end)}
43
44
    Add `values: [...]`. If the attribute genuinely accepts any atom, it is not
45
    an enumeration -- declare it as `:any` and say why in a comment.
46
    """
47
  end
48
49
  defp offense(source, [{name_start, name_length}, {options_start, options_length}]) do
50
    name = binary_part(source, name_start, name_length)
51
    options = binary_part(source, options_start, options_length)
52
53
    if String.contains?(options, "values:") do
54
      []
55
    else
56
      [{name, line_of(source, name_start)}]
57
    end
58
  end
59
60
  defp offense(_source, _match), do: []
61
62
  defp component_sources do
63
    Path.wildcard("lib/openagents_web/**/*.ex")
64
  end
65
66
  defp line_of(source, offset),
67
    do: source |> binary_part(0, offset) |> String.split("\n") |> length()
68
69
  defp relative(path), do: String.replace_leading(path, "lib/openagents_web/", "")
70
end

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