defmodule OpenAgentsWeb.UI.Circle do
@moduledoc """
Issue, project, and team surfaces: the shapes a tracker is built from.
Adapted from Circle (MIT, © 2025 lndev-ui), a Linear-shaped issue tracker
built with Next.js, Tailwind, shadcn/ui, Zustand, `motion/react`, and
`react-dnd`. Nothing is copied. Every source component is a client component
reading a Zustand store, and the interesting ones are wrapped in Radix
primitives; none of that survives the move to HEEx. What carried over is the
**information design**: what an issue row holds and in what order, that the
status glyph is a filled arc rather than a coloured dot, that a group header
is tinted by its own status at a fraction of its strength, that a filter
reads as subject / operator / value with the value removable on its own. See
`docs/2026-08-20-circle-ui-port.md`.
Three departures from the source are deliberate:
* **Tokens, not a second palette.** Circle assigns a hand-picked hex value
to each of thirteen statuses and eleven labels. Those colours belong to
Linear, not to this product, and adopting them would put a second colour
system beside the one every other surface uses. Colour here is assigned
per status *category* — six of them — off the same token ladder as
`OpenAgentsWeb.UI.status_indicator/1`, so activity is `--info`,
completion is `--success`, and anything asking for attention is
`--warning`. Six colours say less than thirteen; they also stay true in
both themes and never disagree with the rest of the interface.
* **No JavaScript except where the keyboard needs it.** Rows, groups,
boards, filters, and headers are server-rendered and static. The command
palette is the exception: a `⌘K` binding and incremental filtering
cannot be expressed in markup, so it carries one colocated hook.
* **State is the caller's.** The source keeps grouping, filters, search,
and drag results in client stores. These components take what to draw and
emit `Phoenix.LiveView.JS` commands the caller supplies; none of them own
state. That is what makes the same row usable in a list, in a board, and
in a search result.
Every component takes plain maps and atoms rather than structs, so a surface
can render from an Ecto schema, a map from an API, or a literal in a test
without a conversion layer.
"""
use Phoenix.Component
alias OpenAgentsWeb.UI
alias Phoenix.LiveView.JS
@categories [:open, :triage, :backlog, :unstarted, :started, :completed, :canceled]
@priorities [:none, :low, :medium, :high, :urgent]
@tones [:neutral, :primary, :info, :success, :warning, :danger]
@presences [:none, :online, :away, :offline]
@doc """
The state of one issue, as a glyph and optionally a word.
The source draws six shapes: a triage disc, a dashed gear for backlog, an
empty ring, a ring with a filled arc, a filled tick, and a filled cross. Five
of those already exist in the vendored icon set. The sixth — the arc — is the
only one that reads a number, so it is drawn in CSS from `progress` rather
than picked from a fixed set of fractions. A ring that is a quarter full is
the one thing in a Linear list that says how far along the work is, and an
icon set cannot carry it.
Colour comes from the category, never from the individual status: `:started`
is `--info` because it is activity, `:completed` is `--success`, `:triage` is
`--warning` because it is asking for a decision, and the two resting states
are grey. This is the same vocabulary `status_indicator/1` uses.
The glyph announces itself unless `show_label` puts the word beside it, in
which case announcing both says the state twice.
"""
attr :category, :atom, values: @categories, required: true
attr :label, :string, required: true, doc: "the status's own name, such as `In review`"
attr :progress, :integer,
default: nil,
doc: "0-100, drawn as a filled arc; only meaningful for `:started`"
attr :show_label, :boolean, default: false
attr :class, :any, default: nil
attr :rest, :global
def issue_status(assigns) do
assigns = assign(assigns, :arc, clamp(assigns.progress))
~H"""
<span class={["issue-status", @class]} data-category={@category} {@rest}>
<span
:if={@category == :started}
class="issue-status__arc"
style={"--issue-arc: #{@arc}"}
role={if(!@show_label, do: "img")}
aria-label={if(!@show_label, do: @label)}
aria-hidden={if(@show_label, do: "true")}
/>
<UI.icon
:if={@category != :started}
name={category_icon(@category)}
label={if(!@show_label, do: @label)}
class="issue-status__glyph"
/>
<span :if={@show_label} class="issue-status__label">{@label}</span>
</span>
"""
end
@doc """
How urgent one issue is, as four ascending bars or an alarm.
The bar chart is the source's own idea and it is a good one: the level reads
from how much of the shape is lit, so the ordering survives greyscale and a
reader who cannot separate the tints. Urgent breaks the pattern on purpose —
it is not one more step up the same ramp, and drawing it as one invites the
eye to skip it.
Drawn in CSS rather than vendored as five glyphs, because the bars are one
shape read at five levels rather than five different pictures.
"""
attr :level, :atom, values: @priorities, required: true
attr :label, :string, default: nil, doc: "overrides the level's own name"
attr :show_label, :boolean, default: false
attr :class, :any, default: nil
attr :rest, :global
def issue_priority(assigns) do
assigns = assign_new(assigns, :name, fn -> assigns.label || priority_name(assigns.level) end)
~H"""
<span class={["issue-priority", @class]} data-level={@level} {@rest}>
<UI.icon
:if={@level == :urgent}
name="triangle-exclamation-filled-error-warning"
label={if(!@show_label, do: @name)}
class="issue-priority__alarm"
/>
<span
:if={@level != :urgent}
class="issue-priority__bars"
role={if(!@show_label, do: "img")}
aria-label={if(!@show_label, do: @name)}
aria-hidden={if(@show_label, do: "true")}
>
<span class="issue-priority__bar" /><span class="issue-priority__bar" /><span class="issue-priority__bar" />
</span>
<span :if={@show_label} class="issue-priority__label">{@name}</span>
</span>
"""
end
@doc """
One label on an issue: a dot and a word in a pill.
The source colours the dot from a per-label hex value chosen when the label
was created. That model does not survive the tokens rule, so `tone` picks one
of six values off the ladder instead. Six tones cannot distinguish eleven
labels by colour alone, which is why the word is never optional here — the
dot is a grouping hint, not the identity.
"""
attr :name, :string, required: true
attr :tone, :atom, values: @tones, default: :neutral
attr :class, :any, default: nil
attr :rest, :global
def issue_label(assigns) do
~H"""
<span class={["issue-label", @class]} data-tone={@tone} {@rest}>
<span class="issue-label__dot" aria-hidden="true" />{@name}
</span>
"""
end
@doc """
Who an issue belongs to, or that it belongs to nobody.
Unassigned is drawn rather than left blank. A blank cell in a list of faces
reads as a rendering failure, and "nobody has picked this up" is one of the
more actionable facts a triage view carries.
`presence` adds the small corner dot. It is decorative here: the row already
names the person, and a second announcement of "online" on every row of a
list is noise.
"""
attr :name, :string, default: nil, doc: "`nil` renders the unassigned state"
attr :src, :string, default: nil
attr :presence, :atom, values: @presences, default: :none
attr :size, :atom, values: [:sm, :default, :lg], default: :default
attr :show_name, :boolean, default: false
attr :class, :any, default: nil
attr :rest, :global
def assignee(assigns) do
~H"""
<span
class={["assignee", @class]}
data-size={@size}
title={@name || "Unassigned"}
{@rest}
>
<span class="assignee__figure">
<UI.avatar
:if={@name}
src={@src}
fallback={String.first(@name)}
size={@size}
label={if(!@show_name, do: @name)}
/>
<span :if={!@name} class="assignee__empty" role="img" aria-label="Unassigned">
<UI.icon name="user" />
</span>
<span :if={@name && @presence != :none} class="assignee__presence" data-presence={@presence} />
</span>
<span :if={@show_name} class="assignee__name">{@name || "Unassigned"}</span>
</span>
"""
end
@doc """
Several people as overlapping faces, with a count for the ones that do not fit.
The count is the point. Six faces and a `+14` says the size of a team; six
faces alone says the team has six people, which would be wrong.
"""
attr :people, :list, required: true, doc: "`[%{name: String.t(), src: String.t() | nil}]`"
attr :limit, :integer, default: 5
attr :class, :any, default: nil
attr :rest, :global
def assignee_stack(assigns) do
assigns =
assigns
|> assign(:shown, Enum.take(assigns.people, assigns.limit))
|> assign(:overflow, max(length(assigns.people) - assigns.limit, 0))
~H"""
<span class={["assignee-stack", @class]} {@rest}>
<span class="assignee-stack__faces">
<UI.avatar
:for={person <- @shown}
src={person[:src]}
fallback={String.first(person[:name])}
size={:sm}
label={person[:name]}
/>
</span>
<span :if={@overflow > 0} class="assignee-stack__count">+{@overflow}</span>
</span>
"""
end
@doc """
One issue as a row: the shape a tracker is mostly made of.
Order is load-bearing and inherited from the source. Priority, identifier,
and status lead because they are the three things a person scans a list for;
the title takes the remaining width and truncates; everything discretionary —
labels, project, dates, assignee — collects at the trailing edge where it can
be dropped by width without disturbing the scan column.
Only the title is a link. The source makes the row a drag handle and the
title a link inside it, which means a click lands on one of two different
things depending on where in a 44-pixel row it falls. One target is easier to
hit and easier to explain.
"""
attr :identifier, :string, required: true, doc: "the short key, such as `OA-142`"
attr :title, :string, required: true
attr :navigate, :any, default: nil, doc: "where the title goes; a plain title without it"
attr :status_category, :atom, values: @categories, required: true
attr :status_label, :string, required: true
attr :progress, :integer, default: nil
attr :priority, :atom, values: @priorities, default: :none
# Which repository the issue is in. A list drawn from one repository already
# knows, and leaves this `nil`; a list drawn across several is unreadable
# without it, so it joins the scan column rather than the trailing edge,
# which drops by width.
attr :repository, :string, default: nil, doc: "`owner/name`, for a cross-repository list"
attr :labels, :list, default: [], doc: "`[%{name: String.t(), tone: atom()}]`"
attr :project, :string, default: nil
attr :due, :string, default: nil, doc: "already formatted; overdue is the caller's judgement"
attr :created, :string, default: nil
# GitHub-native facts the source had no vocabulary for. `comments` is on
# every issue payload and is the one number a reader scans a list for after
# the title; `author` is who opened it, which GitHub prints in the same
# breath as when.
attr :comments, :integer, default: nil
attr :author, :string, default: nil
attr :assignee, :map, default: nil, doc: "`%{name:, src:, presence:}`; `nil` is unassigned"
attr :selected, :boolean, default: false
attr :class, :any, default: nil
attr :rest, :global
# In Circle every one of these cells is a selector. Here the row stays
# presentational and a caller who has somewhere to send a change replaces the
# cell with a control -- usually a `field_menu/1` whose trigger is the same
# glyph or face the static version drew, so the row looks identical until it
# is clicked. The controls sit outside the title link on purpose: a
# state-changing control inside a link target is how people mis-click.
slot :state, doc: "replaces the state glyph with a control that changes it"
slot :people, doc: "replaces the assignee face with a control that changes it"
slot :actions, doc: "controls at the trailing edge, after the assignee"
def issue_row(assigns) do
~H"""
<div class={["issue-row", @class]} data-selected={@selected} {@rest}>
<span class="issue-row__scan">
<.issue_priority level={@priority} />
<span :if={@repository} class="issue-row__repository" title={@repository}>
{@repository}
</span>
<span class="issue-row__identifier">{@identifier}</span>
{render_slot(@state)}
<.issue_status
:if={@state == []}
category={@status_category}
label={@status_label}
progress={@progress}
/>
</span>
<.link :if={@navigate} navigate={@navigate} class="issue-row__title">{@title}</.link>
<span :if={!@navigate} class="issue-row__title">{@title}</span>
<span class="issue-row__trailing">
<span :if={@labels != [] or @project} class="issue-row__chips">
<.issue_label :for={label <- @labels} name={label[:name]} tone={label[:tone] || :neutral} />
<span :if={@project} class="issue-label" data-tone="neutral">
<UI.icon name="cube" class="issue-label__glyph" />{@project}
</span>
</span>
<span :if={@due} class="issue-row__due">Due {@due}</span>
<span :if={@created} class="issue-row__date">
{@created}<span :if={@author}> by {@author}</span>
</span>
<%!-- Absent rather than zero: "0 comments" is a fact nobody scans a
list for, and a column of zeroes is noise. --%>
<span :if={@comments && @comments > 0} class="issue-row__comments">
<UI.icon name="comment" /> {@comments}
</span>
{render_slot(@people)}
<.assignee
:if={@people == []}
name={@assignee && @assignee[:name]}
src={@assignee && @assignee[:src]}
presence={(@assignee && @assignee[:presence]) || :none}
/>
<span :if={@actions != []} class="issue-row__actions">{render_slot(@actions)}</span>
</span>
</div>
"""
end
@doc """
The same issue as a card, for a board column.
A card is not a row turned sideways: it has width and no neighbours, so the
title gets two lines instead of one and the labels get their own band instead
of competing with the trailing edge. The scan column becomes a header line,
and the assignee drops to the foot where it reads as ownership of the whole
card rather than one more attribute.
"""
attr :identifier, :string, required: true
attr :title, :string, required: true
attr :navigate, :any, default: nil
attr :status_category, :atom, values: @categories, required: true
attr :status_label, :string, required: true
attr :progress, :integer, default: nil
attr :priority, :atom, values: @priorities, default: :none
attr :labels, :list, default: []
attr :project, :string, default: nil
attr :created, :string, default: nil
attr :assignee, :map, default: nil
attr :class, :any, default: nil
attr :rest, :global
def issue_card(assigns) do
~H"""
<article class={["issue-card", @class]} {@rest}>
<header class="issue-card__head">
<span class="issue-card__scan">
<.issue_priority level={@priority} />
<span class="issue-row__identifier">{@identifier}</span>
</span>
<.issue_status
category={@status_category}
label={@status_label}
progress={@progress}
/>
</header>
<.link :if={@navigate} navigate={@navigate} class="issue-card__title">{@title}</.link>
<p :if={!@navigate} class="issue-card__title">{@title}</p>
<div :if={@labels != [] or @project} class="issue-card__chips">
<.issue_label :for={label <- @labels} name={label[:name]} tone={label[:tone] || :neutral} />
<span :if={@project} class="issue-label" data-tone="neutral">
<UI.icon name="cube" class="issue-label__glyph" />{@project}
</span>
</div>
<footer class="issue-card__foot">
<span class="issue-row__date">{@created}</span>
<.assignee
name={@assignee && @assignee[:name]}
src={@assignee && @assignee[:src]}
presence={(@assignee && @assignee[:presence]) || :none}
/>
</footer>
</article>
"""
end
@doc """
A named run of issues under a sticky, tinted header.
The tint is the source's idea and it earns its place: a list grouped by
status has no other way to say where one group ends and the next begins once
the header has scrolled past its own rows. It is mixed from the category
colour at a low percentage, so it is a wash rather than a fill, and the
header is still legible over it.
`layout` picks the two arrangements the same group takes: a full-width band
in a list, or a fixed-width column in a board. The header, count, and actions
are identical in both, which is why they are one component.
"""
attr :label, :string, required: true
attr :count, :integer, required: true
attr :category, :atom, values: @categories ++ [:none], default: :none, doc: "drives the tint"
attr :layout, :atom, values: [:list, :board], default: :list
attr :class, :any, default: nil
attr :rest, :global
slot :glyph, doc: "the marker beside the name; a status, a priority, or a face"
slot :actions, doc: "controls at the trailing edge of the header"
slot :inner_block, required: true
def issue_group(assigns) do
~H"""
<section class={["issue-group", @class]} data-layout={@layout} data-category={@category} {@rest}>
<header class="issue-group__head">
<span class="issue-group__name">
{render_slot(@glyph)}
<span class="issue-group__label">{@label}</span>
<span class="issue-group__count">{@count}</span>
</span>
<span :if={@actions != []} class="issue-group__actions">{render_slot(@actions)}</span>
</header>
<div class="issue-group__body">{render_slot(@inner_block)}</div>
</section>
"""
end
@doc """
Board columns side by side, scrolling horizontally.
Each column scrolls on its own so a long backlog does not push the other
columns' headers off the top. The source achieves this with a drag-and-drop
provider wrapped around the same layout; the layout is the part worth having.
"""
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true
def issue_board(assigns) do
~H"""
<div class={["issue-board", @class]} {@rest}>{render_slot(@inner_block)}</div>
"""
end
@doc """
One applied filter, read as subject, operator, value.
Splitting the chip into three segments is what makes a filter editable
without a modal: each segment is its own control, so changing `is` to
`is not` does not mean removing the filter and building it again. The
segments here are static text unless the caller supplies commands; the
division is the part that matters, and it is what the source's
`data-table-filter` spends most of its code on.
"""
attr :subject, :string, required: true
attr :operator, :string, required: true
attr :value, :string, required: true
attr :icon, :string, default: nil, doc: "a glyph for the subject"
attr :on_remove, JS, default: nil, doc: "dropped from the applied set when clicked"
attr :class, :any, default: nil
attr :rest, :global
def filter_chip(assigns) do
~H"""
<span class={["filter-chip", @class]} {@rest}>
<span class="filter-chip__subject">
<UI.icon :if={@icon} name={@icon} />{@subject}
</span>
<span class="filter-chip__operator">{@operator}</span>
<span class="filter-chip__value">{@value}</span>
<button
:if={@on_remove}
type="button"
class="filter-chip__remove"
phx-click={@on_remove}
aria-label={"Remove the #{@subject} filter"}
>
<UI.icon name="x" />
</button>
</span>
"""
end
@doc """
The row of applied filters, with somewhere to add one and a way to drop them all.
It appears only when a filter is applied — the source hides it otherwise and
keeps the entry point in the toolbar, which is right: an empty filter bar is
a permanent reminder of a feature nobody is using. Rendering nothing when
there are no chips is the caller's decision, so this component does not
guess.
"""
attr :on_clear, JS, default: nil
attr :class, :any, default: nil
attr :rest, :global
slot :add, doc: "the control that opens the subject picker"
slot :inner_block, required: true, doc: "the applied chips"
def filter_bar(assigns) do
~H"""
<div class={["filter-bar", @class]} {@rest}>
<div class="filter-bar__chips">
{render_slot(@add)}
{render_slot(@inner_block)}
</div>
<button :if={@on_clear} type="button" class="filter-bar__clear" phx-click={@on_clear}>
Clear
</button>
</div>
"""
end
@doc """
The saved views of one collection, as pills.
Pills rather than underlined tabs because these switch a filter rather than a
page: the content below keeps its shape, and an underline promises a bigger
change than actually happens. The selected pill carries `aria-current`, so
the state is not colour alone.
"""
attr :label, :string, default: "Views", doc: "names the group for assistive technology"
attr :class, :any, default: nil
attr :rest, :global
slot :tab, required: true do
attr :label, :string, required: true
# `navigate` remounts; `patch` keeps the mount and lets `handle_params`
# answer. Tabs that filter one collection belong to the same LiveView, so
# they should patch -- but a tab that leads to a different view exists too,
# hence both.
attr :navigate, :any
attr :patch, :any
attr :selected, :boolean
end
def view_tabs(assigns) do
~H"""
<nav class={["view-tabs", @class]} aria-label={@label} {@rest}>
<.link
:for={tab <- @tab}
navigate={tab[:navigate]}
patch={tab[:patch]}
class="view-tabs__tab"
aria-current={tab[:selected] && "page"}
>
{tab.label}
</.link>
</nav>
"""
end
@doc """
The bar above a collection: what you are looking at, and what you can do to it.
The source splits this into two stacked rows — navigation above, options
below — and the split is worth keeping when both are full. This renders one
row with a leading and a trailing slot; stack two of them for the source's
arrangement. Making it one component rather than two means a surface with
only options does not inherit an empty navigation strip.
"""
attr :class, :any, default: nil
attr :rest, :global
slot :leading, doc: "tabs, a count, or a title"
slot :actions, doc: "filter, display, and view controls"
def issue_toolbar(assigns) do
~H"""
<div class={["issue-toolbar", @class]} {@rest}>
<div class="issue-toolbar__leading">{render_slot(@leading)}</div>
<div class="issue-toolbar__actions">{render_slot(@actions)}</div>
</div>
"""
end
@doc """
The `⌘K` surface: a search field over grouped commands.
This is the one component here that needs script. `⌘K` is a document-level
binding, incremental filtering means hiding rows as characters arrive, and
arrow-key selection has to survive both — none of which markup can express.
The hook does exactly those four things and nothing else; every command is a
real `<button>` that works without it.
Built on `<dialog>` rather than a positioned panel, so the browser supplies
the modal semantics, the focus trap, the backdrop, and `Escape`. Anything
with `data-command-target` matching this palette's id opens it, which is how
a surface offers a visible way in beside the shortcut.
`context` is the source's best idea in this surface: when the palette is
opened from an issue, it says which issue, so `Change status…` is unambiguous
before you pick anything.
"""
attr :id, :string, required: true
attr :placeholder, :string, default: "Type a command or search"
attr :context, :string, default: nil, doc: "what the commands act on, if anything"
attr :empty, :string, default: "No results found."
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true, doc: "`command_group/1` elements"
def command_palette(assigns) do
~H"""
<dialog id={@id} class={["command-palette", @class]} phx-hook=".CommandPalette" {@rest}>
<div class="command-palette__panel">
<p :if={@context} class="command-palette__context">{@context}</p>
<div class="command-palette__search">
<UI.icon name="search" class="command-palette__glyph" />
<input
type="text"
class="command-palette__input"
placeholder={@placeholder}
aria-label={@placeholder}
autocomplete="off"
data-command-input
/>
</div>
<div class="command-palette__list">
{render_slot(@inner_block)}
<p class="command-palette__empty" data-command-empty hidden>{@empty}</p>
</div>
</div>
</dialog>
<script :type={Phoenix.LiveView.ColocatedHook} name=".CommandPalette">
export default {
mounted() {
const input = this.el.querySelector("[data-command-input]")
const empty = this.el.querySelector("[data-command-empty]")
const items = () => Array.from(this.el.querySelectorAll("[data-command-item]"))
const visible = () => items().filter((item) => !item.hidden)
const select = (item) => {
items().forEach((other) => other.removeAttribute("data-active"))
if (!item) return
item.setAttribute("data-active", "")
item.scrollIntoView({block: "nearest"})
}
const filter = () => {
const query = input.value.trim().toLowerCase()
items().forEach((item) => {
item.hidden = query !== "" && !item.dataset.commandLabel.includes(query)
})
this.el.querySelectorAll("[data-command-group]").forEach((group) => {
group.hidden = group.querySelectorAll("[data-command-item]:not([hidden])").length === 0
})
const shown = visible()
if (empty) empty.hidden = shown.length !== 0
select(shown[0])
}
const open = () => {
if (this.el.open) return
input.value = ""
filter()
this.el.showModal()
input.focus()
}
this.onKeyDown = (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault()
this.el.open ? this.el.close() : open()
}
}
this.onClick = (event) => {
const trigger = event.target.closest(`[data-command-target="${this.el.id}"]`)
if (trigger) open()
}
// Arrow keys move a selection the browser has no concept of, so the
// active row is tracked here and Enter forwards to its own click
// handler rather than duplicating what the row does.
this.onPaletteKey = (event) => {
const shown = visible()
if (shown.length === 0) return
const at = shown.findIndex((item) => item.hasAttribute("data-active"))
if (event.key === "ArrowDown") {
event.preventDefault()
select(shown[(at + 1) % shown.length])
} else if (event.key === "ArrowUp") {
event.preventDefault()
select(shown[(at - 1 + shown.length) % shown.length])
} else if (event.key === "Enter" && at >= 0) {
event.preventDefault()
shown[at].click()
}
}
input.addEventListener("input", filter)
this.el.addEventListener("keydown", this.onPaletteKey)
window.addEventListener("keydown", this.onKeyDown)
document.addEventListener("click", this.onClick)
filter()
},
destroyed() {
window.removeEventListener("keydown", this.onKeyDown)
document.removeEventListener("click", this.onClick)
}
}
</script>
"""
end
@doc """
A titled run of commands inside the palette.
Headings are what keep a palette of forty commands readable, and they are
also what makes filtering legible: a group with nothing left in it hides
itself rather than leaving a heading over a gap.
"""
attr :heading, :string, required: true
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true
def command_group(assigns) do
~H"""
<div class={["command-group", @class]} data-command-group {@rest}>
<p class="command-group__heading">{@heading}</p>
{render_slot(@inner_block)}
</div>
"""
end
@doc """
One command: a glyph, a name, and the keys that reach it directly.
The shortcut chips are documentation, not bindings — the palette does not
install them. Showing them anyway is how a person stops needing the palette,
which is the point of having one.
`label` doubles as the filter key, so a command matches on the words a person
would actually type.
"""
attr :label, :string, required: true
attr :icon, :string, default: nil
attr :keys, :list, default: [], doc: "shortcut keys shown at the trailing edge"
attr :on_select, JS, default: nil
attr :class, :any, default: nil
attr :rest, :global
def command_item(assigns) do
~H"""
<button
type="button"
class={["command-item", @class]}
data-command-item
data-command-label={String.downcase(@label)}
phx-click={@on_select}
{@rest}
>
<UI.icon :if={@icon} name={@icon} class="command-item__glyph" />
<span class="command-item__label">{@label}</span>
<span :if={@keys != []} class="command-item__keys">
<UI.kbd :for={key <- @keys}>{key}</UI.kbd>
</span>
</button>
"""
end
@doc """
One project as a row: name on the left, everything measurable on the right.
Projects are read across rather than down — the question is which project is
behind, not what any one of them is called — so the trailing fields sit in
fixed columns that line up between rows. They drop by width from the least
load-bearing inwards, which is why progress is the last to go.
Health is a word, not a colour: `at risk` and `off track` are different
claims, and a reader should not have to learn which shade of amber means
which.
"""
attr :name, :string, required: true
attr :navigate, :any, default: nil
attr :icon, :string, default: "cube"
attr :health, :atom, values: [:on_track, :at_risk, :off_track, :unknown], default: :unknown
attr :priority, :atom, values: @priorities, default: :none
attr :lead, :map, default: nil, doc: "`%{name:, src:}`; `nil` renders unassigned"
attr :target, :string, default: nil, doc: "already formatted target date"
attr :issues, :integer, default: nil
attr :status_category, :atom, values: @categories, required: true
attr :status_label, :string, required: true
attr :percent, :integer, default: nil
attr :labels, :list, default: []
attr :class, :any, default: nil
attr :rest, :global
# Same bargain as `issue_row/1`: the state cell becomes a control when the
# caller has somewhere to send the change, and it sits outside the link to
# the board rather than inside it.
slot :state, doc: "replaces the state glyph with a control that changes it"
def project_row(assigns) do
~H"""
<div class={["project-row", @class]} {@rest}>
<span class="project-row__name">
<span class="project-row__icon"><UI.icon name={@icon} /></span>
<.link :if={@navigate} navigate={@navigate} class="project-row__link">{@name}</.link>
<span :if={!@navigate} class="project-row__link">{@name}</span>
<.issue_label :for={label <- @labels} name={label[:name]} tone={label[:tone] || :neutral} />
</span>
<span :if={@health != :unknown} class="project-row__health" data-health={@health}>
{health_name(@health)}
</span>
<span class="project-row__priority"><.issue_priority level={@priority} /></span>
<span class="project-row__lead">
<.assignee name={@lead && @lead[:name]} src={@lead && @lead[:src]} size={:sm} />
</span>
<span :if={@target} class="project-row__target">{@target}</span>
<span :if={@issues} class="project-row__issues">{@issues}</span>
<span class="project-row__status">
{render_slot(@state)}
<.issue_status
:if={@state == []}
category={@status_category}
label={@status_label}
progress={@percent}
/>
<span :if={@percent} class="project-row__percent">{@percent}%</span>
</span>
</div>
"""
end
@doc """
One team as a row: identity, membership, and what it owns.
The identifier is shown beside the name rather than instead of it because it
is the prefix on every issue key the team produces — `OA-142` is only
findable if somebody can connect `OA` to a team.
"""
attr :name, :string, required: true
attr :identifier, :string, required: true
attr :glyph, :string, default: nil, doc: "a short mark, typically one character"
attr :navigate, :any, default: nil
attr :joined, :boolean, default: false
attr :members, :list, default: []
attr :projects, :integer, default: nil
attr :cycles, :integer, default: nil
attr :class, :any, default: nil
attr :rest, :global
def team_row(assigns) do
~H"""
<div class={["team-row", @class]} {@rest}>
<span class="team-row__name">
<span class="team-row__glyph" aria-hidden="true">{@glyph || String.first(@identifier)}</span>
<.link :if={@navigate} navigate={@navigate} class="team-row__link">{@name}</.link>
<span :if={!@navigate} class="team-row__link">{@name}</span>
<span class="team-row__identifier">{@identifier}</span>
</span>
<span class="team-row__membership">
<span :if={@joined} class="team-row__joined"><UI.icon name="check" />Joined</span>
</span>
<span class="team-row__members">
<.assignee_stack :if={@members != []} people={@members} limit={6} />
</span>
<span :if={@cycles} class="team-row__metric"><UI.icon name="loop" />{@cycles}</span>
<span :if={@projects} class="team-row__metric"><UI.icon name="cube" />{@projects}</span>
</div>
"""
end
@doc """
One person as a row: who they are, what they may do, and where they belong.
Two lines of identity rather than one. A display name is what a colleague
recognises and a handle is what appears in a mention, and a directory that
shows only one of them fails whichever question is being asked.
"""
attr :name, :string, required: true
attr :handle, :string, required: true
attr :src, :string, default: nil
attr :role, :string, default: nil
attr :role_tone, :atom, values: [:neutral, :accent], default: :neutral
attr :joined, :string, default: nil, doc: "already formatted joining date"
attr :teams, :list, default: [], doc: "team identifiers"
attr :presence, :atom, values: @presences, default: :none
attr :navigate, :any, default: nil
attr :class, :any, default: nil
attr :rest, :global
def member_row(assigns) do
assigns =
assigns
|> assign(:shown_teams, Enum.take(assigns.teams, 2))
|> assign(:extra_teams, max(length(assigns.teams) - 2, 0))
~H"""
<div class={["member-row", @class]} {@rest}>
<span class="member-row__identity">
<.assignee name={@name} src={@src} presence={@presence} size={:lg} />
<span class="member-row__names">
<.link :if={@navigate} navigate={@navigate} class="member-row__name">{@name}</.link>
<span :if={!@navigate} class="member-row__name">{@name}</span>
<span class="member-row__handle">{@handle}</span>
</span>
</span>
<span :if={@role} class="member-row__role" data-tone={@role_tone}>{@role}</span>
<span :if={@joined} class="member-row__joined">{@joined}</span>
<span :if={@teams != []} class="member-row__teams">
<UI.icon name="group" />{Enum.join(@shown_teams, ", ")}
<span :if={@extra_teams > 0}>
+{@extra_teams}
</span>
</span>
</div>
"""
end
@doc """
GitHub's issue state as a glyph: open, closed, or closed as not planned.
`issue_status/1` renders six categories because Circle has six. GitHub has
two, and `docs/2026-08-20-linear-design-github-shape.md` rules that we have
what GitHub has. This is the narrower component every GitHub-shaped surface
should reach for: it takes the payload's own `state` and `state_reason` and
maps them once, here, instead of in each page.
Two close reasons read as "not done" rather than "done": `not_planned`, where
the work was decided against, and `duplicate`, where it is being tracked
somewhere else. Both take the cancelled glyph. A bare close and `completed`
take the tick.
"""
attr :state, :string, values: ["open", "closed"], required: true
attr :reason, :string, default: nil, doc: "GitHub's `state_reason`"
attr :show_label, :boolean, default: false
attr :class, :any, default: nil
attr :rest, :global
def issue_state(assigns) do
~H"""
<.issue_status
category={state_category(@state, @reason)}
label={state_label(@state, @reason)}
show_label={@show_label}
class={@class}
{@rest}
/>
"""
end
@doc """
GitHub's pull-request state as a glyph: open, draft, merged, or closed.
A pull request on this forge is an issue row with a `pull_requests` record
pointing at it, which is why the two share a number space and why PR #119 sat
beside issue #114 reading as a duplicate (#120). `issue_state/1` draws the
circle-dot and the tick; this draws the branch-and-node glyphs GitHub uses,
so the two kinds are told apart before a reader has read a word.
Colour comes from the same category ladder the issue glyphs use, which is
what keeps a pull-request row and an issue row in one palette: open is the
green of an open issue, merged the purple of a completed close, closed the
red of a cancelled one, and a draft is grey because it is not yet asking for
anything.
"""
attr :state, :string,
values: ["open", "draft", "merged", "closed"],
required: true,
doc: "from `OpenAgents.PullRequests.state/1`"
attr :show_label, :boolean, default: false
attr :class, :any, default: nil
attr :rest, :global
def pull_request_state(assigns) do
~H"""
<span class={["issue-status", @class]} data-category={pull_request_category(@state)} {@rest}>
<UI.icon
name={UI.pull_request_state_icon(@state)}
label={if(!@show_label, do: pull_request_label(@state))}
class="issue-status__glyph"
/>
<span :if={@show_label} class="issue-status__label">{pull_request_label(@state)}</span>
</span>
"""
end
@doc """
The frame of one issue's page: a heading band, the work, and a properties rail.
Adapted from `issue-details.tsx`. Two decisions from the source are kept and
one is reversed.
Kept: the properties live in a rail rather than above the body, so the thing
a reader came for starts at the top of the page; and the main column is held
to a measure, because a description read at full window width is unreadable
on a wide screen.
Reversed: the source hides the rail below `lg`. State, labels, assignees, and
milestone are not decoration, and a phone is where an issue is most often
read. Here the rail moves under the heading on a narrow screen — directly
where those facts are most useful — and to the side when there is room.
"""
attr :class, :any, default: nil
attr :rest, :global
slot :heading, required: true, doc: "title, number, state, and the actions on them"
slot :rail, doc: "the properties panel"
slot :inner_block, required: true, doc: "body, then the timeline"
def issue_detail(assigns) do
~H"""
<article class={["issue-detail", @class]} {@rest}>
<header class="issue-detail__head">{render_slot(@heading)}</header>
<div :if={@rail != []} class="issue-detail__rail">{render_slot(@rail)}</div>
<div class="issue-detail__main">{render_slot(@inner_block)}</div>
</article>
"""
end
@doc """
The rail beside an issue: labelled groups of properties.
Adapted from `issue-properties-panel.tsx`, minus every group GitHub has no
field for. The source's groups are status, priority, assignee, cycle, labels,
project, blocked-by, related, and linked diffs; of those, priority and cycle
are dropped by the ruling and the relation groups need issue links this
schema does not store.
A group renders even when its value is empty, which is the one place this
departs from what the page did before. An empty section used to be hidden,
and that was right while the rail was read-only — but a field you cannot see
is a field you cannot set, and these are editable now. So the group states
that it is empty rather than vanishing.
"""
attr :class, :any, default: nil
attr :rest, :global
slot :group, required: true do
attr :heading, :string, required: true
end
def properties_panel(assigns) do
~H"""
<div class={["properties-panel", @class]} {@rest}>
<section :for={group <- @group} class="properties-panel__group">
<h3 class="properties-panel__heading">{group.heading}</h3>
<div class="properties-panel__body">{render_slot(group)}</div>
</section>
</div>
"""
end
@doc """
Everything that happened to an issue, oldest first.
An ordered list, because that is what it is: the sequence is the meaning, and
a reader arriving at the bottom of a long thread needs to know they are at
the end rather than at an arbitrary point in a pile.
A hairline runs behind the glyph column. Circle does not draw one and the
feed reads as loose rows because of it; the line is what turns a sequence of
events into a thread.
"""
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true
def timeline(assigns) do
~H"""
<ol class={["timeline", @class]} {@rest}>{render_slot(@inner_block)}</ol>
"""
end
@doc """
One thing that happened, stated in a line.
Adapted from `activity-feed.tsx`'s event row. An event is deliberately
quieter than a comment: it is a fact about the issue rather than something a
person wrote, and giving the two the same weight makes a thread of six label
changes and one real comment look like seven comments.
`text` is the predicate — "closed this as completed" — because the actor is
already the subject and repeating the name inside the sentence reads as a
template that was never filled in.
`actor` is optional because it is sometimes genuinely unknown. This schema
records that an issue was closed and when, but not by whom, and inventing a
name for the sentence would be worse than a sentence without a subject.
"""
attr :actor, :string, default: nil
attr :text, :string, required: true, doc: "what they did, without the name"
attr :icon, :string, default: "circle"
attr :tone, :atom, values: [:neutral, :info, :success, :warning, :danger], default: :neutral
attr :at, :string, default: nil, doc: "already formatted"
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, doc: "what the event acted on, such as the label that was added"
def timeline_event(assigns) do
~H"""
<li class={["timeline-event", @class]} data-tone={@tone} {@rest}>
<span class="timeline-event__glyph" aria-hidden="true"><UI.icon name={@icon} /></span>
<span class="timeline-event__text">
<span :if={@actor} class="timeline-event__actor">{@actor}</span>
{@text}{render_slot(@inner_block)}
</span>
<span :if={@at} class="timeline-event__at">{@at}</span>
</li>
"""
end
@doc """
One comment in the thread.
A card rather than a row, because a comment is authored prose and it needs an
edge to sit inside; the events around it are single lines and the contrast is
what makes the thread scannable.
`badge` is for what GitHub prints beside a name — `Author`, `Member`,
`Owner`. GitHub derives the first of those by comparing the commenter to the
issue's author rather than storing it, which is why this takes a string the
caller worked out instead of a field.
"""
attr :id, :string, required: true
attr :author, :string, required: true
attr :src, :string, default: nil
attr :at, :string, default: nil, doc: "already formatted"
attr :badge, :string, default: nil, doc: "the commenter's relationship to the issue"
attr :class, :any, default: nil
attr :rest, :global
slot :actions, doc: "controls on this comment"
slot :inner_block, required: true, doc: "the rendered body"
def timeline_comment(assigns) do
~H"""
<li id={@id} class={["timeline-comment", @class]} {@rest}>
<header class="timeline-comment__head">
<.assignee name={@author} src={@src} size={:sm} />
<span class="timeline-comment__author">{@author}</span>
<span :if={@badge} class="timeline-comment__badge">{@badge}</span>
<span :if={@at} class="timeline-comment__at">{@at}</span>
<span :if={@actions != []} class="timeline-comment__actions">{render_slot(@actions)}</span>
</header>
<div class="timeline-comment__body">{render_slot(@inner_block)}</div>
</li>
"""
end
@doc """
The well a comment is written in.
Adapted from `activity-feed.tsx`'s composer. What makes it deliberate rather
than a bare textarea is that the whole well is the control: the border, the
writer's own face, and the footer belong to one surface that takes focus as a
unit, instead of a labelled box with a button loose underneath it.
The control and the submit action are slots because this has to live inside
the caller's `<.form>` — the composer owns the shape, the form owns the data.
"""
attr :id, :string, required: true
attr :author, :string, default: nil, doc: "the writer, shown as a face"
attr :src, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global
slot :hint, doc: "what the writer should know, at the foot"
slot :actions, required: true, doc: "the submit control"
slot :inner_block, required: true, doc: "the text control"
def comment_composer(assigns) do
~H"""
<div id={@id} class={["comment-composer", @class]} {@rest}>
<.assignee :if={@author} name={@author} src={@src} size={:sm} class="comment-composer__face" />
<div class="comment-composer__well">
<div class="comment-composer__field">{render_slot(@inner_block)}</div>
<footer class="comment-composer__foot">
<span class="comment-composer__hint">{render_slot(@hint)}</span>
{render_slot(@actions)}
</footer>
</div>
</div>
"""
end
@doc """
A property you can change, as a native popover over its own value.
This is what Circle's row and rail have that ours did not: the value is the
control. Circle reaches for a Radix dropdown wrapping a `cmdk` list; a native
`popover` gives the same behaviour — click out to dismiss, `Escape` to close,
the trigger as the anchor — with no script, and the trigger is a real button
so it is reachable by keyboard for free.
The trigger is a slot rather than a label, so the thing you click is the
glyph or the face itself rather than a control beside it. `label` is the
trigger's accessible name, which matters precisely because its visible
content is usually a picture.
"""
attr :id, :string, required: true
attr :label, :string, required: true, doc: "the accessible name of the trigger"
attr :align, :atom, values: [:start, :end], default: :start
attr :class, :any, default: nil
attr :rest, :global
slot :trigger, required: true, doc: "the value, which is what gets clicked"
slot :inner_block, required: true, doc: "`field_menu_item/1` options"
def field_menu(assigns) do
~H"""
<span class={["field-menu", @class]} {@rest}>
<button
type="button"
class="field-menu__trigger"
popovertarget={@id}
popovertargetaction="toggle"
aria-label={@label}
>
{render_slot(@trigger)}
</button>
<div id={@id} popover class="menu field-menu__panel" data-align={@align}>
{render_slot(@inner_block)}
</div>
</span>
"""
end
@doc """
One option inside a `field_menu/1`.
`mode` decides what the option claims about itself. Labels and assignees are
a set, so each option is a toggle and says `aria-pressed`; state and
milestone are one choice out of several, so the selected one says
`aria-current`. Both draw the same tick, because the tick means the same
thing to a reader either way.
`closes` names the panel to dismiss. A native popover does not close when
something inside it is clicked, and for a single choice a menu that stays
open reads as a choice that did not register. For a set it is the opposite —
leaving it open is what lets you tick three labels — so this is opt-in
rather than automatic.
"""
attr :label, :string, required: true
attr :icon, :string, default: nil
attr :mode, :atom, values: [:toggle, :choice], default: :toggle
attr :selected, :boolean, default: false
attr :closes, :string, default: nil, doc: "the `field_menu/1` id this dismisses"
attr :on_select, JS, default: nil
attr :class, :any, default: nil
attr :rest, :global
slot :glyph, doc: "a dot, a face, or a state marker before the word"
def field_menu_item(assigns) do
~H"""
<button
type="button"
class={["field-menu__item", @class]}
phx-click={@on_select}
popovertarget={@closes}
popovertargetaction={@closes && "hide"}
aria-pressed={@mode == :toggle && to_string(@selected)}
aria-current={@mode == :choice && @selected && "true"}
{@rest}
>
<span class="field-menu__mark">
<UI.icon :if={@selected} name="check" />
</span>
{render_slot(@glyph)}
<UI.icon :if={@icon} name={@icon} class="field-menu__glyph" />
<span class="field-menu__label">{@label}</span>
</button>
"""
end
# ── the fixed vocabularies ─────────────────────────────────────────────────
# GitHub's two states and the one close reason that reads differently. This
# lives here rather than in each page so a surface cannot quietly disagree
# with another about what "closed" looks like.
defp state_category("closed", reason) when reason in ["not_planned", "duplicate"],
do: :canceled
defp state_category("closed", _reason), do: :completed
defp state_category(_state, _reason), do: :open
# GitHub's pull-request colours read against the same category ladder the
# issue glyphs use: open green, merged purple, closed red, draft grey.
defp pull_request_category("merged"), do: :completed
defp pull_request_category("closed"), do: :canceled
defp pull_request_category("draft"), do: :backlog
defp pull_request_category(_open), do: :open
defp pull_request_label("merged"), do: "Merged"
defp pull_request_label("closed"), do: "Closed"
defp pull_request_label("draft"), do: "Draft"
defp pull_request_label(_open), do: "Open"
defp state_label("closed", "not_planned"), do: "Closed as not planned"
defp state_label("closed", "duplicate"), do: "Closed as duplicate"
defp state_label("closed", _reason), do: "Closed"
defp state_label(_state, _reason), do: "Open"
# Five of the source's six status shapes exist in the vendored set. Triage is
# opposing arrows in a disc, which `compare-arrows` says exactly; the dashed
# gear it uses for backlog has no equivalent and `circle-dashed` carries the
# same "not yet real" reading without vendoring a glyph for one state.
# GitHub's issue-state glyphs. Open is the green circle-dot and a completed
# close is the purple check-circle; the other categories keep the triage
# glyphs because GitHub itself has no opinion about them.
defp category_icon(:open), do: "octicon-issue-opened"
defp category_icon(:triage), do: "compare-arrows"
defp category_icon(:backlog), do: "circle-dashed"
defp category_icon(:unstarted), do: "empty-circle"
# :started draws the conic-gradient arc in CSS, never a glyph.
defp category_icon(:completed), do: "octicon-issue-closed"
defp category_icon(:canceled), do: "x-circle-filled"
defp priority_name(:none), do: "No priority"
defp priority_name(:low), do: "Low priority"
defp priority_name(:medium), do: "Medium priority"
defp priority_name(:high), do: "High priority"
defp priority_name(:urgent), do: "Urgent"
# `:unknown` has no word because the row renders no health cell for it. A
# project nobody has reported on should leave a gap in the column, not claim
# "no update" as though that were a fourth health state.
defp health_name(:on_track), do: "On track"
defp health_name(:at_risk), do: "At risk"
defp health_name(:off_track), do: "Off track"
# A progress value arrives from a count of finished issues over a count of
# issues, so it can be anything; the arc has to be drawable regardless.
defp clamp(nil), do: 0
defp clamp(value) when is_integer(value), do: value |> max(0) |> min(100)
defp clamp(_), do: 0
end