Harvest AI Elements' evidence and artifact components

767d8ae74281 · AtlantisPleb · · parent 2b810a38ebfb

Harvest AI Elements' evidence and artifact components

Vercel's AI Elements has already solved the information design for the
surfaces that show what a model produced and where it came from, and it
solved it in Tailwind we can read. This takes ten of those components --
code block, snippet, terminal, sources, inline citation, context meter,
artifact, confirmation, question, image -- and lands them as HEEx
function components in one module, keeping the utility strings and
dropping everything React.

Everything React is a lot: each source component is a client component
built on Radix hover cards, collapsibles, carousels and tooltips, plus
Shiki for highlighting, `ansi-to-react` for terminal output, and
`tokenlens` for pricing. None of that comes with us. `sources/1` is a
details/summary pair, the two hover cards open on `:hover` and
`:focus-within` so a keyboard reaches them, `inline_citation/1` lists its
sources rather than paginating them, and `question/1` selects with real
radio and checkbox inputs styled as chips, so the answer arrives as
ordinary form parameters with no client state. Copy affordances reuse
`UI.copy_button/1` rather than adding a second one.

Three utilities had to change, and the module says why in its
documentation: `--accent` and `--primary` are both the brand indigo here
where shadcn means a quiet hover surface and the near-foreground ink, so
`bg-accent` became `bg-muted` and `text-primary` became `text-foreground`;
`not-prose` was dropped because Tailwind Typography is not installed.
The context ring is a masked conic gradient rather than the source's two
drawn circles, because a product surface may not carry inline SVG.

Two deliberate improvements on the source. `context/1` clamps its bar at
full and flags the overrun with `data-over-budget`, so an over-budget
context does not render identically to a legal one. And `code_block/1`
puts `phx-no-curly-interpolation` on the `pre`, which is what lets a
snippet containing braces survive HEEx -- the first thing a naive port
would silently eat, and the first thing the tests check.

Nothing calls this module yet; the catalog entries and the chat rewiring
land once the other batches are in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o8HwTaqLKEWCHTjsjFtrB
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added lib/openagents_web/components/ai/evidence.ex
  • added test/openagents_web/components/ai/evidence_test.exs

Diff

2 files changed, +1342 -0

lib/openagents_web/components/ai/evidence.ex added +905

@@ -0,0 +1,905 @@

1
defmodule OpenAgentsWeb.AI.Evidence do
2
  @moduledoc """
3
  Evidence and artifacts: what a model produced, and where it came from.
4
5
  Ported from Vercel's AI Elements (MIT), specifically `code-block.tsx`,
6
  `snippet.tsx`, `terminal.tsx`, `sources.tsx`, `inline-citation.tsx`,
7
  `context.tsx`, `artifact.tsx`, `confirmation.tsx`, `question.tsx`, and
8
  `image.tsx`. The Tailwind carried over as written; the React did not. Every
9
  source component is a client component built on Radix — hover cards,
10
  collapsibles, carousels, tooltips — and none of that survives the move to
11
  HEEx. What survives is the information design: what a code block's chrome
12
  holds, that a citation is a hostname chip that opens onto the sources behind
13
  it, that a token meter states a percentage twice (as a number and as an arc),
14
  and that a confirmation shows its decision after the fact rather than
15
  vanishing.
16
17
  ## Departures from the source, and why
18
19
    * **No syntax highlighting.** AI Elements tokenizes with Shiki in the
20
      browser and paints one `span` per token. Shiki is a JavaScript
21
      highlighter and a second rendering engine; `code_block/1` keeps the
22
      chrome, the line numbers, and the copy affordance, and renders the code
23
      as text. Highlighting, if it is ever wanted, belongs on the server.
24
25
    * **No ANSI parsing.** `terminal/1`'s source pipes output through
26
      `ansi-to-react`. Escape sequences arrive here as literal text. Strip them
27
      before passing `output`, or add a server-side parser later.
28
29
    * **No carousel.** `inline_citation/1`'s source paginates its sources with
30
      Embla. The sources are listed instead, which is the static fallback: the
31
      card is already scoped to one citation, so the count is small.
32
33
    * **Hover cards and collapsibles are CSS and markup.** `sources/1` is a
34
      `details`/`summary` pair. `inline_citation/1` and `context/1` reveal
35
      their card on hover and on focus within, so a keyboard reaches them.
36
      Neither needs a script.
37
38
    * **The context ring is a conic gradient, not a drawn arc.** The source
39
      draws two circles and animates `stroke-dashoffset`. Inline SVG is not
40
      allowed in a product surface here (`docs/ICONS.md`), so the ring is a
41
      masked `conic-gradient` in an inline style. It reads identically and
42
      costs no glyph.
43
44
    * **`context/1` clamps its bar and flags the overrun.** The source lets a
45
      progress bar past 100% render however the browser feels. A meter that
46
      silently pins at full hides the one state worth seeing, so an over-budget
47
      meter carries `data-over-budget="true"` and turns to the danger tone.
48
49
    * **Costs are attributes, not computed.** The source prices usage with
50
      `tokenlens`. There is no pricing table here, so a caller that knows the
51
      price passes it.
52
53
    * **`question/1` selects with real form controls.** The source toggles
54
      React state on buttons carrying `aria-checked`. Radio and checkbox inputs
55
      styled as chips give the same look with real keyboard behaviour, real
56
      form submission, and no client state. Its submit control cannot disable
57
      itself until a choice is made — that was React state — so the server
58
      validates instead.
59
60
  ## Utilities that had to change
61
62
  The Tailwind is the source's, class for class, with three exceptions. Two are
63
  name collisions: `assets/css/app.css` defines `--accent` as the brand indigo
64
  rather than shadcn's quiet hover surface, and `--primary` as the same indigo
65
  rather than the near-foreground ink, so `bg-accent` became `bg-muted` in
66
  `inline_citation/1` and `text-primary` became `text-foreground` in
67
  `sources/1`. The third is `not-prose` on `sources/1`, which escapes Tailwind
68
  Typography; that plugin is not installed here, so the class resolved to
69
  nothing and was dropped.
70
71
  ## Images and Markdown
72
73
  `image/1` renders its own `img` element from attributes. It has to:
74
  `OpenAgents.Markdown.to_html/2` sanitizes to an allowlist that has no `img`
75
  in it, so an image written into Markdown is dropped before it reaches the
76
  page. Model-generated images arrive as attributes and are rendered here.
77
  """
78
79
  use Phoenix.Component
80
81
  alias OpenAgentsWeb.UI
82
83
  @byte_units ~w(K M B)
84
85
  @doc """
86
  A block of code with chrome: a filename, a language, actions, and the code.
87
88
  The code is rendered as text. Line numbers, when asked for, come from a CSS
89
  counter incremented once per line, so no number is ever selected with the
90
  code it labels.
91
92
  The `pre` carries `phx-no-curly-interpolation`, which is what lets a snippet
93
  containing `{` and `}` survive HEEx unescaped.
94
  """
95
  attr :id, :string, required: true
96
  attr :code, :string, required: true
97
  attr :language, :string, default: nil, doc: "recorded on the container as `data-language`"
98
  attr :filename, :string, default: nil
99
  attr :show_line_numbers, :boolean, default: false
100
  attr :copy, :boolean, default: true, doc: "render the copy affordance in the header"
101
  attr :class, :any, default: nil
102
  attr :rest, :global
103
104
  slot :actions, doc: "controls at the trailing edge of the header"
105
106
  def code_block(assigns) do
107
    assigns = assign(assigns, :lines, String.split(assigns.code, "\n"))
108
109
    ~H"""
110
    <div
111
      id={@id}
112
      class={[
113
        "group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
114
        @class
115
      ]}
116
      data-language={@language}
117
      {@rest}
118
    >
119
      <div
120
        :if={@filename || @language || @copy || @actions != []}
121
        class="flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs"
122
      >
123
        <div class="flex items-center gap-2">
124
          <span :if={@filename} class="font-mono">{@filename}</span>
125
          <span :if={is_nil(@filename) && @language} class="font-mono">{@language}</span>
126
        </div>
127
        <div class="-my-1 -mr-1 flex items-center gap-2">
128
          {render_slot(@actions)}
129
          <UI.copy_button :if={@copy} id={@id <> "-copy"} text={@code} label="Copy" />
130
        </div>
131
      </div>
132
      <div class="relative overflow-auto">
133
        <pre class="m-0 p-4 text-sm" phx-no-curly-interpolation><code class={["font-mono text-sm", @show_line_numbers && "[counter-reset:line]"]}><span :for={line <- @lines} class={@show_line_numbers && line_number_classes() || "block"}><%= line %></span></code></pre>
134
      </div>
135
    </div>
136
    """
137
  end
138
139
  # The counter, its increment, and the gutter it prints into. Kept out of the
140
  # template because it is nine variants of one idea and reads as noise inline.
141
  defp line_number_classes do
142
    "block before:mr-4 before:inline-block before:w-8 before:[counter-increment:line] " <>
143
      "before:content-[counter(line)] before:select-none before:text-right " <>
144
      "before:font-mono before:text-muted-foreground/50"
145
  end
146
147
  @doc """
148
  One command, in a field you copy rather than retype.
149
150
  The input is read-only and holds the whole command, so selecting it selects
151
  the command and nothing else. The prefix — a shell sigil, a package manager —
152
  sits outside the field for the same reason: it is chrome, not text you want
153
  on the clipboard.
154
155
  The source composes this from shadcn's `InputGroup`. That component is not
156
  vendored here, so the group is assembled from the same utilities.
157
  """
158
  attr :id, :string, required: true
159
  attr :code, :string, required: true
160
  attr :prefix, :string, default: nil, doc: "a sigil shown before the command, such as `$`"
161
  attr :copy, :boolean, default: true
162
  attr :label, :string, default: "Command", doc: "the accessible name of the read-only field"
163
  attr :class, :any, default: nil
164
  attr :rest, :global
165
166
  def snippet(assigns) do
167
    ~H"""
168
    <div
169
      id={@id}
170
      class={[
171
        "flex w-full items-center gap-1 rounded-md border bg-background py-1 pr-1 pl-2 font-mono text-sm",
172
        @class
173
      ]}
174
      {@rest}
175
    >
176
      <span :if={@prefix} class="pl-2 font-normal text-muted-foreground">{@prefix}</span>
177
      <%!-- A bare input rather than `UI.input/1`. The group is the control here
178
            and carries the border, radius, and fill; `.input` carries its own
179
            set, so composing them would draw a field inside a field. --%>
180
      <input
181
        id={@id <> "-input"}
182
        class="min-w-0 flex-1 border-0 bg-transparent px-2 py-1 text-foreground outline-none"
183
        readonly
184
        aria-label={@label}
185
        value={@code}
186
      />
187
      <UI.copy_button :if={@copy} id={@id <> "-copy"} text={@code} label="Copy" />
188
    </div>
189
    """
190
  end
191
192
  @doc """
193
  A terminal transcript: a dark well holding command output.
194
195
  Fixed dark in both themes, as in the source. A terminal that inverts with the
196
  page stops reading as a terminal, and the output inside it was written for a
197
  dark ground.
198
199
  `output` is rendered verbatim. ANSI escape sequences are not parsed — see the
200
  module documentation.
201
  """
202
  attr :id, :string, required: true
203
  attr :output, :string, default: ""
204
  attr :title, :string, default: "Terminal"
205
  attr :streaming, :boolean, default: false, doc: "show the caret and the running status"
206
  attr :status, :string, default: nil, doc: "shown beside the actions while streaming"
207
  attr :copy, :boolean, default: true
208
  attr :class, :any, default: nil
209
  attr :rest, :global
210
211
  slot :actions, doc: "controls beside the copy affordance"
212
  slot :inner_block, doc: "replaces the rendered output, for composing lines by hand"
213
214
  def terminal(assigns) do
215
    ~H"""
216
    <div
217
      id={@id}
218
      class={[
219
        "flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
220
        @class
221
      ]}
222
      {@rest}
223
    >
224
      <div class="flex items-center justify-between border-zinc-800 border-b px-4 py-2">
225
        <div class="flex items-center gap-2 text-sm text-zinc-400">
226
          <UI.icon name="terminal" class="size-4" />{@title}
227
        </div>
228
        <div class="flex items-center gap-1">
229
          <span :if={@streaming && @status} class="flex items-center gap-2 text-xs text-zinc-400">
230
            {@status}
231
          </span>
232
          <div class="flex items-center gap-1">
233
            {render_slot(@actions)}
234
            <UI.copy_button :if={@copy} id={@id <> "-copy"} text={@output} label="Copy" />
235
          </div>
236
        </div>
237
      </div>
238
      <div class="max-h-96 overflow-auto p-4 font-mono text-sm leading-relaxed">
239
        <%= if @inner_block == [] do %>
240
          <pre class="whitespace-pre-wrap break-words" phx-no-curly-interpolation><%= @output %><span :if={@streaming} class="ml-0.5 inline-block h-4 w-2 animate-pulse bg-zinc-100"></span></pre>
241
        <% else %>
242
          {render_slot(@inner_block)}
243
        <% end %>
244
      </div>
245
    </div>
246
    """
247
  end
248
249
  @doc """
250
  One line of a terminal transcript: a prompt sigil and the command after it.
251
252
  For composing a transcript by hand inside `terminal/1`'s inner block, where
253
  the commands and their output are separate values rather than one string.
254
  """
255
  attr :prompt, :string, default: "$"
256
  attr :class, :any, default: nil
257
  attr :rest, :global
258
  slot :inner_block, required: true
259
260
  def terminal_line(assigns) do
261
    ~H"""
262
    <div class={["flex gap-2 whitespace-pre-wrap break-words", @class]} {@rest}>
263
      <span aria-hidden="true" class="select-none text-zinc-500">{@prompt}</span>
264
      <span class="min-w-0 flex-1">{render_slot(@inner_block)}</span>
265
    </div>
266
    """
267
  end
268
269
  @doc """
270
  The sources a message was drawn from, folded away until asked for.
271
272
  A `details`/`summary` pair rather than a collapsible built from script: the
273
  browser already knows how to open and close a disclosure, announce its state,
274
  and reach it from the keyboard.
275
276
  The source carries `not-prose` to escape Tailwind Typography. That plugin is
277
  not installed here, so the class would resolve to nothing and is dropped.
278
  """
279
  attr :id, :string, required: true
280
  attr :count, :integer, required: true
281
  attr :open, :boolean, default: false
282
  attr :class, :any, default: nil
283
  attr :rest, :global
284
285
  slot :trigger, doc: "replaces the default summary line"
286
  slot :inner_block, required: true, doc: "the sources, normally `source/1` calls"
287
288
  def sources(assigns) do
289
    ~H"""
290
    <%!-- `text-foreground`, not the source's `text-primary`: `--primary` here is
291
          the brand indigo (app.css), where shadcn's is the near-foreground ink.
292
          Left as written, the whole disclosure came out indigo. --%>
293
    <details id={@id} open={@open} class={["group mb-4 text-foreground text-xs", @class]} {@rest}>
294
      <summary class="flex cursor-pointer list-none items-center gap-2">
295
        <%= if @trigger == [] do %>
296
          <p class="font-medium">Used {@count} sources</p>
297
          <UI.icon name="chevron-down" class="h-4 w-4 transition-transform group-open:rotate-180" />
298
        <% else %>
299
          {render_slot(@trigger)}
300
        <% end %>
301
      </summary>
302
      <div class="mt-3 flex w-fit flex-col gap-2">
303
        {render_slot(@inner_block)}
304
      </div>
305
    </details>
306
    """
307
  end
308
309
  @doc "One source behind a message: a glyph and the title, linking out."
310
  attr :href, :string, required: true
311
  attr :title, :string, default: nil
312
  attr :class, :any, default: nil
313
  attr :rest, :global
314
  slot :inner_block
315
316
  def source(assigns) do
317
    ~H"""
318
    <a
319
      href={@href}
320
      class={["flex items-center gap-2", @class]}
321
      rel="noreferrer"
322
      target="_blank"
323
      {@rest}
324
    >
325
      <%= if @inner_block == [] do %>
326
        <UI.icon name="book" class="h-4 w-4" />
327
        <span class="block font-medium">{@title || @href}</span>
328
      <% else %>
329
        {render_slot(@inner_block)}
330
      <% end %>
331
    </a>
332
    """
333
  end
334
335
  @doc """
336
  A run of cited text, with the sources behind it one hover away.
337
338
  The chip names the first source by hostname and counts the rest, which is the
339
  useful summary: a reader scanning a paragraph wants to know *whose* claim it
340
  is before deciding to open anything.
341
342
  The card opens on hover and on focus within, so it is reachable without a
343
  pointer. The source paginates with a carousel; the sources are listed here
344
  instead.
345
  """
346
  attr :id, :string, required: true
347
  attr :class, :any, default: nil
348
  attr :rest, :global
349
350
  slot :inner_block, required: true, doc: "the cited text"
351
352
  slot :source, doc: "one source behind the citation" do
353
    attr :url, :string, required: true
354
    attr :title, :string
355
    attr :description, :string
356
  end
357
358
  def inline_citation(assigns) do
359
    ~H"""
360
    <span id={@id} class={["group relative inline items-center gap-1", @class]} {@rest}>
361
      <%!-- `bg-muted`, not the source's `bg-accent`: app.css redefines `--accent` as
362
           the brand indigo rather than shadcn's quiet hover surface, and a run of
363
           prose highlighted in indigo on hover reads as a selection, not a hint. --%>
364
      <span class="transition-colors group-hover:bg-muted">{render_slot(@inner_block)}</span>
365
      <%!-- A button wrapping the badge, not a badge that looks clickable. The
366
            card opens on hover and on focus within, and only a real control
367
            takes focus, so the keyboard reaches what the pointer does. --%>
368
      <button type="button" aria-describedby={@id <> "-card"} class="align-baseline">
369
        <UI.badge variant={:dim} class="ml-1 rounded-full">{citation_label(@source)}</UI.badge>
370
      </button>
371
      <span
372
        id={@id <> "-card"}
373
        role="note"
374
        class={[
375
          "invisible absolute top-full left-0 z-50 mt-1 w-80 rounded-md border bg-popover p-0",
376
          "text-popover-foreground opacity-0 shadow-md transition-opacity",
377
          "group-hover:visible group-hover:opacity-100",
378
          "group-focus-within:visible group-focus-within:opacity-100"
379
        ]}
380
      >
381
        <span class="flex items-center justify-end gap-2 rounded-t-md bg-secondary px-3 py-2 text-muted-foreground text-xs">
382
          {length(@source)} {(length(@source) == 1 && "source") || "sources"}
383
        </span>
384
        <span class="block divide-y">
385
          <span :for={source <- @source} class="block w-full space-y-2 p-4">
386
            <.inline_citation_source
387
              title={source[:title]}
388
              url={source[:url]}
389
              description={source[:description]}
390
            >
391
              {render_slot(source)}
392
            </.inline_citation_source>
393
          </span>
394
        </span>
395
      </span>
396
    </span>
397
    """
398
  end
399
400
  @doc "The title, address, and summary of one cited source."
401
  attr :title, :string, default: nil
402
  attr :url, :string, default: nil
403
  attr :description, :string, default: nil
404
  attr :class, :any, default: nil
405
  attr :rest, :global
406
  slot :inner_block
407
408
  def inline_citation_source(assigns) do
409
    ~H"""
410
    <span class={["block space-y-1", @class]} {@rest}>
411
      <span :if={@title} class="block truncate font-medium text-sm leading-tight">{@title}</span>
412
      <span :if={@url} class="block truncate break-all text-muted-foreground text-xs">{@url}</span>
413
      <span :if={@description} class="block text-muted-foreground text-sm leading-relaxed">
414
        {@description}
415
      </span>
416
      {render_slot(@inner_block)}
417
    </span>
418
    """
419
  end
420
421
  @doc "A quotation lifted from a cited source."
422
  attr :class, :any, default: nil
423
  attr :rest, :global
424
  slot :inner_block, required: true
425
426
  def inline_citation_quote(assigns) do
427
    ~H"""
428
    <span
429
      class={["block border-muted border-l-2 pl-3 text-muted-foreground text-sm italic", @class]}
430
      {@rest}
431
    >
432
      {render_slot(@inner_block)}
433
    </span>
434
    """
435
  end
436
437
  @doc """
438
  How much of a model's context window a conversation has spent.
439
440
  The percentage is stated twice — as a number and as an arc — because the arc
441
  is the thing read at a glance and the number is the thing acted on. Hovering
442
  or focusing the control opens the breakdown: the split by kind, and what it
443
  cost.
444
445
  Past the window, the bar pins at full and the control carries
446
  `data-over-budget="true"`, so the one state worth seeing does not look
447
  identical to a full but legal context.
448
  """
449
  attr :id, :string, required: true
450
  attr :used_tokens, :integer, required: true
451
  attr :max_tokens, :integer, required: true
452
  attr :input_tokens, :integer, default: nil
453
  attr :output_tokens, :integer, default: nil
454
  attr :reasoning_tokens, :integer, default: nil
455
  attr :cached_tokens, :integer, default: nil
456
  attr :input_cost, :float, default: nil, doc: "US dollars, since there is no pricing table here"
457
  attr :output_cost, :float, default: nil
458
  attr :reasoning_cost, :float, default: nil
459
  attr :cached_cost, :float, default: nil
460
  attr :total_cost, :float, default: nil
461
  attr :class, :any, default: nil
462
  attr :rest, :global
463
464
  def context(assigns) do
465
    fraction = safe_fraction(assigns.used_tokens, assigns.max_tokens)
466
467
    assigns =
468
      assign(assigns,
469
        fraction: fraction,
470
        bar_percent: min(fraction * 100, 100.0),
471
        over_budget?: fraction > 1.0
472
      )
473
474
    ~H"""
475
    <div
476
      id={@id}
477
      class={["group relative inline-block", @class]}
478
      data-over-budget={to_string(@over_budget?)}
479
      {@rest}
480
    >
481
      <UI.button variant={:ghost} size={:sm} aria-describedby={@id <> "-detail"}>
482
        <span class={["font-medium", (@over_budget? && "text-danger") || "text-muted-foreground"]}>
483
          {percent_text(@fraction)}
484
        </span>
485
        <span
486
          role="img"
487
          aria-label="Model context usage"
488
          class="inline-block size-5 shrink-0 rounded-full"
489
          style={ring_style(@bar_percent)}
490
        ></span>
491
      </UI.button>
492
      <div
493
        id={@id <> "-detail"}
494
        role="note"
495
        class={[
496
          "invisible absolute top-full right-0 z-50 mt-1 min-w-60 divide-y overflow-hidden",
497
          "rounded-md border bg-popover text-popover-foreground opacity-0 shadow-md",
498
          "transition-opacity group-hover:visible group-hover:opacity-100",
499
          "group-focus-within:visible group-focus-within:opacity-100"
500
        ]}
501
      >
502
        <div class="w-full space-y-2 p-3">
503
          <div class="flex items-center justify-between gap-3 text-xs">
504
            <p>{percent_text(@fraction)}</p>
505
            <p class="font-mono text-muted-foreground">
506
              {compact_number(@used_tokens)} / {compact_number(@max_tokens)}
507
            </p>
508
          </div>
509
          <div
510
            class="h-2 w-full overflow-hidden rounded-full bg-muted"
511
            role="progressbar"
512
            aria-label="Context used"
513
            aria-valuemin="0"
514
            aria-valuemax="100"
515
            aria-valuenow={round(@bar_percent)}
516
          >
517
            <div
518
              class={["h-full rounded-full", (@over_budget? && "bg-danger") || "bg-primary"]}
519
              style={"width: #{@bar_percent}%"}
520
            >
521
            </div>
522
          </div>
523
        </div>
524
        <div
525
          :if={@input_tokens || @output_tokens || @reasoning_tokens || @cached_tokens}
526
          class="w-full space-y-1 p-3"
527
        >
528
          <.usage_row :if={@input_tokens} label="Input" tokens={@input_tokens} cost={@input_cost} />
529
          <.usage_row :if={@output_tokens} label="Output" tokens={@output_tokens} cost={@output_cost} />
530
          <.usage_row
531
            :if={@reasoning_tokens}
532
            label="Reasoning"
533
            tokens={@reasoning_tokens}
534
            cost={@reasoning_cost}
535
          />
536
          <.usage_row :if={@cached_tokens} label="Cache" tokens={@cached_tokens} cost={@cached_cost} />
537
        </div>
538
        <div
539
          :if={@total_cost}
540
          class="flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs"
541
        >
542
          <span class="text-muted-foreground">Total cost</span>
543
          <span>{currency(@total_cost)}</span>
544
        </div>
545
      </div>
546
    </div>
547
    """
548
  end
549
550
  attr :label, :string, required: true
551
  attr :tokens, :integer, required: true
552
  attr :cost, :float, default: nil
553
554
  defp usage_row(assigns) do
555
    ~H"""
556
    <div class="flex items-center justify-between text-xs">
557
      <span class="text-muted-foreground">{@label}</span>
558
      <span>
559
        {compact_number(@tokens)}
560
        <span :if={@cost} class="ml-2 text-muted-foreground">
561
          • {currency(@cost)}
562
        </span>
563
      </span>
564
    </div>
565
    """
566
  end
567
568
  @doc """
569
  A named thing the model produced, in a frame with its own actions.
570
571
  The frame is the point: an artifact is a deliverable, not a paragraph, so it
572
  gets a header that names it and a body that scrolls on its own rather than
573
  extending the transcript.
574
  """
575
  attr :id, :string, required: true
576
  attr :title, :string, required: true
577
  attr :description, :string, default: nil
578
  attr :class, :any, default: nil
579
  attr :rest, :global
580
581
  slot :actions, doc: "controls at the trailing edge of the header, normally `artifact_action/1`"
582
  slot :inner_block, required: true
583
584
  def artifact(assigns) do
585
    ~H"""
586
    <div
587
      id={@id}
588
      class={["flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm", @class]}
589
      {@rest}
590
    >
591
      <div class="flex items-center justify-between border-b bg-muted/50 px-4 py-3">
592
        <div class="min-w-0">
593
          <p class="font-medium text-foreground text-sm">{@title}</p>
594
          <p :if={@description} class="text-muted-foreground text-sm">{@description}</p>
595
        </div>
596
        <div :if={@actions != []} class="flex items-center gap-1">
597
          {render_slot(@actions)}
598
        </div>
599
      </div>
600
      <div class="flex-1 overflow-auto p-4">
601
        {render_slot(@inner_block)}
602
      </div>
603
    </div>
604
    """
605
  end
606
607
  @doc """
608
  One icon-only control in an artifact's header.
609
610
  The source wraps each of these in a Radix tooltip. There is no tooltip
611
  primitive here, so the hint is the native `title` and the accessible name is
612
  `label` — which the control needs regardless, tooltip or not.
613
  """
614
  attr :icon, :string, required: true, doc: "a name from the governed icon set"
615
  attr :label, :string, required: true, doc: "the accessible name of the control"
616
  attr :tooltip, :string, default: nil, doc: "hover hint; falls back to `label`"
617
  attr :class, :any, default: nil
618
619
  attr :rest, :global,
620
    include: ~w(disabled form name value href navigate patch phx-click phx-value-id)
621
622
  def artifact_action(assigns) do
623
    ~H"""
624
    <UI.button
625
      variant={:ghost}
626
      size={:sm}
627
      class={["size-8 p-0 text-muted-foreground hover:text-foreground", @class]}
628
      title={@tooltip || @label}
629
      aria-label={@label}
630
      {@rest}
631
    >
632
      <UI.icon name={@icon} class="size-4" />
633
    </UI.button>
634
    """
635
  end
636
637
  @doc """
638
  A tool asking permission, and the record of what was decided.
639
640
  The decided states are not a smaller version of the request: the controls go
641
  away and the outcome takes their place, so a transcript scrolled back through
642
  still says what was allowed.
643
  """
644
  attr :id, :string, required: true
645
646
  attr :state, :atom,
647
    values: [:requested, :approved, :denied],
648
    required: true,
649
    doc: "`:requested` shows the controls; the other two show the outcome"
650
651
  attr :title, :string, default: nil
652
  attr :reason, :string, default: nil, doc: "why the decision went the way it did"
653
  attr :class, :any, default: nil
654
  attr :rest, :global
655
656
  slot :inner_block, doc: "what is being asked for; replaces `title`"
657
  slot :actions, doc: "the approve and deny controls, normally `confirmation_action/1`"
658
659
  def confirmation(assigns) do
660
    ~H"""
661
    <UI.alert
662
      id={@id}
663
      variant={confirmation_variant(@state)}
664
      appearance={:notice}
665
      class={@class}
666
      data-state={@state}
667
      {@rest}
668
    >
669
      <%!-- One flex column inside the alert, not on it. `UI.alert/1` wraps its
670
            inner block in a section of its own, so a column declared on the
671
            alert would lay out the alert's parts rather than these. --%>
672
      <div class="flex flex-col gap-2">
673
        <span class="inline">
674
          <%= if @inner_block == [] do %>
675
            {@title}
676
          <% else %>
677
            {render_slot(@inner_block)}
678
          <% end %>
679
        </span>
680
        <span :if={@state != :requested} class="inline text-muted-foreground text-sm">
681
          {confirmation_outcome(@state)}<span :if={@reason}>: {@reason}</span>
682
        </span>
683
        <span
684
          :if={@state == :requested && @actions != []}
685
          class="flex items-center justify-end gap-2 self-end"
686
        >
687
          {render_slot(@actions)}
688
        </span>
689
      </div>
690
    </UI.alert>
691
    """
692
  end
693
694
  @doc "One control on a confirmation: approve, or deny."
695
  attr :variant, :atom,
696
    values: [:primary, :secondary, :outline, :ghost, :destructive, :chip, :notched, :link],
697
    default: :secondary
698
699
  attr :class, :any, default: nil
700
  attr :rest, :global, include: ~w(disabled form name value phx-click phx-value-id)
701
  slot :inner_block, required: true
702
703
  def confirmation_action(assigns) do
704
    ~H"""
705
    <UI.button variant={@variant} class={["h-8 px-3 text-sm", @class]} {@rest}>
706
      {render_slot(@inner_block)}
707
    </UI.button>
708
    """
709
  end
710
711
  @doc """
712
  A question back to the reader: a set of choices, and room to say something
713
  else.
714
715
  Both halves matter. A model that can only offer choices asks the wrong
716
  question sooner or later, and a model that only offers a text box makes the
717
  reader do the work it already did.
718
719
  Choices are radio inputs in single mode and checkboxes in multiple mode, so
720
  selection is the browser's job and the answer arrives as ordinary form
721
  parameters.
722
  """
723
  attr :id, :string, required: true
724
  attr :prompt, :string, required: true
725
  attr :description, :string, default: nil
726
  attr :name, :string, default: "question", doc: "the parameter the choices submit under"
727
728
  attr :selection_mode, :atom,
729
    values: [:single, :multiple],
730
    default: :single
731
732
  attr :selected, :list, default: [], doc: "the values that arrive already chosen"
733
  attr :text, :string, default: nil, doc: "the freeform response that arrives already written"
734
  attr :text_label, :string, default: "Anything else?"
735
  attr :placeholder, :string, default: nil
736
  attr :disabled, :boolean, default: false
737
  attr :submit_label, :string, default: "Submit"
738
  attr :class, :any, default: nil
739
  attr :rest, :global, include: ~w(phx-submit phx-change phx-target method action)
740
741
  slot :option, doc: "one choice" do
742
    attr :value, :string, required: true
743
  end
744
745
  def question(assigns) do
746
    assigns =
747
      assign(assigns,
748
        input_type: (assigns.selection_mode == :single && "radio") || "checkbox",
749
        field_name: (assigns.selection_mode == :single && assigns.name) || assigns.name <> "[]"
750
      )
751
752
    ~H"""
753
    <form id={@id} class={["space-y-4 rounded-lg border bg-background p-4", @class]} {@rest}>
754
      <p class="font-medium text-sm">{@prompt}</p>
755
      <p :if={@description} class="text-muted-foreground text-sm">{@description}</p>
756
      <fieldset :if={@option != []} class="flex flex-wrap gap-2" disabled={@disabled}>
757
        <legend class="sr-only">{@prompt}</legend>
758
        <label
759
          :for={{option, index} <- Enum.with_index(@option)}
760
          for={"#{@id}-option-#{index}"}
761
          class={
762
            [
763
              "btn h-auto cursor-pointer whitespace-normal",
764
              "has-[:checked]:bg-primary has-[:checked]:text-primary-foreground",
765
              # The control the reader operates is the chip; the input inside it is
766
              # visually hidden, so the focus ring has to be drawn by the chip or
767
              # keyboard selection happens with nothing on screen moving.
768
              "has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2",
769
              "has-[:focus-visible]:outline-ring"
770
            ]
771
          }
772
          data-variant="outline"
773
          data-size="sm"
774
        >
775
          <input
776
            type={@input_type}
777
            id={"#{@id}-option-#{index}"}
778
            name={@field_name}
779
            value={option.value}
780
            checked={option.value in @selected}
781
            class="sr-only"
782
          />
783
          {render_slot(option)}
784
        </label>
785
      </fieldset>
786
      <div class="space-y-1">
787
        <UI.label for={@id <> "-text"}>{@text_label}</UI.label>
788
        <UI.textarea
789
          id={@id <> "-text"}
790
          name={@name <> "_text"}
791
          value={@text}
792
          class="min-h-20"
793
          placeholder={@placeholder}
794
          disabled={@disabled}
795
        />
796
      </div>
797
      <div class="flex items-center justify-end gap-2">
798
        <UI.button type="submit" disabled={@disabled}>{@submit_label}</UI.button>
799
      </div>
800
    </form>
801
    """
802
  end
803
804
  @doc """
805
  A generated image, in a frame that does not let it push the page around.
806
807
  Takes either a `src` or the `base64` and `media_type` a model returns, which
808
  become a data URI. `alt` is required: an image with no alternative text is
809
  the whole message to a reader who cannot see it, and a generated image is
810
  exactly the case where nothing nearby says what it shows.
811
812
  Markdown cannot carry this. `OpenAgents.Markdown.to_html/2` sanitizes to an
813
  allowlist with no `img` in it, so an image written into Markdown is dropped.
814
  """
815
  attr :id, :string, required: true
816
  attr :alt, :string, required: true
817
  attr :src, :string, default: nil
818
  attr :base64, :string, default: nil
819
  attr :media_type, :string, default: nil, doc: "an IANA media type, such as `image/png`"
820
  attr :class, :any, default: nil
821
  attr :rest, :global
822
823
  def image(assigns) do
824
    ~H"""
825
    <img
826
      id={@id}
827
      src={image_source(@src, @base64, @media_type)}
828
      alt={@alt}
829
      class={["h-auto max-w-full overflow-hidden rounded-md", @class]}
830
      {@rest}
831
    />
832
    """
833
  end
834
835
  # ── formatting ────────────────────────────────────────────────────────────
836
837
  defp image_source(nil, base64, media_type) when is_binary(base64) and is_binary(media_type),
838
    do: "data:#{media_type};base64,#{base64}"
839
840
  defp image_source(src, _base64, _media_type), do: src
841
842
  defp confirmation_variant(:requested), do: :warning
843
  defp confirmation_variant(:approved), do: :success
844
  defp confirmation_variant(:denied), do: :danger
845
846
  defp confirmation_outcome(:approved), do: "Approved"
847
  defp confirmation_outcome(:denied), do: "Denied"
848
849
  defp citation_label([]), do: "unknown"
850
851
  defp citation_label([first | rest]) do
852
    host = URI.parse(first[:url] || "").host || "unknown"
853
    if rest == [], do: host, else: "#{host} +#{length(rest)}"
854
  end
855
856
  defp safe_fraction(_used, max) when max in [nil, 0], do: 0.0
857
  defp safe_fraction(used, max), do: used / max
858
859
  # A masked conic gradient. Two stops draw the arc against a quarter-strength
860
  # track, and the radial mask cuts the middle out, which is what turns a pie
861
  # into a ring. `currentColor` keeps it in the colour of the text beside it,
862
  # exactly as the drawn version did.
863
  defp ring_style(percent) do
864
    "background: conic-gradient(currentColor #{percent}%, " <>
865
      "color-mix(in oklab, currentColor 25%, transparent) 0); " <>
866
      "mask: radial-gradient(closest-side, transparent 70%, black 72%); " <>
867
      "-webkit-mask: radial-gradient(closest-side, transparent 70%, black 72%);"
868
  end
869
870
  defp percent_text(fraction) do
871
    rounded = Float.round(fraction * 100, 1)
872
873
    if rounded == Float.round(rounded, 0) do
874
      "#{trunc(rounded)}%"
875
    else
876
      "#{rounded}%"
877
    end
878
  end
879
880
  defp currency(amount), do: "$#{:erlang.float_to_binary(amount / 1, decimals: 2)}"
881
882
  # `Intl.NumberFormat(..., {notation: "compact"})` in Elixir: two significant
883
  # digits below ten, whole numbers above it, and no trailing zero.
884
  defp compact_number(value) when value < 1000, do: Integer.to_string(value)
885
886
  defp compact_number(value), do: compact_number(value / 1000, @byte_units)
887
888
  defp compact_number(value, [unit]), do: "#{compact_mantissa(value)}#{unit}"
889
890
  defp compact_number(value, [unit | rest]) do
891
    if value < 1000,
892
      do: "#{compact_mantissa(value)}#{unit}",
893
      else: compact_number(value / 1000, rest)
894
  end
895
896
  defp compact_mantissa(value) when value < 10 do
897
    rounded = Float.round(value, 1)
898
899
    if rounded == Float.round(rounded, 0),
900
      do: Integer.to_string(trunc(rounded)),
901
      else: "#{rounded}"
902
  end
903
904
  defp compact_mantissa(value), do: Integer.to_string(round(value))
905
end
test/openagents_web/components/ai/evidence_test.exs added +437

@@ -0,0 +1,437 @@

1
defmodule OpenAgentsWeb.AI.EvidenceTest do
2
  @moduledoc """
3
  The parts of the evidence surfaces a screenshot cannot check.
4
5
  Four failure modes are covered, and all four are invisible by eye. A code
6
  block that eats the braces in the code it is showing looks like a code block
7
  until you read it. A disclosure that renders its sources whether or not it is
8
  open leaks them to anyone reading the markup rather than the page. A token
9
  meter that pins silently at full says the same thing about a legal context
10
  and an overrun one. And a confirmation that keeps its controls after a
11
  decision offers to decide again.
12
  """
13
14
  use ExUnit.Case, async: true
15
16
  import Phoenix.LiveViewTest, only: [render_component: 2]
17
18
  alias OpenAgentsWeb.AI.Evidence
19
20
  defp query(html, selector) do
21
    html
22
    |> LazyHTML.from_fragment()
23
    |> LazyHTML.query(selector)
24
    |> LazyHTML.to_tree()
25
  end
26
27
  defp text(html, selector) do
28
    html
29
    |> LazyHTML.from_fragment()
30
    |> LazyHTML.query(selector)
31
    |> LazyHTML.text()
32
    |> String.trim()
33
  end
34
35
  defp block(name, text, attributes \\ %{}) do
36
    attributes
37
    |> Map.merge(%{__slot__: name, inner_block: fn _changed, _arg -> text end})
38
  end
39
40
  describe "code_block/1" do
41
    test "keeps the braces in the code it is showing" do
42
      html =
43
        render_component(&Evidence.code_block/1,
44
          id: "cb-braces",
45
          code: ~s|let obj = {key: "val"}|,
46
          language: "javascript"
47
        )
48
49
      assert html =~ ~s|let obj = {key: &quot;val&quot;}|
50
    end
51
52
    test "renders one line element per line, so the counter can number them" do
53
      html =
54
        render_component(&Evidence.code_block/1,
55
          id: "cb-lines",
56
          code: "one\ntwo\nthree",
57
          show_line_numbers: true
58
        )
59
60
      assert length(query(html, "#cb-lines pre code > span")) == 3
61
      assert [{_, attrs, _}] = query(html, "#cb-lines pre code")
62
      assert {"class", class} = List.keyfind(attrs, "class", 0)
63
      assert class =~ "[counter-reset:line]"
64
    end
65
66
    test "numbers nothing unless asked" do
67
      html = render_component(&Evidence.code_block/1, id: "cb-plain", code: "one\ntwo")
68
69
      assert [{_, attrs, _}] = query(html, "#cb-plain pre code")
70
      refute List.keyfind(attrs, "class", 0) |> elem(1) =~ "counter-reset"
71
    end
72
73
    test "carries the language and a copy control that holds the whole snippet" do
74
      html =
75
        render_component(&Evidence.code_block/1,
76
          id: "cb-copy",
77
          code: "IO.puts(1)",
78
          language: "elixir"
79
        )
80
81
      assert [{_, attrs, _}] = query(html, "#cb-copy")
82
      assert {"data-language", "elixir"} = List.keyfind(attrs, "data-language", 0)
83
84
      assert [{_, copy, _}] = query(html, "#cb-copy-copy")
85
      assert {"data-copy-text", "IO.puts(1)"} = List.keyfind(copy, "data-copy-text", 0)
86
    end
87
  end
88
89
  describe "snippet/1" do
90
    test "puts the command in a read-only field with an accessible name" do
91
      html =
92
        render_component(&Evidence.snippet/1,
93
          id: "sn",
94
          code: "mix precommit",
95
          prefix: "$",
96
          label: "Install command"
97
        )
98
99
      assert [{_, attrs, _}] = query(html, "#sn-input")
100
      assert {"value", "mix precommit"} = List.keyfind(attrs, "value", 0)
101
      assert {"readonly", _} = List.keyfind(attrs, "readonly", 0)
102
      assert {"aria-label", "Install command"} = List.keyfind(attrs, "aria-label", 0)
103
      assert query(html, "#sn-copy") != []
104
    end
105
  end
106
107
  describe "terminal/1" do
108
    test "shows the caret only while output is still arriving" do
109
      streaming =
110
        render_component(&Evidence.terminal/1, id: "tm-live", output: "building", streaming: true)
111
112
      done = render_component(&Evidence.terminal/1, id: "tm-done", output: "built")
113
114
      assert query(streaming, "#tm-live .animate-pulse") != []
115
      assert query(done, "#tm-done .animate-pulse") == []
116
    end
117
118
    test "renders the output verbatim inside a preformatted block" do
119
      html = render_component(&Evidence.terminal/1, id: "tm-out", output: "a\nb")
120
121
      assert [{_, _, ["a\nb"]}] = query(html, "#tm-out pre")
122
    end
123
  end
124
125
  describe "terminal_line/1" do
126
    test "hides the prompt sigil from assistive technology" do
127
      html =
128
        render_component(&Evidence.terminal_line/1,
129
          prompt: "$",
130
          inner_block: [block(:inner_block, "mix test")]
131
        )
132
133
      assert [{_, attrs, ["$"]}] = query(html, "span[aria-hidden]")
134
      assert {"aria-hidden", "true"} = List.keyfind(attrs, "aria-hidden", 0)
135
      assert html =~ "mix test"
136
    end
137
  end
138
139
  describe "sources/1" do
140
    test "a closed disclosure is closed" do
141
      html =
142
        render_component(&Evidence.sources/1,
143
          id: "src-closed",
144
          count: 3,
145
          inner_block: [block(:inner_block, "the sources")]
146
        )
147
148
      assert [{_, attrs, _}] = query(html, "#src-closed")
149
      assert List.keyfind(attrs, "open", 0) == nil
150
      assert html =~ "Used 3 sources"
151
    end
152
153
    test "an open disclosure says so on the element the browser reads" do
154
      html =
155
        render_component(&Evidence.sources/1,
156
          id: "src-open",
157
          count: 1,
158
          open: true,
159
          inner_block: [block(:inner_block, "the sources")]
160
        )
161
162
      assert [{"details", attrs, _}] = query(html, "#src-open")
163
      assert List.keyfind(attrs, "open", 0) != nil
164
      assert query(html, "#src-open > summary") != []
165
    end
166
  end
167
168
  describe "source/1" do
169
    test "an outbound source opens away from the app and does not leak the referrer" do
170
      html =
171
        render_component(&Evidence.source/1, href: "https://example.com/a", title: "Example")
172
173
      assert [{_, attrs, _}] = query(html, "a")
174
      assert {"target", "_blank"} = List.keyfind(attrs, "target", 0)
175
      assert {"rel", "noreferrer"} = List.keyfind(attrs, "rel", 0)
176
      assert html =~ "Example"
177
    end
178
  end
179
180
  describe "inline_citation/1" do
181
    test "names the first source by host and counts the rest" do
182
      html =
183
        render_component(&Evidence.inline_citation/1,
184
          id: "ic-many",
185
          inner_block: [block(:inner_block, "the claim")],
186
          source: [
187
            block(:source, "", %{url: "https://example.com/a", title: "A", description: nil}),
188
            block(:source, "", %{url: "https://other.test/b", title: "B", description: nil})
189
          ]
190
        )
191
192
      assert text(html, "#ic-many button") == "example.com +1"
193
      assert html =~ "https://example.com/a"
194
      assert html =~ "https://other.test/b"
195
    end
196
197
    test "a single source is named without a count" do
198
      html =
199
        render_component(&Evidence.inline_citation/1,
200
          id: "ic-one",
201
          inner_block: [block(:inner_block, "the claim")],
202
          source: [block(:source, "", %{url: "https://example.com/a", title: "A"})]
203
        )
204
205
      assert text(html, "#ic-one button") == "example.com"
206
    end
207
208
    test "the chip points at the card it opens" do
209
      html =
210
        render_component(&Evidence.inline_citation/1,
211
          id: "ic-aria",
212
          inner_block: [block(:inner_block, "the claim")],
213
          source: [block(:source, "", %{url: "https://example.com/a"})]
214
        )
215
216
      assert [{_, attrs, _}] = query(html, "#ic-aria button")
217
      assert {"aria-describedby", "ic-aria-card"} = List.keyfind(attrs, "aria-describedby", 0)
218
      assert query(html, "#ic-aria-card") != []
219
    end
220
  end
221
222
  describe "context/1" do
223
    test "an empty context reads as nothing spent" do
224
      html =
225
        render_component(&Evidence.context/1, id: "ctx-0", used_tokens: 0, max_tokens: 128_000)
226
227
      assert [{_, attrs, _}] = query(html, "#ctx-0")
228
      assert {"data-over-budget", "false"} = List.keyfind(attrs, "data-over-budget", 0)
229
      assert html =~ "0%"
230
      assert html =~ "128K"
231
    end
232
233
    test "a part-spent context states the percentage and the compact counts" do
234
      html =
235
        render_component(&Evidence.context/1,
236
          id: "ctx-mid",
237
          used_tokens: 64_000,
238
          max_tokens: 128_000
239
        )
240
241
      assert html =~ "50%"
242
      assert html =~ "64K / 128K"
243
244
      assert [{_, attrs, _}] = query(html, "#ctx-mid [role=progressbar]")
245
      assert {"aria-valuenow", "50"} = List.keyfind(attrs, "aria-valuenow", 0)
246
    end
247
248
    test "an over-budget context is flagged rather than silently pinned at full" do
249
      html =
250
        render_component(&Evidence.context/1,
251
          id: "ctx-over",
252
          used_tokens: 150_000,
253
          max_tokens: 128_000
254
        )
255
256
      assert [{_, attrs, _}] = query(html, "#ctx-over")
257
      assert {"data-over-budget", "true"} = List.keyfind(attrs, "data-over-budget", 0)
258
      assert html =~ "117.2%"
259
260
      assert [{_, bar, _}] = query(html, "#ctx-over [role=progressbar]")
261
      assert {"aria-valuenow", "100"} = List.keyfind(bar, "aria-valuenow", 0)
262
    end
263
264
    test "the breakdown appears only for the kinds that were used" do
265
      html =
266
        render_component(&Evidence.context/1,
267
          id: "ctx-parts",
268
          used_tokens: 1200,
269
          max_tokens: 128_000,
270
          input_tokens: 1000,
271
          input_cost: 0.0125,
272
          total_cost: 0.02
273
        )
274
275
      assert html =~ "Input"
276
      refute html =~ "Reasoning"
277
      assert html =~ "$0.01"
278
      assert html =~ "Total cost"
279
      assert html =~ "$0.02"
280
    end
281
  end
282
283
  describe "artifact/1" do
284
    test "names the artifact and holds its actions apart from its body" do
285
      html =
286
        render_component(&Evidence.artifact/1,
287
          id: "af",
288
          title: "report.md",
289
          description: "Draft",
290
          inner_block: [block(:inner_block, "the body")],
291
          actions: [block(:actions, "the actions")]
292
        )
293
294
      assert html =~ "report.md"
295
      assert html =~ "Draft"
296
      assert html =~ "the body"
297
      assert html =~ "the actions"
298
    end
299
  end
300
301
  describe "artifact_action/1" do
302
    test "an icon-only control keeps its name, and the glyph stays quiet" do
303
      html = render_component(&Evidence.artifact_action/1, icon: "download", label: "Download")
304
305
      assert [{_, attrs, _}] = query(html, "button")
306
      assert {"aria-label", "Download"} = List.keyfind(attrs, "aria-label", 0)
307
      assert {"title", "Download"} = List.keyfind(attrs, "title", 0)
308
309
      assert [{_, glyph, _}] = query(html, "button svg")
310
      assert {"aria-hidden", "true"} = List.keyfind(glyph, "aria-hidden", 0)
311
      assert List.keyfind(glyph, "aria-label", 0) == nil
312
    end
313
  end
314
315
  describe "confirmation/1" do
316
    test "before a decision, the controls are the point" do
317
      html =
318
        render_component(&Evidence.confirmation/1,
319
          id: "cf-ask",
320
          state: :requested,
321
          title: "Delete the branch?",
322
          actions: [block(:actions, "the controls")]
323
        )
324
325
      assert [{_, attrs, _}] = query(html, "#cf-ask")
326
      assert {"data-state", "requested"} = List.keyfind(attrs, "data-state", 0)
327
      assert html =~ "Delete the branch?"
328
      assert html =~ "the controls"
329
      refute html =~ "Approved"
330
      refute html =~ "Denied"
331
    end
332
333
    test "after a decision, the outcome replaces the controls" do
334
      html =
335
        render_component(&Evidence.confirmation/1,
336
          id: "cf-yes",
337
          state: :approved,
338
          title: "Delete the branch?",
339
          reason: "merged an hour ago",
340
          actions: [block(:actions, "the controls")]
341
        )
342
343
      assert [{_, attrs, _}] = query(html, "#cf-yes")
344
      assert {"data-state", "approved"} = List.keyfind(attrs, "data-state", 0)
345
      assert html =~ "Approved"
346
      assert html =~ "merged an hour ago"
347
      refute html =~ "the controls"
348
    end
349
350
    test "a denial reads as a denial" do
351
      html =
352
        render_component(&Evidence.confirmation/1,
353
          id: "cf-no",
354
          state: :denied,
355
          title: "Delete the branch?",
356
          actions: [block(:actions, "the controls")]
357
        )
358
359
      assert html =~ "Denied"
360
      refute html =~ "the controls"
361
    end
362
  end
363
364
  describe "question/1" do
365
    test "single choice is a radio group, and the chosen value arrives checked" do
366
      html =
367
        render_component(&Evidence.question/1,
368
          id: "q-one",
369
          prompt: "Which branch?",
370
          name: "branch",
371
          selected: ["main"],
372
          option: [
373
            block(:option, "main", %{value: "main"}),
374
            block(:option, "next", %{value: "next"})
375
          ]
376
        )
377
378
      assert [{_, first, _}, {_, second, _}] = query(html, "#q-one input[type=radio]")
379
      assert {"name", "branch"} = List.keyfind(first, "name", 0)
380
      assert List.keyfind(first, "checked", 0) != nil
381
      assert List.keyfind(second, "checked", 0) == nil
382
      assert query(html, "#q-one-text") != []
383
    end
384
385
    test "multiple choice submits a list" do
386
      html =
387
        render_component(&Evidence.question/1,
388
          id: "q-many",
389
          prompt: "Which files?",
390
          name: "files",
391
          selection_mode: :multiple,
392
          option: [block(:option, "a.ex", %{value: "a.ex"})]
393
        )
394
395
      assert [{_, attrs, _}] = query(html, "#q-many input[type=checkbox]")
396
      assert {"name", "files[]"} = List.keyfind(attrs, "name", 0)
397
    end
398
399
    test "a disabled question disables its choices and its submit control" do
400
      html =
401
        render_component(&Evidence.question/1,
402
          id: "q-off",
403
          prompt: "Which branch?",
404
          disabled: true,
405
          option: [block(:option, "main", %{value: "main"})]
406
        )
407
408
      assert [{_, fieldset, _}] = query(html, "#q-off fieldset")
409
      assert List.keyfind(fieldset, "disabled", 0) != nil
410
      assert [{_, submit, _}] = query(html, "#q-off button[type=submit]")
411
      assert List.keyfind(submit, "disabled", 0) != nil
412
    end
413
  end
414
415
  describe "image/1" do
416
    test "model output becomes a data URI" do
417
      html =
418
        render_component(&Evidence.image/1,
419
          id: "im-data",
420
          alt: "A generated skyline",
421
          base64: "AAAA",
422
          media_type: "image/png"
423
        )
424
425
      assert [{_, attrs, _}] = query(html, "#im-data")
426
      assert {"src", "data:image/png;base64,AAAA"} = List.keyfind(attrs, "src", 0)
427
      assert {"alt", "A generated skyline"} = List.keyfind(attrs, "alt", 0)
428
    end
429
430
    test "an address is used as given" do
431
      html = render_component(&Evidence.image/1, id: "im-src", alt: "A chart", src: "/a.png")
432
433
      assert [{_, attrs, _}] = query(html, "#im-src")
434
      assert {"src", "/a.png"} = List.keyfind(attrs, "src", 0)
435
    end
436
  end
437
end

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