Consolidate the governed presentation stack

e340d7287380 · Christopher David · · parent cee36e1a321f

Consolidate the governed presentation stack

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 AGENTS.md
  • modified INVARIANTS.md
  • modified README.md
  • modified assets/css/app.css
  • added assets/test/css_contract_test.mjs
  • modified docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md
  • added docs/ICONS.md
  • modified docs/architecture.md
  • modified docs/component-library.md
  • added docs/dependencies-and-licenses.md
  • modified lib/openagents/markdown.ex
  • modified lib/openagents_web.ex
  • modified lib/openagents_web/component_catalog.ex
  • deleted lib/openagents_web/components/core_components.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/components/layouts/root.html.heex
  • modified lib/openagents_web/components/ui.ex
  • modified lib/openagents_web/icons.ex
  • modified lib/openagents_web/live/admin_forge_live.ex
  • modified lib/openagents_web/live/admin_live.ex
  • modified lib/openagents_web/live/assignee_index_live.ex
  • modified lib/openagents_web/live/changelog_live.ex
  • modified lib/openagents_web/live/chat_live.ex
  • modified lib/openagents_web/live/code_blob_live.ex
  • modified lib/openagents_web/live/code_commit_live.ex
  • modified lib/openagents_web/live/code_repo_live.ex
  • modified lib/openagents_web/live/components_live.ex
  • modified lib/openagents_web/live/computers_live.ex
  • modified lib/openagents_web/live/docs_live.ex
  • modified lib/openagents_web/live/home_live.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • modified lib/openagents_web/live/issue_new_live.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • modified lib/openagents_web/live/label_index_live.ex
  • modified lib/openagents_web/live/leaderboard_live.ex
  • modified lib/openagents_web/live/milestone_index_live.ex
  • modified lib/openagents_web/live/network_status_live.ex
  • modified lib/openagents_web/live/project_index_live.ex
  • modified lib/openagents_web/live/project_show_live.ex
  • modified lib/openagents_web/live/ui_gallery_live.ex
  • modified lib/openagents_web/live/voice_spike_live.ex
  • modified mix.exs
  • modified mix.lock
  • added ops/staging/generate-sbom.sh
  • modified priv/brand/README.md
  • modified priv/icons/README.md
  • added priv/licenses/HEROICONS-LICENSE
  • added priv/licenses/THIRD_PARTY_NOTICES.md
  • modified test/openagents/markdown_test.exs
  • modified test/openagents_web/icon_affordances_test.exs
  • modified test/openagents_web/live/components_live_test.exs
  • modified test/openagents_web/live/issue_index_live_test.exs

Diff

52 files changed, +1074 -1127

AGENTS.md modified +5 -5

@@ -25,8 +25,8 @@ All text in this repo — docs, README, `AGENTS.md`, commit messages, and agent

25 25
  - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
26 26
  - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
27 27
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
28
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
29
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
28
- Render icons only through `OpenAgentsWeb.UI.icon/1`. Prefer the vendored Apps SDK set. Use a `hero-*` fallback only when `docs/ICONS.md` records why the preferred set has no suitable glyph
29
- **Always** use the imported `OpenAgentsWeb.UI.input/1` component for form inputs. It accepts `Phoenix.HTML.FormField` values and unwrapped raw controls
30 30
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
31 31
custom classes must fully style the input
32 32

@@ -43,11 +43,11 @@ custom classes must fully style the input

43 43
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
44 44
- **Never** use `@apply` when writing raw css
45 45
- **There is exactly one component system: vendored Basecoat plus OpenAgents style pack.** Basecoat lives in `assets/vendor/basecoat/components/` and carries structure (display, padding, min-height); `assets/css/openagents.css` carries OpenAgents' identity (motion tokens, radius scale, colour, the notched variant, corner frames) and must stay the last import so its declarations win.
46
  - **Reach for `OpenAgentsWeb.UI` first.** It wraps that CSS in nineteen ready primitives — `button/1`, `card/1`, `badge/1`, `alert/1`, `input/1`, `textarea/1`, `label/1`, `field/1`, `avatar/1`, `menu/1`, `empty/1`, `kbd/1`, and the rest. Fall back to hand-written classes only when no primitive covers the shape.
46
  - **Reach for `OpenAgentsWeb.UI` first.** It wraps that CSS in 22 ready primitives — `button/1`, `card/1`, `badge/1`, `alert/1`, `input/1`, `textarea/1`, `label/1`, `field/1`, `header/1`, `table/1`, `list/1`, `avatar/1`, `menu/1`, `empty/1`, `kbd/1`, and the rest. Fall back to hand-written classes only when no primitive covers the shape.
47 47
  - **Variants are data attributes, not classes.** A control is `class="btn"` plus `data-variant="primary"` / `data-size="sm"` / `data-tone="danger"`. Never invent `btn-primary`-style variant classes; they defeat the whole point of the split.
48 48
  - **Never add a second component library — DaisyUI above all.** DaisyUI was removed deliberately. It emitted flat `.btn` rules (setting background, colour, border) from a cascade layer that outranked every `.btn[data-variant=…]` in `openagents.css`, so all eight UI button variants rendered identically on staging. Any library with the same shape will do the same thing again.
49 49
  - **Import Basecoat components individually**, one `@import "../vendor/basecoat/components/<name>.css"` per component a surface actually uses. Component CSS lands in `@layer components` and ships whether or not the class appears in markup, so the list is a budget. **Never** import `basecoat.css`, `basecoat-base.css`, or `basecoat-components.css` — each pulls in all 39 components at once.
50
  - The palette is the token ladder at the top of `app.css` (`--ink-void` / `--ink-surface` / `--ink-raised`, the text and line tiers, and the four semantic colours). It is dark-only. Prefer the Basecoat utility names built on it — `bg-background`, `bg-card`, `text-foreground`, `text-muted-foreground`, `border-border` — over the older `base-100`/`base-content` aliases, which are kept only so unconverted surfaces keep resolving.
50
  - The palette is the token ladder at the top of `app.css` (`--ink-void` / `--ink-surface` / `--ink-raised`, the text and line tiers, and the four semantic colours). It is dark-only. Use the Basecoat utility names built on it — `bg-background`, `bg-card`, `text-foreground`, `text-muted-foreground`, and `border-border`. The retired compatibility aliases no longer exist
51 51
- Add custom Tailwind only when the design is unique to OpenAgents and no primitive fits.
52 52
- Out of the box **only the app.js and app.css bundles are supported**
53 53
  - You cannot reference an external vendor'd script `src` or link `href` in the layouts

@@ -510,4 +510,4 @@ Each endpoint test file follows this shape:

510 510
- Run a failed set: `mix test --failed`
511 511
- Before a final commit: run `mix precommit` and fix all compile warnings, formatting, and test failures.
512 512
513
<!-- usage-rules-end -->
\ No newline at end of file
513
<!-- usage-rules-end -->
INVARIANTS.md modified +14 -14

@@ -1482,12 +1482,12 @@ Evidence: `OpenAgents.Conversations.list_tool_step_activity/1`,

1482 1482
1483 1483
### UI-003 — Product surfaces render only through the sanctioned component library
1484 1484
1485
Status: Proposed
1485
Status: Current
1486 1486
1487 1487
OpenAgents's interface is built from `OpenAgentsWeb.UI` components over Basecoat
1488
primitives vendored at a pinned tag and styled by the OpenAgents pack. Product
1489
surfaces do not author component-level CSS classes; hand-authored CSS is
1490
confined to app-shell layout and the one sanctioned brand animation. No
1488
primitives vendored at a pinned tag and styled by the OpenAgents pack. Every
1489
web import exposes that one module; product surfaces compose its primitives
1490
with surface-specific layout classes from the sanctioned style pack. No
1491 1491
component accepts provider identifiers or private recall content as an
1492 1492
attribute; tool activity reaches `event_header` only as the bounded projection
1493 1493
UI-002 sanctions, so UI-002 cannot be violated through a primitive.

@@ -1501,18 +1501,18 @@ identically announced players.

1501 1501
The shared corner radius, the self-hosted Geist faces, the single dark theme,
1502 1502
and the reserved semantic color meanings hold across every component. Depth is
1503 1503
limited to the sanctioned lift, halo, and state-ring tokens.
1504
Adopting an additional Basecoat component requires a `docs/component-library.md` change and an
1505
explicit per-component import.
1506
1507
This is the Gate 4 target. `OpenAgentsWeb.UI` and the OpenAgents style pack are
1508
implemented, but generated `OpenAgentsWeb.CoreComponents`, its legacy icon
1509
entry point, and a nonfunctional theme control still have catalogued callers.
1510
Until those callers are migrated or narrowly justified, this invariant cannot
1511
be used as proof that the product has one fully enforced component path.
1504
Adopting an additional Basecoat component requires a
1505
`docs/component-library.md` change and an explicit per-component import. The
1506
application exposes no light/system theme control while the palette is
1507
dark-only. Apps SDK UI glyphs are preferred; the pinned Heroicons fallback has
1508
an explicit inventory and no current product call sites.
1512 1509
1513 1510
Evidence: `assets/vendor/basecoat/README.md`, `assets/css/openagents.css`,
1514
`priv/static/fonts`, `OpenAgentsWeb.UI`, `OpenAgentsWeb.UITest`,
1515
`OpenAgentsWeb.UIGalleryLiveTest`, and `test/openagents_web/ui_test.exs`.
1511
`priv/static/fonts`, `OpenAgentsWeb.UI`, `OpenAgentsWeb.ComponentCatalog`,
1512
`OpenAgentsWeb.UITest`, `OpenAgentsWeb.UIGalleryLiveTest`,
1513
`test/openagents_web/component_catalog_test.exs`,
1514
`test/openagents_web/icon_affordances_test.exs`, and
1515
`assets/test/css_contract_test.mjs`.
1516 1516
1517 1517
### LEADERBOARD-001 — The public board publishes one bounded projection
1518 1518
README.md modified +4 -2

@@ -71,7 +71,8 @@ The product uses Tailwind CSS, pinned vendored Basecoat component styles, the

71 71
OpenAgents style pack in `assets/css/openagents.css`, and reusable HEEx
72 72
components in `OpenAgentsWeb.UI`. The live component inventory is available at
73 73
`/components`; [docs/component-library.md](docs/component-library.md) records
74
the current transition and extension rules.
74
the supported primitives and extension rules. Follow the
75
[icon policy](docs/ICONS.md) for the governed glyph tiers.
75 76
76 77
Fonts and icons are self-hosted. Do not add a remote font, icon font, second
77 78
component library, or unreviewed browser script.

@@ -110,4 +111,5 @@ OpenAgents is licensed under the GNU Affero General Public License v3.0. See

110 111
Vendored third-party material keeps its own license and notices. In particular,
111 112
Basecoat is under `assets/vendor/basecoat/`, the Apps SDK icon set is under
112 113
`priv/icons/`, and self-hosted font notices are under `priv/static/fonts/`.
113
Review those notices when redistributing the application.
114
Review [the dependency and license inventory](docs/dependencies-and-licenses.md)
115
and `priv/licenses/THIRD_PARTY_NOTICES.md` when redistributing the application.
assets/css/app.css modified +27 -47

@@ -22,8 +22,8 @@

22 22
 * These carry the structure (display, padding, min-height, background) that
23 23
 * openagents.css deliberately does not set — its .btn only adds gap, border,
24 24
 * radius, colour, font, and transition on top. With DaisyUI gone they are the
25
 * only source of that geometry, so nothing here is optional. This list is
26
 * OpenAgents, plus `table` for `CoreComponents.table/1`. */
25
 * only source of that geometry, so nothing here is optional. This list is the
26
 * exact structure consumed by `OpenAgentsWeb.UI`. */
27 27
@import "../vendor/basecoat/base/base.css";
28 28
@import "../vendor/basecoat/components/button.css";
29 29
@import "../vendor/basecoat/components/input.css";

@@ -48,8 +48,8 @@

48 48
/* Required for Tailwind to automatically pick up changes in colocated CSS files in dev */
49 49
@source "../../_build/dev/phoenix-colocated/openagents/*/";
50 50
51
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
52
   The heroicons installation itself is managed by your mix.exs */
51
/* Deliberate second-tier icon fallback. Product call sites prefer the vendored
52
 * Apps SDK set; docs/ICONS.md inventories every exceptional Heroicons use. */
53 53
@plugin "../vendor/heroicons";
54 54
55 55
/* ── Token ladder ───────────────────────────────────────────────────────────

@@ -69,10 +69,8 @@

69 69
 * (--ink-surface), elevated components another step up (--ink-raised). New
70 70
 * surfaces take a rung; they do not invent a fourth.
71 71
 *
72
 * The palette is dark-only, as OpenAgents is (`color-scheme: dark`). The
73
 * `data-theme` plumbing in root.html.heex still runs, but there is no second
74
 * light palette behind it any more — DaisyUI's was the only one, and OpenAgents
75
 * ships no light variant to replace it with.
72
 * The palette is dark-only, as OpenAgents is (`color-scheme: dark`). A light
73
 * palette requires a separate owner-approved design project.
76 74
 */
77 75
:root {
78 76
  color-scheme: dark;

@@ -129,25 +127,10 @@

129 127
  --line-strong: oklch(36.455% 0.0696 264.888); /* exception; best fit 20% */
130 128
}
131 129
132
/* Utility namespaces on top of the ladder.
133
 *
134
 * `base-100/200/300` and `base-content` were DaisyUI's surface vocabulary and
135
 * are still spoken by surfaces this change did not restyle (the component
136
 * catalog, the marketing home template, and the sidebar/docs/chat CSS at the
137
 * foot of this file). They are kept as aliases onto the three surface rungs so
138
 * that vocabulary keeps resolving; they are now names for OpenAgents tokens, not a
139
 * second palette. Prefer Basecoat's own `background` / `card` / `foreground` /
140
 * `border` names in new markup.
141
 *
142
 * The semantic four are OpenAgents, not DaisyUI's, and have no Basecoat
130
/* The semantic four are OpenAgents, not DaisyUI's, and have no Basecoat
143 131
 * equivalent — `--color-destructive` covers danger, but there is no success,
144 132
 * warning, or info in the Basecoat base. */
145 133
@theme {
146
  --color-base-100: var(--ink-void);
147
  --color-base-200: var(--ink-surface);
148
  --color-base-300: var(--ink-raised);
149
  --color-base-content: var(--text-primary);
150
151 134
  --color-success: var(--success);
152 135
  --color-warning: var(--warning);
153 136
  --color-info: var(--info);

@@ -159,9 +142,6 @@

159 142
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
160 143
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
161 144
162
/* Use the data attribute for dark mode  */
163
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
164
165 145
/* Make LiveView wrapper divs transparent for layout */
166 146
[data-phx-session], [data-phx-teleported-src] { display: contents }
167 147

@@ -175,8 +155,8 @@

175 155
  width: 280px;
176 156
  height: 100%;
177 157
  overflow: hidden;
178
  border-right: 1px solid var(--color-base-300);
179
  background: var(--color-base-200);
158
  border-right: 1px solid var(--ink-raised);
159
  background: var(--ink-surface);
180 160
}
181 161
182 162
.sidebar-header {

@@ -192,7 +172,7 @@

192 172
  flex: none;
193 173
  font-size: 1rem;
194 174
  font-weight: 700;
195
  color: var(--color-base-content);
175
  color: var(--text-primary);
196 176
}
197 177
198 178
.sidebar-nav {

@@ -217,7 +197,7 @@

217 197
  padding-inline: 14px;
218 198
  padding-block: 6px;
219 199
  border-radius: 10px;
220
  color: var(--color-base-content);
200
  color: var(--text-primary);
221 201
  font-size: 0.875rem;
222 202
  font-weight: 400;
223 203
  line-height: 1.25rem;

@@ -226,17 +206,17 @@

226 206
227 207
@media (hover: hover) {
228 208
  .sidebar-row:hover {
229
    background: var(--color-base-300);
209
    background: var(--ink-raised);
230 210
    color: #fff;
231 211
  }
232 212
}
233 213
234 214
.sidebar-row:has(.sidebar-row__hit:active) {
235
  background: var(--color-base-300);
215
  background: var(--ink-raised);
236 216
}
237 217
238 218
.sidebar-row[data-selected] {
239
  background: var(--color-base-300);
219
  background: var(--ink-raised);
240 220
}
241 221
242 222
.sidebar-row--static,

@@ -247,7 +227,7 @@

247 227
.sidebar-row--static:hover,
248 228
  .sidebar-row--empty:hover {
249 229
  background: transparent;
250
  color: var(--color-base-content);
230
  color: var(--text-primary);
251 231
}
252 232
253 233
.sidebar-row__hit {

@@ -305,7 +285,7 @@

305 285
  align-items: center;
306 286
  min-height: 32px;
307 287
  padding-inline: 16px;
308
  background: var(--color-base-200);
288
  background: var(--ink-surface);
309 289
  color: var(--text-muted);
310 290
  font-size: 0.75rem;
311 291
  font-weight: 600;

@@ -320,7 +300,7 @@

320 300
  gap: 10px;
321 301
  min-height: 52px;
322 302
  padding: 8px;
323
  border-top: 1px solid var(--color-base-300);
303
  border-top: 1px solid var(--ink-raised);
324 304
}
325 305
326 306
/* Docs layout */

@@ -337,8 +317,8 @@

337 317
  flex: none;
338 318
  flex-direction: column;
339 319
  gap: 12px;
340
  border-right: 1px solid var(--color-base-300);
341
  background: var(--color-base-200);
320
  border-right: 1px solid var(--ink-raised);
321
  background: var(--ink-surface);
342 322
  padding: 16px 0;
343 323
}
344 324

@@ -376,7 +356,7 @@

376 356
.docs-sidebar__link {
377 357
  padding: 6px 8px;
378 358
  border-radius: 8px;
379
  color: var(--color-base-content);
359
  color: var(--text-primary);
380 360
  font-size: 0.875rem;
381 361
  line-height: 1.25rem;
382 362
  text-decoration: none;

@@ -384,7 +364,7 @@

384 364
385 365
@media (hover: hover) {
386 366
  .docs-sidebar__link:hover {
387
    background: var(--color-base-300);
367
    background: var(--ink-raised);
388 368
    color: #fff;
389 369
  }
390 370
}

@@ -436,7 +416,7 @@

436 416
}
437 417
438 418
.message-content {
439
  color: var(--color-base-content);
419
  color: var(--text-primary);
440 420
  font-size: 0.9375rem;
441 421
  line-height: 1.5rem;
442 422
  white-space: pre-wrap;

@@ -454,8 +434,8 @@

454 434
455 435
.composer {
456 436
  padding: 12px 16px;
457
  border-top: 1px solid var(--color-base-300);
458
  background: var(--color-base-100);
437
  border-top: 1px solid var(--ink-raised);
438
  background: var(--ink-void);
459 439
}
460 440
461 441
.composer-card {

@@ -463,9 +443,9 @@

463 443
  max-width: 720px;
464 444
  margin: 0 auto;
465 445
  padding: 6px;
466
  border: 1px solid var(--color-base-300);
446
  border: 1px solid var(--ink-raised);
467 447
  border-radius: 24px;
468
  background: var(--color-base-200);
448
  background: var(--ink-surface);
469 449
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
470 450
}
471 451

@@ -490,7 +470,7 @@

490 470
  border: 0;
491 471
  border-radius: 18px;
492 472
  background: transparent;
493
  color: var(--color-base-content);
473
  color: var(--text-primary);
494 474
  font-family: inherit;
495 475
  font-size: 0.9375rem;
496 476
  line-height: 1.5rem;
assets/test/css_contract_test.mjs added +60

@@ -0,0 +1,60 @@

1
import assert from "node:assert/strict"
2
import {execFileSync} from "node:child_process"
3
import {readFileSync, rmSync} from "node:fs"
4
import {tmpdir} from "node:os"
5
import {resolve} from "node:path"
6
import test from "node:test"
7
import {fileURLToPath} from "node:url"
8
9
const assetsDir = resolve(fileURLToPath(new URL("..", import.meta.url)))
10
const projectDir = resolve(assetsDir, "..")
11
12
test("the compiled cascade preserves every governed button variant", () => {
13
  const output = resolve(tmpdir(), `openagents-css-contract-${process.pid}.css`)
14
15
  try {
16
    execFileSync(
17
      "mix",
18
      ["tailwind", "openagents", "--minify", `--output=${output}`],
19
      {cwd: projectDir, encoding: "utf8", stdio: "pipe"},
20
    )
21
22
    const css = readFileSync(output, "utf8")
23
    const basecoatGeometry = css.indexOf(".btn{")
24
    const governedBase = css.indexOf("border-radius:var(--radius-md)")
25
    const variants = [
26
      "primary",
27
      "secondary",
28
      "outline",
29
      "ghost",
30
      "chip",
31
      "destructive",
32
      "notched",
33
      "link",
34
    ]
35
36
    assert.ok(basecoatGeometry >= 0, "compiled CSS is missing Basecoat button geometry")
37
    assert.ok(governedBase > basecoatGeometry, "the OpenAgents style pack must follow Basecoat")
38
39
    for (const variant of variants) {
40
      const selector = `.btn[data-variant=${variant}]`
41
      assert.ok(css.indexOf(selector) > governedBase, `${selector} must follow the shared button rule`)
42
    }
43
44
    assert.doesNotMatch(css, /\.btn-(primary|secondary|ghost|error|success|warning|info)\b/)
45
    assert.doesNotMatch(css, /--color-base-(100|200|300|content)\b/)
46
    assert.doesNotMatch(css, /data-theme|prefers-color-scheme/)
47
  } finally {
48
    rmSync(output, {force: true})
49
  }
50
})
51
52
test("the source imports only the owned component stack", () => {
53
  const source = readFileSync(resolve(assetsDir, "css/app.css"), "utf8")
54
  const stylePack = source.indexOf('@import "./openagents.css"')
55
56
  assert.ok(stylePack >= 0)
57
  assert.ok(source.indexOf("components/button.css") < stylePack)
58
  assert.doesNotMatch(source, /@import[^;]*(basecoat\.css|basecoat-base\.css|basecoat-components\.css)/)
59
  assert.doesNotMatch(source, /basecoat.*\.js|initAll|MutationObserver/)
60
})
docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md modified +55 -29

@@ -370,10 +370,10 @@ local references resolve.

370 370
371 371
### Complete the Markdown parser migration
372 372
373
The integration plan says MDEx replaced Earmark, while the application still
374
depends on and calls Earmark. Hex marks Earmark as retired and no longer
375
maintained. Migrate to MDEx or another maintained parser before staging, then
376
verify sanitization, security history, and output compatibility.
373
At the initial audit, the integration plan said MDEx replaced Earmark while the
374
application still depended on and called the retired parser. Gate 4 replaces
375
that implementation with MDEx and verifies sanitization, maintained status,
376
and output compatibility.
377 377
378 378
Whichever parser remains must pass tests for:
379 379

@@ -391,8 +391,8 @@ Remove the unused parser and update every related document in the same commit.

391 391
- Choose the final generic module name: preferably `OpenAgentsWeb.UI` once the
392 392
  source-project migration is complete.
393 393
- Move all product surfaces onto that component system.
394
- Keep `CoreComponents` only as a temporary compatibility layer with an explicit
395
  removal list.
394
- Remove the generated compatibility component module after migrating every
395
  caller. Do not recreate a parallel component surface.
396 396
- Remove DaisyUI aliases and compatibility tokens after every surface has
397 397
  migrated.
398 398
- Use one documented icon policy: Apps SDK icons are preferred, and Heroicons

@@ -424,6 +424,37 @@ Remove the unused parser and update every related document in the same commit.

424 424
system, one documented two-tier icon policy, no nonfunctional theme control, no
425 425
unexplained dependency, and complete license records.
426 426
427
**Gate 4 implementation status (2026-08-20): complete; exact-image SBOM
428
evidence pending.**
429
430
- Replaced the retired parser with MDEx and removed its stale lock entry.
431
  `OpenAgents.Markdown` disables dangerous rendering, applies an exact Ammonia
432
  allowlist, normalizes links, and independently bounds input, syntax-tree
433
  nesting, output, and escaped fallback size. The focused suite covers raw
434
  HTML, scripts and handlers, unsafe schemes, supported structures, malformed
435
  input, streaming stability, and every limit.
436
- Consolidated all web imports and product surfaces on `OpenAgentsWeb.UI`.
437
  Added form-aware input, heading, table, and list primitives; removed the
438
  generated compatibility module; and made `/components` an executable
439
  inventory of every public component.
440
- Removed the browser theme script, theme control, theme selectors, and retired
441
  palette aliases. The palette remains deliberately dark-only. The Node suite
442
  now compiles Tailwind and proves that Basecoat geometry precedes the
443
  OpenAgents style pack and that all eight governed button variants survive the
444
  cascade.
445
- Migrated current glyph uses to the preferred vendored Apps SDK set. Retained
446
  Heroicons only as the owner-approved second tier, pinned it to immutable
447
  revision `0435d4ca364a608cc75e2f8683d374e55abbae26`, and recorded an empty
448
  fallback-use inventory in `docs/ICONS.md`.
449
- Added a direct-dependency purpose and license ledger, release notices for
450
  Basecoat, icons, fonts, and the brand mark, and digest-pinned Syft SBOM
451
  tooling. `mix precommit` now runs Hex retirement, MixAudit vulnerability, and
452
  unused-lock checks. All three checks pass locally.
453
- The remaining evidence item is the CycloneDX SBOM for the exact staging image
454
  digest. `ops/staging/generate-sbom.sh` is ready, but Gate 4 does not claim the
455
  artifact until an exact committed candidate is built and scanned. Retain its
456
  JSON and receipt with staging evidence before advancing this gate.
457
427 458
## Gate 5: Make runtime configuration explicit and fail closed
428 459
429 460
Create one typed runtime configuration boundary. Do not scatter environment

@@ -1116,9 +1147,9 @@ each handoff.

1116 1147
- [x] The repository has one accurate architecture narrative.
1117 1148
- [x] Every remaining Sarah reference is intentional and specific.
1118 1149
- [x] All documentation links and invariant evidence resolve.
1119
- [ ] The application has one Markdown parser, component system, and documented
1150
- [x] The application has one Markdown parser, component system, and documented
1120 1151
      two-tier icon policy.
1121
- [ ] The dark-only palette has no nonfunctional theme control.
1152
- [x] The dark-only palette has no nonfunctional theme control.
1122 1153
- [ ] Runtime configuration is typed, redacted, and staging-specific.
1123 1154
- [ ] Every route has an explicit authority class.
1124 1155
- [ ] GitHub token behavior matches code, UI disclosure, and data rights.

@@ -1271,27 +1302,18 @@ disposition: retain the owner-approved Heroicons fallback, keep staging

1271 1302
deliberately dark-only without a nonfunctional toggle, control the palette-file
1272 1303
rename as a visual migration, and reject conflicting cascade-layer behavior.
1273 1304
1274
**Heroicons.** Gate 4 says "Remove Heroicons and its dependency after the
1275
remaining issue and layout surfaces use vendored icons." The owner's ruling on
1276
2026-08-19 was Apps SDK icons **preferred, heroicons as backup** — i.e. retained
1277
deliberately. `{:heroicons, ...}` is still in `mix.exs`. One of the two should
1278
move; the plan should not quietly overwrite a stated decision.
1279
1280
**The palette is dark-only, today.** Gate 4's UI consolidation does not mention
1281
it, but removing DaisyUI removed the only light theme in the application. Sarah's
1282
palette has no light variant. `data-theme` still runs and the toggle still
1283
switches, but it no longer repaints anything. This is a live user-visible state,
1284
not a pending task, and `theme_toggle/1`'s docstring now says so. Inventing a
1285
light Sarah palette is a design decision that needs an owner.
1286
1287
**Basecoat is load-bearing for the palette, not just components.** Gate 4 says to
1288
"rename the style pack after generic application components no longer depend on
1289
the Sarah name." Worth knowing before that rename: `sarah.css` now defines the
1290
palette *primitives* (`--accent`, `--ink-void`, `--text-primary`, `--line-strong`,
1291
…), not merely aliases. Prior to the DaisyUI removal it only aliased them and
1292
nothing defined them — the entire Sarah palette silently resolved to nothing and
1293
DaisyUI's theme was carrying every colour. Renaming that file is therefore a
1294
palette migration, not a cosmetic rename.
1305
**Heroicons, resolved.** Product surfaces now use Apps SDK glyphs. Heroicons
1306
remains the deliberate fallback, is pinned to an immutable revision, and has no
1307
current product call sites. `docs/ICONS.md` governs and inventories exceptions.
1308
1309
**The palette is dark-only, resolved.** The application removed its theme
1310
control, browser theme state, and theme selectors. A light palette remains a
1311
separate owner-approved design project.
1312
1313
**The palette file, resolved.** `assets/css/openagents.css` owns the palette
1314
contract and loads after individually imported Basecoat structure. The retired
1315
compatibility aliases are gone. Future renames remain controlled palette
1316
migrations, not cosmetic changes.
1295 1317
1296 1318
**Why DaisyUI had to be removed rather than layered under.** Recording this so a
1297 1319
similar library is not reintroduced under a different name: DaisyUI emits

@@ -1305,6 +1327,10 @@ into `utilities` cannot coexist beneath a design system** — that is the

1305 1327
acceptance test for Gate 4's "one component system", not merely counting the
1306 1328
libraries in `mix.exs`.
1307 1329
1330
**Cascade behavior, resolved.** `assets/test/css_contract_test.mjs` compiles the
1331
actual bundle and checks selector order, variant survival, retired aliases, and
1332
theme absence. The gate now verifies behavior rather than dependency names.
1333
1308 1334
## A5. Evidence for Gate 5, from failures already observed
1309 1335
1310 1336
Gate 5's "fail closed" requirement is correct and this is what its absence
docs/ICONS.md added +56

@@ -0,0 +1,56 @@

1
# Icon policy
2
3
Date: 2026-08-20
4
5
Status: Current
6
7
OpenAgents uses one component entry point and two governed glyph tiers. Render
8
every glyph with `OpenAgentsWeb.UI.icon/1`. Never paste an SVG into a template,
9
call an icon library module, add an icon font, or introduce a third source.
10
11
## Tier 1: Apps SDK UI
12
13
Use the vendored Apps SDK UI set in `priv/icons` first. These files are pinned,
14
same-origin, and covered by `priv/icons/LICENSE`. `OpenAgentsWeb.Icons` embeds
15
them and fails on unknown names.
16
17
To add or upgrade a glyph:
18
19
1. Check out `openai/apps-sdk-ui` at the reviewed commit.
20
2. Run `mix openagents.icons.vendor <path-to-apps-sdk-ui>`.
21
3. Update the commit, date, and count in `priv/icons/README.md`.
22
4. Run `mix precommit`.
23
24
## Tier 2: Heroicons
25
26
Use a `hero-*` name only when the Apps SDK set has no suitable glyph. Heroicons
27
is pinned to revision `0435d4ca364a608cc75e2f8683d374e55abbae26` and enters
28
the CSS bundle through `assets/vendor/heroicons.js`. The same `icon/1` component
29
renders the fallback, so accessibility and sizing stay governed.
30
31
Before you add a fallback use:
32
33
1. Record the call site, glyph, and missing Apps SDK concept in the inventory
34
   below.
35
2. Confirm that no existing Apps SDK glyph communicates the action.
36
3. Add a test for the control's accessible name or adjacent visible label.
37
4. Run `mix precommit`.
38
39
### Fallback inventory
40
41
No product surface currently uses a Heroicons fallback. The dependency remains
42
available as the documented second tier, and the CSS contract test prevents it
43
from becoming a separate component system.
44
45
## Brand marks
46
47
Brand marks live in `priv/brand`, outside both generic tiers. Use one only to
48
identify the service reached by an action. Render it through `icon/1` with a
49
`brand-*` name, and follow the attribution and trademark rules in
50
`priv/brand/README.md`.
51
52
## Accessibility
53
54
A glyph beside visible words is decorative and needs no label. Put
55
`aria-label` on an icon-only control. Pass `label` to `icon/1` only when the
56
glyph itself is the complete, noninteractive message.
docs/architecture.md modified +11

@@ -117,6 +117,17 @@ Tests replace network providers with explicit fakes. A provider outage must

117 117
produce a bounded durable failure outcome instead of abandoning an in-flight
118 118
turn, work item, or voice session.
119 119
120
## Untrusted Markdown boundary
121
122
Assistant and repository Markdown enters HTML through
123
`OpenAgents.Markdown.to_html/2` only. MDEx parses CommonMark with dangerous
124
rendering disabled, Ammonia applies exact tag, attribute, and URL-scheme
125
allowlists, and the application normalizes links before Phoenix marks the
126
result safe. Independent input, syntax-tree nesting, and output limits produce
127
a bounded escaped fallback instead of partial markup. The same function handles
128
streaming completion and persisted text, so a completed message does not change
129
when its durable projection replaces the stream.
130
120 131
## Forge planes
121 132
122 133
The forge contains two separate planes within the `OpenAgents.Forge`
docs/component-library.md modified +16 -25

@@ -2,7 +2,7 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: Current inventory; consolidation continues in hardening Gate 4
5
Status: Current
6 6
7 7
The public catalog at `/components` is the executable inventory of reusable
8 8
HEEx components. `OpenAgentsWeb.ComponentCatalog` supplies its navigation,

@@ -21,28 +21,23 @@ The current `OpenAgentsWeb.UI` inventory is:

21 21
| Component | Purpose |
22 22
| --- | --- |
23 23
| `button/1`, `text_button/1` | Boxed, link, chip, destructive, and primary actions |
24
| `input/1`, `textarea/1`, `label/1`, `field/1` | Form controls and labelled groups |
24
| `input/1`, `textarea/1`, `label/1`, `field/1` | Form-aware controls and labelled groups |
25
| `header/1`, `table/1`, `list/1` | Page headings and structured data |
25 26
| `alert/1`, `badge/1`, `status_indicator/1` | Explicit feedback and semantic state |
26 27
| `card/1`, `frame/1` | Bounded content and decorative framing |
27 28
| `avatar/1`, `item/1`, `event_header/1` | Identity and activity rows |
28 29
| `empty/1`, `kbd/1`, `menu/1` | Empty states, key hints, and native-popover menus |
29 30
| `audio_player/1` | Accessible native audio control in the product frame |
30
| `icon/1` | Glyphs from the vendored Apps SDK icon set |
31
| `icon/1` | Governed Apps SDK glyphs and documented Heroicons fallbacks |
31 32
32 33
`OpenAgentsWeb.Layouts` owns `app/1`, `flash_group/1`, `command_bar/1`,
33
`account_control/1`, and the currently catalogued theme control. Product
34
templates begin with `Layouts.app` and never render `flash_group/1` directly.
34
and `account_control/1`. Product templates begin with `Layouts.app` and never
35
render `flash_group/1` directly. The staging palette is deliberately dark-only,
36
so the application exposes no theme control or browser theme state.
35 37
36
## Transitional components
38
## Specialized components
37 39
38
`OpenAgentsWeb.CoreComponents` still contains the Phoenix-generated `button`,
39
`input`, `header`, `table`, `list`, `icon`, and `flash` components. The catalog
40
shows these separately because their names overlap the OpenAgents primitives.
41
They are compatibility surface, not a second design system. Gate 4 must either
42
migrate each remaining caller to `OpenAgentsWeb.UI` or document a narrow reason
43
to retain the generated helper.
44
45
`OpenAgentsWeb.Components.RepoHeader.repo_header/1` is the one catalogued
40
`OpenAgentsWeb.Components.RepoHeader.repo_header/1` is the only catalogued
46 41
forge-specific component. Surface-specific components can live in a focused
47 42
module when they encode real domain composition rather than a generic control.
48 43

@@ -55,8 +50,9 @@ module when they encode real domain composition rather than a generic control.

55 50
   component needs it. Never import the aggregate Basecoat bundles.
56 51
4. Put OpenAgents-owned component styles in `assets/css/openagents.css`; do not
57 52
   patch `assets/vendor/basecoat/`.
58
5. Use `OpenAgentsWeb.UI.icon/1` and the vendored icon set. Do not add an icon
59
   font, another glyph library, or handwritten SVG in a template.
53
5. Use `OpenAgentsWeb.UI.icon/1` and follow the two-tier policy in `ICONS.md`.
54
   Do not add an icon font, a third glyph library, or handwritten SVG in a
55
   template.
60 56
6. Give every icon-only action an accessible name. Decorative icons beside text
61 57
   remain hidden from accessibility APIs.
62 58
7. Add the component to `OpenAgentsWeb.ComponentCatalog`, add its demo to

@@ -65,21 +61,16 @@ module when they encode real domain composition rather than a generic control.

65 61
8. Keep forms on `Phoenix.Component.to_form/2`; use LiveView streams for
66 62
   collections and stable DOM IDs for testable controls.
67 63
68
## Current catalog gaps
64
## Domain composition candidates
69 65
70 66
Issue, project, and code surfaces currently compose generic controls directly.
71 67
Create new domain components only when repeated behavior justifies them. Likely
72 68
candidates are issue rows, comment threads, label controls, project columns,
73 69
repository breadcrumbs, file rows, commit rows, and bounded diff panels.
74 70
75
The Gate 4 consolidation also owns these known transitional issues:
76
77
- Remove the generated component/icon path after its callers are migrated.
78
- Enforce the repository's final two-tier icon policy in code and tests.
79
- Remove the nonfunctional light/system theme choice if the shipped palette
80
  remains dark-only.
81
- Prove component variants and semantic colors in compiled CSS rather than by
82
  class-name assertions alone.
71
The compiled CSS contract test proves that Basecoat geometry precedes the
72
OpenAgents style pack, every supported button variant survives compilation, no
73
retired palette alias survives, and no theme selector enters the bundle.
83 74
84 75
See [the UI roadmap](issues-projects-ui-roadmap.md),
85 76
[ADR 0005](decisions/0005-use-basecoat-and-one-component-system.md), and the
docs/dependencies-and-licenses.md added +78

@@ -0,0 +1,78 @@

1
# Dependencies and licenses
2
3
Date: 2026-08-20
4
5
Status: Current
6
7
`mix.lock` is the authoritative resolved dependency set. The release keeps
8
only production dependencies; development and test tools do not enter the
9
runtime image. `mix precommit` rejects retired Hex packages, known Elixir
10
advisories, and unused lock entries.
11
12
## Direct dependency inventory
13
14
| Dependency | Scope | Purpose | License |
15
| --- | --- | --- | --- |
16
| `phoenix`, `phoenix_html`, `phoenix_live_view` | Runtime | HTTP, HEEx, and LiveView product surfaces | MIT |
17
| `phoenix_ecto`, `ecto_sql`, `postgrex` | Runtime | PostgreSQL persistence and migrations | MIT; Apache-2.0 |
18
| `bandit` | Runtime | Phoenix HTTP server | MIT |
19
| `req` | Runtime | Governed outbound HTTP client | Apache-2.0 |
20
| `jason` | Runtime | JSON encoding and decoding | Apache-2.0 |
21
| `gettext` | Runtime | User-facing translation boundary | Apache-2.0 |
22
| `swoosh` | Runtime | Phoenix mail boundary and local mailbox | MIT |
23
| `mdex` | Runtime | Sanitized CommonMark rendering | MIT |
24
| `horde` | Runtime | Distributed registries and supervisors | MIT |
25
| `ra` | Runtime | Raft-backed cluster authority | Apache-2.0 or MPL-2.0 |
26
| `websockex` | Runtime | Outbound Realtime WebSocket adapter | MIT |
27
| `dns_cluster` | Runtime | DNS-based BEAM node discovery | MIT |
28
| `castle` | Runtime and build | OTP hot-upgrade release assembly | MIT |
29
| `telemetry_metrics`, `telemetry_poller` | Runtime | Metrics definitions and periodic VM measurements | Apache-2.0 |
30
| `phoenix_live_dashboard` | Runtime | Operator-only runtime inspection | MIT |
31
| `esbuild`, `tailwind` | Build and development | Owned JavaScript and CSS bundles | MIT |
32
| `heroicons` | Build | Documented second-tier icon fallback | MIT |
33
| `phoenix_live_reload` | Development | Local asset and template reload | MIT |
34
| `lazy_html` | Test | Structural HTML assertions | Apache-2.0 |
35
| `mix_audit` | Development and test | Elixir advisory database check | BSD-3-Clause |
36
37
Transitive packages and operating-system packages are recorded in the
38
CycloneDX SBOM for each image. Do not copy this table into a deployment receipt;
39
generate the SBOM from the exact image digest instead.
40
41
## Required checks
42
43
Run these checks on owned infrastructure:
44
45
```console
46
MIX_ENV=test mix hex.audit
47
MIX_ENV=test mix deps.audit
48
MIX_ENV=test mix deps.unlock --check-unused
49
```
50
51
`mix precommit` runs all three. Update dependencies in a dedicated change when
52
`mix hex.outdated --all` reports an available version; an available major
53
version is review input, not an automatic upgrade.
54
55
## Release inventory
56
57
Generate an SBOM from the exact local or registry image reference:
58
59
```console
60
ops/staging/generate-sbom.sh <image-reference> <evidence-directory>/sbom.cdx.json
61
```
62
63
The script uses digest-pinned Syft `v1.51.0`, resolves the scanned image digest,
64
writes CycloneDX JSON atomically, and writes a receipt with the source commit and
65
SBOM checksum. Retain both files with the staging evidence.
66
67
## Vendored asset notices
68
69
The release includes the notice index at
70
`priv/licenses/THIRD_PARTY_NOTICES.md`. Its source licenses remain at these
71
paths:
72
73
- Basecoat: `assets/vendor/basecoat/LICENSE.md`.
74
- Apps SDK UI icons: `priv/icons/LICENSE`.
75
- Heroicons fallback: `priv/licenses/HEROICONS-LICENSE`.
76
- Geist Sans and Geist Mono: `priv/static/fonts/LICENSE`.
77
- Titillium Web: `priv/static/fonts/LICENSE-titillium-web`.
78
- GitHub mark source and trademark limits: `priv/brand/README.md`.
lib/openagents/markdown.ex modified +108 -60

@@ -6,12 +6,12 @@ defmodule OpenAgents.Markdown do

6 6
7 7
  ## Untrusted input
8 8
9
  Model output is not trusted. Earmark passes raw HTML straight through and will
10
  happily emit a `javascript:` href, so neither its HTML output nor its
11
  transformer is used. Instead the parsed AST is walked against an allowlist of
12
  tags and attributes, and this module writes the HTML itself so every text node
13
  is escaped here rather than somewhere further down. Anything not on the
14
  allowlist is dropped, and its text is kept.
9
  Model output is not trusted. MDEx parses CommonMark with dangerous rendering
10
  disabled, then Ammonia applies a second exact allowlist for tags, attributes,
11
  and URL schemes. Raw HTML is refused, dangerous links lose their `href`, and
12
  external links receive fixed isolation attributes. Input size, AST nesting,
13
  and rendered output are independently bounded before the result is marked
14
  safe.
15 15
16 16
  ## Partial input
17 17

@@ -20,7 +20,7 @@ defmodule OpenAgents.Markdown do

20 20
  Rendering that literally is what produces the raw asterisks a reader sees
21 21
  today; rendering it naively makes the layout jump as each delimiter lands.
22 22
23
  `complete/1` closes the open constructs before parsing, so the parser always
23
  `complete/1` closes the open constructs before parsing, so MDEx always
24 24
  sees a well-formed document. The approach is taken from Streamdown's `remend`
25 25
  package: scan once, track what is open, and close it — while respecting
26 26
  escapes, refusing to close inside an open code fence, and refusing to close a

@@ -28,17 +28,36 @@ defmodule OpenAgents.Markdown do

28 28
  matter more than the completeness.
29 29
  """
30 30
31
  @block_tags ~w(p ul ol li blockquote pre h1 h2 h3 h4 h5 h6 hr table thead tbody tr th td)
32
  @inline_tags ~w(strong em del code a br)
33
  @allowed_tags @block_tags ++ @inline_tags
34
35
  @void_tags ~w(br hr)
36
37
  # `code` carries Earmark's own `inline` marker; everything else is dropped.
38
  @allowed_attributes %{"a" => ["href"], "code" => ["class"], "pre" => ["class"]}
39
31
  @allowed_tags ~w(a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre strong table tbody td th thead tr ul)
40 32
  @safe_schemes ~w(http https mailto)
41 33
34
  @maximum_input_bytes 1_000_000
35
  @maximum_output_bytes 2_000_000
36
  @maximum_nesting_depth 32
37
  @maximum_fallback_bytes 64_000
38
39
  @mdex_options [
40
    extension: [strikethrough: true, table: true],
41
    parse: [relaxed_autolinks: false],
42
    render: [hardbreaks: true, unsafe: false, escape: false],
43
    sanitize: [
44
      tags: @allowed_tags,
45
      clean_content_tags: ~w(script style),
46
      tag_attributes: %{
47
        "a" => ~w(href target),
48
        "code" => ~w(class),
49
        "pre" => ~w(class)
50
      },
51
      generic_attributes: [],
52
      url_schemes: @safe_schemes,
53
      url_relative: :passthrough,
54
      link_rel: "noopener noreferrer nofollow",
55
      set_tag_attribute_values: %{"a" => %{"target" => "_blank"}}
56
    ]
57
  ]
58
59
  @empty_link ~s(<a href="" target="_blank" rel="noopener noreferrer nofollow">)
60
42 61
  @emphasis_markers ~w(*** ___ ** __ ~~ * _)
43 62
44 63
  @doc """

@@ -49,75 +68,73 @@ defmodule OpenAgents.Markdown do

49 68
  """
50 69
  @spec to_html(String.t(), keyword()) :: {:safe, iodata()}
51 70
  def to_html(text, options \\ []) when is_binary(text) do
52
    text
53
    |> then(fn raw -> if options[:streaming], do: complete(raw), else: raw end)
54
    |> parse()
55
    |> Enum.map(&render_node/1)
56
    |> then(&{:safe, &1})
57
  end
71
    cond do
72
      byte_size(text) > @maximum_input_bytes ->
73
        limited(text, "input exceeds #{@maximum_input_bytes} bytes")
58 74
59
  defp parse(text) do
60
    case Earmark.Parser.as_ast(text, gfm: true, breaks: true) do
61
      {:ok, ast, _messages} -> ast
62
      {:error, ast, _messages} -> ast
75
      true ->
76
        source = if options[:streaming], do: complete(text), else: text
77
        render(source)
63 78
    end
64 79
  end
65 80
66
  # ── Rendering ──────────────────────────────────────────────────────────────
67
68
  defp render_node(text) when is_binary(text), do: escape(text)
81
  defp render(text) do
82
    with {:ok, document} <- MDEx.parse_document(text, @mdex_options),
83
         :ok <- validate_nesting(document),
84
         document <- normalize_links(document),
85
         {:ok, rendered} <- MDEx.to_html(document),
86
         rendered <- String.replace(rendered, @empty_link, "<a>"),
87
         :ok <- validate_output(rendered) do
88
      {:safe, rendered}
89
    else
90
      {:error, :nesting_limit} ->
91
        limited(text, "nesting exceeds #{@maximum_nesting_depth} levels")
69 92
70
  defp render_node({tag, attributes, children, _meta}) when tag in @allowed_tags do
71
    rendered = Enum.map(children, &render_node/1)
93
      {:error, :output_limit} ->
94
        limited(text, "rendered output exceeds #{@maximum_output_bytes} bytes")
72 95
73
    cond do
74
      tag in @void_tags -> ["<", tag, attributes(tag, attributes), " />"]
75
      true -> ["<", tag, attributes(tag, attributes), ">", rendered, "</", tag, ">"]
96
      {:error, _parse_or_render_error} ->
97
        limited(text, "input could not be rendered safely")
76 98
    end
77 99
  end
78 100
79
  # An unknown tag keeps its text and loses its markup, which is the safe
80
  # direction: a reader still sees the words.
81
  defp render_node({_tag, _attributes, children, _meta}), do: Enum.map(children, &render_node/1)
82
83
  defp render_node(_other), do: []
84
85
  defp attributes(tag, attributes) do
86
    allowed = Map.get(@allowed_attributes, tag, [])
101
  defp validate_nesting(document) do
102
    if nesting_depth(document) <= @maximum_nesting_depth,
103
      do: :ok,
104
      else: {:error, :nesting_limit}
105
  end
87 106
88
    attributes
89
    |> Enum.filter(fn {name, _value} -> name in allowed end)
90
    |> Enum.flat_map(&attribute(tag, &1))
107
  defp nesting_depth(%{nodes: nodes}) when is_list(nodes) do
108
    1 + Enum.reduce(nodes, 0, fn node, depth -> max(depth, nesting_depth(node)) end)
91 109
  end
92 110
93
  defp attribute("a", {"href", value}) do
94
    case safe_url(value) do
95
      nil ->
96
        []
111
  defp nesting_depth(_leaf), do: 1
97 112
98
      url ->
99
        # Model output can link anywhere, so a link leaves without carrying the
100
        # referrer or a handle on this window.
101
        [~s( href="), escape(url), ~s(" rel="noopener noreferrer nofollow" target="_blank")]
102
    end
113
  defp normalize_links(document) do
114
    MDEx.Document.update_nodes(document, MDEx.Link, fn link ->
115
      %{link | url: safe_url(link.url)}
116
    end)
103 117
  end
104 118
105
  defp attribute(_tag, {name, value}), do: [" ", name, ~s(="), escape(value), ~s(")]
106
107 119
  defp safe_url(value) do
108 120
    trimmed = value |> to_string() |> String.trim()
109 121
110 122
    cond do
111
      trimmed == "" -> nil
123
      trimmed == "" -> ""
124
      String.starts_with?(trimmed, "//") -> "https:" <> trimmed
112 125
      String.starts_with?(trimmed, ["/", "#"]) -> trimmed
113 126
      scheme_of(trimmed) in @safe_schemes -> trimmed
114
      # No scheme at all is a bare domain; treat it as https rather than
115
      # letting the browser resolve it relative to OpenAgents.
116 127
      scheme_of(trimmed) == nil -> "https://" <> trimmed
117
      true -> nil
128
      true -> ""
118 129
    end
119 130
  end
120 131
132
  defp validate_output(rendered) do
133
    if byte_size(rendered) <= @maximum_output_bytes,
134
      do: :ok,
135
      else: {:error, :output_limit}
136
  end
137
121 138
  defp scheme_of(url) do
122 139
    case Regex.run(~r/\A([a-zA-Z][a-zA-Z0-9+.-]*):/, url) do
123 140
      [_, scheme] -> String.downcase(scheme)

@@ -125,6 +142,37 @@ defmodule OpenAgents.Markdown do

125 142
    end
126 143
  end
127 144
145
  defp limited(text, reason) do
146
    excerpt = truncate_utf8(text, @maximum_fallback_bytes)
147
148
    html = [
149
      "<p><strong>Markdown rendering limited.</strong> ",
150
      escape(reason),
151
      ".</p><pre>",
152
      escape(excerpt),
153
      if(byte_size(text) > byte_size(excerpt), do: "\n…", else: ""),
154
      "</pre>"
155
    ]
156
157
    {:safe, html}
158
  end
159
160
  defp truncate_utf8(value, maximum_bytes) when byte_size(value) <= maximum_bytes, do: value
161
162
  defp truncate_utf8(value, maximum_bytes) do
163
    value
164
    |> binary_part(0, maximum_bytes)
165
    |> trim_to_valid_utf8(4)
166
  end
167
168
  defp trim_to_valid_utf8(value, attempts) do
169
    cond do
170
      String.valid?(value) -> value
171
      attempts == 0 or byte_size(value) == 0 -> ""
172
      true -> trim_to_valid_utf8(binary_part(value, 0, byte_size(value) - 1), attempts - 1)
173
    end
174
  end
175
128 176
  defp escape(value), do: value |> to_string() |> Phoenix.HTML.html_escape() |> elem(1)
129 177
130 178
  # ── Completion of partial Markdown ─────────────────────────────────────────
lib/openagents_web.ex modified -34

@@ -60,24 +60,6 @@ defmodule OpenAgentsWeb do

60 60
    end
61 61
  end
62 62
63
  def openagents_live_view do
64
    quote do
65
      use Phoenix.LiveView
66
67
      unquote(openagents_html_helpers())
68
69
      import OpenAgentsWeb.Components.RepoHeader
70
    end
71
  end
72
73
  def openagents_html do
74
    quote do
75
      use Phoenix.Component
76
77
      unquote(openagents_html_helpers())
78
    end
79
  end
80
81 63
  def live_component do
82 64
    quote do
83 65
      use Phoenix.LiveComponent

@@ -104,22 +86,6 @@ defmodule OpenAgentsWeb do

104 86
      # Translation
105 87
      use Gettext, backend: OpenAgentsWeb.Gettext
106 88
107
      # HTML escaping functionality
108
      import Phoenix.HTML
109
      # Core UI components
110
      import OpenAgentsWeb.CoreComponents
111
112
      # Common modules used in templates
113
      alias Phoenix.LiveView.JS
114
      alias OpenAgentsWeb.Layouts
115
116
      # Routes generation with the ~p sigil
117
      unquote(verified_routes())
118
    end
119
  end
120
121
  defp openagents_html_helpers do
122
    quote do
123 89
      # HTML escaping functionality
124 90
      import Phoenix.HTML
125 91
      # OpenAgents interface primitives
lib/openagents_web/component_catalog.ex modified +42 -97

@@ -1,81 +1,18 @@

1 1
defmodule OpenAgentsWeb.ComponentCatalog do
2 2
  @moduledoc """
3
  The component catalog's table of contents.
3
  The executable inventory for the OpenAgents component system.
4 4
5
  Both the sidebar in `layouts/components.html.heex` and the pages rendered by
6
  `OpenAgentsWeb.ComponentsLive` are generated from this list, so a component
7
  cannot appear in one and be missing from the other. Adding a component means
8
  adding an entry here and a matching `component_demo/1` clause in
9
  `ComponentsLive`; `ComponentsLive` has a test that asserts every slug here
10
  resolves to a page, and `ComponentCatalogTest` asserts the catalog covers
11
  every public function component in the modules it claims to document.
5
  The sidebar and demo pages both read this list. Tests require every public
6
  function component in `OpenAgentsWeb.UI` and the documented layout modules to
7
  appear here, so the catalog cannot drift behind the supported API.
12 8
13
  Two component sets ship in this repo and they are catalogued separately:
14
15
    * `OpenAgentsWeb.CoreComponents` — the Phoenix-generated set, restyled onto
16
      basecoat. Imported by `use OpenAgentsWeb, :live_view`.
17
    * `OpenAgentsWeb.UI` — the OpenAgents interface primitives, imported
18
      separately via `openagents_html_helpers`. `button`, `input`, and `icon` exist
19
      in both sets, which is why the UI slugs are prefixed `openagents-`.
20
21
  Icon names are drawn from the vendored Apps SDK set (`OpenAgentsWeb.Icons`).
9
  `OpenAgentsWeb.UI` is the only product component module. It combines the
10
  vendored Basecoat structure with the OpenAgents style pack. Icon demos use the
11
  preferred vendored Apps SDK set; see `docs/ICONS.md` for the exceptional
12
  Heroicons fallback policy and current fallback inventory.
22 13
  """
23 14
24 15
  @sections [
25
    %{
26
      title: "Core components",
27
      items: [
28
        %{
29
          slug: "button",
30
          title: "Button",
31
          icon: "cube",
32
          source: "OpenAgentsWeb.CoreComponents.button/1",
33
          summary: "Default, primary, navigation, and disabled variants."
34
        },
35
        %{
36
          slug: "input",
37
          title: "Input",
38
          icon: "square-text",
39
          source: "OpenAgentsWeb.CoreComponents.input/1",
40
          summary: "Text, select, textarea, and checkbox fields inside a form."
41
        },
42
        %{
43
          slug: "header",
44
          title: "Header",
45
          icon: "book",
46
          source: "OpenAgentsWeb.CoreComponents.header/1",
47
          summary: "Page title with an optional subtitle and action slot."
48
        },
49
        %{
50
          slug: "table",
51
          title: "Table",
52
          icon: "table-cells-filled",
53
          source: "OpenAgentsWeb.CoreComponents.table/1",
54
          summary: "Row listing with column and action slots."
55
        },
56
        %{
57
          slug: "list",
58
          title: "List",
59
          icon: "file-document",
60
          source: "OpenAgentsWeb.CoreComponents.list/1",
61
          summary: "Title and description pairs in a definition list."
62
        },
63
        %{
64
          slug: "icon",
65
          title: "Icon",
66
          icon: "grid",
67
          source: "OpenAgentsWeb.CoreComponents.icon/1",
68
          summary: "Inline glyphs from the vendored icon set."
69
        },
70
        %{
71
          slug: "flash",
72
          title: "Flash",
73
          icon: "bell",
74
          source: "OpenAgentsWeb.CoreComponents.flash/1",
75
          summary: "Info and error toasts rendered by the layout's flash group."
76
        }
77
      ]
78
    },
79 16
    %{
80 17
      title: "OpenAgents UI",
81 18
      items: [

@@ -84,7 +21,7 @@ defmodule OpenAgentsWeb.ComponentCatalog do

84 21
          title: "Button",
85 22
          icon: "cube",
86 23
          source: "OpenAgentsWeb.UI.button/1",
87
          summary: "Eight variants, four sizes, and a danger tone."
24
          summary: "Eight variants, four sizes, navigation, and a danger tone."
88 25
        },
89 26
        %{
90 27
          slug: "openagents-text-button",

@@ -98,28 +35,49 @@ defmodule OpenAgentsWeb.ComponentCatalog do

98 35
          title: "Input",
99 36
          icon: "square-text",
100 37
          source: "OpenAgentsWeb.UI.input/1",
101
          summary: "Bare text input primitive, unwrapped by a form field."
38
          summary: "Form-aware text, select, textarea, checkbox, and raw inputs."
102 39
        },
103 40
        %{
104 41
          slug: "openagents-textarea",
105 42
          title: "Textarea",
106 43
          icon: "text",
107 44
          source: "OpenAgentsWeb.UI.textarea/1",
108
          summary: "Multi-line text primitive."
45
          summary: "Unwrapped multiline text primitive."
109 46
        },
110 47
        %{
111 48
          slug: "openagents-label",
112 49
          title: "Label",
113 50
          icon: "tag",
114 51
          source: "OpenAgentsWeb.UI.label/1",
115
          summary: "Form label bound to a control by id."
52
          summary: "Form label bound to a control by ID."
116 53
        },
117 54
        %{
118 55
          slug: "openagents-field",
119 56
          title: "Field",
120 57
          icon: "file-document",
121 58
          source: "OpenAgentsWeb.UI.field/1",
122
          summary: "Wrapper that stacks a label and its control."
59
          summary: "Wrapper that stacks a label, control, and validation message."
60
        },
61
        %{
62
          slug: "openagents-header",
63
          title: "Header",
64
          icon: "book",
65
          source: "OpenAgentsWeb.UI.header/1",
66
          summary: "Page heading with supporting text and an action slot."
67
        },
68
        %{
69
          slug: "openagents-table",
70
          title: "Table",
71
          icon: "table-cells-filled",
72
          source: "OpenAgentsWeb.UI.table/1",
73
          summary: "Responsive rows with regular-list and LiveView stream support."
74
        },
75
        %{
76
          slug: "openagents-list",
77
          title: "List",
78
          icon: "file-document",
79
          source: "OpenAgentsWeb.UI.list/1",
80
          summary: "Title and description pairs."
123 81
        },
124 82
        %{
125 83
          slug: "openagents-alert",

@@ -182,7 +140,7 @@ defmodule OpenAgentsWeb.ComponentCatalog do

182 140
          title: "Menu",
183 141
          icon: "menu",
184 142
          source: "OpenAgentsWeb.UI.menu/1",
185
          summary: "Popover menu surface used by the account control."
143
          summary: "Native popover menu surface used by the account control."
186 144
        },
187 145
        %{
188 146
          slug: "openagents-frame",

@@ -196,14 +154,14 @@ defmodule OpenAgentsWeb.ComponentCatalog do

196 154
          title: "Status indicator",
197 155
          icon: "check-circle",
198 156
          source: "OpenAgentsWeb.UI.status_indicator/1",
199
          summary: "Labelled state dot, optionally decorative."
157
          summary: "Labeled state dot, optionally decorative."
200 158
        },
201 159
        %{
202 160
          slug: "openagents-audio-player",
203 161
          title: "Audio player",
204 162
          icon: "play",
205 163
          source: "OpenAgentsWeb.UI.audio_player/1",
206
          summary: "Labelled audio element for recordings."
164
          summary: "Labeled audio element for recordings."
207 165
        },
208 166
        %{
209 167
          slug: "openagents-icon",

@@ -217,13 +175,6 @@ defmodule OpenAgentsWeb.ComponentCatalog do

217 175
    %{
218 176
      title: "Layout",
219 177
      items: [
220
        %{
221
          slug: "theme-toggle",
222
          title: "Theme toggle",
223
          icon: "moon",
224
          source: "OpenAgentsWeb.Layouts.theme_toggle/1",
225
          summary: "System, light, and dark. The same control sits in the site header."
226
        },
227 178
        %{
228 179
          slug: "command-bar",
229 180
          title: "Command bar",

@@ -236,7 +187,7 @@ defmodule OpenAgentsWeb.ComponentCatalog do

236 187
          title: "Account control",
237 188
          icon: "user",
238 189
          source: "OpenAgentsWeb.Layouts.account_control/1",
239
          summary: "Avatar trigger and popover menu for the signed-in user."
190
          summary: "Avatar trigger and native popover menu for the signed-in user."
240 191
        }
241 192
      ]
242 193
    },

@@ -263,21 +214,15 @@ defmodule OpenAgentsWeb.ComponentCatalog do

263 214
  @doc "Every slug in the catalog."
264 215
  def slugs, do: Enum.map(items(), & &1.slug)
265 216
266
  @doc "Look up one item by slug. Returns nil when the slug is unknown."
217
  @doc "Looks up one item by slug. Returns `nil` when the slug is unknown."
267 218
  def fetch(slug), do: Enum.find(items(), &(&1.slug == slug))
268 219
269
  @doc """
270
  The modules this catalog claims to document, and the components in each that
271
  are deliberately not given a page.
272
273
  `OpenAgentsWeb.Layouts.app/1` and `flash_group/1` wrap the catalog page
274
  itself, so they cannot be demoed inside it.
275
  """
220
  @doc "Modules covered by the executable catalog and deliberate exclusions."
276 221
  def documented_modules do
277 222
    %{
278
      OpenAgentsWeb.CoreComponents => [],
279 223
      OpenAgentsWeb.UI => [],
280
      OpenAgentsWeb.Layouts => [:app, :flash_group]
224
      OpenAgentsWeb.Layouts => [:app, :flash_group],
225
      OpenAgentsWeb.Components.RepoHeader => []
281 226
    }
282 227
  end
283 228
end
lib/openagents_web/components/core_components.ex deleted -551

@@ -1,551 +0,0 @@

1
defmodule OpenAgentsWeb.CoreComponents do
2
  @moduledoc """
3
  Provides core UI components.
4
5
  At first glance, this module may seem daunting, but its goal is to provide
6
  core building blocks for your application, such as tables, forms, and
7
  inputs. The components consist mostly of markup and are well-documented
8
  with doc strings and declarative assigns. You may customize and style
9
  them in any way you want, based on your application growth and needs.
10
11
  The foundation for styling is Tailwind CSS, a utility-first CSS framework,
12
  on top of the vendored Basecoat component structure in
13
  `assets/vendor/basecoat/components/` and OpenAgents style pack in
14
  `assets/css/openagents.css`. Basecoat expresses variants as data attributes
15
  (`data-variant`, `data-size`), so a control is `class="btn"` plus a data
16
  attribute rather than a stack of variant classes. There is no second
17
  component library — DaisyUI was removed, and reintroducing it would put flat
18
  `.btn`-style rules back above `.btn[data-variant=…]`. Here are useful
19
  references:
20
21
    * `OpenAgentsWeb.UI` - nineteen ready primitives over that CSS
22
      (`button/1`, `card/1`, `badge/1`, `alert/1`, `input/1`, …). Prefer them
23
      to hand-written component classes.
24
25
    * [Tailwind CSS](https://tailwindcss.com) - the foundational framework
26
      we build on. You will use it for layout, sizing, flexbox, grid, and
27
      spacing.
28
29
    * [Heroicons](https://heroicons.com) - see `icon/1` for usage.
30
31
    * [Phoenix.Component](https://phoenix-live-view.hexdocs.pm/Phoenix.Component.html) -
32
      the component system used by Phoenix. Some components, such as `<.link>`
33
      and `<.form>`, are defined there.
34
35
  """
36
  use Phoenix.Component
37
  use Gettext, backend: OpenAgentsWeb.Gettext
38
39
  alias Phoenix.LiveView.JS
40
41
  @doc """
42
  Renders flash notices.
43
44
  ## Examples
45
46
      <.flash kind={:info} flash={@flash} />
47
      <.flash
48
        id="welcome-back"
49
        kind={:info}
50
        phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")}
51
        hidden
52
      >
53
        Welcome Back!
54
      </.flash>
55
  """
56
  attr :id, :string, doc: "the optional id of flash container"
57
  attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
58
  attr :title, :string, default: nil
59
  attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
60
  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
61
62
  slot :inner_block, doc: "the optional inner block that renders the flash message"
63
64
  def flash(assigns) do
65
    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
66
67
    ~H"""
68
    <div
69
      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
70
      id={@id}
71
      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
72
      role="alert"
73
      class="fixed top-4 right-4 z-50 flex flex-col items-end gap-2"
74
      {@rest}
75
    >
76
      <%!-- Basecoat/OpenAgents `.alert` is a three-column grid: leading marker,
77
      body `<section>`, trailing action. Keeping exactly three children is what
78
      lets the close control sit in the third column instead of wrapping. --%>
79
      <div
80
        class="alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap"
81
        data-variant={if @kind == :error, do: "danger", else: "info"}
82
      >
83
        <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
84
        <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
85
        <section>
86
          <p :if={@title} class="font-semibold">{@title}</p>
87
          <p>{msg}</p>
88
        </section>
89
        <%!-- The control is icon-only, so its accessible name is the only name it
90
        has. "close" describes the gesture; "Dismiss notice" describes what the
91
        user is acting on, which is what a screen reader needs when the button
92
        is announced out of context. IconAffordancesTest guards this. --%>
93
        <button
94
          type="button"
95
          class="group self-start cursor-pointer"
96
          aria-label={gettext("Dismiss notice")}
97
        >
98
          <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
99
        </button>
100
      </div>
101
    </div>
102
    """
103
  end
104
105
  @doc """
106
  Renders a button with navigation support.
107
108
  A thin wrapper over the Basecoat/OpenAgents `.btn` recipe: the class is always
109
  `btn` and the look comes from `data-variant`, never from a stack of variant
110
  classes. `class` adds layout utilities alongside it rather than replacing it,
111
  so a caller cannot accidentally drop the component styling.
112
113
  `OpenAgentsWeb.UI.button/1` is the fuller control (eight variants, three
114
  sizes, a danger tone). This one exists so surfaces on `CoreComponents` alone
115
  still get the same two shapes.
116
117
  ## Examples
118
119
      <.button>Send!</.button>
120
      <.button phx-click="go" variant="primary">Send!</.button>
121
      <.button navigate={~p"/"}>Home</.button>
122
  """
123
  attr :rest, :global,
124
    include: ~w(href navigate patch method download name value disabled type id phx-click)
125
126
  attr :class, :any, default: nil
127
  attr :variant, :string, values: ~w(primary secondary ghost outline), default: "secondary"
128
  attr :size, :string, values: [nil | ~w(xs sm lg)], default: nil
129
  slot :inner_block, required: true
130
131
  def button(%{rest: rest} = assigns) do
132
    if rest[:href] || rest[:navigate] || rest[:patch] do
133
      ~H"""
134
      <.link class={["btn", @class]} data-variant={@variant} data-size={@size} {@rest}>
135
        {render_slot(@inner_block)}
136
      </.link>
137
      """
138
    else
139
      ~H"""
140
      <button class={["btn", @class]} data-variant={@variant} data-size={@size} {@rest}>
141
        {render_slot(@inner_block)}
142
      </button>
143
      """
144
    end
145
  end
146
147
  @doc """
148
  Renders an input with label and error messages.
149
150
  A `Phoenix.HTML.FormField` may be passed as argument,
151
  which is used to retrieve the input name, id, and values.
152
  Otherwise all attributes may be passed explicitly.
153
154
  ## Types
155
156
  This function accepts all HTML input types, considering that:
157
158
    * You may also set `type="select"` to render a `<select>` tag
159
160
    * `type="checkbox"` is used exclusively to render boolean values
161
162
    * For live file uploads, see `Phoenix.Component.live_file_input/1`
163
164
  See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
165
  for more information. Unsupported types, such as radio, are best
166
  written directly in your templates.
167
168
  ## Examples
169
170
  ```heex
171
  <.input field={@form[:email]} type="email" />
172
  <.input name="my-input" errors={["oh no!"]} />
173
  ```
174
175
  ## Select type
176
177
  When using `type="select"`, you must pass the `options` and optionally
178
  a `value` to mark which option should be preselected.
179
180
  ```heex
181
  <.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
182
  ```
183
184
  For more information on what kind of data can be passed to `options` see
185
  [`options_for_select`](https://phoenix-html.hexdocs.pm/Phoenix.HTML.Form.html#options_for_select/2).
186
  """
187
  attr :id, :any, default: nil
188
  attr :name, :any
189
  attr :label, :string, default: nil
190
  attr :value, :any
191
192
  attr :type, :string,
193
    default: "text",
194
    values: ~w(checkbox color date datetime-local email file month number password
195
               search select tel text textarea time url week hidden)
196
197
  attr :field, Phoenix.HTML.FormField,
198
    doc: "a form field struct retrieved from the form, for example: @form[:email]"
199
200
  attr :errors, :list, default: []
201
  attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
202
  attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
203
  attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
204
  attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
205
  attr :class, :any, default: nil, doc: "the input class to use over defaults"
206
  attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
207
208
  attr :rest, :global,
209
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
210
                multiple pattern placeholder readonly required rows size step)
211
212
  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
213
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
214
215
    assigns
216
    |> assign(field: nil, id: assigns.id || field.id)
217
    |> assign(:errors, Enum.map(errors, &translate_error(&1)))
218
    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
219
    |> assign_new(:value, fn -> field.value end)
220
    |> input()
221
  end
222
223
  def input(%{type: "hidden"} = assigns) do
224
    ~H"""
225
    <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
226
    """
227
  end
228
229
  def input(%{type: "checkbox"} = assigns) do
230
    assigns =
231
      assign_new(assigns, :checked, fn ->
232
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
233
      end)
234
235
    ~H"""
236
    <div class="field mb-2">
237
      <label for={@id}>
238
        <input
239
          type="hidden"
240
          name={@name}
241
          value="false"
242
          disabled={@rest[:disabled]}
243
          form={@rest[:form]}
244
        />
245
        <span class="label inline-flex items-center gap-2">
246
          <%!-- The one native control the style pack does not restyle. Letting
247
          the browser draw it and tinting it with accent-color keeps it
248
          keyboard- and platform-correct for free. --%>
249
          <input
250
            type="checkbox"
251
            id={@id}
252
            name={@name}
253
            value="true"
254
            checked={@checked}
255
            class={@class || "size-4 shrink-0 accent-primary"}
256
            {@rest}
257
          />{@label}
258
        </span>
259
      </label>
260
      <.error :for={msg <- @errors}>{msg}</.error>
261
    </div>
262
    """
263
  end
264
265
  def input(%{type: "select"} = assigns) do
266
    ~H"""
267
    <div class="field mb-2">
268
      <label for={@id}>
269
        <span :if={@label} class="label mb-1">{@label}</span>
270
        <select
271
          id={@id}
272
          name={@name}
273
          class={[@class || "w-full input", "aria-invalid:border-destructive", @error_class]}
274
          aria-invalid={@errors != [] && "true"}
275
          multiple={@multiple}
276
          {@rest}
277
        >
278
          <option :if={@prompt} value="">{@prompt}</option>
279
          {Phoenix.HTML.Form.options_for_select(@options, @value)}
280
        </select>
281
      </label>
282
      <.error :for={msg <- @errors}>{msg}</.error>
283
    </div>
284
    """
285
  end
286
287
  def input(%{type: "textarea"} = assigns) do
288
    ~H"""
289
    <div class="field mb-2">
290
      <label for={@id}>
291
        <span :if={@label} class="label mb-1">{@label}</span>
292
        <textarea
293
          id={@id}
294
          name={@name}
295
          class={[@class || "w-full textarea", "aria-invalid:border-destructive", @error_class]}
296
          aria-invalid={@errors != [] && "true"}
297
          {@rest}
298
        >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
299
      </label>
300
      <.error :for={msg <- @errors}>{msg}</.error>
301
    </div>
302
    """
303
  end
304
305
  # All other inputs text, datetime-local, url, password, etc. are handled here...
306
  def input(assigns) do
307
    ~H"""
308
    <div class="field mb-2">
309
      <label for={@id}>
310
        <span :if={@label} class="label mb-1">{@label}</span>
311
        <input
312
          type={@type}
313
          name={@name}
314
          id={@id}
315
          value={Phoenix.HTML.Form.normalize_value(@type, @value)}
316
          class={[@class || "w-full input", "aria-invalid:border-destructive", @error_class]}
317
          aria-invalid={@errors != [] && "true"}
318
          {@rest}
319
        />
320
      </label>
321
      <.error :for={msg <- @errors}>{msg}</.error>
322
    </div>
323
    """
324
  end
325
326
  # Helper used by inputs to generate form errors
327
  defp error(assigns) do
328
    ~H"""
329
    <p class="mt-1.5 flex gap-2 items-center text-sm text-destructive">
330
      <.icon name="hero-exclamation-circle" class="size-5" />
331
      {render_slot(@inner_block)}
332
    </p>
333
    """
334
  end
335
336
  @doc """
337
  Renders a header with title.
338
  """
339
  slot :inner_block, required: true
340
  slot :subtitle
341
  slot :actions
342
343
  def header(assigns) do
344
    ~H"""
345
    <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
346
      <div>
347
        <h1 class="text-lg font-semibold leading-8">
348
          {render_slot(@inner_block)}
349
        </h1>
350
        <p :if={@subtitle != []} class="text-sm text-muted-foreground">
351
          {render_slot(@subtitle)}
352
        </p>
353
      </div>
354
      <div class="flex-none">{render_slot(@actions)}</div>
355
    </header>
356
    """
357
  end
358
359
  @doc """
360
  Renders a table with generic styling.
361
362
  ## Examples
363
364
      <.table id="users" rows={@users}>
365
        <:col :let={user} label="id">{user.id}</:col>
366
        <:col :let={user} label="username">{user.username}</:col>
367
      </.table>
368
  """
369
  attr :id, :string, required: true
370
  attr :rows, :list, required: true
371
  attr :row_id, :any, default: nil, doc: "the function for generating the row id"
372
  attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
373
374
  attr :row_item, :any,
375
    default: &Function.identity/1,
376
    doc: "the function for mapping each row before calling the :col and :action slots"
377
378
  slot :col, required: true do
379
    attr :label, :string
380
  end
381
382
  slot :action, doc: "the slot for showing user actions in the last table column"
383
384
  def table(assigns) do
385
    assigns =
386
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
387
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
388
      end
389
390
    ~H"""
391
    <%!-- Basecoat's `.table` carries structure only — widths, alignment, and
392
    the row rule. Rhythm and colour are utilities here rather than a zebra
393
    variant: OpenAgents separates rows with the hairline, not with alternating
394
    fills. --%>
395
    <div class="table-container">
396
      <table class="table text-sm">
397
        <thead>
398
          <tr>
399
            <th :for={col <- @col} class="px-3 py-2 text-left text-muted-foreground">
400
              {col[:label]}
401
            </th>
402
            <th :if={@action != []} class="px-3 py-2">
403
              <span class="sr-only">{gettext("Actions")}</span>
404
            </th>
405
          </tr>
406
        </thead>
407
        <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
408
          <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="hover:bg-muted/40">
409
            <td
410
              :for={col <- @col}
411
              phx-click={@row_click && @row_click.(row)}
412
              class={["px-3 py-2", @row_click && "hover:cursor-pointer"]}
413
            >
414
              {render_slot(col, @row_item.(row))}
415
            </td>
416
            <td :if={@action != []} class="w-0 px-3 py-2 font-semibold">
417
              <div class="flex gap-4">
418
                <%= for action <- @action do %>
419
                  {render_slot(action, @row_item.(row))}
420
                <% end %>
421
              </div>
422
            </td>
423
          </tr>
424
        </tbody>
425
      </table>
426
    </div>
427
    """
428
  end
429
430
  @doc """
431
  Renders a data list.
432
433
  ## Examples
434
435
      <.list>
436
        <:item title="Title">{@post.title}</:item>
437
        <:item title="Views">{@post.views}</:item>
438
      </.list>
439
  """
440
  slot :item, required: true do
441
    attr :title, :string, required: true
442
  end
443
444
  def list(assigns) do
445
    ~H"""
446
    <ul class="divide-y divide-border">
447
      <li :for={item <- @item} class="flex items-start gap-4 py-3">
448
        <div class="min-w-0 grow">
449
          <div class="font-semibold">{item.title}</div>
450
          <div class="text-muted-foreground">{render_slot(item)}</div>
451
        </div>
452
      </li>
453
    </ul>
454
    """
455
  end
456
457
  @doc """
458
  Renders a [Heroicon](https://heroicons.com).
459
460
  Heroicons come in three styles – outline, solid, and mini.
461
  By default, the outline style is used, but solid and mini may
462
  be applied by using the `-solid` and `-mini` suffix.
463
464
  You can customize the size and colors of the icons by setting
465
  width, height, and background color classes.
466
467
  Icons are extracted from the `deps/heroicons` directory and bundled within
468
  your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
469
470
  ## Examples
471
472
      <.icon name="hero-x-mark" />
473
      <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
474
  """
475
  attr :name, :string, required: true
476
  attr :class, :any, default: "size-4"
477
478
  def icon(%{name: "hero-" <> _} = assigns) do
479
    ~H"""
480
    <span class={[@name, @class]} />
481
    """
482
  end
483
484
  def icon(assigns) do
485
    {view_box, inner} = OpenAgentsWeb.Icons.fetch!(assigns.name)
486
    assigns = assign(assigns, :view_box, view_box) |> assign(:inner, inner)
487
488
    ~H"""
489
    <svg
490
      class={["icon", @class]}
491
      viewBox={@view_box}
492
      width="1em"
493
      height="1em"
494
      fill="currentColor"
495
      aria-hidden="true"
496
      focusable="false"
497
    >{Phoenix.HTML.raw(@inner)}</svg>
498
    """
499
  end
500
501
  ## JS Commands
502
503
  def show(js \\ %JS{}, selector) do
504
    JS.show(js,
505
      to: selector,
506
      time: 300,
507
      transition:
508
        {"transition-all ease-out duration-300",
509
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
510
         "opacity-100 translate-y-0 sm:scale-100"}
511
    )
512
  end
513
514
  def hide(js \\ %JS{}, selector) do
515
    JS.hide(js,
516
      to: selector,
517
      time: 200,
518
      transition:
519
        {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
520
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
521
    )
522
  end
523
524
  @doc """
525
  Translates an error message using gettext.
526
  """
527
  def translate_error({msg, opts}) do
528
    # When using gettext, we typically pass the strings we want
529
    # to translate as a static argument:
530
    #
531
    #     # Translate the number of files with plural rules
532
    #     dngettext("errors", "1 file", "%{count} files", count)
533
    #
534
    # However the error messages in our forms and APIs are generated
535
    # dynamically, so we need to translate them by calling Gettext
536
    # with our gettext backend as first argument. Translations are
537
    # available in the errors.po file (as we use the "errors" domain).
538
    if count = opts[:count] do
539
      Gettext.dngettext(OpenAgentsWeb.Gettext, "errors", msg, msg, count, opts)
540
    else
541
      Gettext.dgettext(OpenAgentsWeb.Gettext, "errors", msg, opts)
542
    end
543
  end
544
545
  @doc """
546
  Translates the errors for a field from a keyword list of errors.
547
  """
548
  def translate_errors(errors, field) when is_list(errors) do
549
    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
550
  end
551
end
lib/openagents_web/components/layouts.ex modified +57 -41

@@ -100,7 +100,7 @@ defmodule OpenAgentsWeb.Layouts do

100 100
          <.account_dropdown current_scope={@current_scope} />
101 101
        <% else %>
102 102
          <.form for={%{}} as={:auth} action={~p"/auth/github"} method="post" class="m-0">
103
            <.button type="submit" variant="primary" size="sm">Sign in with GitHub</.button>
103
            <.button type="submit" variant={:primary} size={:sm}>Sign in with GitHub</.button>
104 104
          </.form>
105 105
        <% end %>
106 106
      </div>

@@ -214,7 +214,7 @@ defmodule OpenAgentsWeb.Layouts do

214 214
              type="submit"
215 215
              class="w-full rounded-md px-2 py-1.5 text-left flex items-center gap-2 hover:bg-muted"
216 216
            >
217
              <.icon name="hero-arrow-right-start-on-rectangle" class="size-4" /> Log out
217
              <.icon name="logout" class="size-4" /> Log out
218 218
            </button>
219 219
          </.form>
220 220
        </li>

@@ -350,7 +350,7 @@ defmodule OpenAgentsWeb.Layouts do

350 350
        hidden
351 351
      >
352 352
        {gettext("Attempting to reconnect")}
353
        <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
353
        <.icon name="arrow-rotate-cw" class="ml-1 size-3 motion-safe:animate-spin" />
354 354
      </.flash>
355 355
356 356
      <.flash

@@ -365,53 +365,69 @@ defmodule OpenAgentsWeb.Layouts do

365 365
        hidden
366 366
      >
367 367
        {gettext("Attempting to reconnect")}
368
        <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
368
        <.icon name="arrow-rotate-cw" class="ml-1 size-3 motion-safe:animate-spin" />
369 369
      </.flash>
370 370
    </div>
371 371
    """
372 372
  end
373 373
374
  @doc """
375
  The `data-theme` segmented control.
376
377
  The attribute plumbing in `root.html.heex` still runs, but since DaisyUI was
378
  removed there is only one palette behind it: OpenAgents token ladder is
379
  dark-only (`color-scheme: dark` in app.css) and ships no light variant. The
380
  control therefore moves its indicator without repainting the page. Keep it or
381
  remove it deliberately — do not reintroduce a second theme to make it work.
382
  """
383
  def theme_toggle(assigns) do
384
    ~H"""
385
    <%!-- w-24 is load-bearing: the three buttons are w-1/3, so without an explicit
386
    width this stretches to fill any block-level parent and the segments stretch
387
    with it. 24 (6rem) is the natural content width: 3 buttons x (p-2 + size-4). --%>
388
    <div class="relative flex flex-row items-center w-24 shrink-0 border-2 border-muted bg-muted rounded-full">
389
      <div class="absolute w-1/3 h-full rounded-full border-1 border-card bg-background brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 [[data-theme-source=system]_&]:!left-0 transition-[left]" />
390
391
      <button
392
        class="flex p-2 cursor-pointer w-1/3"
393
        phx-click={JS.dispatch("phx:set-theme")}
394
        data-phx-theme="system"
395
      >
396
        <.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
397
      </button>
374
  attr :id, :string, default: nil
375
  attr :flash, :map, default: %{}
376
  attr :title, :string, default: nil
377
  attr :kind, :atom, values: [:info, :error], required: true
378
  attr :rest, :global
379
  slot :inner_block
398 380
399
      <button
400
        class="flex p-2 cursor-pointer w-1/3"
401
        phx-click={JS.dispatch("phx:set-theme")}
402
        data-phx-theme="light"
403
      >
404
        <.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
405
      </button>
381
  defp flash(assigns) do
382
    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
406 383
407
      <button
408
        class="flex p-2 cursor-pointer w-1/3"
409
        phx-click={JS.dispatch("phx:set-theme")}
410
        data-phx-theme="dark"
384
    ~H"""
385
    <div
386
      :if={message = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
387
      id={@id}
388
      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
389
      role="alert"
390
      class="fixed top-4 right-4 z-50 flex flex-col items-end gap-2"
391
      {@rest}
392
    >
393
      <UI.alert
394
        class="w-80 max-w-80 text-wrap sm:w-96 sm:max-w-96"
395
        variant={if(@kind == :error, do: :danger, else: :info)}
396
        label={@title}
411 397
      >
412
        <.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
413
      </button>
398
        {message}
399
        <:action>
400
          <UI.button
401
            type="button"
402
            variant={:ghost}
403
            size={:xs}
404
            aria-label={gettext("Dismiss notice")}
405
          >
406
            <UI.icon name="x" />
407
          </UI.button>
408
        </:action>
409
      </UI.alert>
414 410
    </div>
415 411
    """
416 412
  end
413
414
  defp show(js \\ %JS{}, selector) do
415
    JS.show(js,
416
      to: selector,
417
      time: 300,
418
      transition:
419
        {"transition-all transform ease-out duration-300", "opacity-0 translate-y-4",
420
         "opacity-100 translate-y-0"}
421
    )
422
  end
423
424
  defp hide(js \\ %JS{}, selector) do
425
    JS.hide(js,
426
      to: selector,
427
      time: 200,
428
      transition:
429
        {"transition-all transform ease-in duration-200", "opacity-100 translate-y-0",
430
         "opacity-0 translate-y-4"}
431
    )
432
  end
417 433
end
lib/openagents_web/components/layouts/root.html.heex modified -28

@@ -12,34 +12,6 @@

12 12
    <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
13 13
    <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
14 14
    </script>
15
    <script>
16
      (() => {
17
        const systemTheme = () => matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
18
19
        const setTheme = (theme) => {
20
          if (theme === "system") {
21
            localStorage.removeItem("phx:theme");
22
            document.documentElement.setAttribute("data-theme", systemTheme());
23
            document.documentElement.setAttribute("data-theme-source", "system");
24
          } else {
25
            localStorage.setItem("phx:theme", theme);
26
            document.documentElement.setAttribute("data-theme", theme);
27
            document.documentElement.setAttribute("data-theme-source", "user");
28
          }
29
        };
30
        if (!document.documentElement.hasAttribute("data-theme")) {
31
          setTheme(localStorage.getItem("phx:theme") || "dark");
32
        }
33
        window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
34
        window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
35
36
        matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
37
          if (document.documentElement.getAttribute("data-theme-source") === "system") {
38
            document.documentElement.setAttribute("data-theme", systemTheme());
39
          }
40
        });
41
      })();
42
    </script>
43 15
  </head>
44 16
  <body class="h-screen overflow-hidden overscroll-none">
45 17
    {@inner_content}
lib/openagents_web/components/ui.ex modified +268 -13

@@ -61,14 +61,15 @@ defmodule OpenAgentsWeb.UI do

61 61
  attr :class, :any, default: nil
62 62
63 63
  attr :rest, :global,
64
    include: ~w(disabled form name value popovertarget popovertargetaction download href)
64
    include:
65
      ~w(disabled form name value popovertarget popovertargetaction download href navigate patch)
65 66
66 67
  slot :inner_block, required: true
67 68
68 69
  def button(assigns) do
69 70
    ~H"""
70 71
    <.link
71
      :if={@rest[:href]}
72
      :if={@rest[:href] || @rest[:navigate] || @rest[:patch]}
72 73
      class={["btn", @class]}
73 74
      data-variant={@variant}
74 75
      data-size={@size != :default && @size}

@@ -78,7 +79,7 @@ defmodule OpenAgentsWeb.UI do

78 79
      {render_slot(@inner_block)}
79 80
    </.link>
80 81
    <button
81
      :if={!@rest[:href]}
82
      :if={!(@rest[:href] || @rest[:navigate] || @rest[:patch])}
82 83
      type={@type}
83 84
      class={["btn", @class]}
84 85
      data-variant={@variant}

@@ -127,19 +128,150 @@ defmodule OpenAgentsWeb.UI do

127 128
    """
128 129
  end
129 130
130
  @doc "A single-line text control."
131
  attr :id, :string, default: nil
132
  attr :name, :string, default: nil
133
  attr :value, :string, default: nil
134
  attr :type, :string, default: "text"
131
  @doc "A form-aware input or an unwrapped single-line text control."
132
  attr :id, :any, default: nil
133
  attr :name, :any, default: nil
134
  attr :label, :string, default: nil
135
  attr :value, :any, default: nil
136
137
  attr :type, :string,
138
    default: "text",
139
    values: ~w(checkbox color date datetime-local email file month number password
140
               search select tel text textarea time url week hidden)
141
142
  attr :field, Phoenix.HTML.FormField,
143
    default: nil,
144
    doc: "a form field struct retrieved from the form, for example: @form[:email]"
145
146
  attr :errors, :list, default: []
147
  attr :checked, :boolean, default: nil
148
  attr :prompt, :string, default: nil
149
  attr :options, :list, default: []
150
  attr :multiple, :boolean, default: false
135 151
  attr :class, :any, default: nil
152
  attr :error_class, :any, default: nil
136 153
137 154
  attr :rest, :global,
138
    include: ~w(autocomplete disabled maxlength minlength pattern placeholder readonly required)
155
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
156
                multiple pattern placeholder readonly required rows size step)
157
158
  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
159
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
160
161
    assigns
162
    |> assign(field: nil, id: assigns.id || field.id)
163
    |> assign(:errors, Enum.map(errors, &translate_error/1))
164
    |> assign(
165
      :name,
166
      assigns.name || if(assigns.multiple, do: field.name <> "[]", else: field.name)
167
    )
168
    |> assign(:value, if(is_nil(assigns.value), do: field.value, else: assigns.value))
169
    |> input()
170
  end
171
172
  def input(%{type: "hidden"} = assigns) do
173
    ~H"""
174
    <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
175
    """
176
  end
177
178
  def input(%{type: "checkbox"} = assigns) do
179
    assigns =
180
      assign_new(assigns, :checked, fn ->
181
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
182
      end)
183
184
    ~H"""
185
    <.field class="mb-2">
186
      <input
187
        type="hidden"
188
        name={@name}
189
        value="false"
190
        disabled={@rest[:disabled]}
191
        form={@rest[:form]}
192
      />
193
      <.label for={@id} class="inline-flex items-center gap-2">
194
        <input
195
          type="checkbox"
196
          id={@id}
197
          name={@name}
198
          value="true"
199
          checked={@checked}
200
          class={@class || "size-4 shrink-0 accent-primary"}
201
          {@rest}
202
        />
203
        {@label}
204
      </.label>
205
      <.error :for={message <- @errors}>{message}</.error>
206
    </.field>
207
    """
208
  end
209
210
  def input(%{type: "select"} = assigns) do
211
    ~H"""
212
    <.field class="mb-2">
213
      <.label :if={@label} for={@id}>{@label}</.label>
214
      <select
215
        id={@id}
216
        name={@name}
217
        class={[@class || "input w-full", "aria-invalid:border-destructive", @error_class]}
218
        aria-invalid={@errors != [] && "true"}
219
        multiple={@multiple}
220
        {@rest}
221
      >
222
        <option :if={@prompt} value="">{@prompt}</option>
223
        {Phoenix.HTML.Form.options_for_select(@options, @value)}
224
      </select>
225
      <.error :for={message <- @errors}>{message}</.error>
226
    </.field>
227
    """
228
  end
229
230
  def input(%{type: "textarea"} = assigns) do
231
    ~H"""
232
    <.field class="mb-2">
233
      <.label :if={@label} for={@id}>{@label}</.label>
234
      <.textarea
235
        id={@id}
236
        name={@name}
237
        value={Phoenix.HTML.Form.normalize_value("textarea", @value)}
238
        class={[@class || "w-full", "aria-invalid:border-destructive", @error_class]}
239
        aria-invalid={@errors != [] && "true"}
240
        {@rest}
241
      />
242
      <.error :for={message <- @errors}>{message}</.error>
243
    </.field>
244
    """
245
  end
246
247
  def input(%{label: nil, errors: []} = assigns) do
248
    ~H"""
249
    <input
250
      type={@type}
251
      id={@id}
252
      name={@name}
253
      value={Phoenix.HTML.Form.normalize_value(@type, @value)}
254
      class={["input", @class]}
255
      {@rest}
256
    />
257
    """
258
  end
139 259
140 260
  def input(assigns) do
141 261
    ~H"""
142
    <input type={@type} id={@id} name={@name} value={@value} class={["input", @class]} {@rest} />
262
    <.field class="mb-2">
263
      <.label :if={@label} for={@id}>{@label}</.label>
264
      <input
265
        type={@type}
266
        id={@id}
267
        name={@name}
268
        value={Phoenix.HTML.Form.normalize_value(@type, @value)}
269
        class={[@class || "input w-full", "aria-invalid:border-destructive", @error_class]}
270
        aria-invalid={@errors != [] && "true"}
271
        {@rest}
272
      />
273
      <.error :for={message <- @errors}>{message}</.error>
274
    </.field>
143 275
    """
144 276
  end
145 277

@@ -181,6 +313,105 @@ defmodule OpenAgentsWeb.UI do

181 313
    """
182 314
  end
183 315
316
  @doc "A page heading with optional supporting text and actions."
317
  slot :inner_block, required: true
318
  slot :subtitle
319
  slot :actions
320
321
  def header(assigns) do
322
    ~H"""
323
    <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
324
      <div>
325
        <h1 class="text-lg font-semibold leading-8">{render_slot(@inner_block)}</h1>
326
        <p :if={@subtitle != []} class="text-sm text-muted-foreground">
327
          {render_slot(@subtitle)}
328
        </p>
329
      </div>
330
      <div class="flex-none">{render_slot(@actions)}</div>
331
    </header>
332
    """
333
  end
334
335
  @doc "A responsive table for regular lists or LiveView streams."
336
  attr :id, :string, required: true
337
  attr :rows, :list, required: true
338
  attr :row_id, :any, default: nil
339
  attr :row_click, :any, default: nil
340
  attr :row_item, :any, default: &Function.identity/1
341
342
  slot :col, required: true do
343
    attr :label, :string
344
  end
345
346
  slot :action
347
348
  def table(assigns) do
349
    assigns =
350
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
351
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
352
      end
353
354
    ~H"""
355
    <div class="table-container">
356
      <table class="table text-sm">
357
        <thead>
358
          <tr>
359
            <th :for={column <- @col} class="px-3 py-2 text-left text-muted-foreground">
360
              {column[:label]}
361
            </th>
362
            <th :if={@action != []} class="px-3 py-2"><span class="sr-only">Actions</span></th>
363
          </tr>
364
        </thead>
365
        <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
366
          <tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="hover:bg-muted/40">
367
            <td
368
              :for={column <- @col}
369
              phx-click={@row_click && @row_click.(row)}
370
              class={["px-3 py-2", @row_click && "hover:cursor-pointer"]}
371
            >
372
              {render_slot(column, @row_item.(row))}
373
            </td>
374
            <td :if={@action != []} class="w-0 px-3 py-2 font-semibold">
375
              <div class="flex gap-4">
376
                <%= for action <- @action do %>
377
                  {render_slot(action, @row_item.(row))}
378
                <% end %>
379
              </div>
380
            </td>
381
          </tr>
382
        </tbody>
383
      </table>
384
    </div>
385
    """
386
  end
387
388
  @doc "A title and description list."
389
  slot :item, required: true do
390
    attr :title, :string, required: true
391
  end
392
393
  def list(assigns) do
394
    ~H"""
395
    <ul class="divide-y divide-border">
396
      <li :for={item <- @item} class="flex items-start gap-4 py-3">
397
        <div class="min-w-0 grow">
398
          <div class="font-semibold">{item.title}</div>
399
          <div class="text-muted-foreground">{render_slot(item)}</div>
400
        </div>
401
      </li>
402
    </ul>
403
    """
404
  end
405
406
  defp error(assigns) do
407
    ~H"""
408
    <p class="mt-1.5 flex items-center gap-2 text-sm text-destructive">
409
      <.icon name="warning" class="size-5" />
410
      {render_slot(@inner_block)}
411
    </p>
412
    """
413
  end
414
184 415
  @doc """
185 416
  An inline notice.
186 417

@@ -546,11 +777,13 @@ defmodule OpenAgentsWeb.UI do

546 777
  end
547 778
548 779
  @doc """
549
  One glyph from the vendored Apps SDK UI set.
780
  One glyph from the governed two-tier icon set.
550 781
551 782
  Renders inline SVG at `1em` in `currentColor`, so a glyph takes the size and
552
  color of the text around it. Icons come only from `priv/icons`; adding one is
553
  a re-vendor, never inline SVG in a surface. See `docs/ICONS.md`.
783
  color of the text around it. Apps SDK UI glyphs come from `priv/icons`.
784
  `hero-*` names are the documented fallback when that set has no suitable
785
  concept. Adding a preferred glyph is a re-vendor, never inline SVG in a
786
  surface. See `docs/ICONS.md`.
554 787
555 788
  Decorative by default. Most glyphs sit beside a word that already names the
556 789
  control, and announcing both is noise, so an icon with no `label` is hidden

@@ -569,6 +802,19 @@ defmodule OpenAgentsWeb.UI do

569 802
  attr :class, :any, default: nil
570 803
  attr :rest, :global
571 804
805
  def icon(%{name: "hero-" <> _} = assigns) do
806
    ~H"""
807
    <span
808
      class={[@name, "icon", @class]}
809
      role={@label && "img"}
810
      aria-label={@label}
811
      aria-hidden={is_nil(@label) && "true"}
812
      data-icon={@name}
813
      {@rest}
814
    />
815
    """
816
  end
817
572 818
  def icon(assigns) do
573 819
    {view_box, inner} = OpenAgentsWeb.Icons.fetch!(assigns.name)
574 820

@@ -585,8 +831,17 @@ defmodule OpenAgentsWeb.UI do

585 831
      aria-label={@label}
586 832
      aria-hidden={is_nil(@label) && "true"}
587 833
      focusable="false"
834
      data-icon={@name}
588 835
      {@rest}
589 836
    >{Phoenix.HTML.raw(@inner)}</svg>
590 837
    """
591 838
  end
839
840
  defp translate_error({message, options}) do
841
    if count = options[:count] do
842
      Gettext.dngettext(OpenAgentsWeb.Gettext, "errors", message, message, count, options)
843
    else
844
      Gettext.dgettext(OpenAgentsWeb.Gettext, "errors", message, options)
845
    end
846
  end
592 847
end
lib/openagents_web/icons.ex modified +1 -1

@@ -8,7 +8,7 @@ defmodule OpenAgentsWeb.Icons do

8 8
9 9
  Each glyph is stored as a complete standalone SVG so the file stays viewable
10 10
  and diffable on its own. This module splits it into the viewBox and the inner
11
  markup so `OpenAgentsWeb.CoreComponents.icon/1` can render a fresh root element
11
  markup so `OpenAgentsWeb.UI.icon/1` can render a fresh root element
12 12
  with the accessibility and sizing attributes the call site needs.
13 13
  """
14 14
lib/openagents_web/live/admin_forge_live.ex modified +1 -1

@@ -9,7 +9,7 @@ defmodule OpenAgentsWeb.AdminForgeLive do

9 9
  live off the forge PubSub topics.
10 10
  """
11 11
12
  use OpenAgentsWeb, :openagents_live_view
12
  use OpenAgentsWeb, :live_view
13 13
14 14
  alias OpenAgents.Accounts
15 15
  alias OpenAgents.Forge
lib/openagents_web/live/admin_live.ex modified +1 -1

@@ -16,7 +16,7 @@ defmodule OpenAgentsWeb.AdminLive do

16 16
      access requires a separate decision and implementation.
17 17
  """
18 18
19
  use OpenAgentsWeb, :openagents_live_view
19
  use OpenAgentsWeb, :live_view
20 20
21 21
  alias OpenAgents.Accounts
22 22
  alias OpenAgents.Admin
lib/openagents_web/live/assignee_index_live.ex modified +1 -1

@@ -28,7 +28,7 @@ defmodule OpenAgentsWeb.AssigneeIndexLive do

28 28
29 29
      <%= if @assignees == [] do %>
30 30
        <div class="alert" data-variant="info" role="status">
31
          <.icon name="hero-information-circle" class="size-5" />
31
          <.icon name="info-circle" class="size-5" />
32 32
          <section>No assignees have been assigned to issues yet.</section>
33 33
        </div>
34 34
      <% else %>
lib/openagents_web/live/changelog_live.ex modified +1 -1

@@ -10,7 +10,7 @@ defmodule OpenAgentsWeb.ChangelogLive do

10 10
  off the forge PubSub — a hot-load appears here seconds after it lands.
11 11
  """
12 12
13
  use OpenAgentsWeb, :openagents_live_view
13
  use OpenAgentsWeb, :live_view
14 14
15 15
  alias OpenAgents.Changelog
16 16
lib/openagents_web/live/chat_live.ex modified +1 -1

@@ -1,5 +1,5 @@

1 1
defmodule OpenAgentsWeb.ChatLive do
2
  use OpenAgentsWeb, :openagents_live_view
2
  use OpenAgentsWeb, :live_view
3 3
4 4
  alias OpenAgents.{
5 5
    Accounts,
lib/openagents_web/live/code_blob_live.ex modified +2 -2

@@ -8,12 +8,12 @@ defmodule OpenAgentsWeb.CodeBlobLive do

8 8
  Served straight from the bare repo through `OpenAgents.Forge.Browse` (bounded
9 9
  git plumbing, WAL-fresh), gated by the repo's disclosure level
10 10
  (`OpenAgents.Forge.Visibility`, TRANSPARENCY-001 — files need :l3). Markdown
11
  renders through `OpenAgents.Markdown`'s allowlist walk; everything else is an
11
  renders through `OpenAgents.Markdown`'s bounded MDEx sanitizer; everything else is an
12 12
  escaped code block; binaries are named, never rendered. Public posture as
13 13
  the leaderboard: read-only, no session required, cannot invoke OpenAgents.
14 14
  """
15 15
16
  use OpenAgentsWeb, :openagents_live_view
16
  use OpenAgentsWeb, :live_view
17 17
18 18
  alias OpenAgents.Forge.{Browse, Visibility}
19 19
lib/openagents_web/live/code_commit_live.ex modified +1 -1

@@ -12,7 +12,7 @@ defmodule OpenAgentsWeb.CodeCommitLive do

12 12
  the other projections: read-only, sessionless, cannot invoke OpenAgents.
13 13
  """
14 14
15
  use OpenAgentsWeb, :openagents_live_view
15
  use OpenAgentsWeb, :live_view
16 16
17 17
  alias OpenAgents.Forge
18 18
  alias OpenAgents.Forge.{Browse, Visibility}
lib/openagents_web/live/code_repo_live.ex modified +1 -1

@@ -7,7 +7,7 @@ defmodule OpenAgentsWeb.CodeRepoLive do

7 7
  projections; data comes from `OpenAgents.Forge.Browse`'s bounded plumbing.
8 8
  """
9 9
10
  use OpenAgentsWeb, :openagents_live_view
10
  use OpenAgentsWeb, :live_view
11 11
12 12
  alias OpenAgents.Forge
13 13
  alias OpenAgents.Forge.{Browse, Visibility}
lib/openagents_web/live/components_live.ex modified +31 -104

@@ -20,12 +20,8 @@ defmodule OpenAgentsWeb.ComponentsLive do

20 20
    %{id: 3, owner: "OpenAgentsInc", repo: "arcade", state: "closed"}
21 21
  ]
22 22
23
  @icons ~w(
24
    hero-information-circle hero-exclamation-circle hero-x-mark
25
    hero-arrow-path hero-sun-micro hero-moon-micro hero-computer-desktop-micro
26
  )
27
28
  # UI.icon/1 renders the vendored Apps SDK set, not heroicons.
23
  # The catalog demonstrates the preferred vendored Apps SDK tier. Heroicons
24
  # remains an exceptional fallback with an empty product-use inventory.
29 25
  @openagents_icons ~w(sparkle compass folder document user bell play star)
30 26
31 27
  # account_control/1 and command_bar/1 read three fields off the current user.

@@ -53,7 +49,6 @@ defmodule OpenAgentsWeb.ComponentsLive do

53 49
     socket
54 50
     |> assign(:form, form)
55 51
     |> assign(:rows, @sample_rows)
56
     |> assign(:icons, @icons)
57 52
     |> assign(:openagents_icons, @openagents_icons)
58 53
     |> assign(:demo_user, @demo_user)}
59 54
  end

@@ -93,42 +88,30 @@ defmodule OpenAgentsWeb.ComponentsLive do

93 88
    {:noreply, put_flash(socket, :info, "Demo form submitted. Nothing was saved.")}
94 89
  end
95 90
96
  def handle_event("flash-info", _params, socket) do
97
    {:noreply, put_flash(socket, :info, "This is the info flash from CoreComponents.")}
98
  end
99
100
  def handle_event("flash-error", _params, socket) do
101
    {:noreply, put_flash(socket, :error, "This is the error flash from CoreComponents.")}
102
  end
103
104 91
  @impl true
105 92
  def render(%{live_action: :index} = assigns) do
106 93
    ~H"""
107 94
    <div id="components-index" class="max-w-3xl">
108 95
      <h1 class="text-3xl font-semibold mb-4">Component library</h1>
109
      <p class="text-base-content/70 mb-8 text-pretty max-w-[68ch]">
110
        Live examples of every reusable function component in this repository, drawn from
111
        <code>OpenAgentsWeb.CoreComponents</code>
112
        (the Phoenix set, restyled onto basecoat), <code>OpenAgentsWeb.UI</code>
113
        (the OpenAgents interface primitives), and <code>OpenAgentsWeb.Layouts</code>. <code>button</code>, <code>input</code>, and
114
        <code>icon</code>
115
        exist in both component sets, so the UI entries are listed separately.
116
        A test asserts this page covers every public component in those modules.
96
      <p class="text-muted-foreground mb-8 text-pretty max-w-[68ch]">
97
        Live examples of every supported function component in <code>OpenAgentsWeb.UI</code>, <code>OpenAgentsWeb.Layouts</code>, and the
98
        repository header. A test asserts this page covers every public component
99
        in those modules.
117 100
        Planned GitHub-shaped components are listed in <code>docs/component-library.md</code>.
118 101
      </p>
119 102
120 103
      <section :for={section <- ComponentCatalog.sections()} class="mb-10">
121
        <h2 class="text-sm font-semibold uppercase tracking-wide text-base-content/50 mb-3">
104
        <h2 class="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
122 105
          {section.title}
123 106
        </h2>
124 107
        <div class="grid gap-4 sm:grid-cols-2">
125 108
          <.link
126 109
            :for={item <- section.items}
127 110
            navigate={~p"/components/#{item.slug}"}
128
            class="card bg-base-200 border border-base-300 p-4 hover:border-base-content/30"
111
            class="card bg-card border border-border p-4 hover:border-foreground/30"
129 112
          >
130 113
            <h3 class="text-lg font-medium mb-1">{item.title}</h3>
131
            <p class="text-sm text-base-content/70">{item.summary}</p>
114
            <p class="text-sm text-muted-foreground">{item.summary}</p>
132 115
          </.link>
133 116
        </div>
134 117
      </section>

@@ -141,11 +124,11 @@ defmodule OpenAgentsWeb.ComponentsLive do

141 124
    <div id={"component-#{@item.slug}"} class="max-w-3xl space-y-6">
142 125
      <header class="space-y-1">
143 126
        <h1 class="text-3xl font-semibold">{@item.title}</h1>
144
        <p class="text-sm text-base-content/70"><code>{@item.source}</code></p>
145
        <p class="text-base text-base-content/70 text-pretty max-w-[68ch]">{@item.summary}</p>
127
        <p class="text-sm text-muted-foreground"><code>{@item.source}</code></p>
128
        <p class="text-base text-muted-foreground text-pretty max-w-[68ch]">{@item.summary}</p>
146 129
      </header>
147 130
148
      <div class="rounded-box border border-base-300 bg-base-100 p-6">
131
      <div class="rounded-lg border border-border bg-background p-6">
149 132
        <.component_demo {assigns} />
150 133
      </div>
151 134
    </div>

@@ -155,22 +138,10 @@ defmodule OpenAgentsWeb.ComponentsLive do

155 138
  attr :item, :map, required: true
156 139
  attr :form, :any, default: nil
157 140
  attr :rows, :list, default: []
158
  attr :icons, :list, default: []
159 141
  attr :openagents_icons, :list, default: []
160 142
  attr :demo_user, :map, default: nil
161 143
162
  defp component_demo(%{item: %{slug: "button"}} = assigns) do
163
    ~H"""
164
    <div class="flex flex-wrap items-center gap-3">
165
      <.button id="demo-button-default">Default</.button>
166
      <.button id="demo-button-primary" variant="primary">Primary</.button>
167
      <.button id="demo-button-navigate" navigate={~p"/"}>Navigate home</.button>
168
      <.button id="demo-button-disabled" disabled>Disabled</.button>
169
    </div>
170
    """
171
  end
172
173
  defp component_demo(%{item: %{slug: "input"}} = assigns) do
144
  defp component_demo(%{item: %{slug: "openagents-input"}} = assigns) do
174 145
    ~H"""
175 146
    <.form
176 147
      for={@form}

@@ -197,25 +168,25 @@ defmodule OpenAgentsWeb.ComponentsLive do

197 168
        <.input field={@form[:public]} type="checkbox" label="Public repository" />
198 169
      </div>
199 170
      <div class="flex items-end">
200
        <.button type="submit" variant="primary">Save demo</.button>
171
        <.button type="submit" variant={:primary}>Save demo</.button>
201 172
      </div>
202 173
    </.form>
203 174
    """
204 175
  end
205 176
206
  defp component_demo(%{item: %{slug: "header"}} = assigns) do
177
  defp component_demo(%{item: %{slug: "openagents-header"}} = assigns) do
207 178
    ~H"""
208 179
    <.header>
209 180
      Repository issues
210 181
      <:subtitle>Open and closed issues for this repository.</:subtitle>
211 182
      <:actions>
212
        <.button variant="primary">New issue</.button>
183
        <.button variant={:primary}>New issue</.button>
213 184
      </:actions>
214 185
    </.header>
215 186
    """
216 187
  end
217 188
218
  defp component_demo(%{item: %{slug: "table"}} = assigns) do
189
  defp component_demo(%{item: %{slug: "openagents-table"}} = assigns) do
219 190
    ~H"""
220 191
    <.table id="demo-table" rows={@rows}>
221 192
      <:col :let={row} label="Owner">{row.owner}</:col>

@@ -230,7 +201,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

230 201
    """
231 202
  end
232 203
233
  defp component_demo(%{item: %{slug: "list"}} = assigns) do
204
  defp component_demo(%{item: %{slug: "openagents-list"}} = assigns) do
234 205
    ~H"""
235 206
    <.list>
236 207
      <:item title="Flash">Toast alerts for info and error.</:item>

@@ -240,38 +211,6 @@ defmodule OpenAgentsWeb.ComponentsLive do

240 211
    """
241 212
  end
242 213
243
  defp component_demo(%{item: %{slug: "icon"}} = assigns) do
244
    ~H"""
245
    <ul id="demo-icons" role="list" class="flex flex-wrap gap-4">
246
      <li :for={name <- @icons} class="flex flex-col items-center gap-2 w-28">
247
        <.icon name={name} class="size-6" />
248
        <p class="text-center text-sm text-base-content/70">{name}</p>
249
      </li>
250
    </ul>
251
    """
252
  end
253
254
  defp component_demo(%{item: %{slug: "flash"}} = assigns) do
255
    ~H"""
256
    <p class="text-pretty text-base text-base-content/70 max-w-[68ch] mb-4">
257
      Flash renders through <code>Layouts.flash_group/1</code>
258
      at the corner of the page. Trigger a sample message:
259
    </p>
260
    <div class="flex flex-wrap gap-3">
261
      <.button id="demo-flash-info" phx-click="flash-info">Show info flash</.button>
262
      <.button id="demo-flash-error" phx-click="flash-error">Show error flash</.button>
263
    </div>
264
    """
265
  end
266
267
  defp component_demo(%{item: %{slug: "theme-toggle"}} = assigns) do
268
    ~H"""
269
    <div id="demo-theme-toggle">
270
      <Layouts.theme_toggle />
271
    </div>
272
    """
273
  end
274
275 214
  defp component_demo(%{item: %{slug: "repo-header"}} = assigns) do
276 215
    ~H"""
277 216
    <OpenAgentsWeb.Components.RepoHeader.repo_header

@@ -285,9 +224,6 @@ defmodule OpenAgentsWeb.ComponentsLive do

285 224
  end
286 225
287 226
  # --- OpenAgents UI -------------------------------------------------------------
288
  # UI is imported by `openagents_html_helpers`, not by `:live_view`, so these
289
  # are called with the full module prefix. That also documents provenance on a
290
  # page whose whole job is showing where a component comes from.
291 227
292 228
  defp component_demo(%{item: %{slug: "openagents-button"}} = assigns) do
293 229
    ~H"""

@@ -295,6 +231,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

295 231
      <div class="flex flex-wrap items-center gap-3">
296 232
        <UI.button
297 233
          :for={v <- ~w(primary secondary outline ghost destructive chip notched link)a}
234
          id={"demo-button-#{v}"}
298 235
          variant={v}
299 236
        >
300 237
          {v |> Atom.to_string() |> String.capitalize()}

@@ -305,7 +242,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

305 242
      </div>
306 243
      <div class="flex flex-wrap items-center gap-3">
307 244
        <UI.button tone={:danger}>Danger tone</UI.button>
308
        <UI.button disabled>Disabled</UI.button>
245
        <UI.button id="demo-button-disabled" disabled>Disabled</UI.button>
309 246
      </div>
310 247
    </div>
311 248
    """

@@ -321,16 +258,6 @@ defmodule OpenAgentsWeb.ComponentsLive do

321 258
    """
322 259
  end
323 260
324
  defp component_demo(%{item: %{slug: "openagents-input"}} = assigns) do
325
    ~H"""
326
    <div class="space-y-3 max-w-sm">
327
      <UI.input id="openagents-input-demo" name="demo" value="Ship the catalog" />
328
      <UI.input id="openagents-input-demo-empty" name="demo_empty" placeholder="Placeholder text" />
329
      <UI.input id="openagents-input-demo-disabled" name="demo_disabled" value="Disabled" disabled />
330
    </div>
331
    """
332
  end
333
334 261
  defp component_demo(%{item: %{slug: "openagents-textarea"}} = assigns) do
335 262
    ~H"""
336 263
    <div class="max-w-sm">

@@ -372,13 +299,13 @@ defmodule OpenAgentsWeb.ComponentsLive do

372 299
    ~H"""
373 300
    <div class="space-y-6">
374 301
      <div class="space-y-3">
375
        <p class="text-sm text-base-content/60">Variants (box appearance)</p>
302
        <p class="text-sm text-muted-foreground">Variants (box appearance)</p>
376 303
        <UI.alert :for={v <- ~w(info success warning danger)a} variant={v} label={Atom.to_string(v)}>
377 304
          A {v} alert in the default box appearance.
378 305
        </UI.alert>
379 306
      </div>
380 307
      <div class="space-y-3">
381
        <p class="text-sm text-base-content/60">Appearances</p>
308
        <p class="text-sm text-muted-foreground">Appearances</p>
382 309
        <UI.alert
383 310
          :for={a <- ~w(box row notice)a}
384 311
          appearance={a}

@@ -407,15 +334,15 @@ defmodule OpenAgentsWeb.ComponentsLive do

407 334
    <div class="grid gap-4 sm:grid-cols-2">
408 335
      <UI.card id="openagents-card-default">
409 336
        <p class="font-medium">Default</p>
410
        <p class="text-sm text-base-content/70">A plain content container.</p>
337
        <p class="text-sm text-muted-foreground">A plain content container.</p>
411 338
      </UI.card>
412 339
      <UI.card id="openagents-card-corners" frame={:corners}>
413 340
        <p class="font-medium">Corner frame</p>
414
        <p class="text-sm text-base-content/70">With bracket decoration.</p>
341
        <p class="text-sm text-muted-foreground">With bracket decoration.</p>
415 342
      </UI.card>
416 343
      <UI.card id="openagents-card-danger" variant={:danger}>
417 344
        <p class="font-medium">Danger</p>
418
        <p class="text-sm text-base-content/70">For destructive context.</p>
345
        <p class="text-sm text-muted-foreground">For destructive context.</p>
419 346
      </UI.card>
420 347
    </div>
421 348
    """

@@ -452,7 +379,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

452 379
      <:chips>
453 380
        <UI.badge variant={:dim}>tool</UI.badge>
454 381
      </:chips>
455
      <p class="text-sm text-base-content/70">Pushed 3 files to the forge.</p>
382
      <p class="text-sm text-muted-foreground">Pushed 3 files to the forge.</p>
456 383
    </UI.event_header>
457 384
    """
458 385
  end

@@ -499,7 +426,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

499 426
    <UI.frame>
500 427
      <div class="p-6">
501 428
        <p class="font-medium">Framed content</p>
502
        <p class="text-sm text-base-content/70">Corner brackets wrap arbitrary children.</p>
429
        <p class="text-sm text-muted-foreground">Corner brackets wrap arbitrary children.</p>
503 430
      </div>
504 431
    </UI.frame>
505 432
    """

@@ -524,7 +451,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

524 451
        src="/audio/does-not-exist.wav"
525 452
        label="Voice recording (demo source, nothing to play)"
526 453
      />
527
      <p class="text-sm text-base-content/60">
454
      <p class="text-sm text-muted-foreground">
528 455
        The src is intentionally a dead path; this page has no recording to serve.
529 456
      </p>
530 457
    </div>

@@ -536,7 +463,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

536 463
    <ul id="openagents-demo-icons" role="list" class="flex flex-wrap gap-4">
537 464
      <li :for={name <- @openagents_icons} class="flex flex-col items-center gap-2 w-28">
538 465
        <UI.icon name={name} class="size-6" />
539
        <p class="text-center text-sm text-base-content/70">{name}</p>
466
        <p class="text-center text-sm text-muted-foreground">{name}</p>
540 467
      </li>
541 468
    </ul>
542 469
    """

@@ -561,10 +488,10 @@ defmodule OpenAgentsWeb.ComponentsLive do

561 488
    ~H"""
562 489
    <div class="space-y-4">
563 490
      <div class="space-y-2">
564
        <p class="text-sm text-base-content/60">Bar context</p>
491
        <p class="text-sm text-muted-foreground">Bar context</p>
565 492
        <Layouts.account_control current_user={@demo_user} context={:bar} />
566 493
      </div>
567
      <p class="text-sm text-base-content/60 max-w-[68ch]">
494
      <p class="text-sm text-muted-foreground max-w-[68ch]">
568 495
        Only one instance is shown. <code>account_control/1</code>
569 496
        hard-codes the ids <code>account-menu</code>, <code>account-menu-trigger</code>, <code>logout-form</code>, and <code>logout</code>, so it can be rendered at most
570 497
        once per page — rendering the <code>:row</code>
lib/openagents_web/live/computers_live.ex modified +1 -1

@@ -3,7 +3,7 @@ defmodule OpenAgentsWeb.ComputersLive do

3 3
  Approve controller pairing codes and manage this account's paired computers.
4 4
  """
5 5
6
  use OpenAgentsWeb, :openagents_live_view
6
  use OpenAgentsWeb, :live_view
7 7
8 8
  alias OpenAgents.Computer
9 9
  alias OpenAgents.Machines
lib/openagents_web/live/docs_live.ex modified +5 -5

@@ -20,16 +20,16 @@ defmodule OpenAgentsWeb.DocsLive do

20 20
      </p>
21 21
22 22
      <div class="grid gap-4 sm:grid-cols-2">
23
        <.card title="Get started" description="Install, configure, and run your first agent." />
24
        <.card title="Guides" description="Authentication, agents, and the forge pipeline." />
25
        <.card title="API reference" description="HTTP endpoints and schemas." />
26
        <.card title="CLI reference" description="Mix tasks and release commands." />
23
        <.docs_card title="Get started" description="Install, configure, and run your first agent." />
24
        <.docs_card title="Guides" description="Authentication, agents, and the forge pipeline." />
25
        <.docs_card title="API reference" description="HTTP endpoints and schemas." />
26
        <.docs_card title="CLI reference" description="Mix tasks and release commands." />
27 27
      </div>
28 28
    </div>
29 29
    """
30 30
  end
31 31
32
  defp card(assigns) do
32
  defp docs_card(assigns) do
33 33
    ~H"""
34 34
    <%!-- `.card` brings its own padding and lift; the surrounding grid supplies
35 35
    the gaps, so the panel margin is dropped here. --%>
lib/openagents_web/live/home_live.ex modified +2 -2

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

25 25
                <.button
26 26
                  id="home-cta-create"
27 27
                  navigate={~p"/OpenAgents/openagents/issues/new"}
28
                  variant="primary"
28
                  variant={:primary}
29 29
                >
30 30
                  Create new issue
31 31
                </.button>

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

34 34
                </.button>
35 35
              <% else %>
36 36
                <.form for={%{}} as={:auth} action={~p"/auth/github"} method="post" class="m-0">
37
                  <.button type="submit" variant="primary" id="home-cta-signin">
37
                  <.button type="submit" variant={:primary} id="home-cta-signin">
38 38
                    Sign in with GitHub
39 39
                  </.button>
40 40
                </.form>
lib/openagents_web/live/issue_index_live.ex modified +11 -11

@@ -45,7 +45,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

45 45
              data-size="sm"
46 46
              aria-current={@state == "open" && "page"}
47 47
            >
48
              <.icon name="hero-exclamation-circle" class="size-4 text-success" />
48
              <.icon name="warning" class="size-4 text-success" />
49 49
              <span class="font-semibold">{@open_count}</span> Open
50 50
            </.link>
51 51
            <.link

@@ -55,14 +55,14 @@ defmodule OpenAgentsWeb.IssueIndexLive do

55 55
              data-size="sm"
56 56
              aria-current={@state == "closed" && "page"}
57 57
            >
58
              <.icon name="hero-check-circle" class="size-4 text-muted-foreground" />
58
              <.icon name="check-circle" class="size-4 text-muted-foreground" />
59 59
              <span class="font-semibold">{@closed_count}</span> Closed
60 60
            </.link>
61 61
          </div>
62 62
63 63
          <div class="flex-1 min-w-[12rem]">
64 64
            <label class="input w-full max-w-xs flex items-center gap-2">
65
              <.icon name="hero-magnifying-glass" class="size-4 text-muted-foreground" />
65
              <.icon name="search" class="size-4 text-muted-foreground" />
66 66
              <input
67 67
                type="text"
68 68
                placeholder="Search or filter results..."

@@ -73,16 +73,16 @@ defmodule OpenAgentsWeb.IssueIndexLive do

73 73
74 74
          <div class="flex items-center gap-1">
75 75
            <button class="btn gap-1" data-variant="ghost" data-size="sm">
76
              <.icon name="hero-tag" class="size-4" /> Labels
76
              <.icon name="tag" class="size-4" /> Labels
77 77
            </button>
78 78
            <button class="btn gap-1" data-variant="ghost" data-size="sm">
79
              <.icon name="hero-flag" class="size-4" /> Milestones
79
              <.icon name="flag" class="size-4" /> Milestones
80 80
            </button>
81 81
            <button class="btn gap-1" data-variant="ghost" data-size="sm">
82
              <.icon name="hero-user" class="size-4" /> Assignees
82
              <.icon name="user" class="size-4" /> Assignees
83 83
            </button>
84 84
            <button class="btn gap-1" data-variant="ghost" data-size="sm">
85
              <.icon name="hero-bars-arrow-down" class="size-4" /> Sort
85
              <.icon name="filter" class="size-4" /> Sort
86 86
            </button>
87 87
          </div>
88 88

@@ -99,7 +99,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

99 99
        <%= if @issues_count == 0 do %>
100 100
          <div class="p-8 text-center">
101 101
            <.icon
102
              name="hero-clipboard-document-list"
102
              name="clipboard"
103 103
              class="size-12 mx-auto mb-3 text-muted-foreground/50"
104 104
            />
105 105
            <h3 class="text-lg font-medium mb-1">

@@ -120,8 +120,8 @@ defmodule OpenAgentsWeb.IssueIndexLive do

120 120
                <.icon
121 121
                  name={
122 122
                    if(issue.state == "open",
123
                      do: "hero-exclamation-circle",
124
                      else: "hero-check-circle"
123
                      do: "warning",
124
                      else: "check-circle"
125 125
                    )
126 126
                  }
127 127
                  class={[

@@ -151,7 +151,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

151 151
                      "anonymous"}
152 152
                    <%= if issue.comments > 0 do %>
153 153
                      <span class="inline-flex items-center gap-1 ml-2">
154
                        <.icon name="hero-chat-bubble-left" class="size-4" />
154
                        <.icon name="comment" class="size-4" />
155 155
                        {issue.comments}
156 156
                      </span>
157 157
                    <% end %>
lib/openagents_web/live/issue_new_live.ex modified +1 -1

@@ -101,7 +101,7 @@ defmodule OpenAgentsWeb.IssueNewLive do

101 101
          <.link navigate={~p"/#{@owner}/#{@repo}/issues"} class="btn" data-variant="ghost">
102 102
            Cancel
103 103
          </.link>
104
          <.button variant="primary">Create issue</.button>
104
          <.button variant={:primary}>Create issue</.button>
105 105
        </footer>
106 106
      </.form>
107 107
    </Layouts.app>
lib/openagents_web/live/issue_show_live.ex modified +2 -2

@@ -115,7 +115,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

115 115
            <button type="button" class="btn" data-variant="ghost" phx-click="toggle_edit">
116 116
              Cancel
117 117
            </button>
118
            <.button variant="primary">Save</.button>
118
            <.button variant={:primary}>Save</.button>
119 119
          </footer>
120 120
        </.form>
121 121
      <% else %>

@@ -208,7 +208,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

208 208
          >
209 209
            <.input field={@comment_form[:body]} type="textarea" label="Write a comment" />
210 210
            <footer class="flex justify-end mt-2">
211
              <.button variant="primary">Comment</.button>
211
              <.button variant={:primary}>Comment</.button>
212 212
            </footer>
213 213
          </.form>
214 214
        </div>
lib/openagents_web/live/label_index_live.ex modified +2 -2

@@ -60,13 +60,13 @@ defmodule OpenAgentsWeb.LabelIndexLive do

60 60
          <.input field={@form[:description]} label="Description" />
61 61
        </div>
62 62
        <footer class="flex justify-end mt-2">
63
          <.button variant="primary">Add label</.button>
63
          <.button variant={:primary}>Add label</.button>
64 64
        </footer>
65 65
      </.form>
66 66
67 67
      <%= if @labels == [] do %>
68 68
        <div class="alert" data-variant="info" role="status">
69
          <.icon name="hero-information-circle" class="size-5" />
69
          <.icon name="info-circle" class="size-5" />
70 70
          <section>No labels yet.</section>
71 71
        </div>
72 72
      <% else %>
lib/openagents_web/live/leaderboard_live.ex modified +1 -1

@@ -12,7 +12,7 @@ defmodule OpenAgentsWeb.LeaderboardLive do

12 12
  be rendered here by accident.
13 13
  """
14 14
15
  use OpenAgentsWeb, :openagents_live_view
15
  use OpenAgentsWeb, :live_view
16 16
17 17
  alias OpenAgents.Leaderboard
18 18
  alias OpenAgents.Leaderboard.Entry
lib/openagents_web/live/milestone_index_live.ex modified +2 -2

@@ -98,13 +98,13 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

98 98
          <.input field={@form[:description]} label="Description" />
99 99
        </div>
100 100
        <footer class="flex justify-end mt-2">
101
          <.button variant="primary">Add milestone</.button>
101
          <.button variant={:primary}>Add milestone</.button>
102 102
        </footer>
103 103
      </.form>
104 104
105 105
      <%= if @milestones == [] do %>
106 106
        <div class="alert" data-variant="info" role="status">
107
          <.icon name="hero-information-circle" class="size-5" />
107
          <.icon name="info-circle" class="size-5" />
108 108
          <section>No milestones yet.</section>
109 109
        </div>
110 110
      <% else %>
lib/openagents_web/live/network_status_live.ex modified +1 -1

@@ -11,7 +11,7 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

11 11
  full fleet to render — it is most useful precisely when something is down.
12 12
  """
13 13
14
  use OpenAgentsWeb, :openagents_live_view
14
  use OpenAgentsWeb, :live_view
15 15
16 16
  alias OpenAgents.NetworkStatus
17 17
lib/openagents_web/live/project_index_live.ex modified +2 -2

@@ -64,13 +64,13 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

64 64
      >
65 65
        <.input field={@form[:title]} label="Title" required />
66 66
        <footer class="flex justify-end mt-2">
67
          <.button variant="primary">Add project</.button>
67
          <.button variant={:primary}>Add project</.button>
68 68
        </footer>
69 69
      </.form>
70 70
71 71
      <%= if @projects == [] do %>
72 72
        <div class="alert" data-variant="info" role="status">
73
          <.icon name="hero-information-circle" class="size-5" />
73
          <.icon name="info-circle" class="size-5" />
74 74
          <section>No projects yet.</section>
75 75
        </div>
76 76
      <% else %>
lib/openagents_web/live/project_show_live.ex modified +1 -1

@@ -105,7 +105,7 @@ defmodule OpenAgentsWeb.ProjectShowLive do

105 105
          />
106 106
        </div>
107 107
        <footer class="flex justify-end mt-2">
108
          <.button variant="primary">Add to board</.button>
108
          <.button variant={:primary}>Add to board</.button>
109 109
        </footer>
110 110
      </.form>
111 111
lib/openagents_web/live/voice_spike_live.ex modified +1 -1

@@ -1,7 +1,7 @@

1 1
defmodule OpenAgentsWeb.VoiceSpikeLive do
2 2
  @moduledoc false
3 3
4
  use OpenAgentsWeb, :openagents_live_view
4
  use OpenAgentsWeb, :live_view
5 5
6 6
  @impl true
7 7
  def mount(_params, _session, socket) do
mix.exs modified +6 -3

@@ -63,19 +63,20 @@ defmodule OpenAgents.MixProject do

63 63
      {:phoenix_live_reload, "~> 1.2", only: :dev},
64 64
      {:phoenix_live_view, "~> 1.2.0"},
65 65
      {:lazy_html, ">= 0.1.0", only: :test},
66
      {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
66 67
      {:phoenix_live_dashboard, "~> 0.8.3"},
67 68
      {:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
68 69
      {:tailwind, "~> 0.5", runtime: Mix.env() == :dev},
69 70
      {:heroicons,
70 71
       github: "tailwindlabs/heroicons",
71
       tag: "v2.2.0",
72
       ref: "0435d4ca364a608cc75e2f8683d374e55abbae26",
72 73
       sparse: "optimized",
73 74
       app: false,
74 75
       compile: false,
75 76
       depth: 1},
76 77
      {:swoosh, "~> 1.16"},
77 78
      {:req, "~> 0.5"},
78
      {:earmark, "~> 1.4"},
79
      {:mdex, "~> 0.13.5"},
79 80
      {:horde, "~> 0.9.0"},
80 81
      {:ra, "~> 2.16"},
81 82
      {:websockex, "~> 0.5.1"},

@@ -111,8 +112,10 @@ defmodule OpenAgents.MixProject do

111 112
        "phx.digest"
112 113
      ],
113 114
      precommit: [
115
        "hex.audit",
116
        "deps.audit",
114 117
        "compile --warnings-as-errors",
115
        "deps.unlock --unused",
118
        "deps.unlock --check-unused",
116 119
        "format",
117 120
        "cmd ops/ci/reference-check.sh",
118 121
        "cmd elixir ops/ci/docs-check.exs",
mix.lock modified +8 -2

@@ -7,7 +7,6 @@

7 7
  "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
8 8
  "delta_crdt": {:hex, :delta_crdt, "0.6.5", "c7bb8c2c7e60f59e46557ab4e0224f67ba22f04c02826e273738f3dcc4767adc", [:mix], [{:merkle_map, "~> 0.2.0", [hex: :merkle_map, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c6ae23a525d30f96494186dd11bf19ed9ae21d9fe2c1f1b217d492a7cc7294ae"},
9 9
  "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
10
  "earmark": {:hex, :earmark, "1.4.49", "024521298c2308f0193ba7a5df742a5e901dad03cd44ba57f861d3f0fada5d1b", [:mix], [], "hexpm", "ffad0257b92ac342b236d1744f7f19793da77bbd7281f32ccdfb44c047a05bf2"},
11 10
  "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"},
12 11
  "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
13 12
  "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},

@@ -19,17 +18,21 @@

19 18
  "forecastle": {:hex, :forecastle, "0.1.3", "b07d217ef10799e6d6cc7e47407858e77b1a8cb248f15185534de3403de3aa42", [:mix], [], "hexpm", "07e1ffa79c56f3e0ead59f17c0163a747dafc210ca8f244a7e65a4bfa98dc96d"},
20 19
  "gen_batch_server": {:hex, :gen_batch_server, "0.8.9", "1c6bc0f530bf8c17e8b4acc20c2cc369ffa5bee2b46de01e21410745f24b1bc9", [:rebar3], [], "hexpm", "c8581fe4a4b6bccf91e53ce6a8c7e6c27c8c591bab5408b160166463f5579c22"},
21 20
  "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
22
  "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
21
  "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [ref: "0435d4ca364a608cc75e2f8683d374e55abbae26", sparse: "optimized", depth: 1]},
23 22
  "horde": {:hex, :horde, "0.9.1", "547507dfe1228471d9a31cece7653be81df2cf8f210816f672ce507dc9606128", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.7", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "14e1be4d6a84b066101641628a64f849d6df4f0311e93b7a9c39709e9c0bf4ba"},
24 23
  "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
25 24
  "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
26 25
  "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
27 26
  "lazy_html": {:hex, :lazy_html, "0.1.12", "31a55ee622918fce988c94b06232227b42daa64e4eab14ac32081d0f3fd8db6f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "8a0da594776caee58782c6f93b2abaa5bdb809daf8d43351a561f7de9dc2e2a8"},
28 27
  "libring": {:hex, :libring, "1.7.0", "4f245d2f1476cd7ed8f03740f6431acba815401e40299208c7f5c640e1883bda", [:mix], [], "hexpm", "070e3593cb572e04f2c8470dd0c119bc1817a7a0a7f88229f43cf0345268ec42"},
28
  "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"},
29
  "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"},
29 30
  "merkle_map": {:hex, :merkle_map, "0.2.2", "f36ff730cca1f2658e317a3c73406f50bbf5ac8aff54cf837d7ca2069a6e251c", [:mix], [], "hexpm", "383107f0503f230ac9175e0631647c424efd027e89ea65ab5ea12eeb54257aaf"},
30 31
  "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
31 32
  "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"},
33
  "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
32 34
  "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
35
  "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
33 36
  "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
34 37
  "phoenix": {:hex, :phoenix, "1.8.11", "9bb8f0c4e9f0b9eaf7f34cf8cb0bd25bd8599763a5587d64918a9ebb04027823", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "44f028f4129e5a29487e868f84903373e3d032da151ad0c789c3849f464e7351"},
35 38
  "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},

@@ -44,6 +47,7 @@

44 47
  "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"},
45 48
  "ra": {:hex, :ra, "2.17.3", "a72a4b4d1517fdf4168bfbd49a2fa9c2e77dcd17434dd3aa0da9daf6cad49329", [:rebar3], [{:aten, "0.6.0", [hex: :aten, repo: "hexpm", optional: false]}, {:gen_batch_server, "0.8.9", [hex: :gen_batch_server, repo: "hexpm", optional: false]}, {:seshat, "1.0.1", [hex: :seshat, repo: "hexpm", optional: false]}], "hexpm", "f4de530341bf416e43768fea56c72f08a074dd87c80d116f84a6e2e8277cc06a"},
46 49
  "req": {:hex, :req, "0.7.3", "b141f1b465dabc5fb8ce67bd2f15a85fc80f6c75719910699560e32ce49f62ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "73b303030dccc2b6d023ee5ada380825ab3a7cd3863aead493db09ec420ffdf2"},
50
  "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
47 51
  "seshat": {:hex, :seshat, "1.0.1", "fa7a8e89218d19394f7ddc47ba6725471103d654cd0bd0a98e5fdd922a943eac", [:rebar3], [], "hexpm", "38324fe8c5782c69d73b334dd00b20e16c0d99eef3e7b4005c519fe9bc0e34fc"},
48 52
  "swoosh": {:hex, :swoosh, "1.27.1", "ca5c1e4a4914c6d1d73d17aaefc3ed53e96edf6b84e0cca03620652ef689cf9d", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9e732f469259e5e84364967b6e0b5d5562adf35bd77576b92a6f3753de6ae9e1"},
49 53
  "tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},

@@ -54,4 +58,6 @@

54 58
  "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
55 59
  "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
56 60
  "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"},
61
  "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
62
  "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
57 63
}
ops/staging/generate-sbom.sh added +66

@@ -0,0 +1,66 @@

1
#!/usr/bin/env bash
2
set -euo pipefail
3
4
readonly SYFT_IMAGE="anchore/syft@sha256:678bfa565b60f747aac0f8e964fe5588a24445b8d0a480e91f6efd70020dfbb0"
5
6
if [[ $# -ne 2 ]]; then
7
  echo "usage: $0 <image-reference> <output.cdx.json>" >&2
8
  exit 64
9
fi
10
11
readonly image_reference="$1"
12
readonly output_path="$2"
13
readonly output_directory="$(dirname -- "$output_path")"
14
15
mkdir -p -- "$output_directory"
16
17
readonly temporary_sbom="$(mktemp "${output_path}.tmp.XXXXXX")"
18
readonly temporary_receipt="$(mktemp "${output_path}.receipt.tmp.XXXXXX")"
19
20
cleanup() {
21
  unlink "$temporary_sbom" 2>/dev/null || true
22
  unlink "$temporary_receipt" 2>/dev/null || true
23
}
24
trap cleanup EXIT
25
26
readonly image_digest="$(
27
  docker image inspect "$image_reference" \
28
    --format '{{index .RepoDigests 0}}' 2>/dev/null
29
)"
30
31
if [[ -z "$image_digest" || "$image_digest" == "<no value>" ]]; then
32
  echo "image must be present locally with a resolved repository digest: $image_reference" >&2
33
  exit 65
34
fi
35
36
docker run --rm \
37
  --volume /var/run/docker.sock:/var/run/docker.sock \
38
  "$SYFT_IMAGE" \
39
  "docker:$image_digest" \
40
  --quiet \
41
  --output cyclonedx-json >"$temporary_sbom"
42
43
jq -e '
44
  .bomFormat == "CycloneDX" and
45
  (.specVersion | type == "string") and
46
  (.components | type == "array" and length > 0)
47
' "$temporary_sbom" >/dev/null
48
49
readonly source_commit="$(git rev-parse HEAD)"
50
readonly sbom_sha256="$(sha256sum "$temporary_sbom" | cut -d ' ' -f 1)"
51
52
{
53
  echo "source_commit=$source_commit"
54
  echo "image_digest=$image_digest"
55
  echo "syft_image=$SYFT_IMAGE"
56
  echo "format=cyclonedx-json"
57
  echo "generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
58
  echo "sbom_sha256=$sbom_sha256"
59
} >"$temporary_receipt"
60
61
mv -- "$temporary_sbom" "$output_path"
62
mv -- "$temporary_receipt" "${output_path}.receipt"
63
trap - EXIT
64
65
echo "wrote $output_path"
66
echo "wrote ${output_path}.receipt"
priv/brand/README.md modified +2 -2

@@ -3,7 +3,7 @@

3 3
Third-party marks. **Do not edit these files.**
4 4
5 5
Separate from `priv/icons` for two reasons: a brand mark can never come from a
6
generic icon set, and `mix sarah.icons.vendor` clears `priv/icons` on every
6
generic icon set, and `mix openagents.icons.vendor` clears `priv/icons` on every
7 7
re-vendor, which would delete anything hand-added there.
8 8
9 9
- Source: [simple-icons](https://github.com/simple-icons/simple-icons), CC0-1.0.

@@ -14,5 +14,5 @@ label the action that reaches that service — a GitHub mark on the control that

14 14
signs in with GitHub — which is what the marks are for. Do not use one
15 15
decoratively, and do not imply endorsement.
16 16
17
Rendered through `SarahWeb.UI.icon/1` as `brand-<name>`, e.g.
17
Rendered through `OpenAgentsWeb.UI.icon/1` as `brand-<name>`, e.g.
18 18
`<.icon name="brand-github" />`. See `docs/ICONS.md`.
priv/icons/README.md modified +1 -1

@@ -12,7 +12,7 @@ Generated files. **Do not edit these by hand.**

12 12
13 13
Upstream ships one React component per glyph under
14 14
`src/components/Icon/svg/`, each drawing a `currentColor` SVG sized at `1em`.
15
Sarah has no React and no `node_modules`, so the components cannot be consumed
15
OpenAgents does not ship React in its Phoenix bundle, so the components cannot be consumed
16 16
as published. `mix openagents.icons.vendor <path-to-apps-sdk-ui>` converts them:
17 17
18 18
- JSX attribute names become their SVG spelling (`fillRule` → `fill-rule`, and
priv/licenses/HEROICONS-LICENSE added +21

@@ -0,0 +1,21 @@

1
MIT License
2
3
Copyright (c) Tailwind Labs, Inc.
4
5
Permission is hereby granted, free of charge, to any person obtaining a copy
6
of this software and associated documentation files (the "Software"), to deal
7
in the Software without restriction, including without limitation the rights
8
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
copies of the Software, and to permit persons to whom the Software is
10
furnished to do so, subject to the following conditions:
11
12
The above copyright notice and this permission notice shall be included in all
13
copies or substantial portions of the Software.
14
15
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
SOFTWARE.
priv/licenses/THIRD_PARTY_NOTICES.md added +22

@@ -0,0 +1,22 @@

1
# Third-party notices
2
3
OpenAgents is licensed under AGPL-3.0-or-later. The release also contains the
4
following third-party assets. Their licenses apply to those assets only.
5
6
- Basecoat `1.0.2` at commit `6953a4a`: MIT, copyright 2025 Ronan Berder. See
7
  `assets/vendor/basecoat/LICENSE.md` in the source distribution.
8
- OpenAI Apps SDK UI icons at commit `0f00143`: MIT, copyright 2025 OpenAI. See
9
  `priv/icons/LICENSE`.
10
- Heroicons `2.2.0` at revision
11
  `0435d4ca364a608cc75e2f8683d374e55abbae26`: MIT, copyright Tailwind Labs,
12
  Inc. See `priv/licenses/HEROICONS-LICENSE`.
13
- Geist Sans and Geist Mono: SIL Open Font License 1.1, copyright 2023 Vercel
14
  in collaboration with basement.studio. See `priv/static/fonts/LICENSE`.
15
- Titillium Web: SIL Open Font License 1.1, copyright 2009–2011 Accademia di
16
  Belle Arti di Urbino and contributors. See
17
  `priv/static/fonts/LICENSE-titillium-web`.
18
- The GitHub brand mark comes from Simple Icons under CC0-1.0. GitHub retains
19
  its trademarks. See `priv/brand/README.md` for the permitted product use.
20
21
The image SBOM records Elixir and operating-system packages for each exact
22
release artifact.
test/openagents/markdown_test.exs modified +61 -2

@@ -42,14 +42,39 @@ defmodule OpenAgents.MarkdownTest do

42 42
      assert html("> quoted") =~ "<blockquote>"
43 43
      assert html("~~gone~~") =~ "<del>"
44 44
    end
45
46
    test "renders tables and malformed Markdown without crashing" do
47
      table = html("| A | B |\n|---|---|\n| 1 | 2 |")
48
49
      assert table =~ "<table>"
50
      assert table =~ "<th>A</th>"
51
      assert table =~ "<td>2</td>"
52
53
      malformed = html("[unfinished **link](https://example.com and `code")
54
55
      assert is_binary(malformed)
56
      refute malformed =~ "<script"
57
    end
58
59
    test "complete persisted content is stable through the streaming path" do
60
      persisted = """
61
      ## Result
62
63
      - **Safe** [link](https://example.com)
64
      - `mix precommit`
65
      """
66
67
      assert html(persisted, streaming: true) == html(persisted)
68
    end
45 69
  end
46 70
47 71
  describe "untrusted input" do
48
    test "raw HTML is escaped, never emitted" do
72
    test "raw HTML is refused while its harmless text remains" do
49 73
      rendered = html("before <script>alert(1)</script> after")
50 74
51 75
      refute rendered =~ "<script>"
52
      assert rendered =~ "&lt;script&gt;"
76
      refute rendered =~ "&lt;script&gt;"
77
      assert rendered =~ "alert(1)"
53 78
    end
54 79
55 80
    test "an event-handler attribute cannot survive" do

@@ -77,6 +102,13 @@ defmodule OpenAgents.MarkdownTest do

77 102
      assert rendered =~ ~s(target="_blank")
78 103
    end
79 104
105
    test "relative, fragment, mail, and protocol-relative links are normalized safely" do
106
      assert html("[path](/docs)") =~ ~s(href="/docs")
107
      assert html("[section](#part)") =~ ~s(href="#part")
108
      assert html("[mail](mailto:team@example.com)") =~ ~s(href="mailto:team@example.com")
109
      assert html("[cdn](//example.com/x)") =~ ~s(href="https://example.com/x")
110
    end
111
80 112
    test "a bare domain is resolved as https rather than relative to Sarah" do
81 113
      assert html("[site](example.com)") =~ ~s(href="https://example.com")
82 114
    end

@@ -86,6 +118,33 @@ defmodule OpenAgents.MarkdownTest do

86 118
87 119
      refute rendered =~ ~s(onmouseover=")
88 120
    end
121
122
    test "oversized input returns a bounded escaped fallback" do
123
      rendered = html(String.duplicate("<", 1_000_001))
124
125
      assert rendered =~ "Markdown rendering limited."
126
      assert rendered =~ "input exceeds 1000000 bytes"
127
      assert byte_size(rendered) < 400_000
128
      refute rendered =~ "<script"
129
    end
130
131
    test "excessive nesting returns a bounded fallback" do
132
      # Blockquotes produce real nested AST nodes and exercise the structural
133
      # limit independently of raw input size.
134
      rendered = html(String.duplicate("> ", 40) <> "deep")
135
136
      assert rendered =~ "Markdown rendering limited."
137
      assert rendered =~ "nesting exceeds 32 levels"
138
      assert rendered =~ "deep"
139
    end
140
141
    test "render expansion is bounded independently of input size" do
142
      rendered = html(String.duplicate("&", 500_000))
143
144
      assert rendered =~ "Markdown rendering limited."
145
      assert rendered =~ "rendered output exceeds 2000000 bytes"
146
      assert byte_size(rendered) < 400_000
147
    end
89 148
  end
90 149
91 150
  describe "complete/1 for partial streams" do
test/openagents_web/icon_affordances_test.exs modified +2 -10

@@ -66,21 +66,13 @@ defmodule OpenAgentsWeb.IconAffordancesTest do

66 66
    test "no surface hand-writes an svg outside the vendored set" do
67 67
      # Every glyph must come from `priv/icons` through `icon/1`. A pasted
68 68
      # `<svg>` in a template is how a second, unmanaged icon set starts.
69
      # The exempt files implement the vendored set. `icons.ex` holds the
70
      # embedded SVG markup, while `ui.ex` and `core_components.ex` render it.
71
      # The assertions below fail if an exemption stops being icon plumbing.
72
      renderers = ["lib/openagents_web/components/core_components.ex"]
73
74
      for renderer <- renderers do
75
        assert File.read!(renderer) =~ "OpenAgentsWeb.Icons.fetch!",
76
               "the inline-SVG exemption for #{renderer} is stale; it no longer renders the vendored set"
77
      end
69
      # The exempt files implement the vendored set. `icons.ex` holds embedded
70
      # markup and `ui.ex` renders the one governed root element.
78 71
79 72
      offenders =
80 73
        "lib/openagents_web/**/*.{ex,heex}"
81 74
        |> Path.wildcard()
82 75
        |> Enum.reject(&String.ends_with?(&1, ["ui.ex", "icons.ex"]))
83
        |> Enum.reject(&(&1 in renderers))
84 76
        |> Enum.filter(fn path -> path |> File.read!() |> String.contains?("<svg") end)
85 77
86 78
      assert offenders == [],
test/openagents_web/live/components_live_test.exs modified +6 -10

@@ -42,7 +42,7 @@ defmodule OpenAgentsWeb.ComponentsLiveTest do

42 42
  end
43 43
44 44
  test "the sidebar is present on component pages", %{conn: conn} do
45
    {:ok, view, _html} = live(conn, ~p"/components/button")
45
    {:ok, view, _html} = live(conn, ~p"/components/openagents-button")
46 46
47 47
    assert has_element?(view, ~s{nav[aria-label="Component library"]})
48 48
    assert has_element?(view, ~s{a[href="/components/icons"]})

@@ -61,25 +61,21 @@ defmodule OpenAgentsWeb.ComponentsLiveTest do

61 61
  end
62 62
63 63
  test "the button page renders the button variants", %{conn: conn} do
64
    {:ok, view, _html} = live(conn, ~p"/components/button")
64
    {:ok, view, _html} = live(conn, ~p"/components/openagents-button")
65 65
66 66
    assert has_element?(view, "#demo-button-primary", "Primary")
67 67
    assert has_element?(view, "#demo-button-disabled")
68 68
  end
69 69
70 70
  test "the input page renders the demo form", %{conn: conn} do
71
    {:ok, view, _html} = live(conn, ~p"/components/input")
71
    {:ok, view, _html} = live(conn, ~p"/components/openagents-input")
72 72
73 73
    assert has_element?(view, "#component-form")
74 74
  end
75 75
76
  test "info flash action shows the flash", %{conn: conn} do
77
    {:ok, view, _html} = live(conn, ~p"/components/flash")
76
  test "the catalog exposes no nonfunctional theme control", %{conn: conn} do
77
    {:ok, view, _html} = live(conn, ~p"/components")
78 78
79
    view
80
    |> element("#demo-flash-info")
81
    |> render_click()
82
83
    assert render(view) =~ "This is the info flash from CoreComponents."
79
    refute has_element?(view, ~s{a[href="/components/theme-toggle"]})
84 80
  end
85 81
end
test/openagents_web/live/issue_index_live_test.exs modified +4 -4

@@ -71,8 +71,8 @@ defmodule OpenAgentsWeb.IssueIndexLiveTest do

71 71
  test "the comment count only renders once an issue has comments", %{conn: conn} do
72 72
    {:ok, issue} = Issues.create_issue(%{"title" => "Chatty"})
73 73
74
    {:ok, _view, html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")
75
    refute html =~ "hero-chat-bubble-left"
74
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")
75
    refute has_element?(view, ~s{svg[data-icon="comment"]})
76 76
77 77
    {:ok, _} =
78 78
      Issues.create_comment(%{

@@ -81,8 +81,8 @@ defmodule OpenAgentsWeb.IssueIndexLiveTest do

81 81
        user: %{"login" => "ada"}
82 82
      })
83 83
84
    {:ok, _view, html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")
85
    assert html =~ "hero-chat-bubble-left"
84
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")
85
    assert has_element?(view, ~s{svg[data-icon="comment"]})
86 86
  end
87 87
88 88
  test "patching to the closed filter swaps the stream and the current marker", %{conn: conn} do

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