Porting Circle's issue surfaces
2026-08-20
OpenAgentsWeb.UI.Circle is adapted from Circle, MIT-licensed,
© 2025 lndev-ui. This document records what was taken, what was not, and why —
so a later reader can tell which decisions are inherited and which are ours.
The work list at the bottom is the tracker for this effort: update it in the
same change that lands the work, not afterwards.
What Circle is
Circle is a Linear-shaped issue, project, and team tracker: Next.js App Router,
TypeScript, Tailwind, shadcn/ui, Zustand for state, nuqs for URL state,
motion/react for layout animation, and react-dnd for the board. It ships no
backend — every surface reads a mock-data module — which makes it unusually
good source material, because the data shapes are stated plainly instead of
being inferred from an API.
This assessment uses revision c60371c, the tip of the local Circle checkout,
whose newest commit adds the ⌘K command palette. The licence is LICENSE.md at
the repository root: MIT, requiring the copyright notice be retained. Since this
is adaptation rather than copying, attribution lives in the module doc, in the
stylesheet section header, and here.
What "porting" means here
Almost none of the code survives. Every component in components/common/issues
is a client component reading a Zustand store, and the interesting ones are
wrapped in Radix context menus, popovers, and dialogs. None of that moves to
HEEx.
What carried over is the information design, which is the expensive part and transfers intact:
- what an issue row holds and in what order — that priority, identifier, and status form a fixed-width scan column on the leading edge, and that everything discretionary collects on the trailing edge where width can drop it;
- that the status glyph is a filled arc, not a coloured dot, so a list says how far along each piece of work is;
- that priority is one shape read at four levels, with urgent deliberately outside the ramp;
- that a group header carries a wash mixed from its own status, which is the only thing that marks a boundary once the header has scrolled past its rows;
- that a filter reads as subject / operator / value, with each segment its own control;
- that a card is not a row turned sideways.
Deliberate departures
Tokens, not a second palette
Circle assigns a hand-picked hex value to each of thirteen statuses
(#facc15, #5e6ad2, #26b5ce, …) and to each of eleven labels. Those
colours are Linear's. Adopting them would put a second colour system beside the
one every other surface uses, and the tracker would stop looking like the
product it is part of.
Colour here is assigned per status category — six of them — off the same
token ladder OpenAgentsWeb.UI.status_indicator/1 already uses:
| Category | Token | Why |
|---|---|---|
:triage |
--warning |
awaiting a decision |
:backlog |
--text-dim |
resting, not yet real |
:unstarted |
--text-muted |
resting, real |
:started |
--info |
activity, as everywhere else in the product |
:completed |
--success |
done |
:canceled |
--text-dim |
resting, closed |
Six colours say less than thirteen. That is the cost, and it is stated in the
component docs rather than hidden: In progress, In review, and Blocked are
all one blue here, and only the word tells them apart. In exchange the same
component is correct in both themes with no second set of declarations, and a
status glyph never disagrees with the status dot in the sidebar beside it.
Labels take the same treatment through a tone attribute over the same six
values. Because six tones cannot distinguish eleven labels, the label's word
is not optional in this port — the dot is a grouping hint, not the identity.
No JavaScript, except where the keyboard needs it
Rows, cards, groups, boards, filters, headers, and every project, team, and member row are server-rendered and carry no script.
The command palette is the one exception, and it is a real one: ⌘K is a
document-level binding, incremental filtering means hiding rows as characters
arrive, and arrow-key selection has to survive both. The palette carries one
colocated hook doing exactly those things, following the pattern already
established by copy_button/1 and github_login/1. It is built on native
<dialog>, so the browser supplies the focus trap, the backdrop, Escape, and
inertness of the page behind — the parts hand-built palettes usually get wrong.
Every command is a real <button> and does its job without the hook.
No drag-and-drop
The source's board is react-dnd: a drag layer, a custom preview, per-column
drop targets, and a full-column overlay reading "Drop to update status". About
250 lines across two files, and the behaviour it buys is a status change.
issue_board/1 and issue_group/1 port the layout — columns side by side,
each scrolling on its own so a long backlog does not push the other headers off
the top. Changing an issue's status stays a control, which also means it works
on a touch screen and from a keyboard, neither of which the source's board does.
State is the caller's
Circle keeps grouping, ordering, filters, search, display properties, and drag
results in eight Zustand stores, and every component reads them directly. That
is why IssueLine cannot be rendered anywhere the store is not.
None of these components own state. They take what to draw and emit
Phoenix.LiveView.JS commands the caller supplies. That is what makes the same
issue_row/1 usable in a list, in a search result, and in a group, which is
three call sites in the source with three different wrappers.
Icons
Every glyph resolves to the vendored Apps SDK set through
OpenAgentsWeb.UI.icon/1. Nothing was vendored for this port. The mapping
for the status shapes:
| Circle | Ours | Note |
|---|---|---|
| triage disc with opposing arrows | compare-arrows |
the same picture |
| dashed gear (backlog, idea) | circle-dashed |
see below |
| empty ring (todo) | empty-circle |
|
| ring with filled arc (in progress) | drawn in CSS | see below |
| filled tick (done, shipped) | check-circle-filled |
|
| filled cross (cancelled) | x-circle-filled |
|
| filled slash-equal (duplicate) | x-circle-filled |
folded into cancelled |
Two of these need explaining.
The dashed gear has no equivalent and none was vendored. It is Linear's mark
for "this is not real work yet", and circle-dashed carries the same reading
with a shape already in the set. Vendoring a glyph for one status would put a
Linear-specific mark in a general icon set.
The arc is not an icon. Its fill is a number — the fraction of a project's
issues that are finished — so it cannot come from a fixed set. It is drawn in
CSS as a conic-gradient inside a ring, which is a handful of declarations, no
SVG, and correct at any percentage. The priority bars are drawn the same way for
the same reason: they are one shape read at four levels, not four pictures, and
lighting them from a data-level attribute keeps the ordering in one place.
Neither is inline SVG in a template, which docs/ICONS.md rules out. Both are
CSS-drawn indicators of the kind status_indicator/1 already establishes.
What we are not porting
Stated so nobody re-litigates it later:
components/data-table-filter— about 2,000 lines implementing a typed filter engine (columns, operators, faceted value counts, i18n, URL serialisation) over TanStack Table. That is a query builder, and it belongs on the server here.filter_chip/1andfilter_bar/1port the row it renders, which is the part a reader sees.- The insights panel. A 420-pixel side panel of charts computed from the visible issues. Worth revisiting when there is a real corpus to compute from; charting invented numbers demonstrates nothing.
motion/reactlayout animation. The source animates a row into a card when the view switches between list and board, via sharedlayoutId. It is genuinely nice and it needs a JavaScript animation library plus DOM measurement. Not for a first port.- Context menus. Right-click on a row opens a twelve-item Radix menu. Every action in it also exists in the command palette, which is reachable from a keyboard.
- The create-issue modal, cycles, initiatives, inbox, reviews, and the agent surface. Out of scope: they are product decisions, not components, and this application has not made them.
components/ui/*— shadcn/ui primitives. Button, badge, avatar, input, table, and the rest already exist inOpenAgentsWeb.UI, and adding a second set is precisely whatAGENTS.mdforbids.
What this port does not yet do
- Grouping, filtering, and display options are not implemented. The components render a grouped view; deciding what the groups are is the caller's job and nothing here does it yet.
- The board is display-only, as above.
- The timeline is derived, not recorded. GitHub's issue timeline is an
endpoint backed by an event log. This schema has no
issue_eventstable, so the feed on the issue page is assembled from the columns that do exist: opened frominserted_atanduser, closed fromclosed_atandstate_reason, plus the comments. The result is honest but partial — a label added and removed leaves no trace, and a close records when but not who, which is why a close event has no actor. Anissue_eventstable is what would complete it, and it is a schema change rather than a component one. - The demos hold invented data. Six issues, eight people, four projects. They span every status category, every priority, assigned and unassigned, because a demo that shows one happy row hides the cases the component exists to keep legible.
Work list
Status is one of done, next, or planned.
1. Indicators — done
issue_status/1, issue_priority/1, issue_label/1, assignee/1,
assignee_stack/1. The vocabulary everything else is built from. Catalogued at
/components/issue-status, /components/issue-priority,
/components/issue-label, /components/assignee,
/components/assignee-stack.
2. Rows and collections — done
issue_row/1, issue_card/1, issue_group/1, issue_board/1. Catalogued at
/components/issue-row, /components/issue-card, /components/issue-group,
/components/issue-board.
3. Headers and filters — done
view_tabs/1, issue_toolbar/1, filter_chip/1, filter_bar/1. Catalogued at
/components/view-tabs, /components/issue-toolbar, /components/filter-chip,
/components/filter-bar.
4. Command palette — done
command_palette/1, command_group/1, command_item/1. Catalogued at
/components/command-palette, /components/command-group,
/components/command-item.
5. Project, team, and member rows — done
project_row/1, team_row/1, member_row/1. Catalogued at
/components/project-row, /components/team-row, /components/member-row.
6. Compose the issue LiveViews from these — done
OpenAgentsWeb.IssueIndexLive renders issue_row/1, issue_toolbar/1 and
view_tabs/1; OpenAgentsWeb.ProjectIndexLive renders project_row/1; and
OpenAgentsWeb.IssueShowLive is built from issue_detail/1,
properties_panel/1, timeline/1 and comment_composer/1. Changing a
component now changes the product, which is what stops the library and the
pages drifting apart.
The schema decision this item was waiting on turned out not to exist. The
ruling in docs/2026-08-20-linear-design-github-shape.md is that we have what
GitHub has, and the components take GitHub's fields and leave the rest of their
attributes at their defaults, which is what those defaults are for.
7. The issue page — done
Adapted from components/common/issues/details/, GitHub-shaped:
| Ours | Source | Slug |
|---|---|---|
issue_state/1 |
— | /components/issue-state |
issue_detail/1 |
issue-details.tsx |
/components/issue-detail |
properties_panel/1 |
issue-properties-panel.tsx |
/components/properties-panel |
timeline/1 |
activity-feed.tsx |
/components/timeline |
timeline_event/1 |
activity-feed.tsx |
/components/timeline-event |
timeline_comment/1 |
activity-feed.tsx |
/components/timeline-comment |
comment_composer/1 |
activity-feed.tsx |
/components/comment-composer |
issue_state/1 has no counterpart in Circle. It exists because
issue_status/1 renders six categories and GitHub has two, and every page was
writing its own three-clause mapping from state and state_reason to a
glyph. One place now owns it, so two surfaces cannot disagree about what closed
looks like. not_planned and duplicate both take the cancelled glyph,
because both mean the work was not done.
Three departures from the source on this page:
- The rail moves rather than hiding. Circle hides its properties panel
below
lg. State, labels and assignees are not decoration and a phone is where an issue is most often read, so the rail sits under the heading on a narrow screen and beside the body when there is room. - A property group renders even when empty. The page used to hide
Labels,AssigneesandMilestoneuntil the issue had one, which was right while the rail was read-only. It is editable now, and hidingLabelsuntil an issue has a label means an issue can never get its first one. The test that asserted the old behaviour was rewritten to assert the new intent rather than deleted. - The body and comments render as Markdown.
OpenAgents.Markdown.to_html/2already exists, sanitized and bounded, and was not being used on this page. GitHub renders issue bodies as Markdown, so this is parity rather than a new idea.
Circle's content-blocks.tsx (243 lines) is not ported. It is a mock
rich-text model — paragraphs, checklists, image and video placeholders —
standing in for a real document. We have a real one.
8. Make the rows' parts controls — done
In Circle every cell of a row is a selector. Two new components carry that:
| Ours | Source | Slug |
|---|---|---|
field_menu/1 |
status-selector.tsx, assignee-user.tsx |
/components/field-menu |
field_menu_item/1 |
the cmdk rows inside them |
/components/field-menu-item |
Circle wraps a Radix dropdown around 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 the keyboard
reaches it for free. mode decides whether an option is a toggle in a set
(aria-pressed, for labels and assignees) or one choice out of several
(aria-current, for state and milestone).
issue_row/1 and project_row/1 grew slots (:state, :people, :actions)
that replace a static cell with a control. The row stays presentational; a
caller with somewhere to send a change supplies the menu. Every control sits
outside the title link, because a state-changing control inside a link
target is how people mis-click.
What is interactive where, and why:
- Issue list row — state and assignee. They are the two facts worth changing without opening the issue. Labels and milestone need option lists longer than a row has room to explain.
- Issue page rail — state (including the close reason, which the header's two buttons cannot express), assignees, labels, milestone.
- Project list row — state. Projects V2 carries
state, and it is the only property of a project this schema holds that is worth changing from a list. Delete stays a separate control beside the row rather than inside it.
9. Re-checked against the GitHub ruling
docs/2026-08-20-linear-design-github-shape.md changed what is portable.
Re-reading the earlier "not porting" list against it:
- Priority selector — now clearly out. It was skipped for scope; it is
dropped on principle. GitHub has no priority field and the
priority: highlabel convention is a convention, not a contract.issue_priority/1stays in the library unused. - Cycles — out. GitHub has milestones. The UI calls them milestones.
- Sub-issues, blocked-by, related, linked diffs — out for now. The strategy
doc lists
sub_issues_summaryandissue_dependencies_summaryas fields we have. We do not: neither appears anywhere in this schema. So Circle'sIssueRefRowwas left unported rather than being built against a field that does not exist. When those columns land, the arc inissue_status/1is already the renderer forpercent_completed. - Reactions — out. GitHub has a reactions API and Circle's comment card has reaction pills. This schema stores none, so per the ruling we do not have the concept.
author_association— out as a field, in as a derivation.timeline_comment/1takes abadge, and the issue page passesAuthorwhen the commenter's login matches the issue's. GitHub derives that same badge by comparing rather than storing, so this is parity, not invention.locked/locked_reason— in, and now used. Both are GitHub fields this schema already carried and nothing rendered. A locked issue's page now says so and does not offer a composer.
10. Grouping and filtering on the server — planned
Group by state, assignee, label, or milestone; filter by the same. Both are
server concerns — a query and a GROUP BY — and issue_group/1 already
accepts the result. The filter chips need somewhere to send their changes.
11. Display options — planned
Circle's DisplayOptions popover switches list and board, picks the grouping
and ordering, and toggles nine per-property visibility flags. The toggles are
worth having and they need somewhere to persist; field_menu/1 is now the
control they would be built from, and a per-user preference is the missing
half.