Rebuild the issue page, and make the rows' parts controls

f560bb3de759 · AtlantisPleb · · parent 6cf546b580a4

Rebuild the issue page, and make the rows' parts controls

The issue page was a read view with an edit form's shape: body in a card,
a Comments heading, a bare labelled textarea, and a right column that
hid Labels, Assignees and Milestone until the issue happened to have
them. It is now built from ported components, so changing one changes
the product rather than only the catalogue.

Nine components, all catalogued and demoed. Seven adapted from Circle's
`details/` directory, which the first port skipped entirely:
issue_detail/1, properties_panel/1, timeline/1, timeline_event/1,
timeline_comment/1, comment_composer/1, and issue_state/1. Two more make
a row's cells editable: field_menu/1 and field_menu_item/1.

issue_state/1 has no counterpart in Circle. issue_status/1 renders six
categories because Circle has six; GitHub has two, and every page was
writing its own mapping from `state` and `state_reason` to a glyph. One
place owns it now, 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.

Made interactive, all through a native <dialog>-free popover with no
script -- the trigger is a real button, so the keyboard reaches it for
free, and every control sits OUTSIDE the title link, because a
state-changing control inside a link target is how people mis-click:

- issue list row: state and assignee, the two facts worth changing
  without opening the issue
- issue page rail: state (including the close reason, which the
  header's two buttons cannot express), assignees, labels, milestone
- project list row: state, which Projects V2 carries

Labels and milestone are deliberately not on the list row: their option
lists are longer than a row has room to explain.

Re-checked the earlier "not porting" list against the GitHub ruling.
Priority moves from skipped-for-scope to dropped-on-principle. Cycles
likewise. Sub-issues, blocked-by, related and linked diffs stay out, but
for a corrected reason: the strategy doc lists `sub_issues_summary` and
`issue_dependencies_summary` as fields we have, and neither exists
anywhere in this schema -- so Circle's IssueRefRow was left unported
rather than built against a column that is not there. Reactions are out
for the same reason. Two GitHub fields we DO store and were rendering
nowhere are now used: `locked`/`locked_reason` closes the composer, and
the Author badge is derived by comparing a commenter to the issue's
author, which is how GitHub derives it too.

The timeline is honest about being derived. GitHub's is an endpoint
backed by an event log; this schema has none, so the feed is assembled
from `inserted_at`, `user`, `closed_at`, `state_reason` and the
comments. A close therefore has no actor -- nothing records who did it,
and a sentence with an invented subject is worse than one without a
subject.

One existing test changed meaning rather than being deleted: it asserted
that an empty property group was hidden, which was right while the rail
was read-only. Hiding Labels until an issue has a label means an issue
can never get its first one, so the group is present and states its own
emptiness. Issue bodies and comments now render through the sanitized
OpenAgents.Markdown path, which already existed and this page was not
using.

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified assets/css/openagents.css
  • modified docs/2026-08-20-circle-ui-port.md
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/circle.ex
  • modified lib/openagents_web/live/components_live.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • modified lib/openagents_web/live/project_index_live.ex
  • modified test/openagents_web/components/circle_test.exs
  • modified test/openagents_web/live/issue_index_live_test.exs
  • modified test/openagents_web/live/issue_show_live_test.exs
  • modified test/openagents_web/live/project_index_live_test.exs

Diff

12 files changed, +2340 -223

assets/css/openagents.css modified +527

@@ -6231,3 +6231,530 @@

6231 6231
    flex: none;
6232 6232
  }
6233 6233
}
6234
6235
/* ── Issue detail ─────────────────────────────────────────────────────────── */
6236
6237
/* The issue page and its editable properties, adapted from Circle's
6238
 * `details/` directory (MIT, © 2025 lndev-ui). The source's properties panel
6239
 * carries status, priority, cycle, project, relations and linked diffs; all
6240
 * but state, labels, assignees and milestone are dropped, because GitHub has
6241
 * no field for them and this application does not invent fields. See
6242
 * docs/2026-08-20-linear-design-github-shape.md. */
6243
6244
@layer components {
6245
  /* Areas rather than columns, because the rail changes position rather than
6246
     disappearing: under the heading on a narrow screen, where state and
6247
     assignee are the first things worth knowing, and beside the body when
6248
     there is room. Circle hides it below `lg`; a phone is where an issue is
6249
     most often read, so hiding it there is the wrong trade. */
6250
  .issue-detail {
6251
    display: grid;
6252
    grid-template-areas:
6253
      "head"
6254
      "rail"
6255
      "main";
6256
    grid-template-columns: minmax(0, 1fr);
6257
    gap: 24px;
6258
  }
6259
6260
  .issue-detail__head {
6261
    grid-area: head;
6262
    min-width: 0;
6263
  }
6264
6265
  .issue-detail__main {
6266
    grid-area: main;
6267
    min-width: 0;
6268
    /* A measure, not the window. Prose read edge to edge on a wide screen
6269
       loses its place between lines. */
6270
    max-width: 68ch;
6271
  }
6272
6273
  .issue-detail__rail {
6274
    grid-area: rail;
6275
    min-width: 0;
6276
  }
6277
6278
  @media (width >= 64rem) {
6279
    .issue-detail {
6280
      grid-template-areas:
6281
        "head head"
6282
        "main rail";
6283
      grid-template-columns: minmax(0, 1fr) 280px;
6284
      column-gap: 40px;
6285
    }
6286
6287
    .issue-detail__rail {
6288
      padding-inline-start: 24px;
6289
      border-inline-start: 1px solid var(--line);
6290
    }
6291
  }
6292
6293
  .properties-panel {
6294
    display: flex;
6295
    flex-direction: column;
6296
    gap: 20px;
6297
  }
6298
6299
  .properties-panel__heading {
6300
    padding-block-end: 6px;
6301
    color: var(--text-dim);
6302
    font-size: 0.75rem;
6303
    font-weight: 600;
6304
  }
6305
6306
  .properties-panel__body {
6307
    display: flex;
6308
    flex-wrap: wrap;
6309
    align-items: center;
6310
    gap: 6px;
6311
    color: var(--text-body);
6312
    font-size: 0.8125rem;
6313
  }
6314
6315
  /* The words a group says when it holds nothing. Muted enough to read as an
6316
     absence rather than a value, but present, because the group is now a
6317
     control and a control you cannot see is one you cannot use. */
6318
  .properties-panel__none {
6319
    color: var(--text-dim);
6320
    font-size: 0.8125rem;
6321
  }
6322
6323
  /* ── timeline ──────────────────────────────────────────────────────────── */
6324
6325
  .timeline {
6326
    position: relative;
6327
    display: flex;
6328
    flex-direction: column;
6329
    gap: 2px;
6330
    padding-block: 4px;
6331
    list-style: none;
6332
  }
6333
6334
  /* The thread. Circle draws no line and its feed reads as loose rows; this is
6335
     what turns a sequence of facts into one history. It stops short at both
6336
     ends so it does not appear to continue past the first and last events. */
6337
  .timeline::before {
6338
    content: "";
6339
    position: absolute;
6340
    inset-block: 14px;
6341
    inset-inline-start: 11px;
6342
    width: 1px;
6343
    background: var(--line);
6344
  }
6345
6346
  .timeline-event {
6347
    position: relative;
6348
    display: flex;
6349
    align-items: center;
6350
    gap: 10px;
6351
    padding-block: 6px;
6352
    color: var(--text-muted);
6353
    font-size: 0.8125rem;
6354
  }
6355
6356
  .timeline-event__glyph {
6357
    display: inline-flex;
6358
    align-items: center;
6359
    justify-content: center;
6360
    width: 22px;
6361
    height: 22px;
6362
    flex: none;
6363
    /* Painted, not transparent: the glyph has to cover the thread behind it
6364
       or the line runs through the middle of every marker. */
6365
    border-radius: 50%;
6366
    background: var(--ink-surface);
6367
    box-shadow: 0 0 0 3px var(--ink-surface);
6368
    color: var(--icon-tertiary);
6369
    font-size: 13px;
6370
  }
6371
6372
  .timeline-event[data-tone="info"] .timeline-event__glyph { color: var(--info); }
6373
  .timeline-event[data-tone="success"] .timeline-event__glyph { color: var(--success); }
6374
  .timeline-event[data-tone="warning"] .timeline-event__glyph { color: var(--warning); }
6375
  .timeline-event[data-tone="danger"] .timeline-event__glyph { color: var(--danger); }
6376
6377
  .timeline-event__text {
6378
    min-width: 0;
6379
    display: inline-flex;
6380
    align-items: center;
6381
    flex-wrap: wrap;
6382
    gap: 4px;
6383
  }
6384
6385
  .timeline-event__actor {
6386
    color: var(--text-primary);
6387
    font-weight: 500;
6388
  }
6389
6390
  .timeline-event__at {
6391
    flex: none;
6392
    margin-inline-start: auto;
6393
    color: var(--text-dim);
6394
    font-size: 0.75rem;
6395
  }
6396
6397
  /* A card against the events' single lines. The contrast is what makes a
6398
     thread of six label changes and one real comment scannable. */
6399
  .timeline-comment {
6400
    position: relative;
6401
    margin-block: 8px;
6402
    margin-inline-start: 32px;
6403
    border: 1px solid var(--line);
6404
    border-radius: var(--radius-md);
6405
    background: var(--ink-raised);
6406
  }
6407
6408
  .timeline-comment__head {
6409
    display: flex;
6410
    align-items: center;
6411
    gap: 8px;
6412
    padding: 10px 14px;
6413
    border-block-end: 1px solid var(--line);
6414
    background: var(--wash-hover);
6415
    border-start-start-radius: var(--radius-md);
6416
    border-start-end-radius: var(--radius-md);
6417
  }
6418
6419
  .timeline-comment__author {
6420
    color: var(--text-primary);
6421
    font-size: 0.8125rem;
6422
    font-weight: 600;
6423
  }
6424
6425
  .timeline-comment__badge {
6426
    padding: 1px 6px;
6427
    border: 1px solid var(--line-strong);
6428
    border-radius: 999px;
6429
    color: var(--text-dim);
6430
    font-size: 0.6875rem;
6431
  }
6432
6433
  .timeline-comment__at {
6434
    color: var(--text-dim);
6435
    font-size: 0.75rem;
6436
  }
6437
6438
  .timeline-comment__actions {
6439
    margin-inline-start: auto;
6440
  }
6441
6442
  .timeline-comment__body {
6443
    padding: 14px;
6444
    color: var(--text-body);
6445
    font-size: 0.875rem;
6446
    line-height: 1.6;
6447
    overflow-wrap: anywhere;
6448
  }
6449
6450
  .timeline-comment__body > :is(p, ul, ol, pre, blockquote, table) + * {
6451
    margin-block-start: 0.75em;
6452
  }
6453
6454
  .timeline-comment__body :is(ul, ol) {
6455
    padding-inline-start: 1.25em;
6456
    list-style: revert;
6457
  }
6458
6459
  .timeline-comment__body code {
6460
    padding: 1px 4px;
6461
    border-radius: var(--radius-sm);
6462
    background: var(--wash-strong);
6463
    font-family: var(--font-mono);
6464
    font-size: 0.9em;
6465
  }
6466
6467
  .timeline-comment__body pre {
6468
    padding: 10px 12px;
6469
    overflow-x: auto;
6470
    border-radius: var(--radius-sm);
6471
    background: var(--ink-void);
6472
  }
6473
6474
  .timeline-comment__body pre code {
6475
    padding: 0;
6476
    background: none;
6477
  }
6478
6479
  .timeline-comment__body a {
6480
    color: var(--text-primary);
6481
    text-decoration: underline;
6482
    text-underline-offset: 2px;
6483
  }
6484
6485
  /* ── composer ──────────────────────────────────────────────────────────── */
6486
6487
  .comment-composer {
6488
    display: flex;
6489
    align-items: flex-start;
6490
    gap: 10px;
6491
    margin-block-start: 16px;
6492
  }
6493
6494
  .comment-composer__face {
6495
    padding-block-start: 8px;
6496
  }
6497
6498
  /* The whole well is the control. A labelled box with a button loose
6499
     underneath is what made the old composer read as a form field that
6500
     happened to be on an issue page rather than as somewhere to write. */
6501
  .comment-composer__well {
6502
    flex: 1;
6503
    min-width: 0;
6504
    border: 1px solid var(--line);
6505
    border-radius: var(--radius-md);
6506
    background: var(--ink-raised);
6507
    transition: border-color var(--motion-fast) var(--ease);
6508
  }
6509
6510
  .comment-composer__well:focus-within {
6511
    border-color: var(--line-strong);
6512
  }
6513
6514
  .comment-composer__field {
6515
    padding: 4px 4px 0;
6516
  }
6517
6518
  /* The control inside comes from the caller, so it is stripped here rather
6519
     than restyled: the well already draws the edge, and a second border inside
6520
     it is the thing that made this look unfinished. */
6521
  .comment-composer__field :is(textarea, input) {
6522
    width: 100%;
6523
    min-height: 84px;
6524
    border: 0;
6525
    background: transparent;
6526
    resize: vertical;
6527
  }
6528
6529
  .comment-composer__field :is(textarea, input):focus,
6530
  .comment-composer__field :is(textarea, input):focus-visible {
6531
    outline: none;
6532
    box-shadow: none;
6533
  }
6534
6535
  .comment-composer__field label {
6536
    /* The label is the placeholder's job here; keeping both stacks two
6537
       prompts over one empty box. It stays in the markup for the control's
6538
       accessible name. */
6539
    position: absolute;
6540
    width: 1px;
6541
    height: 1px;
6542
    overflow: hidden;
6543
    clip-path: inset(50%);
6544
    white-space: nowrap;
6545
  }
6546
6547
  .comment-composer__foot {
6548
    display: flex;
6549
    align-items: center;
6550
    justify-content: space-between;
6551
    gap: 12px;
6552
    padding: 8px 12px;
6553
    border-block-start: 1px solid var(--line);
6554
  }
6555
6556
  .comment-composer__hint {
6557
    color: var(--text-dim);
6558
    font-size: 0.75rem;
6559
  }
6560
6561
  /* ── field menus ───────────────────────────────────────────────────────── */
6562
6563
  .field-menu {
6564
    display: inline-flex;
6565
  }
6566
6567
  /* The trigger is the value: no chrome until it is hovered, so a row of them
6568
     reads as facts rather than as a row of buttons. */
6569
  .field-menu__trigger {
6570
    display: inline-flex;
6571
    align-items: center;
6572
    gap: 6px;
6573
    padding: 2px;
6574
    border: 0;
6575
    border-radius: var(--radius-sm);
6576
    background: transparent;
6577
    color: inherit;
6578
    font: inherit;
6579
    cursor: pointer;
6580
    transition: background var(--motion-quick) var(--ease);
6581
  }
6582
6583
  @media (hover: hover) {
6584
    .field-menu__trigger:hover {
6585
      background: var(--wash-hover);
6586
    }
6587
  }
6588
6589
  .field-menu__panel {
6590
    width: max-content;
6591
    min-width: 200px;
6592
    max-width: 280px;
6593
    max-height: 320px;
6594
    padding: 4px;
6595
    overflow-y: auto;
6596
  }
6597
6598
  @supports (position-area: bottom span-right) {
6599
    .field-menu__panel[data-align="end"] {
6600
      position-area: bottom span-left;
6601
    }
6602
  }
6603
6604
  .field-menu__item {
6605
    display: flex;
6606
    align-items: center;
6607
    gap: 8px;
6608
    width: 100%;
6609
    padding: 6px 8px;
6610
    border: 0;
6611
    border-radius: var(--radius-sm);
6612
    background: transparent;
6613
    color: var(--text-body);
6614
    font-size: 0.8125rem;
6615
    text-align: start;
6616
    cursor: pointer;
6617
  }
6618
6619
  @media (hover: hover) {
6620
    .field-menu__item:hover {
6621
      background: var(--wash-hover);
6622
      color: var(--text-primary);
6623
    }
6624
  }
6625
6626
  .field-menu__item:focus-visible {
6627
    background: var(--wash-selected);
6628
    color: var(--text-primary);
6629
    outline: none;
6630
  }
6631
6632
  /* The tick keeps its column whether or not it is drawn, so the words below a
6633
     selected option do not shift left. */
6634
  .field-menu__mark {
6635
    display: inline-flex;
6636
    align-items: center;
6637
    justify-content: center;
6638
    width: 14px;
6639
    flex: none;
6640
    color: var(--text-primary);
6641
    font-size: 13px;
6642
  }
6643
6644
  .field-menu__glyph {
6645
    flex: none;
6646
    color: var(--icon-tertiary);
6647
    font-size: 15px;
6648
  }
6649
6650
  .field-menu__label {
6651
    min-width: 0;
6652
    overflow: hidden;
6653
    white-space: nowrap;
6654
    text-overflow: ellipsis;
6655
  }
6656
6657
  .issue-row__actions {
6658
    display: inline-flex;
6659
    align-items: center;
6660
    flex: none;
6661
  }
6662
6663
  /* A bare dot outside a label pill -- in a menu, beside the option it stands
6664
     for. The pill's own rule cannot reach it, so the tone is read from the dot
6665
     itself. */
6666
  .issue-label__dot[data-tone="primary"] { background: var(--text-primary); }
6667
  .issue-label__dot[data-tone="info"] { background: var(--info); }
6668
  .issue-label__dot[data-tone="success"] { background: var(--success); }
6669
  .issue-label__dot[data-tone="warning"] { background: var(--warning); }
6670
  .issue-label__dot[data-tone="danger"] { background: var(--danger); }
6671
6672
  /* ── the issue page's own heading ──────────────────────────────────────── */
6673
6674
  .issue-heading__title {
6675
    color: var(--text-primary);
6676
    font-size: 1.75rem;
6677
    font-weight: 600;
6678
    line-height: 1.2;
6679
    text-wrap: balance;
6680
  }
6681
6682
  /* The number is part of the title, not a separate fact: it is how the issue
6683
     is referred to out loud and in a commit message. Lighter, not smaller than
6684
     the words it belongs to. */
6685
  .issue-heading__number {
6686
    color: var(--text-dim);
6687
    font-weight: 400;
6688
  }
6689
6690
  .issue-heading__meta {
6691
    display: flex;
6692
    align-items: center;
6693
    flex-wrap: wrap;
6694
    gap: 6px;
6695
    padding-block-start: 10px;
6696
    color: var(--text-muted);
6697
    font-size: 0.8125rem;
6698
  }
6699
6700
  .issue-heading__dot {
6701
    color: var(--text-dim);
6702
  }
6703
6704
  .issue-heading__actions {
6705
    display: flex;
6706
    align-items: center;
6707
    gap: 8px;
6708
    padding-block-start: 16px;
6709
  }
6710
6711
  .issue-editor {
6712
    display: flex;
6713
    flex-direction: column;
6714
    gap: 12px;
6715
  }
6716
6717
  .issue-editor__foot {
6718
    display: flex;
6719
    justify-content: flex-end;
6720
    gap: 8px;
6721
  }
6722
6723
  .issue-body {
6724
    padding-block-end: 8px;
6725
    border-block-end: 1px solid var(--line);
6726
  }
6727
6728
  /* The control that opens a property's menu. Faint until hovered, because in
6729
     a rail of four groups four bright plus signs read as the point of the
6730
     panel rather than as a way to change what it says. */
6731
  .properties-panel__add {
6732
    width: 16px;
6733
    height: 16px;
6734
    color: var(--icon-faint);
6735
    font-size: 16px;
6736
    transition: color var(--motion-fast) var(--ease);
6737
  }
6738
6739
  @media (hover: hover) {
6740
    .field-menu__trigger:hover .properties-panel__add {
6741
      color: var(--icon-primary);
6742
    }
6743
  }
6744
6745
  .properties-panel__link {
6746
    display: inline-flex;
6747
    align-items: center;
6748
    gap: 6px;
6749
    color: var(--text-body);
6750
    text-decoration: none;
6751
  }
6752
6753
  @media (hover: hover) {
6754
    .properties-panel__link:hover {
6755
      color: var(--text-primary);
6756
      text-decoration: underline;
6757
      text-underline-offset: 2px;
6758
    }
6759
  }
6760
}
docs/2026-08-20-circle-ui-port.md modified +131 -26

@@ -175,15 +175,18 @@ Stated so nobody re-litigates it later:

175 175
176 176
## What this port does not yet do
177 177
178
- **Nothing renders these on a real page.** The existing issue LiveViews at
179
  `/:owner/:repo/issues` still compose generic controls directly. Wiring them up
180
  is a separate change against a real schema, and it should be done deliberately
181
  rather than as a side effect of adding components. Until it happens, the
182
  component library is the only place these appear.
183 178
- **Grouping, filtering, and display options are not implemented.** The
184
  components render a grouped view; deciding what the groups are is the caller's
185
  job and nothing here does it yet.
179
  components render a grouped view; deciding what the groups are is the
180
  caller's job and nothing here does it yet.
186 181
- **The board is display-only,** as above.
182
- **The timeline is derived, not recorded.** GitHub's issue timeline is an
183
  endpoint backed by an event log. This schema has no `issue_events` table, so
184
  the feed on the issue page is assembled from the columns that do exist:
185
  opened from `inserted_at` and `user`, closed from `closed_at` and
186
  `state_reason`, plus the comments. The result is honest but partial — a label
187
  added and removed leaves no trace, and a close records when but not who,
188
  which is why a close event has no actor. An `issue_events` table is what
189
  would complete it, and it is a schema change rather than a component one.
187 190
- **The demos hold invented data.** Six issues, eight people, four projects.
188 191
  They span every status category, every priority, assigned and unassigned,
189 192
  because a demo that shows one happy row hides the cases the component exists

@@ -224,32 +227,134 @@ Status is one of **done**, **next**, or **planned**.

224 227
`project_row/1`, `team_row/1`, `member_row/1`. Catalogued at
225 228
`/components/project-row`, `/components/team-row`, `/components/member-row`.
226 229
227
### 6. Compose the issue LiveViews from these — **next**
230
### 6. Compose the issue LiveViews from these — **done**
228 231
229
`OpenAgentsWeb.IssueIndexLive` should render `issue_row/1` and `issue_group/1`
230
against real issues, the way `OpenAgentsWeb.HomeLive` is built from catalogued
231
landing components. That is what stops the library and the product drifting
232
apart: changing a component changes the page, and the library demonstrates the
233
same thing a user sees.
232
`OpenAgentsWeb.IssueIndexLive` renders `issue_row/1`, `issue_toolbar/1` and
233
`view_tabs/1`; `OpenAgentsWeb.ProjectIndexLive` renders `project_row/1`; and
234
`OpenAgentsWeb.IssueShowLive` is built from `issue_detail/1`,
235
`properties_panel/1`, `timeline/1` and `comment_composer/1`. Changing a
236
component now changes the product, which is what stops the library and the
237
pages drifting apart.
234 238
235
This needs a decision first. The application's issues have `state` (open,
236
closed) where these components have six categories, and no priority column at
237
all. Either the schema grows to carry what the components render, or the
238
components render less. Guessing at that in a component port would have been
239
the wrong place to decide it.
239
The schema decision this item was waiting on turned out not to exist. The
240
ruling in `docs/2026-08-20-linear-design-github-shape.md` is that we have what
241
GitHub has, and the components take GitHub's fields and leave the rest of their
242
attributes at their defaults, which is what those defaults are for.
240 243
241
### 7. Grouping and filtering on the server — **planned**
244
### 7. The issue page — **done**
242 245
243
Group by status, assignee, priority, or project; filter by the same. Both are
244
server concerns — a query and a `GROUP BY` — and the components already accept
245
the result. The filter chips need somewhere to send their changes, which is the
246
same decision as item 6.
246
Adapted from `components/common/issues/details/`, GitHub-shaped:
247 247
248
### 8. Display options — **planned**
248
| Ours | Source | Slug |
249
| --- | --- | --- |
250
| `issue_state/1` | — | `/components/issue-state` |
251
| `issue_detail/1` | `issue-details.tsx` | `/components/issue-detail` |
252
| `properties_panel/1` | `issue-properties-panel.tsx` | `/components/properties-panel` |
253
| `timeline/1` | `activity-feed.tsx` | `/components/timeline` |
254
| `timeline_event/1` | `activity-feed.tsx` | `/components/timeline-event` |
255
| `timeline_comment/1` | `activity-feed.tsx` | `/components/timeline-comment` |
256
| `comment_composer/1` | `activity-feed.tsx` | `/components/comment-composer` |
257
258
`issue_state/1` has no counterpart in Circle. It exists because
259
`issue_status/1` renders six categories and GitHub has two, and every page was
260
writing its own three-clause mapping from `state` and `state_reason` to a
261
glyph. One place now owns it, so two surfaces cannot disagree about what closed
262
looks like. `not_planned` and `duplicate` both take the cancelled glyph,
263
because both mean the work was not done.
264
265
Three departures from the source on this page:
266
267
- **The rail moves rather than hiding.** Circle hides its properties panel
268
  below `lg`. State, labels and assignees are not decoration and a phone is
269
  where an issue is most often read, so the rail sits under the heading on a
270
  narrow screen and beside the body when there is room.
271
- **A property group renders even when empty.** The page used to hide
272
  `Labels`, `Assignees` and `Milestone` until the issue had one, which was
273
  right while the rail was read-only. It is editable now, and hiding `Labels`
274
  until an issue has a label means an issue can never get its first one. The
275
  test that asserted the old behaviour was rewritten to assert the new intent
276
  rather than deleted.
277
- **The body and comments render as Markdown.** `OpenAgents.Markdown.to_html/2`
278
  already exists, sanitized and bounded, and was not being used on this page.
279
  GitHub renders issue bodies as Markdown, so this is parity rather than a
280
  new idea.
281
282
Circle's `content-blocks.tsx` (243 lines) is **not** ported. It is a mock
283
rich-text model — paragraphs, checklists, image and video placeholders —
284
standing in for a real document. We have a real one.
285
286
### 8. Make the rows' parts controls — **done**
287
288
In Circle every cell of a row is a selector. Two new components carry that:
289
290
| Ours | Source | Slug |
291
| --- | --- | --- |
292
| `field_menu/1` | `status-selector.tsx`, `assignee-user.tsx` | `/components/field-menu` |
293
| `field_menu_item/1` | the `cmdk` rows inside them | `/components/field-menu-item` |
294
295
Circle wraps a Radix dropdown around a `cmdk` list. A native `popover` gives
296
the same behaviour — click out to dismiss, `Escape` to close, the trigger as
297
the anchor — with no script, and the trigger is a real button so the keyboard
298
reaches it for free. `mode` decides whether an option is a toggle in a set
299
(`aria-pressed`, for labels and assignees) or one choice out of several
300
(`aria-current`, for state and milestone).
301
302
`issue_row/1` and `project_row/1` grew slots (`:state`, `:people`, `:actions`)
303
that replace a static cell with a control. The row stays presentational; a
304
caller with somewhere to send a change supplies the menu. Every control sits
305
**outside** the title link, because a state-changing control inside a link
306
target is how people mis-click.
307
308
What is interactive where, and why:
309
310
- **Issue list row** — state and assignee. They are the two facts worth
311
  changing without opening the issue. Labels and milestone need option lists
312
  longer than a row has room to explain.
313
- **Issue page rail** — state (including the close reason, which the header's
314
  two buttons cannot express), assignees, labels, milestone.
315
- **Project list row** — state. Projects V2 carries `state`, and it is the only
316
  property of a project this schema holds that is worth changing from a list.
317
  Delete stays a separate control beside the row rather than inside it.
318
319
### 9. Re-checked against the GitHub ruling
320
321
`docs/2026-08-20-linear-design-github-shape.md` changed what is portable.
322
Re-reading the earlier "not porting" list against it:
323
324
- **Priority selector — now clearly out.** It was skipped for scope; it is
325
  dropped on principle. GitHub has no priority field and the `priority: high`
326
  label convention is a convention, not a contract. `issue_priority/1` stays in
327
  the library unused.
328
- **Cycles — out.** GitHub has milestones. The UI calls them milestones.
329
- **Sub-issues, blocked-by, related, linked diffs — out for now.** The strategy
330
  doc lists `sub_issues_summary` and `issue_dependencies_summary` as fields we
331
  have. We do not: neither appears anywhere in this schema. So Circle's
332
  `IssueRefRow` was left unported rather than being built against a field that
333
  does not exist. When those columns land, the arc in `issue_status/1` is
334
  already the renderer for `percent_completed`.
335
- **Reactions — out.** GitHub has a reactions API and Circle's comment card has
336
  reaction pills. This schema stores none, so per the ruling we do not have the
337
  concept.
338
- **`author_association` — out as a field, in as a derivation.**
339
  `timeline_comment/1` takes a `badge`, and the issue page passes `Author` when
340
  the commenter's login matches the issue's. GitHub derives that same badge by
341
  comparing rather than storing, so this is parity, not invention.
342
- **`locked` / `locked_reason` — in, and now used.** Both are GitHub fields
343
  this schema already carried and nothing rendered. A locked issue's page now
344
  says so and does not offer a composer.
345
346
### 10. Grouping and filtering on the server — **planned**
347
348
Group by state, assignee, label, or milestone; filter by the same. Both are
349
server concerns — a query and a `GROUP BY` — and `issue_group/1` already
350
accepts the result. The filter chips need somewhere to send their changes.
351
352
### 11. Display options — **planned**
249 353
250 354
Circle's `DisplayOptions` popover switches list and board, picks the grouping
251 355
and ordering, and toggles nine per-property visibility flags. The toggles are
252
worth having and they need somewhere to persist; a native popover over
253
`OpenAgentsWeb.UI.menu/1` plus a per-user preference would do it without script.
356
worth having and they need somewhere to persist; `field_menu/1` is now the
357
control they would be built from, and a per-user preference is the missing
358
half.
254 359
255 360
[circle]: https://github.com/ln-dev7/circle
lib/openagents_web/component_catalog.ex modified +63

@@ -401,6 +401,69 @@ defmodule OpenAgentsWeb.ComponentCatalog do

401 401
          source: "OpenAgentsWeb.UI.Circle.issue_status/1",
402 402
          summary: "Six category shapes, one of them a filled arc read from a number."
403 403
        },
404
        %{
405
          slug: "issue-state",
406
          title: "Issue state",
407
          icon: "check-circle",
408
          source: "OpenAgentsWeb.UI.Circle.issue_state/1",
409
          summary: "GitHub's two states and the one close reason that reads differently."
410
        },
411
        %{
412
          slug: "issue-detail",
413
          title: "Issue detail",
414
          icon: "document",
415
          source: "OpenAgentsWeb.UI.Circle.issue_detail/1",
416
          summary: "Heading, the work, and a rail that moves rather than hiding."
417
        },
418
        %{
419
          slug: "properties-panel",
420
          title: "Properties panel",
421
          icon: "settings-slider",
422
          source: "OpenAgentsWeb.UI.Circle.properties_panel/1",
423
          summary: "Labelled groups of editable properties, present even when empty."
424
        },
425
        %{
426
          slug: "timeline",
427
          title: "Timeline",
428
          icon: "history",
429
          source: "OpenAgentsWeb.UI.Circle.timeline/1",
430
          summary: "Everything that happened to an issue, threaded oldest first."
431
        },
432
        %{
433
          slug: "timeline-event",
434
          title: "Timeline event",
435
          icon: "dot",
436
          source: "OpenAgentsWeb.UI.Circle.timeline_event/1",
437
          summary: "One fact about an issue, deliberately quieter than a comment."
438
        },
439
        %{
440
          slug: "timeline-comment",
441
          title: "Timeline comment",
442
          icon: "comment",
443
          source: "OpenAgentsWeb.UI.Circle.timeline_comment/1",
444
          summary: "Authored prose in a card, so the thread stays scannable."
445
        },
446
        %{
447
          slug: "comment-composer",
448
          title: "Comment composer",
449
          icon: "chat-compose",
450
          source: "OpenAgentsWeb.UI.Circle.comment_composer/1",
451
          summary: "A well that is the control, rather than a labelled box and a loose button."
452
        },
453
        %{
454
          slug: "field-menu",
455
          title: "Field menu",
456
          icon: "dropdown",
457
          source: "OpenAgentsWeb.UI.Circle.field_menu/1",
458
          summary: "A property you can change, as a native popover over its own value."
459
        },
460
        %{
461
          slug: "field-menu-item",
462
          title: "Field menu item",
463
          icon: "check",
464
          source: "OpenAgentsWeb.UI.Circle.field_menu_item/1",
465
          summary: "One option: a toggle in a set, or one choice out of several."
466
        },
404 467
        %{
405 468
          slug: "issue-priority",
406 469
          title: "Issue priority",
lib/openagents_web/components/circle.ex modified +352

@@ -284,13 +284,25 @@ defmodule OpenAgentsWeb.UI.Circle do

284 284
  attr :class, :any, default: nil
285 285
  attr :rest, :global
286 286
287
  # In Circle every one of these cells is a selector. Here the row stays
288
  # presentational and a caller who has somewhere to send a change replaces the
289
  # cell with a control -- usually a `field_menu/1` whose trigger is the same
290
  # glyph or face the static version drew, so the row looks identical until it
291
  # is clicked. The controls sit outside the title link on purpose: a
292
  # state-changing control inside a link target is how people mis-click.
293
  slot :state, doc: "replaces the state glyph with a control that changes it"
294
  slot :people, doc: "replaces the assignee face with a control that changes it"
295
  slot :actions, doc: "controls at the trailing edge, after the assignee"
296
287 297
  def issue_row(assigns) do
288 298
    ~H"""
289 299
    <div class={["issue-row", @class]} data-selected={@selected} {@rest}>
290 300
      <span class="issue-row__scan">
291 301
        <.issue_priority level={@priority} />
292 302
        <span class="issue-row__identifier">{@identifier}</span>
303
        {render_slot(@state)}
293 304
        <.issue_status
305
          :if={@state == []}
294 306
          category={@status_category}
295 307
          label={@status_label}
296 308
          progress={@progress}

@@ -316,11 +328,14 @@ defmodule OpenAgentsWeb.UI.Circle do

316 328
        <span :if={@comments && @comments > 0} class="issue-row__comments">
317 329
          <UI.icon name="comment" /> {@comments}
318 330
        </span>
331
        {render_slot(@people)}
319 332
        <.assignee
333
          :if={@people == []}
320 334
          name={@assignee && @assignee[:name]}
321 335
          src={@assignee && @assignee[:src]}
322 336
          presence={(@assignee && @assignee[:presence]) || :none}
323 337
        />
338
        <span :if={@actions != []} class="issue-row__actions">{render_slot(@actions)}</span>
324 339
      </span>
325 340
    </div>
326 341
    """

@@ -786,6 +801,11 @@ defmodule OpenAgentsWeb.UI.Circle do

786 801
  attr :class, :any, default: nil
787 802
  attr :rest, :global
788 803
804
  # Same bargain as `issue_row/1`: the state cell becomes a control when the
805
  # caller has somewhere to send the change, and it sits outside the link to
806
  # the board rather than inside it.
807
  slot :state, doc: "replaces the state glyph with a control that changes it"
808
789 809
  def project_row(assigns) do
790 810
    ~H"""
791 811
    <div class={["project-row", @class]} {@rest}>

@@ -806,7 +826,9 @@ defmodule OpenAgentsWeb.UI.Circle do

806 826
      <span :if={@target} class="project-row__target">{@target}</span>
807 827
      <span :if={@issues} class="project-row__issues">{@issues}</span>
808 828
      <span class="project-row__status">
829
        {render_slot(@state)}
809 830
        <.issue_status
831
          :if={@state == []}
810 832
          category={@status_category}
811 833
          label={@status_label}
812 834
          progress={@percent}

@@ -905,8 +927,338 @@ defmodule OpenAgentsWeb.UI.Circle do

905 927
    """
906 928
  end
907 929
930
  @doc """
931
  GitHub's issue state as a glyph: open, closed, or closed as not planned.
932
933
  `issue_status/1` renders six categories because Circle has six. GitHub has
934
  two, and `docs/2026-08-20-linear-design-github-shape.md` rules that we have
935
  what GitHub has. This is the narrower component every GitHub-shaped surface
936
  should reach for: it takes the payload's own `state` and `state_reason` and
937
  maps them once, here, instead of in each page.
938
939
  Two close reasons read as "not done" rather than "done": `not_planned`, where
940
  the work was decided against, and `duplicate`, where it is being tracked
941
  somewhere else. Both take the cancelled glyph. A bare close and `completed`
942
  take the tick.
943
  """
944
  attr :state, :string, values: ["open", "closed"], required: true
945
  attr :reason, :string, default: nil, doc: "GitHub's `state_reason`"
946
  attr :show_label, :boolean, default: false
947
  attr :class, :any, default: nil
948
  attr :rest, :global
949
950
  def issue_state(assigns) do
951
    ~H"""
952
    <.issue_status
953
      category={state_category(@state, @reason)}
954
      label={state_label(@state, @reason)}
955
      show_label={@show_label}
956
      class={@class}
957
      {@rest}
958
    />
959
    """
960
  end
961
962
  @doc """
963
  The frame of one issue's page: a heading band, the work, and a properties rail.
964
965
  Adapted from `issue-details.tsx`. Two decisions from the source are kept and
966
  one is reversed.
967
968
  Kept: the properties live in a rail rather than above the body, so the thing
969
  a reader came for starts at the top of the page; and the main column is held
970
  to a measure, because a description read at full window width is unreadable
971
  on a wide screen.
972
973
  Reversed: the source hides the rail below `lg`. State, labels, assignees, and
974
  milestone are not decoration, and a phone is where an issue is most often
975
  read. Here the rail moves under the heading on a narrow screen — directly
976
  where those facts are most useful — and to the side when there is room.
977
  """
978
  attr :class, :any, default: nil
979
  attr :rest, :global
980
  slot :heading, required: true, doc: "title, number, state, and the actions on them"
981
  slot :rail, doc: "the properties panel"
982
  slot :inner_block, required: true, doc: "body, then the timeline"
983
984
  def issue_detail(assigns) do
985
    ~H"""
986
    <article class={["issue-detail", @class]} {@rest}>
987
      <header class="issue-detail__head">{render_slot(@heading)}</header>
988
      <div :if={@rail != []} class="issue-detail__rail">{render_slot(@rail)}</div>
989
      <div class="issue-detail__main">{render_slot(@inner_block)}</div>
990
    </article>
991
    """
992
  end
993
994
  @doc """
995
  The rail beside an issue: labelled groups of properties.
996
997
  Adapted from `issue-properties-panel.tsx`, minus every group GitHub has no
998
  field for. The source's groups are status, priority, assignee, cycle, labels,
999
  project, blocked-by, related, and linked diffs; of those, priority and cycle
1000
  are dropped by the ruling and the relation groups need issue links this
1001
  schema does not store.
1002
1003
  A group renders even when its value is empty, which is the one place this
1004
  departs from what the page did before. An empty section used to be hidden,
1005
  and that was right while the rail was read-only — but a field you cannot see
1006
  is a field you cannot set, and these are editable now. So the group states
1007
  that it is empty rather than vanishing.
1008
  """
1009
  attr :class, :any, default: nil
1010
  attr :rest, :global
1011
1012
  slot :group, required: true do
1013
    attr :heading, :string, required: true
1014
  end
1015
1016
  def properties_panel(assigns) do
1017
    ~H"""
1018
    <div class={["properties-panel", @class]} {@rest}>
1019
      <section :for={group <- @group} class="properties-panel__group">
1020
        <h3 class="properties-panel__heading">{group.heading}</h3>
1021
        <div class="properties-panel__body">{render_slot(group)}</div>
1022
      </section>
1023
    </div>
1024
    """
1025
  end
1026
1027
  @doc """
1028
  Everything that happened to an issue, oldest first.
1029
1030
  An ordered list, because that is what it is: the sequence is the meaning, and
1031
  a reader arriving at the bottom of a long thread needs to know they are at
1032
  the end rather than at an arbitrary point in a pile.
1033
1034
  A hairline runs behind the glyph column. Circle does not draw one and the
1035
  feed reads as loose rows because of it; the line is what turns a sequence of
1036
  events into a thread.
1037
  """
1038
  attr :class, :any, default: nil
1039
  attr :rest, :global
1040
  slot :inner_block, required: true
1041
1042
  def timeline(assigns) do
1043
    ~H"""
1044
    <ol class={["timeline", @class]} {@rest}>{render_slot(@inner_block)}</ol>
1045
    """
1046
  end
1047
1048
  @doc """
1049
  One thing that happened, stated in a line.
1050
1051
  Adapted from `activity-feed.tsx`'s event row. An event is deliberately
1052
  quieter than a comment: it is a fact about the issue rather than something a
1053
  person wrote, and giving the two the same weight makes a thread of six label
1054
  changes and one real comment look like seven comments.
1055
1056
  `text` is the predicate — "closed this as completed" — because the actor is
1057
  already the subject and repeating the name inside the sentence reads as a
1058
  template that was never filled in.
1059
1060
  `actor` is optional because it is sometimes genuinely unknown. This schema
1061
  records that an issue was closed and when, but not by whom, and inventing a
1062
  name for the sentence would be worse than a sentence without a subject.
1063
  """
1064
  attr :actor, :string, default: nil
1065
  attr :text, :string, required: true, doc: "what they did, without the name"
1066
  attr :icon, :string, default: "circle"
1067
  attr :tone, :atom, values: [:neutral, :info, :success, :warning, :danger], default: :neutral
1068
  attr :at, :string, default: nil, doc: "already formatted"
1069
  attr :class, :any, default: nil
1070
  attr :rest, :global
1071
  slot :inner_block, doc: "what the event acted on, such as the label that was added"
1072
1073
  def timeline_event(assigns) do
1074
    ~H"""
1075
    <li class={["timeline-event", @class]} data-tone={@tone} {@rest}>
1076
      <span class="timeline-event__glyph" aria-hidden="true"><UI.icon name={@icon} /></span>
1077
      <span class="timeline-event__text">
1078
        <span :if={@actor} class="timeline-event__actor">{@actor}</span>
1079
        {@text}{render_slot(@inner_block)}
1080
      </span>
1081
      <span :if={@at} class="timeline-event__at">{@at}</span>
1082
    </li>
1083
    """
1084
  end
1085
1086
  @doc """
1087
  One comment in the thread.
1088
1089
  A card rather than a row, because a comment is authored prose and it needs an
1090
  edge to sit inside; the events around it are single lines and the contrast is
1091
  what makes the thread scannable.
1092
1093
  `badge` is for what GitHub prints beside a name — `Author`, `Member`,
1094
  `Owner`. GitHub derives the first of those by comparing the commenter to the
1095
  issue's author rather than storing it, which is why this takes a string the
1096
  caller worked out instead of a field.
1097
  """
1098
  attr :id, :string, required: true
1099
  attr :author, :string, required: true
1100
  attr :src, :string, default: nil
1101
  attr :at, :string, default: nil, doc: "already formatted"
1102
  attr :badge, :string, default: nil, doc: "the commenter's relationship to the issue"
1103
  attr :class, :any, default: nil
1104
  attr :rest, :global
1105
  slot :actions, doc: "controls on this comment"
1106
  slot :inner_block, required: true, doc: "the rendered body"
1107
1108
  def timeline_comment(assigns) do
1109
    ~H"""
1110
    <li id={@id} class={["timeline-comment", @class]} {@rest}>
1111
      <header class="timeline-comment__head">
1112
        <.assignee name={@author} src={@src} size={:sm} />
1113
        <span class="timeline-comment__author">{@author}</span>
1114
        <span :if={@badge} class="timeline-comment__badge">{@badge}</span>
1115
        <span :if={@at} class="timeline-comment__at">{@at}</span>
1116
        <span :if={@actions != []} class="timeline-comment__actions">{render_slot(@actions)}</span>
1117
      </header>
1118
      <div class="timeline-comment__body">{render_slot(@inner_block)}</div>
1119
    </li>
1120
    """
1121
  end
1122
1123
  @doc """
1124
  The well a comment is written in.
1125
1126
  Adapted from `activity-feed.tsx`'s composer. What makes it deliberate rather
1127
  than a bare textarea is that the whole well is the control: the border, the
1128
  writer's own face, and the footer belong to one surface that takes focus as a
1129
  unit, instead of a labelled box with a button loose underneath it.
1130
1131
  The control and the submit action are slots because this has to live inside
1132
  the caller's `<.form>` — the composer owns the shape, the form owns the data.
1133
  """
1134
  attr :id, :string, required: true
1135
  attr :author, :string, default: nil, doc: "the writer, shown as a face"
1136
  attr :src, :string, default: nil
1137
  attr :class, :any, default: nil
1138
  attr :rest, :global
1139
  slot :hint, doc: "what the writer should know, at the foot"
1140
  slot :actions, required: true, doc: "the submit control"
1141
  slot :inner_block, required: true, doc: "the text control"
1142
1143
  def comment_composer(assigns) do
1144
    ~H"""
1145
    <div id={@id} class={["comment-composer", @class]} {@rest}>
1146
      <.assignee :if={@author} name={@author} src={@src} size={:sm} class="comment-composer__face" />
1147
      <div class="comment-composer__well">
1148
        <div class="comment-composer__field">{render_slot(@inner_block)}</div>
1149
        <footer class="comment-composer__foot">
1150
          <span class="comment-composer__hint">{render_slot(@hint)}</span>
1151
          {render_slot(@actions)}
1152
        </footer>
1153
      </div>
1154
    </div>
1155
    """
1156
  end
1157
1158
  @doc """
1159
  A property you can change, as a native popover over its own value.
1160
1161
  This is what Circle's row and rail have that ours did not: the value is the
1162
  control. Circle reaches for a Radix dropdown wrapping a `cmdk` list; a native
1163
  `popover` gives the same behaviour — click out to dismiss, `Escape` to close,
1164
  the trigger as the anchor — with no script, and the trigger is a real button
1165
  so it is reachable by keyboard for free.
1166
1167
  The trigger is a slot rather than a label, so the thing you click is the
1168
  glyph or the face itself rather than a control beside it. `label` is the
1169
  trigger's accessible name, which matters precisely because its visible
1170
  content is usually a picture.
1171
  """
1172
  attr :id, :string, required: true
1173
  attr :label, :string, required: true, doc: "the accessible name of the trigger"
1174
  attr :align, :atom, values: [:start, :end], default: :start
1175
  attr :class, :any, default: nil
1176
  attr :rest, :global
1177
  slot :trigger, required: true, doc: "the value, which is what gets clicked"
1178
  slot :inner_block, required: true, doc: "`field_menu_item/1` options"
1179
1180
  def field_menu(assigns) do
1181
    ~H"""
1182
    <span class={["field-menu", @class]} {@rest}>
1183
      <button
1184
        type="button"
1185
        class="field-menu__trigger"
1186
        popovertarget={@id}
1187
        popovertargetaction="toggle"
1188
        aria-label={@label}
1189
      >
1190
        {render_slot(@trigger)}
1191
      </button>
1192
      <div id={@id} popover class="menu field-menu__panel" data-align={@align}>
1193
        {render_slot(@inner_block)}
1194
      </div>
1195
    </span>
1196
    """
1197
  end
1198
1199
  @doc """
1200
  One option inside a `field_menu/1`.
1201
1202
  `mode` decides what the option claims about itself. Labels and assignees are
1203
  a set, so each option is a toggle and says `aria-pressed`; state and
1204
  milestone are one choice out of several, so the selected one says
1205
  `aria-current`. Both draw the same tick, because the tick means the same
1206
  thing to a reader either way.
1207
1208
  `closes` names the panel to dismiss. A native popover does not close when
1209
  something inside it is clicked, and for a single choice a menu that stays
1210
  open reads as a choice that did not register. For a set it is the opposite —
1211
  leaving it open is what lets you tick three labels — so this is opt-in
1212
  rather than automatic.
1213
  """
1214
  attr :label, :string, required: true
1215
  attr :icon, :string, default: nil
1216
  attr :mode, :atom, values: [:toggle, :choice], default: :toggle
1217
  attr :selected, :boolean, default: false
1218
  attr :closes, :string, default: nil, doc: "the `field_menu/1` id this dismisses"
1219
  attr :on_select, JS, default: nil
1220
  attr :class, :any, default: nil
1221
  attr :rest, :global
1222
  slot :glyph, doc: "a dot, a face, or a state marker before the word"
1223
1224
  def field_menu_item(assigns) do
1225
    ~H"""
1226
    <button
1227
      type="button"
1228
      class={["field-menu__item", @class]}
1229
      phx-click={@on_select}
1230
      popovertarget={@closes}
1231
      popovertargetaction={@closes && "hide"}
1232
      aria-pressed={@mode == :toggle && to_string(@selected)}
1233
      aria-current={@mode == :choice && @selected && "true"}
1234
      {@rest}
1235
    >
1236
      <span class="field-menu__mark">
1237
        <UI.icon :if={@selected} name="check" />
1238
      </span>
1239
      {render_slot(@glyph)}
1240
      <UI.icon :if={@icon} name={@icon} class="field-menu__glyph" />
1241
      <span class="field-menu__label">{@label}</span>
1242
    </button>
1243
    """
1244
  end
1245
908 1246
  # ── the fixed vocabularies ─────────────────────────────────────────────────
909 1247
1248
  # GitHub's two states and the one close reason that reads differently. This
1249
  # lives here rather than in each page so a surface cannot quietly disagree
1250
  # with another about what "closed" looks like.
1251
  defp state_category("closed", reason) when reason in ["not_planned", "duplicate"],
1252
    do: :canceled
1253
1254
  defp state_category("closed", _reason), do: :completed
1255
  defp state_category(_state, _reason), do: :unstarted
1256
1257
  defp state_label("closed", "not_planned"), do: "Closed as not planned"
1258
  defp state_label("closed", "duplicate"), do: "Closed as duplicate"
1259
  defp state_label("closed", _reason), do: "Closed"
1260
  defp state_label(_state, _reason), do: "Open"
1261
910 1262
  # Five of the source's six status shapes exist in the vendored set. Triage is
911 1263
  # opposing arrows in a disc, which `compare-arrows` says exactly; the dashed
912 1264
  # gear it uses for backlog has no equivalent and `circle-dashed` carries the
lib/openagents_web/live/components_live.ex modified +288

@@ -2123,6 +2123,294 @@ defmodule OpenAgentsWeb.ComponentsLive do

2123 2123
    """
2124 2124
  end
2125 2125
2126
  defp component_demo(%{item: %{slug: "issue-state"}} = assigns) do
2127
    ~H"""
2128
    <div class="space-y-3">
2129
      <p class="text-sm text-base-content/60">
2130
        <code>issue_status/1</code>
2131
        renders six categories because Circle has six. GitHub has two, and the ruling in
2132
        <code>docs/2026-08-20-linear-design-github-shape.md</code>
2133
        is that we have what GitHub has. This is the narrower component every
2134
        GitHub-shaped surface reaches for: it takes the payload's own <code>state</code>
2135
        and <code>state_reason</code>
2136
        and maps them once, here, so two pages cannot disagree about what closed looks
2137
        like.
2138
      </p>
2139
      <div class="flex flex-wrap gap-x-6 gap-y-3">
2140
        <Circle.issue_state state="open" show_label />
2141
        <Circle.issue_state state="closed" reason="completed" show_label />
2142
        <Circle.issue_state state="closed" reason="not_planned" show_label />
2143
        <Circle.issue_state state="closed" reason="duplicate" show_label />
2144
      </div>
2145
    </div>
2146
    """
2147
  end
2148
2149
  defp component_demo(%{item: %{slug: "issue-detail"}} = assigns) do
2150
    ~H"""
2151
    <div class="space-y-3">
2152
      <p class="text-sm text-base-content/60">
2153
        A heading band, the work, and a properties rail. Circle hides the rail below <code>lg</code>; state, labels and assignees are not decoration and a phone is
2154
        where an issue is most often read, so here the rail moves under the heading on a
2155
        narrow screen and to the side when there is room. Resize to see it.
2156
      </p>
2157
      <div class="demo-frame">
2158
        <Circle.issue_detail>
2159
          <:heading>
2160
            <h2 class="text-2xl font-semibold">Wire the issue list to the ported row component</h2>
2161
            <p class="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
2162
              <Circle.issue_state state="open" show_label /> · opened 2d ago by ada
2163
            </p>
2164
          </:heading>
2165
          <:rail>
2166
            <Circle.properties_panel>
2167
              <:group heading="Assignees">
2168
                <Circle.assignee name="Mason Carter" show_name size={:sm} />
2169
              </:group>
2170
              <:group heading="Labels">
2171
                <Circle.issue_label name="Design" tone={:primary} />
2172
              </:group>
2173
            </Circle.properties_panel>
2174
          </:rail>
2175
          <p class="text-sm text-muted-foreground">
2176
            The description, then the timeline, held to a measure rather than the window.
2177
          </p>
2178
        </Circle.issue_detail>
2179
      </div>
2180
    </div>
2181
    """
2182
  end
2183
2184
  defp component_demo(%{item: %{slug: "properties-panel"}} = assigns) do
2185
    ~H"""
2186
    <div class="space-y-3">
2187
      <p class="text-sm text-base-content/60">
2188
        The source's panel carries status, priority, cycle, project, relations and linked
2189
        diffs. All but state, labels, assignees and milestone are dropped: GitHub has no
2190
        field for them. A group renders even when empty, which is the one departure from
2191
        what this page used to do — a field you cannot see is a field you cannot set, and
2192
        these are editable now.
2193
      </p>
2194
      <div class="max-w-xs rounded-lg border border-border p-4">
2195
        <Circle.properties_panel>
2196
          <:group heading="State">
2197
            <Circle.issue_state state="open" show_label />
2198
          </:group>
2199
          <:group heading="Assignees">
2200
            <Circle.assignee name="Priya Raman" show_name size={:sm} />
2201
          </:group>
2202
          <:group heading="Labels">
2203
            <Circle.issue_label name="Bug" tone={:danger} />
2204
            <Circle.issue_label name="Cloud" tone={:info} />
2205
          </:group>
2206
          <:group heading="Milestone">
2207
            <span class="properties-panel__none">No milestone</span>
2208
          </:group>
2209
        </Circle.properties_panel>
2210
      </div>
2211
    </div>
2212
    """
2213
  end
2214
2215
  defp component_demo(%{item: %{slug: "timeline"}} = assigns) do
2216
    ~H"""
2217
    <div class="space-y-3">
2218
      <p class="text-sm text-base-content/60">
2219
        An ordered list, because the sequence is the meaning. A hairline runs behind the
2220
        glyph column: Circle draws no line and its feed reads as loose rows, and the line
2221
        is what turns a run of facts into one history.
2222
      </p>
2223
      <Circle.timeline>
2224
        <Circle.timeline_event actor="ada" text="opened this issue" icon="plus-circle" at="2d ago" />
2225
        <Circle.timeline_event actor="ada" text="added the" icon="tag" at="2d ago">
2226
          <Circle.issue_label name="Bug" tone={:danger} /> label
2227
        </Circle.timeline_event>
2228
        <Circle.timeline_comment
2229
          id="demo-timeline-comment"
2230
          author="Mason Carter"
2231
          at="1d ago"
2232
          badge="Author"
2233
        >
2234
          <p>Reproduced on staging. The row renders but the state glyph is inert.</p>
2235
        </Circle.timeline_comment>
2236
        <Circle.timeline_event
2237
          actor="mason"
2238
          text="closed this as completed"
2239
          icon="check-circle-filled"
2240
          tone={:success}
2241
          at="4h ago"
2242
        />
2243
      </Circle.timeline>
2244
    </div>
2245
    """
2246
  end
2247
2248
  defp component_demo(%{item: %{slug: "timeline-event"}} = assigns) do
2249
    ~H"""
2250
    <div class="space-y-3">
2251
      <p class="text-sm text-base-content/60">
2252
        An event is deliberately quieter than a comment: it is a fact about the issue
2253
        rather than something a person wrote, and giving them the same weight makes a
2254
        thread of six label changes and one real comment look like seven comments. The
2255
        text is the predicate only — the actor is already the subject, and repeating the
2256
        name inside the sentence reads as a template nobody filled in.
2257
      </p>
2258
      <Circle.timeline>
2259
        <Circle.timeline_event actor="ada" text="opened this issue" icon="plus-circle" at="2d ago" />
2260
        <Circle.timeline_event
2261
          actor="ada"
2262
          text="assigned this to mason"
2263
          icon="user-add"
2264
          tone={:info}
2265
          at="2d ago"
2266
        />
2267
        <Circle.timeline_event
2268
          actor="mason"
2269
          text="closed this as completed"
2270
          icon="check-circle-filled"
2271
          tone={:success}
2272
          at="4h ago"
2273
        />
2274
        <Circle.timeline_event
2275
          actor="mason"
2276
          text="closed this as not planned"
2277
          icon="x-circle-filled"
2278
          tone={:danger}
2279
          at="4h ago"
2280
        />
2281
      </Circle.timeline>
2282
    </div>
2283
    """
2284
  end
2285
2286
  defp component_demo(%{item: %{slug: "timeline-comment"}} = assigns) do
2287
    ~H"""
2288
    <div class="space-y-3">
2289
      <p class="text-sm text-base-content/60">
2290
        A card against the events' single lines; the contrast is what keeps a long thread
2291
        scannable. <code>badge</code>
2292
        is what GitHub prints beside a name — <strong>Author</strong>, <strong>Member</strong>,
2293
        <strong>Owner</strong>
2294
        — which GitHub derives by comparing the commenter to the issue's author rather
2295
        than storing it, so this takes a string the caller worked out.
2296
      </p>
2297
      <Circle.timeline>
2298
        <Circle.timeline_comment id="demo-comment-author" author="ada" at="2d ago" badge="Author">
2299
          <p>The list renders but nothing in the row responds to a click.</p>
2300
        </Circle.timeline_comment>
2301
        <Circle.timeline_comment id="demo-comment-plain" author="Mason Carter" at="1d ago">
2302
          <p>
2303
            Fixed by making the state glyph a <code>field_menu/1</code> trigger.
2304
          </p>
2305
        </Circle.timeline_comment>
2306
      </Circle.timeline>
2307
    </div>
2308
    """
2309
  end
2310
2311
  defp component_demo(%{item: %{slug: "comment-composer"}} = assigns) do
2312
    ~H"""
2313
    <div class="space-y-3">
2314
      <p class="text-sm text-base-content/60">
2315
        What makes it deliberate rather than a bare textarea is that the whole well is the
2316
        control: the border, the writer's face, and the footer are one surface that takes
2317
        focus as a unit. The text control and the submit action are slots, because this
2318
        lives inside the caller's form — the composer owns the shape, the form owns the
2319
        data.
2320
      </p>
2321
      <.form for={@form} id="demo-composer-form" phx-submit="save">
2322
        <Circle.comment_composer id="demo-composer" author="Demo Account">
2323
          <.input field={@form[:body]} type="textarea" label="Comment" placeholder="Leave a comment" />
2324
          <:hint>Markdown is supported.</:hint>
2325
          <:actions>
2326
            <UI.button type="submit" variant={:primary} size={:sm}>Comment</UI.button>
2327
          </:actions>
2328
        </Circle.comment_composer>
2329
      </.form>
2330
    </div>
2331
    """
2332
  end
2333
2334
  defp component_demo(%{item: %{slug: "field-menu"}} = assigns) do
2335
    ~H"""
2336
    <div class="space-y-3">
2337
      <p class="text-sm text-base-content/60">
2338
        This is what Circle's rows have that ours did not: the value is the control.
2339
        Circle wraps a Radix dropdown around a <code>cmdk</code>
2340
        list; a native popover gives the same behaviour — click out to dismiss,
2341
        <UI.kbd>Esc</UI.kbd>
2342
        to close, the trigger as the anchor — with no script, and the trigger is a real
2343
        button so the keyboard reaches it for free. The trigger is a slot, so what you
2344
        click is the glyph itself rather than a control beside it.
2345
      </p>
2346
      <div class="flex items-center gap-6">
2347
        <Circle.field_menu id="demo-state-menu" label="Change state">
2348
          <:trigger><Circle.issue_state state="open" /></:trigger>
2349
          <Circle.field_menu_item label="Open" mode={:choice} selected closes="demo-state-menu">
2350
            <:glyph><Circle.issue_state state="open" /></:glyph>
2351
          </Circle.field_menu_item>
2352
          <Circle.field_menu_item label="Closed as completed" mode={:choice} closes="demo-state-menu">
2353
            <:glyph><Circle.issue_state state="closed" reason="completed" /></:glyph>
2354
          </Circle.field_menu_item>
2355
          <Circle.field_menu_item
2356
            label="Closed as not planned"
2357
            mode={:choice}
2358
            closes="demo-state-menu"
2359
          >
2360
            <:glyph><Circle.issue_state state="closed" reason="not_planned" /></:glyph>
2361
          </Circle.field_menu_item>
2362
        </Circle.field_menu>
2363
2364
        <Circle.field_menu id="demo-assignee-menu" label="Assign this issue">
2365
          <:trigger><Circle.assignee name="Mason Carter" /></:trigger>
2366
          <Circle.field_menu_item
2367
            :for={person <- @demo_people}
2368
            label={person.name}
2369
            selected={person.name == "Mason Carter"}
2370
          >
2371
            <:glyph><Circle.assignee name={person.name} size={:sm} /></:glyph>
2372
          </Circle.field_menu_item>
2373
        </Circle.field_menu>
2374
2375
        <Circle.field_menu id="demo-label-menu" label="Change labels" align={:end}>
2376
          <:trigger><UI.icon name="tag" class="size-4" /></:trigger>
2377
          <Circle.field_menu_item label="Bug" selected>
2378
            <:glyph><span class="issue-label__dot" data-tone="danger" /></:glyph>
2379
          </Circle.field_menu_item>
2380
          <Circle.field_menu_item label="Cloud">
2381
            <:glyph><span class="issue-label__dot" data-tone="info" /></:glyph>
2382
          </Circle.field_menu_item>
2383
        </Circle.field_menu>
2384
      </div>
2385
    </div>
2386
    """
2387
  end
2388
2389
  defp component_demo(%{item: %{slug: "field-menu-item"}} = assigns) do
2390
    ~H"""
2391
    <div class="space-y-3">
2392
      <p class="text-sm text-base-content/60">
2393
        <code>mode</code>
2394
        decides what the option claims. Labels and assignees are a set, so each option is
2395
        a toggle and says <code>aria-pressed</code>; state and milestone are one choice
2396
        out of several, so the selected one says <code>aria-current</code>. Both draw the
2397
        same tick, because it means the same thing to a reader either way. The tick keeps
2398
        its column when absent, so words do not shift.
2399
      </p>
2400
      <div class="menu !static max-w-xs p-1">
2401
        <Circle.field_menu_item label="Bug" selected />
2402
        <Circle.field_menu_item label="Documentation" icon="book" />
2403
        <Circle.field_menu_item label="Closed as completed" mode={:choice} selected>
2404
          <:glyph><Circle.issue_state state="closed" reason="completed" /></:glyph>
2405
        </Circle.field_menu_item>
2406
        <Circle.field_menu_item label="Closed as not planned" mode={:choice}>
2407
          <:glyph><Circle.issue_state state="closed" reason="not_planned" /></:glyph>
2408
        </Circle.field_menu_item>
2409
      </div>
2410
    </div>
2411
    """
2412
  end
2413
2126 2414
  # The breadcrumb names the section a component lives in, so the trail matches
2127 2415
  # the sidebar the reader navigated through.
2128 2416
  defp section_title_for(slug) do
lib/openagents_web/live/issue_index_live.ex modified +117 -11

@@ -15,9 +15,6 @@ defmodule OpenAgentsWeb.IssueIndexLive do

15 15
  def handle_params(%{"owner" => owner, "repo" => repo} = params, _url, socket) do
16 16
    state = params["state"] || "open"
17 17
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
18
    issues = Issues.list_issues(repository, state: state)
19
    open_count = Issues.list_issues(repository, state: "open") |> length()
20
    closed_count = Issues.list_issues(repository, state: "closed") |> length()
21 18
22 19
    socket =
23 20
      socket

@@ -25,14 +22,62 @@ defmodule OpenAgentsWeb.IssueIndexLive do

25 22
      |> assign(:repo, repo)
26 23
      |> assign(:repository, repository)
27 24
      |> assign(:state, state)
28
      |> assign(:open_count, open_count)
29
      |> assign(:closed_count, closed_count)
30
      |> assign(:issues_count, length(issues))
31
      |> stream(:issues, issues, reset: true)
25
      |> assign(:assignable, Repositories.list_assignable_users(repository))
26
      |> load()
32 27
33 28
    {:noreply, socket}
34 29
  end
35 30
31
  # The row's own controls, which is what Circle has that a static list does
32
  # not. Only state and assignee: they are the two facts worth changing without
33
  # opening the issue, and labels and milestone need option lists longer than a
34
  # row has room to explain. Both live in the rail on the issue page instead.
35
  def handle_event("set_state", %{"id" => id, "state" => "open"}, socket),
36
    do: write(socket, id, %{"state" => "open", "state_reason" => nil})
37
38
  def handle_event("set_state", %{"id" => id, "state" => "closed"} = params, socket),
39
    do: write(socket, id, %{"state" => "closed", "state_reason" => params["reason"]})
40
41
  def handle_event("toggle_assignee", %{"id" => id, "login" => login}, socket) do
42
    issue = issue!(socket, id)
43
44
    {:ok, _updated} =
45
      if Enum.any?(issue.assignees || [], &(&1["login"] == login)) do
46
        Issues.remove_assignees(issue, [login])
47
      else
48
        Issues.add_assignees(issue, [login])
49
      end
50
51
    {:noreply, load(socket)}
52
  end
53
54
  defp write(socket, id, attrs) do
55
    {:ok, _updated} = Issues.update_issue(issue!(socket, id), attrs)
56
    {:noreply, load(socket)}
57
  end
58
59
  # `JS.push` sends the id as a number; a `phx-value-` attribute would send a
60
  # string. The handler takes whichever arrives.
61
  defp issue!(socket, id) when is_integer(id),
62
    do: Issues.get_issue!(socket.assigns.repository, id)
63
64
  defp issue!(socket, id) when is_binary(id),
65
    do: Issues.get_issue!(socket.assigns.repository, String.to_integer(id))
66
67
  # Reloading rather than patching one row: closing an issue while the Open tab
68
  # is showing has to remove it from the list and change both tab counts, and a
69
  # row that stays visible after being closed is worse than a reload.
70
  defp load(socket) do
71
    repository = socket.assigns.repository
72
    issues = Issues.list_issues(repository, state: socket.assigns.state)
73
74
    socket
75
    |> assign(:open_count, length(Issues.list_issues(repository, state: "open")))
76
    |> assign(:closed_count, length(Issues.list_issues(repository, state: "closed")))
77
    |> assign(:issues_count, length(issues))
78
    |> stream(:issues, issues, reset: true)
79
  end
80
36 81
  def render(assigns) do
37 82
    ~H"""
38 83
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Issues" wide>

@@ -102,15 +147,60 @@ defmodule OpenAgentsWeb.IssueIndexLive do

102 147
          created={"opened #{relative(issue.inserted_at)} ago"}
103 148
          author={author(issue)}
104 149
          comments={issue.comments}
105
        />
150
        >
151
          <:state>
152
            <Circle.field_menu
153
              id={"row-state-#{issue.id}"}
154
              label={"Change the state of issue ##{issue.number}"}
155
            >
156
              <:trigger>
157
                <Circle.issue_state state={issue.state} reason={issue.state_reason} />
158
              </:trigger>
159
              <Circle.field_menu_item
160
                :for={{label, state, reason} <- state_options()}
161
                label={label}
162
                mode={:choice}
163
                selected={issue.state == state and close_reason(issue) == reason}
164
                closes={"row-state-#{issue.id}"}
165
                on_select={JS.push("set_state", value: %{id: issue.id, state: state, reason: reason})}
166
              >
167
                <:glyph><Circle.issue_state state={state} reason={reason} /></:glyph>
168
              </Circle.field_menu_item>
169
            </Circle.field_menu>
170
          </:state>
171
          <:people>
172
            <Circle.field_menu
173
              id={"row-assignee-#{issue.id}"}
174
              label={"Assign issue ##{issue.number}"}
175
              align={:end}
176
            >
177
              <:trigger>
178
                <Circle.assignee
179
                  name={assignee(issue) && assignee(issue)[:name]}
180
                  src={assignee(issue) && assignee(issue)[:src]}
181
                />
182
              </:trigger>
183
              <Circle.field_menu_item
184
                :for={user <- @assignable}
185
                label={user.github_login}
186
                selected={assigned?(issue, user.github_login)}
187
                on_select={
188
                  JS.push("toggle_assignee", value: %{id: issue.id, login: user.github_login})
189
                }
190
              >
191
                <:glyph><Circle.assignee name={user.github_login} size={:sm} /></:glyph>
192
              </Circle.field_menu_item>
193
            </Circle.field_menu>
194
          </:people>
195
        </Circle.issue_row>
106 196
      </div>
107 197
    </Layouts.app>
108 198
    """
109 199
  end
110 200
111
  # GitHub's two states, and nothing invented on top of them. `not_planned` is
112
  # the one close reason with a distinct reading, so it takes the cancelled
113
  # glyph; every other close is a completion.
201
  # The row's own state cell is a control now, so the static category and label
202
  # it would otherwise draw come from `Circle.issue_state/1` instead. They stay
203
  # as the row's defaults for a caller with nowhere to send a change.
114 204
  defp category(%{state: "closed", state_reason: "not_planned"}), do: :canceled
115 205
  defp category(%{state: "closed"}), do: :completed
116 206
  defp category(_issue), do: :unstarted

@@ -119,6 +209,22 @@ defmodule OpenAgentsWeb.IssueIndexLive do

119 209
  defp status_label(%{state: "closed"}), do: "Closed"
120 210
  defp status_label(_issue), do: "Open"
121 211
212
  # `duplicate` is rendered when it arrives from the API but is not offered:
213
  # the menu has nowhere to record which issue it duplicates, and a duplicate
214
  # that does not say of what is worse than a plain close.
215
  defp state_options do
216
    [
217
      {"Open", "open", nil},
218
      {"Closed as completed", "closed", "completed"},
219
      {"Closed as not planned", "closed", "not_planned"}
220
    ]
221
  end
222
223
  defp close_reason(%{state: "closed", state_reason: reason}), do: reason || "completed"
224
  defp close_reason(_issue), do: nil
225
226
  defp assigned?(issue, login), do: Enum.any?(issue.assignees || [], &(&1["login"] == login))
227
122 228
  # A label carries a colour on GitHub; the row takes a tone from our ladder
123 229
  # rather than that hex, so the list stays in one palette.
124 230
  defp labels(%{labels: labels}) when is_list(labels) do
lib/openagents_web/live/issue_show_live.ex modified +479 -178

@@ -1,17 +1,42 @@

1 1
defmodule OpenAgentsWeb.IssueShowLive do
2 2
  @moduledoc """
3
  Shows a single issue, its metadata, and its comment thread.
3
  One issue: its description, its history, and the properties you can change.
4
5
  Built from `OpenAgentsWeb.UI.Circle`, so the page and the component library
6
  cannot drift. Every field it shows or edits is one GitHub already has —
7
  `state`, `state_reason`, `labels`, `assignees`, `milestone`, `locked`, and
8
  comments. See `docs/2026-08-20-linear-design-github-shape.md`.
9
10
  Two structural decisions:
11
12
    * **Reading is the default state.** Editing the title and body is behind
13
      `Edit`, and everything else is edited in place from the rail, so the page
14
      is a description of the issue rather than a form that happens to be
15
      showing one.
16
17
    * **The history is one feed.** Comments and state changes interleave in a
18
      single timeline rather than sitting in separate sections, because they
19
      answer the same question and their order relative to each other is most
20
      of the answer.
21
22
  This schema has no `issue_events` table, so the events are derived from the
23
  fields it does store: opened from `inserted_at` and `user`, closed from
24
  `closed_at` and `state_reason`. That is why a close has no actor — nothing
25
  records who did it, and naming somebody would be a guess.
4 26
  """
5 27
  use OpenAgentsWeb, :live_view
6 28
7 29
  alias OpenAgents.Issues
8 30
  alias OpenAgents.Issues.Comment
31
  alias OpenAgents.Labels
32
  alias OpenAgents.Markdown
33
  alias OpenAgents.Milestones
9 34
  alias OpenAgents.Repositories
35
  alias OpenAgentsWeb.UI.Circle
10 36
11 37
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
12 38
    repository = Repositories.get_writable_by_path!(owner, repo, socket.assigns.current_user)
13 39
    issue = Issues.get_issue_by_number!(repository, String.to_integer(number))
14
    comments = Issues.list_comments(issue)
15 40
16 41
    {:ok,
17 42
     socket

@@ -19,242 +44,518 @@ defmodule OpenAgentsWeb.IssueShowLive do

19 44
     |> assign(:owner, owner)
20 45
     |> assign(:repo, repo)
21 46
     |> assign(:repository, repository)
22
     |> assign(:issue, issue)
23
     |> assign(:comments, comments)
24 47
     |> assign(:editing, false)
25
     |> assign(:form, to_form(Issues.change_issue(issue)))
26
     |> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))}
48
     |> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))
49
     |> assign(:repo_labels, Labels.list_labels(repository))
50
     |> assign(:repo_milestones, Milestones.list_milestones(repository))
51
     |> assign(:assignable, Repositories.list_assignable_users(repository))
52
     |> load(issue)}
27 53
  end
28 54
29 55
  def handle_event("toggle_edit", _params, socket) do
30 56
    issue = socket.assigns.issue
31 57
32
    socket =
33
      socket
34
      |> assign(:editing, !socket.assigns.editing)
35
      |> assign(:form, to_form(Issues.change_issue(issue)))
36
37
    {:noreply, socket}
58
    {:noreply,
59
     socket
60
     |> assign(:editing, !socket.assigns.editing)
61
     |> assign(:form, to_form(Issues.change_issue(issue)))}
38 62
  end
39 63
40 64
  def handle_event("save", %{"issue" => issue_params}, socket) do
41 65
    issue = socket.assigns.issue
42
    title = issue_params["title"]
43
    body = issue_params["body"]
66
    attrs = %{"title" => issue_params["title"], "body" => issue_params["body"]}
44 67
45
    case Issues.update_issue(issue, %{"title" => title, "body" => body}) do
68
    case Issues.update_issue(issue, attrs) do
46 69
      {:ok, updated} ->
47 70
        {:noreply,
48 71
         socket
49
         |> assign(:issue, updated)
50 72
         |> assign(:editing, false)
51 73
         |> put_flash(:info, "Issue updated")
52
         |> assign(:form, to_form(Issues.change_issue(updated)))}
74
         |> load(updated)}
53 75
54 76
      {:error, changeset} ->
55 77
        {:noreply, assign(socket, :form, to_form(changeset))}
56 78
    end
57 79
  end
58 80
59
  def handle_event("close", _params, socket) do
81
  def handle_event("close", _params, socket), do: set_state(socket, "closed", "completed")
82
  def handle_event("reopen", _params, socket), do: set_state(socket, "open", nil)
83
84
  # The rail's state menu picks a close reason as well as a state, which the
85
  # header's two buttons cannot. Both end in the same write.
86
  def handle_event("set_state", %{"state" => "open"}, socket),
87
    do: set_state(socket, "open", nil)
88
89
  def handle_event("set_state", %{"state" => "closed"} = params, socket),
90
    do: set_state(socket, "closed", params["reason"] || "completed")
91
92
  def handle_event("toggle_label", %{"name" => name}, socket) do
60 93
    issue = socket.assigns.issue
61 94
62 95
    {:ok, updated} =
63
      Issues.update_issue(issue, %{
64
        "state" => "closed",
65
        "state_reason" => "completed"
66
      })
96
      if Enum.any?(issue.labels || [], &(&1["name"] == name)) do
97
        Issues.remove_label(issue, name)
98
      else
99
        Issues.add_labels(issue, [name])
100
      end
67 101
68
    {:noreply,
69
     socket
70
     |> assign(:issue, updated)
71
     |> put_flash(:info, "Issue closed")}
102
    {:noreply, load(socket, updated)}
72 103
  end
73 104
74
  def handle_event("reopen", _params, socket) do
105
  def handle_event("toggle_assignee", %{"login" => login}, socket) do
75 106
    issue = socket.assigns.issue
76
    {:ok, updated} = Issues.update_issue(issue, %{"state" => "open"})
77 107
78
    {:noreply,
79
     socket
80
     |> assign(:issue, updated)
81
     |> put_flash(:info, "Issue reopened")}
108
    {:ok, updated} =
109
      if Enum.any?(issue.assignees || [], &(&1["login"] == login)) do
110
        Issues.remove_assignees(issue, [login])
111
      else
112
        Issues.add_assignees(issue, [login])
113
      end
114
115
    {:noreply, load(socket, updated)}
116
  end
117
118
  def handle_event("set_milestone", %{"number" => number}, socket) do
119
    {:ok, updated} = Issues.set_milestone(socket.assigns.issue, number_or_nil(number))
120
    {:noreply, load(socket, updated)}
82 121
  end
83 122
84 123
  def handle_event("add_comment", %{"comment" => %{"body" => body}}, socket) do
85 124
    issue = socket.assigns.issue
86 125
87 126
    case Issues.create_comment(issue, %{body: body}, socket.assigns.current_user) do
88
      {:ok, comment} ->
127
      {:ok, _comment} ->
89 128
        {:noreply,
90 129
         socket
91
         |> assign(:comments, socket.assigns.comments ++ [comment])
92 130
         |> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))
93
         |> assign(:issue, %{issue | comments: issue.comments + 1})
94
         |> put_flash(:info, "Comment added")}
131
         |> put_flash(:info, "Comment added")
132
         |> load(%{issue | comments: issue.comments + 1})}
95 133
96 134
      {:error, changeset} ->
97 135
        {:noreply, assign(socket, :comment_form, to_form(changeset))}
98 136
    end
99 137
  end
100 138
139
  defp set_state(socket, state, reason) do
140
    attrs = %{"state" => state, "state_reason" => reason}
141
    {:ok, updated} = Issues.update_issue(socket.assigns.issue, attrs)
142
143
    flash = if state == "closed", do: "Issue closed", else: "Issue reopened"
144
145
    {:noreply,
146
     socket
147
     |> put_flash(:info, flash)
148
     |> load(updated)}
149
  end
150
151
  # One place rebuilds everything derived from the issue, so a write cannot
152
  # leave the timeline describing the previous version of the page.
153
  defp load(socket, issue) do
154
    comments = Issues.list_comments(issue)
155
156
    socket
157
    |> assign(:issue, issue)
158
    |> assign(:comments, comments)
159
    |> assign(:form, to_form(Issues.change_issue(issue)))
160
    |> assign(:events, timeline(issue, comments))
161
  end
162
101 163
  def render(assigns) do
102 164
    ~H"""
103
    <Layouts.app flash={@flash} current_scope={@current_scope}>
104
      <%= if @editing do %>
105
        <.form
106
          for={@form}
107
          id="issue-edit-form"
108
          phx-submit="save"
109
          class="card !mx-0 !mt-0 mb-6"
110
        >
111
          <.input field={@form[:title]} label="Title" />
112
          <.input field={@form[:body]} type="textarea" label="Body" />
113
          <footer class="flex justify-end mt-2 gap-2">
114
            <button type="button" class="btn" data-variant="ghost" phx-click="toggle_edit">
115
              Cancel
116
            </button>
117
            <.button type="submit" variant={:primary}>Save</.button>
118
          </footer>
119
        </.form>
120
      <% else %>
121
        <div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
122
          <div class="flex-1">
123
            <h1 class="text-3xl font-bold mb-2">
165
    <Layouts.app flash={@flash} current_scope={@current_scope} wide>
166
      <Circle.issue_detail>
167
        <:heading>
168
          <.form
169
            :if={@editing}
170
            for={@form}
171
            id="issue-edit-form"
172
            phx-submit="save"
173
            class="issue-editor"
174
          >
175
            <.input field={@form[:title]} label="Title" />
176
            <.input field={@form[:body]} type="textarea" label="Body" />
177
            <footer class="issue-editor__foot">
178
              <button type="button" class="btn" data-variant="ghost" phx-click="toggle_edit">
179
                Cancel
180
              </button>
181
              <.button type="submit" variant={:primary}>Save</.button>
182
            </footer>
183
          </.form>
184
185
          <div :if={!@editing} class="issue-heading">
186
            <h1 class="issue-heading__title">
124 187
              {@issue.title}
125
              <span class="text-muted-foreground text-xl font-normal">
126
                #{@issue.number}
127
              </span>
188
              <span class="issue-heading__number">#{@issue.number}</span>
128 189
            </h1>
129
            <div class="flex items-center gap-2 mb-3">
130
              <span class="badge" data-variant={if @issue.state == "open", do: "success", else: "dim"}>
131
                {@issue.state}
190
            <p class="issue-heading__meta">
191
              <Circle.issue_state state={@issue.state} reason={@issue.state_reason} show_label />
192
              <span class="issue-heading__dot" aria-hidden="true">·</span>
193
              <span>
194
                {author(@issue)} opened this {relative(@issue.inserted_at)} ago
195
              </span>
196
              <span :if={@issue.comments > 0} class="issue-heading__dot" aria-hidden="true">·</span>
197
              <span :if={@issue.comments > 0}>
198
                {@issue.comments} {ngettext_comments(@issue.comments)}
132 199
              </span>
133
              <p class="text-sm text-muted-foreground">
134
                Opened on {Calendar.strftime(@issue.inserted_at, "%b %d, %Y")} by {(@issue.user &&
135
                                                                                      @issue.user[
136
                                                                                        "login"
137
                                                                                      ]) ||
138
                  "anonymous"}
139
              </p>
140
            </div>
141
          </div>
142
          <div class="flex gap-2 shrink-0">
143
            <button
144
              :if={@issue.state == "open"}
145
              class="btn"
146
              data-variant="primary"
147
              data-size="sm"
148
              phx-click="close"
149
            >
150
              Close issue
151
            </button>
152
            <button
153
              :if={@issue.state == "closed"}
154
              class="btn"
155
              data-variant="ghost"
156
              data-size="sm"
157
              phx-click="reopen"
158
            >
159
              Reopen issue
160
            </button>
161
            <button class="btn" data-variant="ghost" data-size="sm" phx-click="toggle_edit">
162
              Edit
163
            </button>
164
          </div>
165
        </div>
166
      <% end %>
167
168
      <div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
169
        <div class="lg:col-span-3 space-y-6">
170
          <div class="card !m-0">
171
            <p class="whitespace-pre-wrap text-foreground">
172
              {@issue.body || "No description provided."}
173 200
            </p>
201
            <div class="issue-heading__actions">
202
              <button
203
                :if={@issue.state == "open"}
204
                class="btn"
205
                data-variant="primary"
206
                data-size="sm"
207
                phx-click="close"
208
              >
209
                Close issue
210
              </button>
211
              <button
212
                :if={@issue.state == "closed"}
213
                class="btn"
214
                data-variant="ghost"
215
                data-size="sm"
216
                phx-click="reopen"
217
              >
218
                Reopen issue
219
              </button>
220
              <button class="btn" data-variant="ghost" data-size="sm" phx-click="toggle_edit">
221
                <.icon name="edit" /> Edit
222
              </button>
223
            </div>
174 224
          </div>
225
        </:heading>
226
227
        <:rail>
228
          <Circle.properties_panel>
229
            <:group heading="State">
230
              <Circle.field_menu id="issue-state-menu" label="Change the state of this issue">
231
                <:trigger>
232
                  <Circle.issue_state
233
                    state={@issue.state}
234
                    reason={@issue.state_reason}
235
                    show_label
236
                  />
237
                </:trigger>
238
                <Circle.field_menu_item
239
                  :for={{label, state, reason} <- state_options()}
240
                  label={label}
241
                  mode={:choice}
242
                  selected={@issue.state == state and close_reason(@issue) == reason}
243
                  closes="issue-state-menu"
244
                  on_select={JS.push("set_state", value: %{state: state, reason: reason})}
245
                >
246
                  <:glyph><Circle.issue_state state={state} reason={reason} /></:glyph>
247
                </Circle.field_menu_item>
248
              </Circle.field_menu>
249
            </:group>
175 250
176
          <h2 class="text-xl font-bold">Comments</h2>
177
178
          <div class="space-y-4">
179
            <%= for comment <- @comments do %>
180
              <div class="card !m-0">
181
                <div class="flex items-center gap-2 mb-2">
182
                  <span class="avatar size-8 font-semibold">
183
                    <span>
184
                      {(comment.user && comment.user["login"] |> String.first() |> String.upcase()) ||
185
                        "?"}
186
                    </span>
187
                  </span>
188
                  <span class="font-semibold text-sm">
189
                    {(comment.user && comment.user["login"]) || "anonymous"}
190
                  </span>
191
                  <span class="text-sm text-muted-foreground">
192
                    {Calendar.strftime(comment.created_at, "%b %d, %Y %H:%M")}
193
                  </span>
194
                </div>
195
                <p class="whitespace-pre-wrap text-foreground">
196
                  {comment.body}
251
            <:group heading="Assignees">
252
              <Circle.assignee
253
                :for={assignee <- @issue.assignees}
254
                name={assignee["login"]}
255
                show_name
256
                size={:sm}
257
              />
258
              <span :if={@issue.assignees == []} class="properties-panel__none">No one assigned</span>
259
              <Circle.field_menu id="issue-assignee-menu" label="Assign this issue" align={:end}>
260
                <:trigger><.icon name="user-add" class="properties-panel__add" /></:trigger>
261
                <Circle.field_menu_item
262
                  :for={user <- @assignable}
263
                  label={user.github_login}
264
                  selected={assigned?(@issue, user.github_login)}
265
                  on_select={JS.push("toggle_assignee", value: %{login: user.github_login})}
266
                >
267
                  <:glyph><Circle.assignee name={user.github_login} size={:sm} /></:glyph>
268
                </Circle.field_menu_item>
269
                <p :if={@assignable == []} class="properties-panel__none">
270
                  Nobody in this repository can be assigned yet.
197 271
                </p>
198
              </div>
199
            <% end %>
272
              </Circle.field_menu>
273
            </:group>
274
275
            <:group heading="Labels">
276
              <Circle.issue_label
277
                :for={label <- @issue.labels}
278
                name={label["name"]}
279
                tone={label_tone(label["color"])}
280
              />
281
              <span :if={@issue.labels == []} class="properties-panel__none">None yet</span>
282
              <Circle.field_menu
283
                id="issue-label-menu"
284
                label="Change the labels on this issue"
285
                align={:end}
286
              >
287
                <:trigger><.icon name="tag" class="properties-panel__add" /></:trigger>
288
                <Circle.field_menu_item
289
                  :for={label <- @repo_labels}
290
                  label={label.name}
291
                  selected={labelled?(@issue, label.name)}
292
                  on_select={JS.push("toggle_label", value: %{name: label.name})}
293
                >
294
                  <:glyph>
295
                    <span class="issue-label__dot" data-tone={label_tone(label.color)} />
296
                  </:glyph>
297
                </Circle.field_menu_item>
298
                <p :if={@repo_labels == []} class="properties-panel__none">
299
                  This repository has no labels yet.
300
                </p>
301
              </Circle.field_menu>
302
            </:group>
303
304
            <:group heading="Milestone">
305
              <.link
306
                :if={@issue.milestone}
307
                navigate={~p"/#{@owner}/#{@repo}/milestones"}
308
                class="properties-panel__link"
309
              >
310
                <.icon name="flag" /> {@issue.milestone["title"]}
311
              </.link>
312
              <span :if={!@issue.milestone} class="properties-panel__none">No milestone</span>
313
              <Circle.field_menu id="issue-milestone-menu" label="Set the milestone" align={:end}>
314
                <:trigger><.icon name="edit-pencil" class="properties-panel__add" /></:trigger>
315
                <Circle.field_menu_item
316
                  label="No milestone"
317
                  mode={:choice}
318
                  selected={is_nil(@issue.milestone)}
319
                  closes="issue-milestone-menu"
320
                  on_select={JS.push("set_milestone", value: %{number: ""})}
321
                />
322
                <Circle.field_menu_item
323
                  :for={milestone <- @repo_milestones}
324
                  label={milestone.title}
325
                  icon="flag"
326
                  mode={:choice}
327
                  selected={milestoned?(@issue, milestone.number)}
328
                  closes="issue-milestone-menu"
329
                  on_select={JS.push("set_milestone", value: %{number: milestone.number})}
330
                />
331
              </Circle.field_menu>
332
            </:group>
333
          </Circle.properties_panel>
334
        </:rail>
335
336
        <section class="issue-body">
337
          <div :if={@issue.body} class="timeline-comment__body !p-0">
338
            {Markdown.to_html(@issue.body)}
200 339
          </div>
340
          <p :if={!@issue.body} class="properties-panel__none">No description provided.</p>
341
        </section>
201 342
202
          <.form
203
            for={@comment_form}
204
            id="comment-form"
205
            phx-submit="add_comment"
206
            class="card !m-0"
207
          >
208
            <.input field={@comment_form[:body]} type="textarea" label="Write a comment" />
209
            <footer class="flex justify-end mt-2">
210
              <.button type="submit" variant={:primary}>Comment</.button>
211
            </footer>
212
          </.form>
213
        </div>
214
215
        <div class="space-y-4">
216
          <%= if @issue.labels != [] do %>
217
            <div>
218
              <h3 class="font-semibold text-sm text-muted-foreground mb-2">Labels</h3>
219
              <div class="flex flex-wrap gap-1">
220
                <%= for label <- @issue.labels do %>
221
                  <span
222
                    class="badge rounded-full px-2 py-0.5"
223
                    style={"background-color: ##{label["color"]}; color: #000;"}
224
                  >
225
                    {label["name"]}
226
                  </span>
227
                <% end %>
228
              </div>
229
            </div>
343
        <Circle.timeline>
344
          <%= for event <- @events do %>
345
            <Circle.timeline_comment
346
              :if={event.kind == :comment}
347
              id={"comment-#{event.id}"}
348
              author={event.actor}
349
              at={event.at}
350
              badge={event.badge}
351
            >
352
              {Markdown.to_html(event.body)}
353
            </Circle.timeline_comment>
354
            <Circle.timeline_event
355
              :if={event.kind == :event}
356
              actor={event.actor}
357
              text={event.text}
358
              icon={event.icon}
359
              tone={event.tone}
360
              at={event.at}
361
            />
230 362
          <% end %>
363
        </Circle.timeline>
231 364
232
          <%= if @issue.assignees != [] do %>
233
            <div>
234
              <h3 class="font-semibold text-sm text-muted-foreground mb-2">Assignees</h3>
235
              <div class="avatar-group">
236
                <%= for assignee <- @issue.assignees do %>
237
                  <span class="avatar size-7 font-semibold" title={assignee["login"]}>
238
                    <span class="!text-xs">
239
                      {assignee["login"] |> String.first() |> String.upcase()}
240
                    </span>
241
                  </span>
242
                <% end %>
243
              </div>
244
            </div>
245
          <% end %>
365
        <.alert :if={@issue.locked} variant={:warning} appearance={:notice}>
366
          This conversation is locked{if @issue.locked_reason, do: " as #{@issue.locked_reason}"}.
367
        </.alert>
246 368
247
          <%= if @issue.milestone do %>
248
            <div>
249
              <h3 class="font-semibold text-sm text-muted-foreground mb-2">Milestone</h3>
250
              <.link navigate={~p"/#{@owner}/#{@repo}/milestones"} class="badge" data-variant="dim">
251
                {@issue.milestone["title"]}
252
              </.link>
253
            </div>
254
          <% end %>
255
        </div>
256
      </div>
369
        <.form :if={!@issue.locked} for={@comment_form} id="comment-form" phx-submit="add_comment">
370
          <Circle.comment_composer id="issue-composer" author={viewer(@current_user)}>
371
            <.input
372
              field={@comment_form[:body]}
373
              type="textarea"
374
              label="Comment"
375
              placeholder="Leave a comment"
376
            />
377
            <:hint>Markdown is supported.</:hint>
378
            <:actions>
379
              <.button type="submit" variant={:primary} size={:sm}>Comment</.button>
380
            </:actions>
381
          </Circle.comment_composer>
382
        </.form>
383
      </Circle.issue_detail>
257 384
    </Layouts.app>
258 385
    """
259 386
  end
387
388
  # ── the derived history ────────────────────────────────────────────────────
389
390
  # GitHub's timeline is an endpoint backed by an event log. This schema has no
391
  # such table, so the feed is assembled from the columns that do exist. The
392
  # result is honest but partial: a label added and removed leaves no trace,
393
  # and a close records when but not who.
394
  defp timeline(issue, comments) do
395
    opened = %{
396
      kind: :event,
397
      actor: author(issue),
398
      text: "opened this issue",
399
      icon: "plus-circle",
400
      tone: :neutral,
401
      at: stamp(issue.inserted_at),
402
      sort: issue.inserted_at
403
    }
404
405
    commented =
406
      Enum.map(comments, fn comment ->
407
        login = login(comment.user)
408
409
        %{
410
          kind: :comment,
411
          id: comment.id,
412
          actor: login,
413
          badge: if(login == author(issue), do: "Author"),
414
          body: comment.body,
415
          at: stamp(comment.created_at),
416
          sort: comment.created_at
417
        }
418
      end)
419
420
    closed =
421
      if issue.state == "closed" and issue.closed_at do
422
        [
423
          %{
424
            kind: :event,
425
            actor: nil,
426
            text: close_text(issue),
427
            icon: close_icon(issue),
428
            tone: close_tone(issue),
429
            at: stamp(issue.closed_at),
430
            sort: issue.closed_at
431
          }
432
        ]
433
      else
434
        []
435
      end
436
437
    Enum.sort_by([opened | commented] ++ closed, & &1.sort, DateTime)
438
  end
439
440
  defp close_text(%{state_reason: "not_planned"}), do: "closed this as not planned"
441
  defp close_text(%{state_reason: "duplicate"}), do: "closed this as a duplicate"
442
  defp close_text(_issue), do: "closed this as completed"
443
444
  defp close_icon(%{state_reason: reason}) when reason in ["not_planned", "duplicate"],
445
    do: "x-circle-filled"
446
447
  defp close_icon(_issue), do: "check-circle-filled"
448
449
  defp close_tone(%{state_reason: reason}) when reason in ["not_planned", "duplicate"],
450
    do: :danger
451
452
  defp close_tone(_issue), do: :success
453
454
  # ── GitHub's fields, read the way this interface reads them ────────────────
455
456
  # The three states this application can express. `duplicate` is a valid
457
  # GitHub close reason and is rendered when it arrives from the API, but it is
458
  # not offered here: the menu that sets it has nowhere to record which issue
459
  # it duplicates, and a duplicate that does not say of what is worse than a
460
  # plain close.
461
  defp state_options do
462
    [
463
      {"Open", "open", nil},
464
      {"Closed as completed", "closed", "completed"},
465
      {"Closed as not planned", "closed", "not_planned"}
466
    ]
467
  end
468
469
  defp close_reason(%{state: "closed", state_reason: reason}), do: reason || "completed"
470
  defp close_reason(_issue), do: nil
471
472
  # `JS.push` sends a number as a number and a form sends it as a string, so
473
  # the handler takes whichever arrives rather than making the call sites agree.
474
  defp number_or_nil(""), do: nil
475
  defp number_or_nil(number) when is_integer(number), do: number
476
  defp number_or_nil(number) when is_binary(number), do: String.to_integer(number)
477
478
  defp assigned?(issue, login), do: Enum.any?(issue.assignees || [], &(&1["login"] == login))
479
  defp labelled?(issue, name), do: Enum.any?(issue.labels || [], &(&1["name"] == name))
480
  defp milestoned?(%{milestone: %{"number" => n}}, number), do: n == number
481
  defp milestoned?(_issue, _number), do: false
482
483
  defp author(issue), do: login(issue.user)
484
485
  defp login(%{} = user), do: user["login"] || user[:login] || "anonymous"
486
  defp login(_user), do: "anonymous"
487
488
  defp viewer(%{github_login: login}) when is_binary(login), do: login
489
  defp viewer(_user), do: nil
490
491
  defp ngettext_comments(1), do: "comment"
492
  defp ngettext_comments(_count), do: "comments"
493
494
  # A GitHub label carries a hex colour chosen by whoever made it. Rendering it
495
  # would put a second palette on the page, and dropping it would make every
496
  # label grey. So the hue is read and mapped to the nearest tone on our
497
  # ladder: the author's intent survives -- red means broken, green means new
498
  # -- while the values stay ours.
499
  defp label_tone(color) when is_binary(color) do
500
    case Integer.parse(String.trim_leading(color, "#"), 16) do
501
      {value, _rest} -> hue_tone(value)
502
      :error -> :neutral
503
    end
504
  end
505
506
  defp label_tone(_color), do: :neutral
507
508
  defp hue_tone(value) do
509
    r = div(value, 65_536)
510
    g = value |> div(256) |> rem(256)
511
    b = rem(value, 256)
512
    high = Enum.max([r, g, b])
513
    low = Enum.min([r, g, b])
514
515
    # A grey label is as deliberate a choice as a red one, so anything close to
516
    # the diagonal stays neutral rather than being forced into a hue.
517
    if high == 0 or (high - low) / high < 0.2 do
518
      :neutral
519
    else
520
      r |> hue(g, b, high, low) |> tone_for_hue()
521
    end
522
  end
523
524
  defp hue(r, g, b, high, low) do
525
    delta = high - low
526
527
    sextant =
528
      cond do
529
        high == r -> wrap((g - b) / delta)
530
        high == g -> (b - r) / delta + 2
531
        true -> (r - g) / delta + 4
532
      end
533
534
    sextant * 60
535
  end
536
537
  # The red sextant straddles zero: a value of -0.5 is 330 degrees, not -30.
538
  defp wrap(sextant) when sextant < 0, do: sextant + 6
539
  defp wrap(sextant), do: sextant
540
541
  defp tone_for_hue(h) when h < 20 or h >= 340, do: :danger
542
  defp tone_for_hue(h) when h < 70, do: :warning
543
  defp tone_for_hue(h) when h < 170, do: :success
544
  defp tone_for_hue(h) when h < 260, do: :info
545
  defp tone_for_hue(_h), do: :primary
546
547
  defp stamp(nil), do: nil
548
  defp stamp(at), do: "#{relative(at)} ago"
549
550
  defp relative(nil), do: nil
551
552
  defp relative(at) do
553
    at = if is_struct(at, NaiveDateTime), do: DateTime.from_naive!(at, "Etc/UTC"), else: at
554
555
    case DateTime.diff(DateTime.utc_now(), at, :second) do
556
      s when s < 3_600 -> "#{max(div(s, 60), 1)}m"
557
      s when s < 86_400 -> "#{div(s, 3_600)}h"
558
      s -> "#{div(s, 86_400)}d"
559
    end
560
  end
260 561
end
lib/openagents_web/live/project_index_live.ex modified +31 -1

@@ -40,6 +40,16 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

40 40
    end
41 41
  end
42 42
43
  # Projects V2 has `state`, so closing one is a GitHub field rather than an
44
  # invented concept -- and it is the only property of a project this schema
45
  # carries that is worth changing without opening the board.
46
  def handle_event("set_state", %{"id" => id, "state" => state}, socket) do
47
    project = Projects.get_project!(socket.assigns.repository, id)
48
    {:ok, _updated} = Projects.update_project(project, %{"state" => state})
49
50
    {:noreply, assign(socket, :projects, Projects.list_projects(socket.assigns.repository))}
51
  end
52
43 53
  def handle_event("delete", %{"id" => id}, socket) do
44 54
    project = Projects.get_project!(socket.assigns.repository, String.to_integer(id))
45 55
    {:ok, _} = Projects.delete_project(project)

@@ -77,7 +87,27 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

77 87
            navigate={~p"/#{@owner}/#{@repo}/projects/#{project.number}"}
78 88
            status_category={if project.state == "closed", do: :completed, else: :unstarted}
79 89
            status_label={String.capitalize(project.state)}
80
          />
90
          >
91
            <:state>
92
              <Circle.field_menu
93
                id={"project-state-#{project.id}"}
94
                label={"Change the state of #{project.title}"}
95
                align={:end}
96
              >
97
                <:trigger><Circle.issue_state state={project.state} /></:trigger>
98
                <Circle.field_menu_item
99
                  :for={state <- ~w(open closed)}
100
                  label={String.capitalize(state)}
101
                  mode={:choice}
102
                  selected={project.state == state}
103
                  closes={"project-state-#{project.id}"}
104
                  on_select={JS.push("set_state", value: %{id: project.id, state: state})}
105
                >
106
                  <:glyph><Circle.issue_state state={state} /></:glyph>
107
                </Circle.field_menu_item>
108
              </Circle.field_menu>
109
            </:state>
110
          </Circle.project_row>
81 111
          <%!-- Deletion stays a control of its own rather than something the
82 112
          row grew: the row is a link to a board, and a destructive action
83 113
          inside a link target is how people delete things by accident. --%>
test/openagents_web/components/circle_test.exs modified +157

@@ -207,6 +207,163 @@ defmodule OpenAgentsWeb.UI.CircleTest do

207 207
    end
208 208
  end
209 209
210
  describe "issue_state/1" do
211
    # The ruling in docs/2026-08-20-linear-design-github-shape.md: GitHub has
212
    # two states and we have what GitHub has. This is the one place that maps
213
    # them, so a page cannot quietly disagree with another about what closed
214
    # looks like.
215
    test "GitHub's two states, plus the one close reason that reads differently" do
216
      for {state, reason, category, label} <- [
217
            {"open", nil, "unstarted", "Open"},
218
            {"closed", "completed", "completed", "Closed"},
219
            {"closed", nil, "completed", "Closed"},
220
            {"closed", "not_planned", "canceled", "Closed as not planned"},
221
            {"closed", "duplicate", "canceled", "Closed as duplicate"}
222
          ] do
223
        rendered = render_component(&Circle.issue_state/1, state: state, reason: reason)
224
225
        assert query(rendered, ~s{.issue-status[data-category="#{category}"]}) != [],
226
               "#{state}/#{reason || "nil"} did not take the #{category} shape"
227
228
        assert query(rendered, ~s{[aria-label="#{label}"]}) != []
229
      end
230
    end
231
232
    test "a duplicate is cancelled rather than completed" do
233
      # It is the one reason where GitHub's own glyph differs from a plain
234
      # close, and reading it as completed would claim work that never happened.
235
      completed = render_component(&Circle.issue_state/1, state: "closed", reason: "completed")
236
      duplicate = render_component(&Circle.issue_state/1, state: "closed", reason: "duplicate")
237
238
      assert query(completed, ~s{[data-category="completed"]}) != []
239
      assert query(duplicate, ~s{[data-category="canceled"]}) != []
240
    end
241
  end
242
243
  describe "field_menu/1" do
244
    test "the trigger names itself, because what it shows is a picture" do
245
      rendered =
246
        render_component(&Circle.field_menu/1,
247
          id: "state-menu",
248
          label: "Change the state",
249
          trigger: [%{__slot__: :trigger, inner_block: fn _, _ -> "glyph" end}],
250
          inner_block: [%{__slot__: :inner_block, inner_block: fn _, _ -> "options" end}]
251
        )
252
253
      assert query(rendered, ~s{button[aria-label="Change the state"]}) != []
254
    end
255
256
    test "the trigger points at the panel it opens, and the panel exists" do
257
      rendered =
258
        render_component(&Circle.field_menu/1,
259
          id: "state-menu",
260
          label: "Change the state",
261
          trigger: [%{__slot__: :trigger, inner_block: fn _, _ -> "glyph" end}],
262
          inner_block: [%{__slot__: :inner_block, inner_block: fn _, _ -> "options" end}]
263
        )
264
265
      assert query(rendered, ~s{button[popovertarget="state-menu"]}) != []
266
      assert query(rendered, ~s{[popover]#state-menu}) != []
267
    end
268
  end
269
270
  describe "field_menu_item/1" do
271
    # A set and a single choice claim different things, and a screen reader
272
    # cannot tell them apart from a tick.
273
    test "a toggle presses, a choice becomes current" do
274
      toggled = render_component(&Circle.field_menu_item/1, label: "Bug", selected: true)
275
      unset = render_component(&Circle.field_menu_item/1, label: "Bug")
276
277
      assert query(toggled, ~s{button[aria-pressed="true"]}) != []
278
      assert query(unset, ~s{button[aria-pressed="false"]}) != []
279
280
      chosen =
281
        render_component(&Circle.field_menu_item/1,
282
          label: "Closed",
283
          mode: :choice,
284
          selected: true
285
        )
286
287
      assert query(chosen, ~s{button[aria-current="true"]}) != []
288
      assert query(chosen, "button[aria-pressed]") == []
289
    end
290
291
    test "dismissing the panel is opt-in, because a set stays open" do
292
      # Ticking three labels in a row only works if the menu survives the first
293
      # tick, so `closes` is asked for rather than assumed.
294
      staying = render_component(&Circle.field_menu_item/1, label: "Bug")
295
      closing = render_component(&Circle.field_menu_item/1, label: "Open", closes: "state-menu")
296
297
      assert query(staying, "button[popovertarget]") == []
298
299
      assert query(closing, ~s{button[popovertarget="state-menu"][popovertargetaction="hide"]}) !=
300
               []
301
    end
302
  end
303
304
  describe "timeline_event/1" do
305
    test "an event with no known actor states the fact without inventing a subject" do
306
      rendered = render_component(&Circle.timeline_event/1, text: "closed this as completed")
307
308
      assert query(rendered, ".timeline-event__actor") == []
309
      assert rendered =~ "closed this as completed"
310
    end
311
312
    test "the glyph is decorative, because the sentence beside it says the same thing" do
313
      rendered =
314
        render_component(&Circle.timeline_event/1, actor: "ada", text: "opened this issue")
315
316
      assert query(rendered, ".timeline-event__glyph[aria-hidden]") != []
317
      assert query(rendered, ".timeline-event svg[aria-label]") == []
318
    end
319
  end
320
321
  describe "issue_row/1 controls" do
322
    # The row stays presentational; a caller with somewhere to send a change
323
    # replaces the cell. The static rendering has to get out of the way when it
324
    # does, or the row shows the value twice.
325
    test "a state slot replaces the static glyph rather than joining it" do
326
      rendered =
327
        render_component(&Circle.issue_row/1,
328
          identifier: "#1",
329
          title: "A title",
330
          status_category: :unstarted,
331
          status_label: "Open",
332
          state: [%{__slot__: :state, inner_block: fn _, _ -> "CONTROL" end}]
333
        )
334
335
      assert rendered =~ "CONTROL"
336
      assert query(rendered, ".issue-row__scan .issue-status") == []
337
    end
338
339
    test "an assignee slot replaces the static face" do
340
      rendered =
341
        render_component(&Circle.issue_row/1,
342
          identifier: "#1",
343
          title: "A title",
344
          status_category: :unstarted,
345
          status_label: "Open",
346
          people: [%{__slot__: :people, inner_block: fn _, _ -> "CONTROL" end}]
347
        )
348
349
      assert rendered =~ "CONTROL"
350
      assert query(rendered, ".assignee") == []
351
    end
352
353
    test "with no slots the row renders exactly what it did before" do
354
      rendered =
355
        render_component(&Circle.issue_row/1,
356
          identifier: "#1",
357
          title: "A title",
358
          status_category: :unstarted,
359
          status_label: "Open"
360
        )
361
362
      assert query(rendered, ".issue-row__scan .issue-status") != []
363
      assert query(rendered, ".assignee") != []
364
    end
365
  end
366
210 367
  describe "the stylesheet" do
211 368
    setup do
212 369
      section =
test/openagents_web/live/issue_index_live_test.exs modified +60

@@ -147,6 +147,66 @@ defmodule OpenAgentsWeb.IssueIndexLiveTest do

147 147
    end
148 148
  end
149 149
150
  # In Circle the row's parts are selectors. Only two of them survive here:
151
  # state and assignee are the facts worth changing without opening the issue,
152
  # and both are GitHub fields. Labels and milestone need option lists longer
153
  # than a row can explain, so they are edited from the issue page's rail.
154
  test "closing an issue from its row drops it out of the open filter", %{conn: conn} do
155
    {:ok, issue} = Issues.create_issue(%{"title" => "Closeable"})
156
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")
157
158
    assert has_element?(view, ~s{#row-state-#{issue.id}})
159
160
    view
161
    |> element(~s{#row-state-#{issue.id} button}, "Closed as not planned")
162
    |> render_click()
163
164
    closed = Issues.get_issue!(issue.id)
165
    assert closed.state == "closed"
166
    assert closed.state_reason == "not_planned"
167
168
    # The list is filtered to open issues, so a closed one has to leave it --
169
    # a row that stays visible after being closed is worse than a reload.
170
    refute has_element?(view, ~s{#row-state-#{issue.id}})
171
172
    assert has_element?(
173
             view,
174
             ~s{a[href="/OpenAgentsInc/openagents.com/issues?state=closed"]},
175
             "1 Closed"
176
           )
177
  end
178
179
  test "assigning from a row keeps the row and updates the face", %{conn: conn} do
180
    repository_user_fixture("hopper-index")
181
    {:ok, issue} = Issues.create_issue(%{"title" => "Assignable"})
182
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")
183
184
    view
185
    |> element(~s{#row-assignee-#{issue.id} button}, "hopper-index")
186
    |> render_click()
187
188
    assert Issues.get_issue!(issue.id).assignees |> Enum.map(& &1["login"]) == ["hopper-index"]
189
    assert has_element?(view, ~s{#row-assignee-#{issue.id}})
190
    assert has_element?(view, ~s{[title="hopper-index"]})
191
  end
192
193
  test "the row's controls sit outside the link to the issue", %{conn: conn} do
194
    # A state-changing control inside a link target is how people mis-click.
195
    {:ok, issue} = Issues.create_issue(%{"title" => "Separate hit areas"})
196
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/issues")
197
198
    title_link =
199
      view
200
      |> render()
201
      |> LazyHTML.from_fragment()
202
      |> LazyHTML.query("a.issue-row__title")
203
      |> LazyHTML.to_tree()
204
205
    assert title_link != []
206
    refute inspect(title_link) =~ "row-state-#{issue.id}"
207
    refute inspect(title_link) =~ "row-assignee-#{issue.id}"
208
  end
209
150 210
  test "an anonymous visitor is redirected away from the issue list" do
151 211
    assert {:error, {:redirect, %{to: to}}} =
152 212
             live(build_conn(), ~p"/OpenAgentsInc/openagents.com/issues")
test/openagents_web/live/issue_show_live_test.exs modified +116 -7

@@ -46,14 +46,28 @@ defmodule OpenAgentsWeb.IssueShowLiveTest do

46 46
    assert html =~ "anonymous"
47 47
  end
48 48
49
  test "the sidebar only renders sections the issue actually has", %{conn: conn} do
49
  # This used to assert that an empty section was hidden, which was right while
50
  # the rail was read-only. The rail is editable now, and a field you cannot
51
  # see is a field you cannot set: hiding "Labels" until an issue has one means
52
  # an issue can never get its first label. So the group is always present and
53
  # states its own emptiness instead.
54
  test "an empty property group says so rather than disappearing", %{conn: conn} do
50 55
    repository_user_fixture("grace-show")
51 56
    bare = issue!(%{"title" => "Bare"})
52
    {:ok, _view, html} = live(conn, path(bare))
57
    {:ok, view, html} = live(conn, path(bare))
53 58
54
    refute html =~ "Labels"
55
    refute html =~ "Assignees"
56
    refute html =~ "Milestone"
59
    assert html =~ "Labels"
60
    assert html =~ "Assignees"
61
    assert html =~ "Milestone"
62
    assert html =~ "None yet"
63
    assert html =~ "No one assigned"
64
    assert html =~ "No milestone"
65
66
    # And each one offers the control that fills it.
67
    for id <- ~w(issue-label-menu issue-assignee-menu issue-milestone-menu issue-state-menu) do
68
      assert has_element?(view, ~s{button[popovertarget="#{id}"]}),
69
             "the #{id} property is stated but cannot be changed"
70
    end
57 71
58 72
    label_fixture(%{name: "bug", color: "d73a4a"})
59 73
    milestone = milestone_fixture(%{title: "v1.0", due_on: nil})

@@ -63,13 +77,108 @@ defmodule OpenAgentsWeb.IssueShowLiveTest do

63 77
64 78
    {:ok, view, html} = live(conn, path(rich))
65 79
66
    assert html =~ "Labels"
67 80
    assert html =~ "bug"
68
    assert html =~ "Assignees"
81
    refute html =~ "None yet"
69 82
    assert has_element?(view, ~s{[title="grace-show"]})
70 83
    assert has_element?(view, ~s{a[href="/OpenAgentsInc/openagents.com/milestones"]}, "v1.0")
71 84
  end
72 85
86
  test "the rail changes state, labels, assignees, and the milestone", %{conn: conn} do
87
    repository_user_fixture("hopper-show")
88
    label_fixture(%{name: "bug", color: "d73a4a"})
89
    milestone = milestone_fixture(%{title: "v2.0", due_on: nil})
90
    issue = issue!(%{"title" => "Editable"})
91
92
    {:ok, view, _html} = live(conn, path(issue))
93
94
    view
95
    |> element(~s{#issue-label-menu button}, "bug")
96
    |> render_click()
97
98
    assert Issues.get_issue!(issue.id).labels |> Enum.map(& &1["name"]) == ["bug"]
99
100
    view
101
    |> element(~s{#issue-assignee-menu button}, "hopper-show")
102
    |> render_click()
103
104
    assert Issues.get_issue!(issue.id).assignees |> Enum.map(& &1["login"]) == ["hopper-show"]
105
106
    view
107
    |> element(~s{#issue-milestone-menu button}, "v2.0")
108
    |> render_click()
109
110
    assert Issues.get_issue!(issue.id).milestone["number"] == milestone.number
111
112
    # The rail can pick a close reason the header's two buttons cannot.
113
    view
114
    |> element(~s{#issue-state-menu button}, "Closed as not planned")
115
    |> render_click()
116
117
    closed = Issues.get_issue!(issue.id)
118
    assert closed.state == "closed"
119
    assert closed.state_reason == "not_planned"
120
121
    # Clicking the same option again unsets it, because a set is a toggle.
122
    view
123
    |> element(~s{#issue-label-menu button}, "bug")
124
    |> render_click()
125
126
    assert Issues.get_issue!(issue.id).labels == []
127
  end
128
129
  test "the history is one feed of comments and state changes", %{conn: conn} do
130
    issue = issue!(%{"title" => "Threaded"})
131
    {:ok, view, html} = live(conn, path(issue))
132
133
    assert html =~ "opened this issue"
134
135
    view
136
    |> form("#comment-form", comment: %{body: "First"})
137
    |> render_submit()
138
139
    html = view |> element(~s{button[phx-click="close"]}) |> render_click()
140
141
    assert html =~ "First"
142
    assert html =~ "closed this as completed"
143
144
    # The close has no actor. This schema records when an issue was closed but
145
    # not by whom, and a sentence with an invented subject would be worse than
146
    # one without a subject at all.
147
    refute html =~ "issue-show closed this"
148
  end
149
150
  test "a comment from the issue's own author is marked as such", %{conn: conn} do
151
    # GitHub derives the Author badge by comparing the commenter to the issue's
152
    # author rather than storing it, so this asserts the comparison, not a field.
153
    issue = issue!(%{"title" => "Self-answered", "user" => %{"login" => "ada"}})
154
155
    {:ok, _} =
156
      Issues.create_comment(%{
157
        "issue_id" => issue.id,
158
        "body" => "Answering my own question",
159
        "user" => %{"login" => "ada"}
160
      })
161
162
    {:ok, _} =
163
      Issues.create_comment(%{
164
        "issue_id" => issue.id,
165
        "body" => "So am I",
166
        "user" => %{"login" => "grace"}
167
      })
168
169
    {:ok, view, _html} = live(conn, path(issue))
170
171
    assert has_element?(view, ".timeline-comment__badge", "Author")
172
173
    assert length(
174
             LazyHTML.query(document(view), ".timeline-comment__badge")
175
             |> LazyHTML.to_tree()
176
           ) ==
177
             1
178
  end
179
180
  defp document(view), do: view |> render() |> LazyHTML.from_fragment()
181
73 182
  test "a missing issue number raises rather than rendering an empty page", %{conn: conn} do
74 183
    assert_raise Ecto.NoResultsError, fn ->
75 184
      live(conn, ~p"/OpenAgentsInc/openagents.com/issues/9999")
test/openagents_web/live/project_index_live_test.exs modified +19

@@ -100,4 +100,23 @@ defmodule OpenAgentsWeb.ProjectIndexLiveTest do

100 100
    assert has_element?(view, ~s{[role="status"]}, "No projects yet")
101 101
    assert Projects.list_projects() == []
102 102
  end
103
104
  test "closing a project from its row uses the state GitHub already has", %{conn: conn} do
105
    # Projects V2 carries `state`, so this changes a real field rather than an
106
    # invented one. Delete stays a separate control beside the row.
107
    project = project_fixture(%{title: "Closeable", owner: "OpenAgentsInc", state: "open"})
108
    {:ok, view, _html} = live(conn, ~p"/OpenAgentsInc/openagents.com/projects")
109
110
    view
111
    |> element(~s{#project-state-#{project.id} button}, "Closed")
112
    |> render_click()
113
114
    assert Projects.get_project!(project.id).state == "closed"
115
116
    assert has_element?(
117
             view,
118
             ~s{#project-state-#{project.id} button[aria-current="true"]},
119
             "Closed"
120
           )
121
  end
103 122
end

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