Port Circle's issue, project, and team surfaces

ed404daa1d30 · AtlantisPleb · · parent f088bb7d9f33

Port Circle's issue, project, and team surfaces

Adds OpenAgentsWeb.UI.Circle: nineteen components adapted from Circle
(MIT, © 2025 lndev-ui), a Linear-shaped tracker built in Next.js and
shadcn/ui. Nothing is copied — every source component reads a Zustand
store through Radix primitives. What carried over is the information
design: what an issue row holds and in what order, that the status glyph
is a filled arc rather than a coloured dot, that a group header is
washed by its own status, that a filter reads as subject / operator /
value with each segment its own control.

Ported: issue_status, issue_priority, issue_label, assignee,
assignee_stack, issue_row, issue_card, issue_group, issue_board,
filter_chip, filter_bar, view_tabs, issue_toolbar, command_palette,
command_group, command_item, project_row, team_row, member_row. All
nineteen are catalogued and demoed under a new "Issues" section at
/components.

Three deliberate departures:

- Tokens, not a second palette. Circle assigns a hand-picked hex to each
  of thirteen statuses and eleven labels. Colour here is assigned per
  status CATEGORY off the same ladder status_indicator/1 uses, so
  activity is --info and completion is --success everywhere. Six colours
  say less than thirteen; that cost is stated rather than hidden, and in
  exchange nothing needs a second set of declarations per theme. A test
  fails if a hex value enters the Issues stylesheet section.
- No JavaScript except where the keyboard needs it. Everything is
  server-rendered but the command palette, which is a native <dialog>
  plus one colocated hook for ⌘K, incremental filtering, and arrow-key
  selection. Every command is a real button and works without it.
- State is the caller's. Nothing here owns state; components take what
  to draw and emit JS commands the caller supplies, which is what makes
  one issue_row usable in a list, a board, and a search result.

Deliberately not ported: the 2,000-line data-table-filter engine (a
query builder, which belongs on the server), react-dnd board dragging
(the layout is ported; changing status stays a control, so it also works
from a touch screen and a keyboard), motion/react layout animation,
right-click context menus (every action is in the palette), the insights
panel (charting invented numbers demonstrates nothing), and the shadcn
primitives that already exist in OpenAgentsWeb.UI.

No icon was vendored. Five of the six status shapes map to the existing
Apps SDK set; the backlog gear has no equivalent and reuses
circle-dashed rather than putting a Linear-specific mark in a general
set. The progress arc and the priority bars are CSS-drawn indicators
like status_indicator/1, because their fill reads a number rather than
naming a picture.

Nothing renders these on a real page yet — the existing issue LiveViews
still compose generic controls, and wiring them up needs a schema
decision the port deliberately did not make. Recorded, with the rest,
in docs/2026-08-20-circle-ui-port.md.

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
  • added docs/2026-08-20-circle-ui-port.md
  • modified lib/openagents_web/component_catalog.ex
  • added lib/openagents_web/components/circle.ex
  • modified lib/openagents_web/live/components_live.ex
  • added test/openagents_web/components/circle_test.exs

Diff

6 files changed, +3245 -2

assets/css/openagents.css modified +991

@@ -5188,3 +5188,994 @@

5188 5188
    border-radius: 5px;
5189 5189
  }
5190 5190
}
5191
5192
/* ── Issues ───────────────────────────────────────────────────────────────── */
5193
5194
/* Issue, project, and team surfaces, adapted from Circle (MIT, © 2025
5195
 * lndev-ui). The source assigns a hand-picked hex value to each of thirteen
5196
 * statuses and eleven labels; those colours are Linear's, and adopting them
5197
 * would put a second palette beside the one every other surface uses. Colour
5198
 * here is assigned per status CATEGORY off the same token ladder as
5199
 * .status-indicator: activity is --info, completion is --success, anything
5200
 * awaiting a decision is --warning, and both resting states are grey.
5201
 * See docs/2026-08-20-circle-ui-port.md. */
5202
5203
@layer components {
5204
  /* One custom property carries the category colour, so every element that
5205
     tints itself from a status -- the glyph, the group header wash -- reads
5206
     the same value instead of repeating the mapping. */
5207
  .issue-status,
5208
  .issue-group {
5209
    --issue-tint: var(--text-muted);
5210
  }
5211
5212
  .issue-status[data-category="triage"],
5213
  .issue-group[data-category="triage"] {
5214
    --issue-tint: var(--warning);
5215
  }
5216
5217
  .issue-status[data-category="backlog"],
5218
  .issue-group[data-category="backlog"],
5219
  .issue-status[data-category="canceled"],
5220
  .issue-group[data-category="canceled"] {
5221
    --issue-tint: var(--text-dim);
5222
  }
5223
5224
  .issue-status[data-category="started"],
5225
  .issue-group[data-category="started"] {
5226
    --issue-tint: var(--info);
5227
  }
5228
5229
  .issue-status[data-category="completed"],
5230
  .issue-group[data-category="completed"] {
5231
    --issue-tint: var(--success);
5232
  }
5233
5234
  .issue-status {
5235
    display: inline-flex;
5236
    align-items: center;
5237
    gap: 6px;
5238
    flex: none;
5239
    color: var(--issue-tint);
5240
  }
5241
5242
  .issue-status__glyph {
5243
    width: 14px;
5244
    height: 14px;
5245
    font-size: 14px;
5246
  }
5247
5248
  /* The one shape an icon set cannot carry: the fill is a number. A conic
5249
     gradient inside a ring is the whole drawing -- no SVG, no second glyph per
5250
     fraction, and it stays correct at any percentage the caller has. */
5251
  .issue-status__arc {
5252
    position: relative;
5253
    width: 14px;
5254
    height: 14px;
5255
    flex: none;
5256
    border: 1.5px solid currentColor;
5257
    border-radius: 50%;
5258
  }
5259
5260
  .issue-status__arc::after {
5261
    content: "";
5262
    position: absolute;
5263
    inset: 1.5px;
5264
    border-radius: 50%;
5265
    background: conic-gradient(currentColor calc(var(--issue-arc, 0) * 1%), transparent 0);
5266
  }
5267
5268
  .issue-status__label {
5269
    color: var(--text-body);
5270
    font-size: 0.8125rem;
5271
  }
5272
5273
  .issue-priority {
5274
    display: inline-flex;
5275
    align-items: center;
5276
    gap: 6px;
5277
    flex: none;
5278
    color: var(--icon-tertiary);
5279
  }
5280
5281
  .issue-priority[data-level="urgent"] {
5282
    color: var(--danger);
5283
  }
5284
5285
  .issue-priority__alarm {
5286
    width: 14px;
5287
    height: 14px;
5288
    font-size: 14px;
5289
  }
5290
5291
  /* Bars rather than five pictures: one shape read at four levels, so the
5292
     ordering is visible without learning a legend. The unlit bars stay drawn
5293
     -- removing them would make "low" and "no priority" the same silhouette. */
5294
  .issue-priority__bars {
5295
    display: inline-flex;
5296
    align-items: flex-end;
5297
    gap: 1.5px;
5298
    width: 14px;
5299
    height: 14px;
5300
    padding-block: 1px;
5301
  }
5302
5303
  .issue-priority__bar {
5304
    width: 3px;
5305
    border-radius: 1px;
5306
    background: currentColor;
5307
    opacity: 0.28;
5308
  }
5309
5310
  .issue-priority__bar:nth-child(1) {
5311
    height: 50%;
5312
  }
5313
5314
  .issue-priority__bar:nth-child(2) {
5315
    height: 75%;
5316
  }
5317
5318
  .issue-priority__bar:nth-child(3) {
5319
    height: 100%;
5320
  }
5321
5322
  /* No priority is three equal dashes at the midline: a shape of its own,
5323
     rather than the low end of the same ramp, because "nobody has decided" is
5324
     not a degree of urgency. */
5325
  .issue-priority[data-level="none"] .issue-priority__bar {
5326
    height: 2px;
5327
    opacity: 0.5;
5328
  }
5329
5330
  .issue-priority[data-level="low"] .issue-priority__bar:nth-child(1),
5331
  .issue-priority[data-level="medium"] .issue-priority__bar:nth-child(-n + 2),
5332
  .issue-priority[data-level="high"] .issue-priority__bar {
5333
    opacity: 1;
5334
  }
5335
5336
  .issue-priority__label {
5337
    color: var(--text-body);
5338
    font-size: 0.8125rem;
5339
  }
5340
5341
  .issue-label {
5342
    display: inline-flex;
5343
    align-items: center;
5344
    gap: 6px;
5345
    flex: none;
5346
    max-width: 14rem;
5347
    padding: 1px 8px;
5348
    overflow: hidden;
5349
    border: 1px solid var(--line);
5350
    border-radius: 999px;
5351
    color: var(--text-muted);
5352
    font-size: 0.75rem;
5353
    white-space: nowrap;
5354
    text-overflow: ellipsis;
5355
  }
5356
5357
  .issue-label__dot {
5358
    width: 6px;
5359
    height: 6px;
5360
    flex: none;
5361
    border-radius: 50%;
5362
    background: var(--text-dim);
5363
  }
5364
5365
  .issue-label__glyph {
5366
    width: 12px;
5367
    height: 12px;
5368
    flex: none;
5369
    font-size: 12px;
5370
    color: var(--icon-tertiary);
5371
  }
5372
5373
  .issue-label[data-tone="primary"] .issue-label__dot { background: var(--text-primary); }
5374
  .issue-label[data-tone="info"] .issue-label__dot { background: var(--info); }
5375
  .issue-label[data-tone="success"] .issue-label__dot { background: var(--success); }
5376
  .issue-label[data-tone="warning"] .issue-label__dot { background: var(--warning); }
5377
  .issue-label[data-tone="danger"] .issue-label__dot { background: var(--danger); }
5378
5379
  .assignee {
5380
    display: inline-flex;
5381
    align-items: center;
5382
    gap: 8px;
5383
    flex: none;
5384
  }
5385
5386
  .assignee__figure {
5387
    position: relative;
5388
    display: inline-flex;
5389
  }
5390
5391
  /* Unassigned is drawn, not left blank. A gap in a column of faces reads as a
5392
     failure to render rather than as an unclaimed issue. */
5393
  .assignee__empty {
5394
    display: inline-flex;
5395
    align-items: center;
5396
    justify-content: center;
5397
    width: 24px;
5398
    height: 24px;
5399
    border: 1px dashed var(--line-strong);
5400
    border-radius: 50%;
5401
    color: var(--icon-faint);
5402
    font-size: 13px;
5403
  }
5404
5405
  .assignee[data-size="sm"] .assignee__empty {
5406
    width: 20px;
5407
    height: 20px;
5408
    font-size: 11px;
5409
  }
5410
5411
  .assignee[data-size="lg"] .assignee__empty {
5412
    width: 32px;
5413
    height: 32px;
5414
    font-size: 16px;
5415
  }
5416
5417
  .assignee__presence {
5418
    position: absolute;
5419
    right: -1px;
5420
    bottom: -1px;
5421
    width: 9px;
5422
    height: 9px;
5423
    border: 2px solid var(--ink-surface);
5424
    border-radius: 50%;
5425
    background: var(--text-dim);
5426
  }
5427
5428
  .assignee__presence[data-presence="online"] { background: var(--success); }
5429
  .assignee__presence[data-presence="away"] { background: var(--warning); }
5430
5431
  .assignee__name {
5432
    color: var(--text-body);
5433
    font-size: 0.8125rem;
5434
  }
5435
5436
  /* Faces overlap until hovered, then separate. Overlapping is how a stack
5437
     says "a group" rather than "these five people"; separating on hover is how
5438
     the individuals stay reachable. */
5439
  .assignee-stack {
5440
    display: inline-flex;
5441
    align-items: center;
5442
    gap: 6px;
5443
  }
5444
5445
  .assignee-stack__faces {
5446
    display: inline-flex;
5447
  }
5448
5449
  .assignee-stack__faces .avatar {
5450
    margin-inline-start: -6px;
5451
    box-shadow: 0 0 0 2px var(--ink-surface);
5452
    transition: margin var(--motion-fast) var(--ease);
5453
  }
5454
5455
  .assignee-stack__faces .avatar:first-child {
5456
    margin-inline-start: 0;
5457
  }
5458
5459
  @media (hover: hover) {
5460
    .assignee-stack:hover .assignee-stack__faces .avatar {
5461
      margin-inline-start: 2px;
5462
    }
5463
5464
    .assignee-stack:hover .assignee-stack__faces .avatar:first-child {
5465
      margin-inline-start: 0;
5466
    }
5467
  }
5468
5469
  .assignee-stack__count {
5470
    color: var(--text-dim);
5471
    font-size: 0.75rem;
5472
    font-variant-numeric: tabular-nums;
5473
  }
5474
5475
  /* ── rows ──────────────────────────────────────────────────────────────── */
5476
5477
  .issue-row {
5478
    display: flex;
5479
    align-items: center;
5480
    gap: 10px;
5481
    min-height: 44px;
5482
    padding-inline: 16px;
5483
    border-block-end: 1px solid var(--line-faint);
5484
  }
5485
5486
  @media (hover: hover) {
5487
    .issue-row:hover {
5488
      background: var(--wash-hover);
5489
    }
5490
  }
5491
5492
  .issue-row[data-selected="true"] {
5493
    background: var(--wash-selected);
5494
  }
5495
5496
  /* The three fields a person scans a list for, in a fixed order and at a
5497
     fixed width, so they form a column down the list rather than shifting with
5498
     each title's length. */
5499
  .issue-row__scan {
5500
    display: inline-flex;
5501
    align-items: center;
5502
    gap: 8px;
5503
    flex: none;
5504
  }
5505
5506
  .issue-row__identifier {
5507
    width: 68px;
5508
    overflow: hidden;
5509
    color: var(--text-dim);
5510
    font-size: 0.75rem;
5511
    font-variant-numeric: tabular-nums;
5512
    white-space: nowrap;
5513
    text-overflow: ellipsis;
5514
  }
5515
5516
  .issue-row__title {
5517
    min-width: 0;
5518
    flex: 1;
5519
    overflow: hidden;
5520
    color: var(--text-primary);
5521
    font-size: 0.875rem;
5522
    font-weight: 500;
5523
    white-space: nowrap;
5524
    text-overflow: ellipsis;
5525
    text-decoration: none;
5526
  }
5527
5528
  @media (hover: hover) {
5529
    a.issue-row__title:hover {
5530
      text-decoration: underline;
5531
      text-underline-offset: 2px;
5532
    }
5533
  }
5534
5535
  .issue-row__trailing {
5536
    display: inline-flex;
5537
    align-items: center;
5538
    gap: 10px;
5539
    flex: none;
5540
    margin-inline-start: auto;
5541
  }
5542
5543
  .issue-row__chips {
5544
    display: none;
5545
    align-items: center;
5546
    gap: 6px;
5547
  }
5548
5549
  .issue-row__due {
5550
    color: var(--warning);
5551
    font-size: 0.75rem;
5552
  }
5553
5554
  .issue-row__date {
5555
    color: var(--text-dim);
5556
    font-size: 0.75rem;
5557
    font-variant-numeric: tabular-nums;
5558
  }
5559
5560
  /* Discretionary fields drop from the least load-bearing inwards. The scan
5561
     column and the title never drop: a row that cannot be identified is not a
5562
     narrower row, it is a broken one. */
5563
  @media (width >= 40rem) {
5564
    .issue-row {
5565
      gap: 12px;
5566
      padding-inline: 24px;
5567
    }
5568
5569
    .issue-row__chips {
5570
      display: inline-flex;
5571
    }
5572
  }
5573
5574
  .issue-card {
5575
    display: flex;
5576
    flex-direction: column;
5577
    gap: 10px;
5578
    padding: 12px;
5579
    border: 1px solid var(--line);
5580
    border-radius: var(--radius-md);
5581
    background: var(--ink-raised);
5582
    box-shadow: var(--shadow-l1);
5583
  }
5584
5585
  .issue-card__head,
5586
  .issue-card__foot {
5587
    display: flex;
5588
    align-items: center;
5589
    justify-content: space-between;
5590
    gap: 8px;
5591
  }
5592
5593
  .issue-card__scan {
5594
    display: inline-flex;
5595
    align-items: center;
5596
    gap: 8px;
5597
  }
5598
5599
  .issue-card__title {
5600
    display: -webkit-box;
5601
    -webkit-box-orient: vertical;
5602
    -webkit-line-clamp: 2;
5603
    overflow: hidden;
5604
    color: var(--text-primary);
5605
    font-size: 0.875rem;
5606
    font-weight: 500;
5607
    text-decoration: none;
5608
  }
5609
5610
  .issue-card__chips {
5611
    display: flex;
5612
    flex-wrap: wrap;
5613
    gap: 6px;
5614
  }
5615
5616
  /* ── groups and boards ─────────────────────────────────────────────────── */
5617
5618
  .issue-group__head {
5619
    position: sticky;
5620
    top: 0;
5621
    z-index: 1;
5622
    display: flex;
5623
    align-items: center;
5624
    justify-content: space-between;
5625
    gap: 8px;
5626
    height: 40px;
5627
    padding-inline: 24px;
5628
    border-block-end: 1px solid var(--line-faint);
5629
    /* The wash is mixed over the canvas rather than laid on it with alpha, so
5630
       rows scrolling underneath a sticky header are covered instead of showing
5631
       through it. */
5632
    background: color-mix(in oklab, var(--issue-tint) 7%, var(--ink-surface));
5633
  }
5634
5635
  .issue-group__name {
5636
    display: inline-flex;
5637
    align-items: center;
5638
    gap: 8px;
5639
    min-width: 0;
5640
  }
5641
5642
  .issue-group__label {
5643
    overflow: hidden;
5644
    color: var(--text-primary);
5645
    font-size: 0.8125rem;
5646
    font-weight: 600;
5647
    white-space: nowrap;
5648
    text-overflow: ellipsis;
5649
  }
5650
5651
  .issue-group__count {
5652
    color: var(--text-dim);
5653
    font-size: 0.8125rem;
5654
    font-variant-numeric: tabular-nums;
5655
  }
5656
5657
  .issue-group__actions {
5658
    display: inline-flex;
5659
    align-items: center;
5660
    gap: 4px;
5661
    flex: none;
5662
  }
5663
5664
  .issue-group[data-layout="board"] {
5665
    display: flex;
5666
    flex-direction: column;
5667
    width: 320px;
5668
    max-height: 100%;
5669
    flex: none;
5670
    overflow: hidden;
5671
    border: 1px solid var(--line);
5672
    border-radius: var(--radius-lg);
5673
    background: var(--ink-void);
5674
  }
5675
5676
  .issue-group[data-layout="board"] .issue-group__head {
5677
    padding-inline: 12px;
5678
  }
5679
5680
  .issue-group[data-layout="board"] .issue-group__body {
5681
    display: flex;
5682
    flex-direction: column;
5683
    gap: 8px;
5684
    padding: 8px;
5685
    overflow-y: auto;
5686
  }
5687
5688
  /* Each column scrolls on its own. Scrolling the board as one surface pushes
5689
     every header off the top to read the bottom of one column. */
5690
  .issue-board {
5691
    display: flex;
5692
    gap: 12px;
5693
    align-items: stretch;
5694
    overflow-x: auto;
5695
    padding-block-end: 4px;
5696
  }
5697
5698
  /* ── filters ───────────────────────────────────────────────────────────── */
5699
5700
  .filter-bar {
5701
    display: flex;
5702
    align-items: flex-start;
5703
    justify-content: space-between;
5704
    gap: 8px;
5705
    padding: 8px 24px;
5706
    border-block-end: 1px solid var(--line);
5707
  }
5708
5709
  .filter-bar__chips {
5710
    display: flex;
5711
    flex-wrap: wrap;
5712
    gap: 8px;
5713
  }
5714
5715
  .filter-bar__clear {
5716
    flex: none;
5717
    padding: 4px 8px;
5718
    border: 0;
5719
    border-radius: var(--radius-sm);
5720
    background: transparent;
5721
    color: var(--text-muted);
5722
    font-size: 0.75rem;
5723
    cursor: pointer;
5724
    transition: color var(--motion-fast) var(--ease);
5725
  }
5726
5727
  @media (hover: hover) {
5728
    .filter-bar__clear:hover {
5729
      color: var(--text-primary);
5730
    }
5731
  }
5732
5733
  /* Three segments divided by hairlines rather than three separate controls:
5734
     the chip is one filter, and splitting it visually is what says the
5735
     operator can change without the filter being rebuilt. */
5736
  .filter-chip {
5737
    display: inline-flex;
5738
    align-items: center;
5739
    height: 28px;
5740
    overflow: hidden;
5741
    border: 1px solid var(--line);
5742
    border-radius: 999px;
5743
    background: var(--ink-raised);
5744
    font-size: 0.75rem;
5745
  }
5746
5747
  .filter-chip > * {
5748
    display: inline-flex;
5749
    align-items: center;
5750
    gap: 6px;
5751
    height: 100%;
5752
    padding-inline: 10px;
5753
  }
5754
5755
  .filter-chip > * + * {
5756
    border-inline-start: 1px solid var(--line);
5757
  }
5758
5759
  .filter-chip__subject {
5760
    color: var(--text-body);
5761
    font-weight: 500;
5762
  }
5763
5764
  .filter-chip__operator {
5765
    color: var(--text-dim);
5766
  }
5767
5768
  .filter-chip__value {
5769
    color: var(--text-primary);
5770
  }
5771
5772
  .filter-chip__remove {
5773
    border: 0;
5774
    background: transparent;
5775
    color: var(--icon-tertiary);
5776
    cursor: pointer;
5777
    transition:
5778
      color var(--motion-fast) var(--ease),
5779
      background var(--motion-fast) var(--ease);
5780
  }
5781
5782
  @media (hover: hover) {
5783
    .filter-chip__remove:hover {
5784
      background: var(--wash-hover);
5785
      color: var(--text-primary);
5786
    }
5787
  }
5788
5789
  /* ── headers ───────────────────────────────────────────────────────────── */
5790
5791
  .view-tabs {
5792
    display: inline-flex;
5793
    align-items: center;
5794
    gap: 4px;
5795
  }
5796
5797
  .view-tabs__tab {
5798
    display: inline-flex;
5799
    align-items: center;
5800
    height: 28px;
5801
    padding-inline: 10px;
5802
    border: 1px solid transparent;
5803
    border-radius: 999px;
5804
    color: var(--text-muted);
5805
    font-size: 0.75rem;
5806
    font-weight: 500;
5807
    text-decoration: none;
5808
    transition:
5809
      color var(--motion-fast) var(--ease),
5810
      background var(--motion-fast) var(--ease),
5811
      border-color var(--motion-fast) var(--ease);
5812
  }
5813
5814
  @media (hover: hover) {
5815
    .view-tabs__tab:hover {
5816
      background: var(--wash-hover);
5817
      color: var(--text-primary);
5818
    }
5819
  }
5820
5821
  .view-tabs__tab[aria-current="page"] {
5822
    border-color: var(--line);
5823
    background: var(--wash-selected);
5824
    color: var(--text-primary);
5825
  }
5826
5827
  .issue-toolbar {
5828
    display: flex;
5829
    align-items: center;
5830
    justify-content: space-between;
5831
    gap: 12px;
5832
    min-height: 40px;
5833
    padding: 4px 24px;
5834
    border-block-end: 1px solid var(--line);
5835
  }
5836
5837
  .issue-toolbar__leading,
5838
  .issue-toolbar__actions {
5839
    display: flex;
5840
    align-items: center;
5841
    gap: 8px;
5842
    min-width: 0;
5843
  }
5844
5845
  /* ── command palette ───────────────────────────────────────────────────── */
5846
5847
  /* A native dialog: the browser owns the focus trap, the backdrop, Escape,
5848
     and inertness of the page behind. Reimplementing those is where hand-built
5849
     palettes usually go wrong. */
5850
  .command-palette {
5851
    width: min(640px, calc(100vw - 32px));
5852
    max-width: none;
5853
    margin-block-start: 12vh;
5854
    padding: 0;
5855
    border: 1px solid var(--line-strong);
5856
    border-radius: var(--radius-xl);
5857
    background: var(--ink-surface);
5858
    color: var(--text-body);
5859
    box-shadow: var(--shadow-l3);
5860
  }
5861
5862
  .command-palette::backdrop {
5863
    background: color-mix(in oklab, #08090a 55%, transparent);
5864
  }
5865
5866
  .command-palette__panel {
5867
    display: flex;
5868
    flex-direction: column;
5869
    max-height: min(70vh, 32rem);
5870
  }
5871
5872
  .command-palette__context {
5873
    margin: 12px 12px 0;
5874
    padding: 4px 8px;
5875
    overflow: hidden;
5876
    border: 1px solid var(--line);
5877
    border-radius: var(--radius-sm);
5878
    background: var(--wash-hover);
5879
    color: var(--text-muted);
5880
    font-size: 0.75rem;
5881
    white-space: nowrap;
5882
    text-overflow: ellipsis;
5883
  }
5884
5885
  .command-palette__search {
5886
    display: flex;
5887
    align-items: center;
5888
    gap: 10px;
5889
    padding: 14px 16px;
5890
    border-block-end: 1px solid var(--line);
5891
  }
5892
5893
  .command-palette__glyph {
5894
    flex: none;
5895
    color: var(--icon-tertiary);
5896
    font-size: 16px;
5897
  }
5898
5899
  .command-palette__input {
5900
    width: 100%;
5901
    border: 0;
5902
    background: transparent;
5903
    color: var(--text-primary);
5904
    font-size: 0.9375rem;
5905
    outline: none;
5906
  }
5907
5908
  .command-palette__input::placeholder {
5909
    color: var(--text-dim);
5910
  }
5911
5912
  .command-palette__list {
5913
    padding: 6px;
5914
    overflow-y: auto;
5915
  }
5916
5917
  .command-palette__empty {
5918
    padding: 24px 12px;
5919
    color: var(--text-dim);
5920
    font-size: 0.8125rem;
5921
    text-align: center;
5922
  }
5923
5924
  .command-group + .command-group {
5925
    margin-block-start: 4px;
5926
    padding-block-start: 4px;
5927
    border-block-start: 1px solid var(--line-faint);
5928
  }
5929
5930
  .command-group__heading {
5931
    padding: 6px 10px;
5932
    color: var(--text-dim);
5933
    font-size: 0.6875rem;
5934
    font-weight: 600;
5935
    letter-spacing: 0.04em;
5936
    text-transform: uppercase;
5937
  }
5938
5939
  .command-item {
5940
    display: flex;
5941
    align-items: center;
5942
    gap: 10px;
5943
    width: 100%;
5944
    padding: 8px 10px;
5945
    border: 0;
5946
    border-radius: var(--radius-sm);
5947
    background: transparent;
5948
    color: var(--text-body);
5949
    font-size: 0.875rem;
5950
    text-align: start;
5951
    cursor: pointer;
5952
  }
5953
5954
  /* Keyboard selection and pointer hover paint the same row the same way. Two
5955
     different highlights in one list means the reader has to work out which
5956
     one Enter will take. */
5957
  .command-item[data-active],
5958
  .command-item:focus-visible {
5959
    background: var(--wash-selected);
5960
    color: var(--text-primary);
5961
    outline: none;
5962
  }
5963
5964
  @media (hover: hover) {
5965
    .command-item:hover {
5966
      background: var(--wash-hover);
5967
      color: var(--text-primary);
5968
    }
5969
  }
5970
5971
  .command-item__glyph {
5972
    flex: none;
5973
    color: var(--icon-tertiary);
5974
    font-size: 16px;
5975
  }
5976
5977
  .command-item__label {
5978
    flex: 1;
5979
    min-width: 0;
5980
    overflow: hidden;
5981
    white-space: nowrap;
5982
    text-overflow: ellipsis;
5983
  }
5984
5985
  .command-item__keys {
5986
    display: inline-flex;
5987
    align-items: center;
5988
    gap: 4px;
5989
    flex: none;
5990
  }
5991
5992
  /* ── project, team, and member rows ────────────────────────────────────── */
5993
5994
  .project-row,
5995
  .team-row,
5996
  .member-row {
5997
    display: flex;
5998
    align-items: center;
5999
    gap: 12px;
6000
    min-height: 48px;
6001
    padding: 8px 24px;
6002
    border-block-end: 1px solid var(--line-faint);
6003
    font-size: 0.8125rem;
6004
  }
6005
6006
  @media (hover: hover) {
6007
    .project-row:hover,
6008
    .team-row:hover,
6009
    .member-row:hover {
6010
      background: var(--wash-hover);
6011
    }
6012
  }
6013
6014
  .project-row__name,
6015
  .team-row__name {
6016
    display: inline-flex;
6017
    align-items: center;
6018
    gap: 8px;
6019
    flex: 1;
6020
    min-width: 0;
6021
  }
6022
6023
  .project-row__icon,
6024
  .team-row__glyph {
6025
    display: inline-flex;
6026
    align-items: center;
6027
    justify-content: center;
6028
    width: 24px;
6029
    height: 24px;
6030
    flex: none;
6031
    border-radius: var(--radius-sm);
6032
    background: var(--wash-hover);
6033
    color: var(--icon-secondary);
6034
    font-size: 13px;
6035
  }
6036
6037
  .project-row__link,
6038
  .team-row__link,
6039
  .member-row__name {
6040
    overflow: hidden;
6041
    color: var(--text-primary);
6042
    font-weight: 500;
6043
    white-space: nowrap;
6044
    text-overflow: ellipsis;
6045
    text-decoration: none;
6046
  }
6047
6048
  @media (hover: hover) {
6049
    a.project-row__link:hover,
6050
    a.team-row__link:hover,
6051
    a.member-row__name:hover {
6052
      text-decoration: underline;
6053
      text-underline-offset: 2px;
6054
    }
6055
  }
6056
6057
  .team-row__identifier {
6058
    flex: none;
6059
    color: var(--text-dim);
6060
    font-size: 0.6875rem;
6061
    letter-spacing: 0.06em;
6062
    text-transform: uppercase;
6063
  }
6064
6065
  /* Health is a word. "At risk" and "off track" are different claims, and no
6066
     reader should have to learn which shade of amber means which. */
6067
  .project-row__health {
6068
    flex: none;
6069
    padding: 2px 8px;
6070
    border: 1px solid var(--line);
6071
    border-radius: var(--radius-sm);
6072
    color: var(--text-muted);
6073
    font-size: 0.6875rem;
6074
  }
6075
6076
  .project-row__health[data-health="on_track"] {
6077
    border-color: color-mix(in oklab, var(--success) 40%, transparent);
6078
    color: var(--success);
6079
  }
6080
6081
  .project-row__health[data-health="at_risk"] {
6082
    border-color: color-mix(in oklab, var(--warning) 40%, transparent);
6083
    color: var(--warning);
6084
  }
6085
6086
  .project-row__health[data-health="off_track"] {
6087
    border-color: color-mix(in oklab, var(--danger) 40%, transparent);
6088
    color: var(--danger);
6089
  }
6090
6091
  .project-row__priority,
6092
  .project-row__lead,
6093
  .project-row__target,
6094
  .project-row__issues,
6095
  .project-row__status,
6096
  .team-row__membership,
6097
  .team-row__members,
6098
  .team-row__metric,
6099
  .member-row__role,
6100
  .member-row__joined,
6101
  .member-row__teams {
6102
    display: none;
6103
    align-items: center;
6104
    gap: 6px;
6105
    flex: none;
6106
    color: var(--text-muted);
6107
    font-size: 0.75rem;
6108
  }
6109
6110
  .project-row__status,
6111
  .member-row__role {
6112
    display: inline-flex;
6113
  }
6114
6115
  .project-row__percent {
6116
    color: var(--text-body);
6117
    font-variant-numeric: tabular-nums;
6118
  }
6119
6120
  .team-row__joined {
6121
    display: inline-flex;
6122
    align-items: center;
6123
    gap: 4px;
6124
    padding: 2px 8px;
6125
    border: 1px solid var(--line);
6126
    border-radius: var(--radius-sm);
6127
  }
6128
6129
  .member-row__identity {
6130
    display: inline-flex;
6131
    align-items: center;
6132
    gap: 10px;
6133
    flex: 1;
6134
    min-width: 0;
6135
  }
6136
6137
  .member-row__names {
6138
    display: flex;
6139
    flex-direction: column;
6140
    min-width: 0;
6141
  }
6142
6143
  .member-row__handle {
6144
    overflow: hidden;
6145
    color: var(--text-dim);
6146
    font-size: 0.75rem;
6147
    white-space: nowrap;
6148
    text-overflow: ellipsis;
6149
  }
6150
6151
  .member-row__role {
6152
    padding: 2px 8px;
6153
    border: 1px solid var(--line);
6154
    border-radius: var(--radius-sm);
6155
  }
6156
6157
  .member-row__role[data-tone="accent"] {
6158
    border-color: color-mix(in oklab, var(--info) 40%, transparent);
6159
    color: var(--info);
6160
  }
6161
6162
  /* Fixed widths only once the columns exist. Below this the rows are name and
6163
     status, which is what a narrow screen can carry honestly. */
6164
  @media (width >= 48rem) {
6165
    .project-row__priority { display: inline-flex; width: 40px; }
6166
    .project-row__lead { display: inline-flex; width: 40px; }
6167
    .project-row__status { width: 96px; }
6168
    .team-row__members { display: inline-flex; width: 140px; }
6169
    .team-row__metric { display: inline-flex; width: 56px; }
6170
    .member-row__role { width: 96px; }
6171
    .member-row__teams { display: inline-flex; width: 160px; min-width: 0; }
6172
  }
6173
6174
  @media (width >= 64rem) {
6175
    .project-row__health { min-width: 96px; text-align: center; }
6176
    .project-row__target { display: inline-flex; width: 96px; }
6177
    .project-row__issues { display: inline-flex; width: 48px; }
6178
    .team-row__membership { display: inline-flex; width: 96px; }
6179
    .member-row__joined { display: inline-flex; width: 88px; }
6180
  }
6181
}
docs/2026-08-20-circle-ui-port.md added +255

@@ -0,0 +1,255 @@

1
# Porting Circle's issue surfaces
2
3
*2026-08-20*
4
5
`OpenAgentsWeb.UI.Circle` is adapted from [Circle][circle], MIT-licensed,
6
© 2025 lndev-ui. This document records what was taken, what was not, and why —
7
so a later reader can tell which decisions are inherited and which are ours.
8
**The work list at the bottom is the tracker for this effort**: update it in the
9
same change that lands the work, not afterwards.
10
11
## What Circle is
12
13
Circle is a Linear-shaped issue, project, and team tracker: Next.js App Router,
14
TypeScript, Tailwind, shadcn/ui, Zustand for state, `nuqs` for URL state,
15
`motion/react` for layout animation, and `react-dnd` for the board. It ships no
16
backend — every surface reads a `mock-data` module — which makes it unusually
17
good source material, because the data shapes are stated plainly instead of
18
being inferred from an API.
19
20
This assessment uses revision `c60371c`, the tip of the local Circle checkout,
21
whose newest commit adds the ⌘K command palette. The licence is `LICENSE.md` at
22
the repository root: MIT, requiring the copyright notice be retained. Since this
23
is adaptation rather than copying, attribution lives in the module doc, in the
24
stylesheet section header, and here.
25
26
## What "porting" means here
27
28
Almost none of the code survives. Every component in `components/common/issues`
29
is a client component reading a Zustand store, and the interesting ones are
30
wrapped in Radix context menus, popovers, and dialogs. None of that moves to
31
HEEx.
32
33
What carried over is the **information design**, which is the expensive part and
34
transfers intact:
35
36
- what an issue row holds and in what order — that priority, identifier, and
37
  status form a fixed-width scan column on the leading edge, and that everything
38
  discretionary collects on the trailing edge where width can drop it;
39
- that the status glyph is a **filled arc**, not a coloured dot, so a list says
40
  how far along each piece of work is;
41
- that priority is one shape read at four levels, with urgent deliberately
42
  outside the ramp;
43
- that a group header carries a wash mixed from its own status, which is the
44
  only thing that marks a boundary once the header has scrolled past its rows;
45
- that a filter reads as subject / operator / value, with each segment its own
46
  control;
47
- that a card is not a row turned sideways.
48
49
## Deliberate departures
50
51
### Tokens, not a second palette
52
53
Circle assigns a hand-picked hex value to each of thirteen statuses
54
(`#facc15`, `#5e6ad2`, `#26b5ce`, …) and to each of eleven labels. Those
55
colours are Linear's. Adopting them would put a second colour system beside the
56
one every other surface uses, and the tracker would stop looking like the
57
product it is part of.
58
59
Colour here is assigned per status **category** — six of them — off the same
60
token ladder `OpenAgentsWeb.UI.status_indicator/1` already uses:
61
62
| Category | Token | Why |
63
| --- | --- | --- |
64
| `:triage` | `--warning` | awaiting a decision |
65
| `:backlog` | `--text-dim` | resting, not yet real |
66
| `:unstarted` | `--text-muted` | resting, real |
67
| `:started` | `--info` | activity, as everywhere else in the product |
68
| `:completed` | `--success` | done |
69
| `:canceled` | `--text-dim` | resting, closed |
70
71
Six colours say less than thirteen. That is the cost, and it is stated in the
72
component docs rather than hidden: `In progress`, `In review`, and `Blocked` are
73
all one blue here, and only the word tells them apart. In exchange the same
74
component is correct in both themes with no second set of declarations, and a
75
status glyph never disagrees with the status dot in the sidebar beside it.
76
77
Labels take the same treatment through a `tone` attribute over the same six
78
values. Because six tones cannot distinguish eleven labels, the label's **word**
79
is not optional in this port — the dot is a grouping hint, not the identity.
80
81
### No JavaScript, except where the keyboard needs it
82
83
Rows, cards, groups, boards, filters, headers, and every project, team, and
84
member row are server-rendered and carry no script.
85
86
The command palette is the one exception, and it is a real one: `⌘K` is a
87
document-level binding, incremental filtering means hiding rows as characters
88
arrive, and arrow-key selection has to survive both. The palette carries one
89
colocated hook doing exactly those things, following the pattern already
90
established by `copy_button/1` and `github_login/1`. It is built on native
91
`<dialog>`, so the browser supplies the focus trap, the backdrop, `Escape`, and
92
inertness of the page behind — the parts hand-built palettes usually get wrong.
93
Every command is a real `<button>` and does its job without the hook.
94
95
### No drag-and-drop
96
97
The source's board is `react-dnd`: a drag layer, a custom preview, per-column
98
drop targets, and a full-column overlay reading "Drop to update status". About
99
250 lines across two files, and the behaviour it buys is a status change.
100
101
`issue_board/1` and `issue_group/1` port the **layout** — columns side by side,
102
each scrolling on its own so a long backlog does not push the other headers off
103
the top. Changing an issue's status stays a control, which also means it works
104
on a touch screen and from a keyboard, neither of which the source's board does.
105
106
### State is the caller's
107
108
Circle keeps grouping, ordering, filters, search, display properties, and drag
109
results in eight Zustand stores, and every component reads them directly. That
110
is why `IssueLine` cannot be rendered anywhere the store is not.
111
112
None of these components own state. They take what to draw and emit
113
`Phoenix.LiveView.JS` commands the caller supplies. That is what makes the same
114
`issue_row/1` usable in a list, in a search result, and in a group, which is
115
three call sites in the source with three different wrappers.
116
117
## Icons
118
119
Every glyph resolves to the vendored Apps SDK set through
120
`OpenAgentsWeb.UI.icon/1`. **Nothing was vendored for this port.** The mapping
121
for the status shapes:
122
123
| Circle | Ours | Note |
124
| --- | --- | --- |
125
| triage disc with opposing arrows | `compare-arrows` | the same picture |
126
| dashed gear (backlog, idea) | `circle-dashed` | see below |
127
| empty ring (todo) | `empty-circle` | |
128
| ring with filled arc (in progress) | *drawn in CSS* | see below |
129
| filled tick (done, shipped) | `check-circle-filled` | |
130
| filled cross (cancelled) | `x-circle-filled` | |
131
| filled slash-equal (duplicate) | `x-circle-filled` | folded into cancelled |
132
133
Two of these need explaining.
134
135
**The dashed gear has no equivalent** and none was vendored. It is Linear's mark
136
for "this is not real work yet", and `circle-dashed` carries the same reading
137
with a shape already in the set. Vendoring a glyph for one status would put a
138
Linear-specific mark in a general icon set.
139
140
**The arc is not an icon.** Its fill is a number — the fraction of a project's
141
issues that are finished — so it cannot come from a fixed set. It is drawn in
142
CSS as a `conic-gradient` inside a ring, which is a handful of declarations, no
143
SVG, and correct at any percentage. The priority bars are drawn the same way for
144
the same reason: they are one shape read at four levels, not four pictures, and
145
lighting them from a `data-level` attribute keeps the ordering in one place.
146
147
Neither is inline SVG in a template, which `docs/ICONS.md` rules out. Both are
148
CSS-drawn indicators of the kind `status_indicator/1` already establishes.
149
150
## What we are not porting
151
152
Stated so nobody re-litigates it later:
153
154
- **`components/data-table-filter`** — about 2,000 lines implementing a typed
155
  filter engine (columns, operators, faceted value counts, i18n, URL
156
  serialisation) over TanStack Table. That is a query builder, and it belongs on
157
  the server here. `filter_chip/1` and `filter_bar/1` port the **row it
158
  renders**, which is the part a reader sees.
159
- **The insights panel.** A 420-pixel side panel of charts computed from the
160
  visible issues. Worth revisiting when there is a real corpus to compute from;
161
  charting invented numbers demonstrates nothing.
162
- **`motion/react` layout animation.** The source animates a row into a card
163
  when the view switches between list and board, via shared `layoutId`. It is
164
  genuinely nice and it needs a JavaScript animation library plus DOM
165
  measurement. Not for a first port.
166
- **Context menus.** Right-click on a row opens a twelve-item Radix menu. Every
167
  action in it also exists in the command palette, which is reachable from a
168
  keyboard.
169
- **The create-issue modal, cycles, initiatives, inbox, reviews, and the agent
170
  surface.** Out of scope: they are product decisions, not components, and this
171
  application has not made them.
172
- **`components/ui/*`** — shadcn/ui primitives. Button, badge, avatar, input,
173
  table, and the rest already exist in `OpenAgentsWeb.UI`, and adding a second
174
  set is precisely what `AGENTS.md` forbids.
175
176
## What this port does not yet do
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
- **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.
186
- **The board is display-only,** as above.
187
- **The demos hold invented data.** Six issues, eight people, four projects.
188
  They span every status category, every priority, assigned and unassigned,
189
  because a demo that shows one happy row hides the cases the component exists
190
  to keep legible.
191
192
## Work list
193
194
Status is one of **done**, **next**, or **planned**.
195
196
### 1. Indicators — **done**
197
198
`issue_status/1`, `issue_priority/1`, `issue_label/1`, `assignee/1`,
199
`assignee_stack/1`. The vocabulary everything else is built from. Catalogued at
200
`/components/issue-status`, `/components/issue-priority`,
201
`/components/issue-label`, `/components/assignee`,
202
`/components/assignee-stack`.
203
204
### 2. Rows and collections — **done**
205
206
`issue_row/1`, `issue_card/1`, `issue_group/1`, `issue_board/1`. Catalogued at
207
`/components/issue-row`, `/components/issue-card`, `/components/issue-group`,
208
`/components/issue-board`.
209
210
### 3. Headers and filters — **done**
211
212
`view_tabs/1`, `issue_toolbar/1`, `filter_chip/1`, `filter_bar/1`. Catalogued at
213
`/components/view-tabs`, `/components/issue-toolbar`, `/components/filter-chip`,
214
`/components/filter-bar`.
215
216
### 4. Command palette — **done**
217
218
`command_palette/1`, `command_group/1`, `command_item/1`. Catalogued at
219
`/components/command-palette`, `/components/command-group`,
220
`/components/command-item`.
221
222
### 5. Project, team, and member rows — **done**
223
224
`project_row/1`, `team_row/1`, `member_row/1`. Catalogued at
225
`/components/project-row`, `/components/team-row`, `/components/member-row`.
226
227
### 6. Compose the issue LiveViews from these — **next**
228
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.
234
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.
240
241
### 7. Grouping and filtering on the server — **planned**
242
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.
247
248
### 8. Display options — **planned**
249
250
Circle's `DisplayOptions` popover switches list and board, picks the grouping
251
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.
254
255
[circle]: https://github.com/ln-dev7/circle
lib/openagents_web/component_catalog.ex modified +140 -1

@@ -391,6 +391,144 @@ defmodule OpenAgentsWeb.ComponentCatalog do

391 391
        }
392 392
      ]
393 393
    },
394
    %{
395
      title: "Issues",
396
      items: [
397
        %{
398
          slug: "issue-status",
399
          title: "Issue status",
400
          icon: "circle",
401
          source: "OpenAgentsWeb.UI.Circle.issue_status/1",
402
          summary: "Six category shapes, one of them a filled arc read from a number."
403
        },
404
        %{
405
          slug: "issue-priority",
406
          title: "Issue priority",
407
          icon: "bar-chart",
408
          source: "OpenAgentsWeb.UI.Circle.issue_priority/1",
409
          summary: "Four ascending bars, plus an alarm that breaks the ramp on purpose."
410
        },
411
        %{
412
          slug: "issue-label",
413
          title: "Issue label",
414
          icon: "tag",
415
          source: "OpenAgentsWeb.UI.Circle.issue_label/1",
416
          summary: "A dot and a word, toned from the ladder rather than a per-label colour."
417
        },
418
        %{
419
          slug: "assignee",
420
          title: "Assignee",
421
          icon: "user",
422
          source: "OpenAgentsWeb.UI.Circle.assignee/1",
423
          summary: "Who owns an issue, including the drawn state for nobody."
424
        },
425
        %{
426
          slug: "assignee-stack",
427
          title: "Assignee stack",
428
          icon: "group",
429
          source: "OpenAgentsWeb.UI.Circle.assignee_stack/1",
430
          summary: "Overlapping faces that separate on hover, with a count for the rest."
431
        },
432
        %{
433
          slug: "issue-row",
434
          title: "Issue row",
435
          icon: "menu",
436
          source: "OpenAgentsWeb.UI.Circle.issue_row/1",
437
          summary: "The shape a tracker is mostly made of: scan column, title, trailing facts."
438
        },
439
        %{
440
          slug: "issue-card",
441
          title: "Issue card",
442
          icon: "square-text",
443
          source: "OpenAgentsWeb.UI.Circle.issue_card/1",
444
          summary: "The same issue with width and no neighbours, for a board column."
445
        },
446
        %{
447
          slug: "issue-group",
448
          title: "Issue group",
449
          icon: "stack",
450
          source: "OpenAgentsWeb.UI.Circle.issue_group/1",
451
          summary: "A named run of issues under a sticky header washed by its own status."
452
        },
453
        %{
454
          slug: "issue-board",
455
          title: "Issue board",
456
          icon: "grid",
457
          source: "OpenAgentsWeb.UI.Circle.issue_board/1",
458
          summary: "Columns side by side, each scrolling on its own."
459
        },
460
        %{
461
          slug: "filter-chip",
462
          title: "Filter chip",
463
          icon: "filter",
464
          source: "OpenAgentsWeb.UI.Circle.filter_chip/1",
465
          summary: "One filter read as subject, operator, value, each its own segment."
466
        },
467
        %{
468
          slug: "filter-bar",
469
          title: "Filter bar",
470
          icon: "filter",
471
          source: "OpenAgentsWeb.UI.Circle.filter_bar/1",
472
          summary: "The applied filters, somewhere to add one, and a way to drop them all."
473
        },
474
        %{
475
          slug: "view-tabs",
476
          title: "View tabs",
477
          icon: "category",
478
          source: "OpenAgentsWeb.UI.Circle.view_tabs/1",
479
          summary: "Saved views as pills; the current one carries aria-current, not just colour."
480
        },
481
        %{
482
          slug: "issue-toolbar",
483
          title: "Issue toolbar",
484
          icon: "settings-slider",
485
          source: "OpenAgentsWeb.UI.Circle.issue_toolbar/1",
486
          summary: "What you are looking at on the left, what you can do to it on the right."
487
        },
488
        %{
489
          slug: "command-palette",
490
          title: "Command palette",
491
          icon: "search",
492
          source: "OpenAgentsWeb.UI.Circle.command_palette/1",
493
          summary: "A native dialog on ⌘K, filtered as you type."
494
        },
495
        %{
496
          slug: "command-group",
497
          title: "Command group",
498
          icon: "folders",
499
          source: "OpenAgentsWeb.UI.Circle.command_group/1",
500
          summary: "A titled run of commands that hides itself when filtering empties it."
501
        },
502
        %{
503
          slug: "command-item",
504
          title: "Command item",
505
          icon: "keyboard-shortcut",
506
          source: "OpenAgentsWeb.UI.Circle.command_item/1",
507
          summary: "A glyph, a name, and the keys that reach it without the palette."
508
        },
509
        %{
510
          slug: "project-row",
511
          title: "Project row",
512
          icon: "cube",
513
          source: "OpenAgentsWeb.UI.Circle.project_row/1",
514
          summary: "Name on the left, everything measurable in columns that line up."
515
        },
516
        %{
517
          slug: "team-row",
518
          title: "Team row",
519
          icon: "members",
520
          source: "OpenAgentsWeb.UI.Circle.team_row/1",
521
          summary: "Identity, membership, and what a team owns."
522
        },
523
        %{
524
          slug: "member-row",
525
          title: "Member row",
526
          icon: "avatar-profile",
527
          source: "OpenAgentsWeb.UI.Circle.member_row/1",
528
          summary: "Display name and handle together, with role, tenure, and teams."
529
        }
530
      ]
531
    },
394 532
    %{
395 533
      title: "Forge",
396 534
      items: [

@@ -427,7 +565,8 @@ defmodule OpenAgentsWeb.ComponentCatalog do

427 565
      # is the host element and is demoed through the components that use it.
428 566
      OpenAgentsWeb.UI.Graph => [:graph_defs, :graph_surface],
429 567
      OpenAgentsWeb.Components.RepoHeader => [],
430
      OpenAgentsWeb.UI.Landing => []
568
      OpenAgentsWeb.UI.Landing => [],
569
      OpenAgentsWeb.UI.Circle => []
431 570
    }
432 571
  end
433 572
end
lib/openagents_web/components/circle.ex added +914

@@ -0,0 +1,914 @@

1
defmodule OpenAgentsWeb.UI.Circle do
2
  @moduledoc """
3
  Issue, project, and team surfaces: the shapes a tracker is built from.
4
5
  Adapted from Circle (MIT, © 2025 lndev-ui), a Linear-shaped issue tracker
6
  built with Next.js, Tailwind, shadcn/ui, Zustand, `motion/react`, and
7
  `react-dnd`. Nothing is copied. Every source component is a client component
8
  reading a Zustand store, and the interesting ones are wrapped in Radix
9
  primitives; none of that survives the move to HEEx. What carried over is the
10
  **information design**: what an issue row holds and in what order, that the
11
  status glyph is a filled arc rather than a coloured dot, that a group header
12
  is tinted by its own status at a fraction of its strength, that a filter
13
  reads as subject / operator / value with the value removable on its own. See
14
  `docs/2026-08-20-circle-ui-port.md`.
15
16
  Three departures from the source are deliberate:
17
18
    * **Tokens, not a second palette.** Circle assigns a hand-picked hex value
19
      to each of thirteen statuses and eleven labels. Those colours belong to
20
      Linear, not to this product, and adopting them would put a second colour
21
      system beside the one every other surface uses. Colour here is assigned
22
      per status *category* — six of them — off the same token ladder as
23
      `OpenAgentsWeb.UI.status_indicator/1`, so activity is `--info`,
24
      completion is `--success`, and anything asking for attention is
25
      `--warning`. Six colours say less than thirteen; they also stay true in
26
      both themes and never disagree with the rest of the interface.
27
28
    * **No JavaScript except where the keyboard needs it.** Rows, groups,
29
      boards, filters, and headers are server-rendered and static. The command
30
      palette is the exception: a `⌘K` binding and incremental filtering
31
      cannot be expressed in markup, so it carries one colocated hook.
32
33
    * **State is the caller's.** The source keeps grouping, filters, search,
34
      and drag results in client stores. These components take what to draw and
35
      emit `Phoenix.LiveView.JS` commands the caller supplies; none of them own
36
      state. That is what makes the same row usable in a list, in a board, and
37
      in a search result.
38
39
  Every component takes plain maps and atoms rather than structs, so a surface
40
  can render from an Ecto schema, a map from an API, or a literal in a test
41
  without a conversion layer.
42
  """
43
44
  use Phoenix.Component
45
46
  alias OpenAgentsWeb.UI
47
  alias Phoenix.LiveView.JS
48
49
  @categories [:triage, :backlog, :unstarted, :started, :completed, :canceled]
50
  @priorities [:none, :low, :medium, :high, :urgent]
51
  @tones [:neutral, :primary, :info, :success, :warning, :danger]
52
  @presences [:none, :online, :away, :offline]
53
54
  @doc """
55
  The state of one issue, as a glyph and optionally a word.
56
57
  The source draws six shapes: a triage disc, a dashed gear for backlog, an
58
  empty ring, a ring with a filled arc, a filled tick, and a filled cross. Five
59
  of those already exist in the vendored icon set. The sixth — the arc — is the
60
  only one that reads a number, so it is drawn in CSS from `progress` rather
61
  than picked from a fixed set of fractions. A ring that is a quarter full is
62
  the one thing in a Linear list that says how far along the work is, and an
63
  icon set cannot carry it.
64
65
  Colour comes from the category, never from the individual status: `:started`
66
  is `--info` because it is activity, `:completed` is `--success`, `:triage` is
67
  `--warning` because it is asking for a decision, and the two resting states
68
  are grey. This is the same vocabulary `status_indicator/1` uses.
69
70
  The glyph announces itself unless `show_label` puts the word beside it, in
71
  which case announcing both says the state twice.
72
  """
73
  attr :category, :atom, values: @categories, required: true
74
  attr :label, :string, required: true, doc: "the status's own name, such as `In review`"
75
76
  attr :progress, :integer,
77
    default: nil,
78
    doc: "0-100, drawn as a filled arc; only meaningful for `:started`"
79
80
  attr :show_label, :boolean, default: false
81
  attr :class, :any, default: nil
82
  attr :rest, :global
83
84
  def issue_status(assigns) do
85
    assigns = assign(assigns, :arc, clamp(assigns.progress))
86
87
    ~H"""
88
    <span class={["issue-status", @class]} data-category={@category} {@rest}>
89
      <span
90
        :if={@category == :started}
91
        class="issue-status__arc"
92
        style={"--issue-arc: #{@arc}"}
93
        role={if(!@show_label, do: "img")}
94
        aria-label={if(!@show_label, do: @label)}
95
        aria-hidden={if(@show_label, do: "true")}
96
      />
97
      <UI.icon
98
        :if={@category != :started}
99
        name={category_icon(@category)}
100
        label={if(!@show_label, do: @label)}
101
        class="issue-status__glyph"
102
      />
103
      <span :if={@show_label} class="issue-status__label">{@label}</span>
104
    </span>
105
    """
106
  end
107
108
  @doc """
109
  How urgent one issue is, as four ascending bars or an alarm.
110
111
  The bar chart is the source's own idea and it is a good one: the level reads
112
  from how much of the shape is lit, so the ordering survives greyscale and a
113
  reader who cannot separate the tints. Urgent breaks the pattern on purpose —
114
  it is not one more step up the same ramp, and drawing it as one invites the
115
  eye to skip it.
116
117
  Drawn in CSS rather than vendored as five glyphs, because the bars are one
118
  shape read at five levels rather than five different pictures.
119
  """
120
  attr :level, :atom, values: @priorities, required: true
121
  attr :label, :string, default: nil, doc: "overrides the level's own name"
122
  attr :show_label, :boolean, default: false
123
  attr :class, :any, default: nil
124
  attr :rest, :global
125
126
  def issue_priority(assigns) do
127
    assigns = assign_new(assigns, :name, fn -> assigns.label || priority_name(assigns.level) end)
128
129
    ~H"""
130
    <span class={["issue-priority", @class]} data-level={@level} {@rest}>
131
      <UI.icon
132
        :if={@level == :urgent}
133
        name="triangle-exclamation-filled-error-warning"
134
        label={if(!@show_label, do: @name)}
135
        class="issue-priority__alarm"
136
      />
137
      <span
138
        :if={@level != :urgent}
139
        class="issue-priority__bars"
140
        role={if(!@show_label, do: "img")}
141
        aria-label={if(!@show_label, do: @name)}
142
        aria-hidden={if(@show_label, do: "true")}
143
      >
144
        <span class="issue-priority__bar" /><span class="issue-priority__bar" /><span class="issue-priority__bar" />
145
      </span>
146
      <span :if={@show_label} class="issue-priority__label">{@name}</span>
147
    </span>
148
    """
149
  end
150
151
  @doc """
152
  One label on an issue: a dot and a word in a pill.
153
154
  The source colours the dot from a per-label hex value chosen when the label
155
  was created. That model does not survive the tokens rule, so `tone` picks one
156
  of six values off the ladder instead. Six tones cannot distinguish eleven
157
  labels by colour alone, which is why the word is never optional here — the
158
  dot is a grouping hint, not the identity.
159
  """
160
  attr :name, :string, required: true
161
  attr :tone, :atom, values: @tones, default: :neutral
162
  attr :class, :any, default: nil
163
  attr :rest, :global
164
165
  def issue_label(assigns) do
166
    ~H"""
167
    <span class={["issue-label", @class]} data-tone={@tone} {@rest}>
168
      <span class="issue-label__dot" aria-hidden="true" />{@name}
169
    </span>
170
    """
171
  end
172
173
  @doc """
174
  Who an issue belongs to, or that it belongs to nobody.
175
176
  Unassigned is drawn rather than left blank. A blank cell in a list of faces
177
  reads as a rendering failure, and "nobody has picked this up" is one of the
178
  more actionable facts a triage view carries.
179
180
  `presence` adds the small corner dot. It is decorative here: the row already
181
  names the person, and a second announcement of "online" on every row of a
182
  list is noise.
183
  """
184
  attr :name, :string, default: nil, doc: "`nil` renders the unassigned state"
185
  attr :src, :string, default: nil
186
  attr :presence, :atom, values: @presences, default: :none
187
  attr :size, :atom, values: [:sm, :default, :lg], default: :default
188
  attr :show_name, :boolean, default: false
189
  attr :class, :any, default: nil
190
  attr :rest, :global
191
192
  def assignee(assigns) do
193
    ~H"""
194
    <span class={["assignee", @class]} data-size={@size} {@rest}>
195
      <span class="assignee__figure">
196
        <UI.avatar
197
          :if={@name}
198
          src={@src}
199
          fallback={String.first(@name)}
200
          size={@size}
201
          label={if(!@show_name, do: @name)}
202
        />
203
        <span :if={!@name} class="assignee__empty" role="img" aria-label="Unassigned">
204
          <UI.icon name="user" />
205
        </span>
206
        <span :if={@name && @presence != :none} class="assignee__presence" data-presence={@presence} />
207
      </span>
208
      <span :if={@show_name} class="assignee__name">{@name || "Unassigned"}</span>
209
    </span>
210
    """
211
  end
212
213
  @doc """
214
  Several people as overlapping faces, with a count for the ones that do not fit.
215
216
  The count is the point. Six faces and a `+14` says the size of a team; six
217
  faces alone says the team has six people, which would be wrong.
218
  """
219
  attr :people, :list, required: true, doc: "`[%{name: String.t(), src: String.t() | nil}]`"
220
  attr :limit, :integer, default: 5
221
  attr :class, :any, default: nil
222
  attr :rest, :global
223
224
  def assignee_stack(assigns) do
225
    assigns =
226
      assigns
227
      |> assign(:shown, Enum.take(assigns.people, assigns.limit))
228
      |> assign(:overflow, max(length(assigns.people) - assigns.limit, 0))
229
230
    ~H"""
231
    <span class={["assignee-stack", @class]} {@rest}>
232
      <span class="assignee-stack__faces">
233
        <UI.avatar
234
          :for={person <- @shown}
235
          src={person[:src]}
236
          fallback={String.first(person[:name])}
237
          size={:sm}
238
          label={person[:name]}
239
        />
240
      </span>
241
      <span :if={@overflow > 0} class="assignee-stack__count">+{@overflow}</span>
242
    </span>
243
    """
244
  end
245
246
  @doc """
247
  One issue as a row: the shape a tracker is mostly made of.
248
249
  Order is load-bearing and inherited from the source. Priority, identifier,
250
  and status lead because they are the three things a person scans a list for;
251
  the title takes the remaining width and truncates; everything discretionary —
252
  labels, project, dates, assignee — collects at the trailing edge where it can
253
  be dropped by width without disturbing the scan column.
254
255
  Only the title is a link. The source makes the row a drag handle and the
256
  title a link inside it, which means a click lands on one of two different
257
  things depending on where in a 44-pixel row it falls. One target is easier to
258
  hit and easier to explain.
259
  """
260
  attr :identifier, :string, required: true, doc: "the short key, such as `OA-142`"
261
  attr :title, :string, required: true
262
  attr :navigate, :any, default: nil, doc: "where the title goes; a plain title without it"
263
  attr :status_category, :atom, values: @categories, required: true
264
  attr :status_label, :string, required: true
265
  attr :progress, :integer, default: nil
266
  attr :priority, :atom, values: @priorities, default: :none
267
  attr :labels, :list, default: [], doc: "`[%{name: String.t(), tone: atom()}]`"
268
  attr :project, :string, default: nil
269
  attr :due, :string, default: nil, doc: "already formatted; overdue is the caller's judgement"
270
  attr :created, :string, default: nil
271
  attr :assignee, :map, default: nil, doc: "`%{name:, src:, presence:}`; `nil` is unassigned"
272
  attr :selected, :boolean, default: false
273
  attr :class, :any, default: nil
274
  attr :rest, :global
275
276
  def issue_row(assigns) do
277
    ~H"""
278
    <div class={["issue-row", @class]} data-selected={@selected} {@rest}>
279
      <span class="issue-row__scan">
280
        <.issue_priority level={@priority} />
281
        <span class="issue-row__identifier">{@identifier}</span>
282
        <.issue_status
283
          category={@status_category}
284
          label={@status_label}
285
          progress={@progress}
286
        />
287
      </span>
288
289
      <.link :if={@navigate} navigate={@navigate} class="issue-row__title">{@title}</.link>
290
      <span :if={!@navigate} class="issue-row__title">{@title}</span>
291
292
      <span class="issue-row__trailing">
293
        <span :if={@labels != [] or @project} class="issue-row__chips">
294
          <.issue_label :for={label <- @labels} name={label[:name]} tone={label[:tone] || :neutral} />
295
          <span :if={@project} class="issue-label" data-tone="neutral">
296
            <UI.icon name="cube" class="issue-label__glyph" />{@project}
297
          </span>
298
        </span>
299
        <span :if={@due} class="issue-row__due">Due {@due}</span>
300
        <span :if={@created} class="issue-row__date">{@created}</span>
301
        <.assignee
302
          name={@assignee && @assignee[:name]}
303
          src={@assignee && @assignee[:src]}
304
          presence={(@assignee && @assignee[:presence]) || :none}
305
        />
306
      </span>
307
    </div>
308
    """
309
  end
310
311
  @doc """
312
  The same issue as a card, for a board column.
313
314
  A card is not a row turned sideways: it has width and no neighbours, so the
315
  title gets two lines instead of one and the labels get their own band instead
316
  of competing with the trailing edge. The scan column becomes a header line,
317
  and the assignee drops to the foot where it reads as ownership of the whole
318
  card rather than one more attribute.
319
  """
320
  attr :identifier, :string, required: true
321
  attr :title, :string, required: true
322
  attr :navigate, :any, default: nil
323
  attr :status_category, :atom, values: @categories, required: true
324
  attr :status_label, :string, required: true
325
  attr :progress, :integer, default: nil
326
  attr :priority, :atom, values: @priorities, default: :none
327
  attr :labels, :list, default: []
328
  attr :project, :string, default: nil
329
  attr :created, :string, default: nil
330
  attr :assignee, :map, default: nil
331
  attr :class, :any, default: nil
332
  attr :rest, :global
333
334
  def issue_card(assigns) do
335
    ~H"""
336
    <article class={["issue-card", @class]} {@rest}>
337
      <header class="issue-card__head">
338
        <span class="issue-card__scan">
339
          <.issue_priority level={@priority} />
340
          <span class="issue-row__identifier">{@identifier}</span>
341
        </span>
342
        <.issue_status
343
          category={@status_category}
344
          label={@status_label}
345
          progress={@progress}
346
        />
347
      </header>
348
349
      <.link :if={@navigate} navigate={@navigate} class="issue-card__title">{@title}</.link>
350
      <p :if={!@navigate} class="issue-card__title">{@title}</p>
351
352
      <div :if={@labels != [] or @project} class="issue-card__chips">
353
        <.issue_label :for={label <- @labels} name={label[:name]} tone={label[:tone] || :neutral} />
354
        <span :if={@project} class="issue-label" data-tone="neutral">
355
          <UI.icon name="cube" class="issue-label__glyph" />{@project}
356
        </span>
357
      </div>
358
359
      <footer class="issue-card__foot">
360
        <span class="issue-row__date">{@created}</span>
361
        <.assignee
362
          name={@assignee && @assignee[:name]}
363
          src={@assignee && @assignee[:src]}
364
          presence={(@assignee && @assignee[:presence]) || :none}
365
        />
366
      </footer>
367
    </article>
368
    """
369
  end
370
371
  @doc """
372
  A named run of issues under a sticky, tinted header.
373
374
  The tint is the source's idea and it earns its place: a list grouped by
375
  status has no other way to say where one group ends and the next begins once
376
  the header has scrolled past its own rows. It is mixed from the category
377
  colour at a low percentage, so it is a wash rather than a fill, and the
378
  header is still legible over it.
379
380
  `layout` picks the two arrangements the same group takes: a full-width band
381
  in a list, or a fixed-width column in a board. The header, count, and actions
382
  are identical in both, which is why they are one component.
383
  """
384
  attr :label, :string, required: true
385
  attr :count, :integer, required: true
386
  attr :category, :atom, values: @categories ++ [:none], default: :none, doc: "drives the tint"
387
  attr :layout, :atom, values: [:list, :board], default: :list
388
  attr :class, :any, default: nil
389
  attr :rest, :global
390
  slot :glyph, doc: "the marker beside the name; a status, a priority, or a face"
391
  slot :actions, doc: "controls at the trailing edge of the header"
392
  slot :inner_block, required: true
393
394
  def issue_group(assigns) do
395
    ~H"""
396
    <section class={["issue-group", @class]} data-layout={@layout} data-category={@category} {@rest}>
397
      <header class="issue-group__head">
398
        <span class="issue-group__name">
399
          {render_slot(@glyph)}
400
          <span class="issue-group__label">{@label}</span>
401
          <span class="issue-group__count">{@count}</span>
402
        </span>
403
        <span :if={@actions != []} class="issue-group__actions">{render_slot(@actions)}</span>
404
      </header>
405
      <div class="issue-group__body">{render_slot(@inner_block)}</div>
406
    </section>
407
    """
408
  end
409
410
  @doc """
411
  Board columns side by side, scrolling horizontally.
412
413
  Each column scrolls on its own so a long backlog does not push the other
414
  columns' headers off the top. The source achieves this with a drag-and-drop
415
  provider wrapped around the same layout; the layout is the part worth having.
416
  """
417
  attr :class, :any, default: nil
418
  attr :rest, :global
419
  slot :inner_block, required: true
420
421
  def issue_board(assigns) do
422
    ~H"""
423
    <div class={["issue-board", @class]} {@rest}>{render_slot(@inner_block)}</div>
424
    """
425
  end
426
427
  @doc """
428
  One applied filter, read as subject, operator, value.
429
430
  Splitting the chip into three segments is what makes a filter editable
431
  without a modal: each segment is its own control, so changing `is` to
432
  `is not` does not mean removing the filter and building it again. The
433
  segments here are static text unless the caller supplies commands; the
434
  division is the part that matters, and it is what the source's
435
  `data-table-filter` spends most of its code on.
436
  """
437
  attr :subject, :string, required: true
438
  attr :operator, :string, required: true
439
  attr :value, :string, required: true
440
  attr :icon, :string, default: nil, doc: "a glyph for the subject"
441
  attr :on_remove, JS, default: nil, doc: "dropped from the applied set when clicked"
442
  attr :class, :any, default: nil
443
  attr :rest, :global
444
445
  def filter_chip(assigns) do
446
    ~H"""
447
    <span class={["filter-chip", @class]} {@rest}>
448
      <span class="filter-chip__subject">
449
        <UI.icon :if={@icon} name={@icon} />{@subject}
450
      </span>
451
      <span class="filter-chip__operator">{@operator}</span>
452
      <span class="filter-chip__value">{@value}</span>
453
      <button
454
        :if={@on_remove}
455
        type="button"
456
        class="filter-chip__remove"
457
        phx-click={@on_remove}
458
        aria-label={"Remove the #{@subject} filter"}
459
      >
460
        <UI.icon name="x" />
461
      </button>
462
    </span>
463
    """
464
  end
465
466
  @doc """
467
  The row of applied filters, with somewhere to add one and a way to drop them all.
468
469
  It appears only when a filter is applied — the source hides it otherwise and
470
  keeps the entry point in the toolbar, which is right: an empty filter bar is
471
  a permanent reminder of a feature nobody is using. Rendering nothing when
472
  there are no chips is the caller's decision, so this component does not
473
  guess.
474
  """
475
  attr :on_clear, JS, default: nil
476
  attr :class, :any, default: nil
477
  attr :rest, :global
478
  slot :add, doc: "the control that opens the subject picker"
479
  slot :inner_block, required: true, doc: "the applied chips"
480
481
  def filter_bar(assigns) do
482
    ~H"""
483
    <div class={["filter-bar", @class]} {@rest}>
484
      <div class="filter-bar__chips">
485
        {render_slot(@add)}
486
        {render_slot(@inner_block)}
487
      </div>
488
      <button :if={@on_clear} type="button" class="filter-bar__clear" phx-click={@on_clear}>
489
        Clear
490
      </button>
491
    </div>
492
    """
493
  end
494
495
  @doc """
496
  The saved views of one collection, as pills.
497
498
  Pills rather than underlined tabs because these switch a filter rather than a
499
  page: the content below keeps its shape, and an underline promises a bigger
500
  change than actually happens. The selected pill carries `aria-current`, so
501
  the state is not colour alone.
502
  """
503
  attr :label, :string, default: "Views", doc: "names the group for assistive technology"
504
  attr :class, :any, default: nil
505
  attr :rest, :global
506
507
  slot :tab, required: true do
508
    attr :label, :string, required: true
509
    attr :navigate, :any, required: true
510
    attr :selected, :boolean
511
  end
512
513
  def view_tabs(assigns) do
514
    ~H"""
515
    <nav class={["view-tabs", @class]} aria-label={@label} {@rest}>
516
      <.link
517
        :for={tab <- @tab}
518
        navigate={tab.navigate}
519
        class="view-tabs__tab"
520
        aria-current={tab[:selected] && "page"}
521
      >
522
        {tab.label}
523
      </.link>
524
    </nav>
525
    """
526
  end
527
528
  @doc """
529
  The bar above a collection: what you are looking at, and what you can do to it.
530
531
  The source splits this into two stacked rows — navigation above, options
532
  below — and the split is worth keeping when both are full. This renders one
533
  row with a leading and a trailing slot; stack two of them for the source's
534
  arrangement. Making it one component rather than two means a surface with
535
  only options does not inherit an empty navigation strip.
536
  """
537
  attr :class, :any, default: nil
538
  attr :rest, :global
539
  slot :leading, doc: "tabs, a count, or a title"
540
  slot :actions, doc: "filter, display, and view controls"
541
542
  def issue_toolbar(assigns) do
543
    ~H"""
544
    <div class={["issue-toolbar", @class]} {@rest}>
545
      <div class="issue-toolbar__leading">{render_slot(@leading)}</div>
546
      <div class="issue-toolbar__actions">{render_slot(@actions)}</div>
547
    </div>
548
    """
549
  end
550
551
  @doc """
552
  The `⌘K` surface: a search field over grouped commands.
553
554
  This is the one component here that needs script. `⌘K` is a document-level
555
  binding, incremental filtering means hiding rows as characters arrive, and
556
  arrow-key selection has to survive both — none of which markup can express.
557
  The hook does exactly those four things and nothing else; every command is a
558
  real `<button>` that works without it.
559
560
  Built on `<dialog>` rather than a positioned panel, so the browser supplies
561
  the modal semantics, the focus trap, the backdrop, and `Escape`. Anything
562
  with `data-command-target` matching this palette's id opens it, which is how
563
  a surface offers a visible way in beside the shortcut.
564
565
  `context` is the source's best idea in this surface: when the palette is
566
  opened from an issue, it says which issue, so `Change status…` is unambiguous
567
  before you pick anything.
568
  """
569
  attr :id, :string, required: true
570
  attr :placeholder, :string, default: "Type a command or search"
571
  attr :context, :string, default: nil, doc: "what the commands act on, if anything"
572
  attr :empty, :string, default: "No results found."
573
  attr :class, :any, default: nil
574
  attr :rest, :global
575
  slot :inner_block, required: true, doc: "`command_group/1` elements"
576
577
  def command_palette(assigns) do
578
    ~H"""
579
    <dialog id={@id} class={["command-palette", @class]} phx-hook=".CommandPalette" {@rest}>
580
      <div class="command-palette__panel">
581
        <p :if={@context} class="command-palette__context">{@context}</p>
582
        <div class="command-palette__search">
583
          <UI.icon name="search" class="command-palette__glyph" />
584
          <input
585
            type="text"
586
            class="command-palette__input"
587
            placeholder={@placeholder}
588
            aria-label={@placeholder}
589
            autocomplete="off"
590
            data-command-input
591
          />
592
        </div>
593
        <div class="command-palette__list">
594
          {render_slot(@inner_block)}
595
          <p class="command-palette__empty" data-command-empty hidden>{@empty}</p>
596
        </div>
597
      </div>
598
    </dialog>
599
    <script :type={Phoenix.LiveView.ColocatedHook} name=".CommandPalette">
600
      export default {
601
        mounted() {
602
          const input = this.el.querySelector("[data-command-input]")
603
          const empty = this.el.querySelector("[data-command-empty]")
604
          const items = () => Array.from(this.el.querySelectorAll("[data-command-item]"))
605
          const visible = () => items().filter((item) => !item.hidden)
606
607
          const select = (item) => {
608
            items().forEach((other) => other.removeAttribute("data-active"))
609
            if (!item) return
610
            item.setAttribute("data-active", "")
611
            item.scrollIntoView({block: "nearest"})
612
          }
613
614
          const filter = () => {
615
            const query = input.value.trim().toLowerCase()
616
            items().forEach((item) => {
617
              item.hidden = query !== "" && !item.dataset.commandLabel.includes(query)
618
            })
619
            this.el.querySelectorAll("[data-command-group]").forEach((group) => {
620
              group.hidden = group.querySelectorAll("[data-command-item]:not([hidden])").length === 0
621
            })
622
            const shown = visible()
623
            if (empty) empty.hidden = shown.length !== 0
624
            select(shown[0])
625
          }
626
627
          const open = () => {
628
            if (this.el.open) return
629
            input.value = ""
630
            filter()
631
            this.el.showModal()
632
            input.focus()
633
          }
634
635
          this.onKeyDown = (event) => {
636
            if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
637
              event.preventDefault()
638
              this.el.open ? this.el.close() : open()
639
            }
640
          }
641
642
          this.onClick = (event) => {
643
            const trigger = event.target.closest(`[data-command-target="${this.el.id}"]`)
644
            if (trigger) open()
645
          }
646
647
          // Arrow keys move a selection the browser has no concept of, so the
648
          // active row is tracked here and Enter forwards to its own click
649
          // handler rather than duplicating what the row does.
650
          this.onPaletteKey = (event) => {
651
            const shown = visible()
652
            if (shown.length === 0) return
653
            const at = shown.findIndex((item) => item.hasAttribute("data-active"))
654
            if (event.key === "ArrowDown") {
655
              event.preventDefault()
656
              select(shown[(at + 1) % shown.length])
657
            } else if (event.key === "ArrowUp") {
658
              event.preventDefault()
659
              select(shown[(at - 1 + shown.length) % shown.length])
660
            } else if (event.key === "Enter" && at >= 0) {
661
              event.preventDefault()
662
              shown[at].click()
663
            }
664
          }
665
666
          input.addEventListener("input", filter)
667
          this.el.addEventListener("keydown", this.onPaletteKey)
668
          window.addEventListener("keydown", this.onKeyDown)
669
          document.addEventListener("click", this.onClick)
670
          filter()
671
        },
672
        destroyed() {
673
          window.removeEventListener("keydown", this.onKeyDown)
674
          document.removeEventListener("click", this.onClick)
675
        }
676
      }
677
    </script>
678
    """
679
  end
680
681
  @doc """
682
  A titled run of commands inside the palette.
683
684
  Headings are what keep a palette of forty commands readable, and they are
685
  also what makes filtering legible: a group with nothing left in it hides
686
  itself rather than leaving a heading over a gap.
687
  """
688
  attr :heading, :string, required: true
689
  attr :class, :any, default: nil
690
  attr :rest, :global
691
  slot :inner_block, required: true
692
693
  def command_group(assigns) do
694
    ~H"""
695
    <div class={["command-group", @class]} data-command-group {@rest}>
696
      <p class="command-group__heading">{@heading}</p>
697
      {render_slot(@inner_block)}
698
    </div>
699
    """
700
  end
701
702
  @doc """
703
  One command: a glyph, a name, and the keys that reach it directly.
704
705
  The shortcut chips are documentation, not bindings — the palette does not
706
  install them. Showing them anyway is how a person stops needing the palette,
707
  which is the point of having one.
708
709
  `label` doubles as the filter key, so a command matches on the words a person
710
  would actually type.
711
  """
712
  attr :label, :string, required: true
713
  attr :icon, :string, default: nil
714
  attr :keys, :list, default: [], doc: "shortcut keys shown at the trailing edge"
715
  attr :on_select, JS, default: nil
716
  attr :class, :any, default: nil
717
  attr :rest, :global
718
719
  def command_item(assigns) do
720
    ~H"""
721
    <button
722
      type="button"
723
      class={["command-item", @class]}
724
      data-command-item
725
      data-command-label={String.downcase(@label)}
726
      phx-click={@on_select}
727
      {@rest}
728
    >
729
      <UI.icon :if={@icon} name={@icon} class="command-item__glyph" />
730
      <span class="command-item__label">{@label}</span>
731
      <span :if={@keys != []} class="command-item__keys">
732
        <UI.kbd :for={key <- @keys}>{key}</UI.kbd>
733
      </span>
734
    </button>
735
    """
736
  end
737
738
  @doc """
739
  One project as a row: name on the left, everything measurable on the right.
740
741
  Projects are read across rather than down — the question is which project is
742
  behind, not what any one of them is called — so the trailing fields sit in
743
  fixed columns that line up between rows. They drop by width from the least
744
  load-bearing inwards, which is why progress is the last to go.
745
746
  Health is a word, not a colour: `at risk` and `off track` are different
747
  claims, and a reader should not have to learn which shade of amber means
748
  which.
749
  """
750
  attr :name, :string, required: true
751
  attr :navigate, :any, default: nil
752
  attr :icon, :string, default: "cube"
753
  attr :health, :atom, values: [:on_track, :at_risk, :off_track, :unknown], default: :unknown
754
  attr :priority, :atom, values: @priorities, default: :none
755
  attr :lead, :map, default: nil, doc: "`%{name:, src:}`; `nil` renders unassigned"
756
  attr :target, :string, default: nil, doc: "already formatted target date"
757
  attr :issues, :integer, default: nil
758
  attr :status_category, :atom, values: @categories, required: true
759
  attr :status_label, :string, required: true
760
  attr :percent, :integer, default: nil
761
  attr :labels, :list, default: []
762
  attr :class, :any, default: nil
763
  attr :rest, :global
764
765
  def project_row(assigns) do
766
    ~H"""
767
    <div class={["project-row", @class]} {@rest}>
768
      <span class="project-row__name">
769
        <span class="project-row__icon"><UI.icon name={@icon} /></span>
770
        <.link :if={@navigate} navigate={@navigate} class="project-row__link">{@name}</.link>
771
        <span :if={!@navigate} class="project-row__link">{@name}</span>
772
        <.issue_label :for={label <- @labels} name={label[:name]} tone={label[:tone] || :neutral} />
773
      </span>
774
775
      <span :if={@health != :unknown} class="project-row__health" data-health={@health}>
776
        {health_name(@health)}
777
      </span>
778
      <span class="project-row__priority"><.issue_priority level={@priority} /></span>
779
      <span class="project-row__lead">
780
        <.assignee name={@lead && @lead[:name]} src={@lead && @lead[:src]} size={:sm} />
781
      </span>
782
      <span :if={@target} class="project-row__target">{@target}</span>
783
      <span :if={@issues} class="project-row__issues">{@issues}</span>
784
      <span class="project-row__status">
785
        <.issue_status
786
          category={@status_category}
787
          label={@status_label}
788
          progress={@percent}
789
        />
790
        <span :if={@percent} class="project-row__percent">{@percent}%</span>
791
      </span>
792
    </div>
793
    """
794
  end
795
796
  @doc """
797
  One team as a row: identity, membership, and what it owns.
798
799
  The identifier is shown beside the name rather than instead of it because it
800
  is the prefix on every issue key the team produces — `OA-142` is only
801
  findable if somebody can connect `OA` to a team.
802
  """
803
  attr :name, :string, required: true
804
  attr :identifier, :string, required: true
805
  attr :glyph, :string, default: nil, doc: "a short mark, typically one character"
806
  attr :navigate, :any, default: nil
807
  attr :joined, :boolean, default: false
808
  attr :members, :list, default: []
809
  attr :projects, :integer, default: nil
810
  attr :cycles, :integer, default: nil
811
  attr :class, :any, default: nil
812
  attr :rest, :global
813
814
  def team_row(assigns) do
815
    ~H"""
816
    <div class={["team-row", @class]} {@rest}>
817
      <span class="team-row__name">
818
        <span class="team-row__glyph" aria-hidden="true">{@glyph || String.first(@identifier)}</span>
819
        <.link :if={@navigate} navigate={@navigate} class="team-row__link">{@name}</.link>
820
        <span :if={!@navigate} class="team-row__link">{@name}</span>
821
        <span class="team-row__identifier">{@identifier}</span>
822
      </span>
823
824
      <span class="team-row__membership">
825
        <span :if={@joined} class="team-row__joined"><UI.icon name="check" />Joined</span>
826
      </span>
827
      <span class="team-row__members">
828
        <.assignee_stack :if={@members != []} people={@members} limit={6} />
829
      </span>
830
      <span :if={@cycles} class="team-row__metric"><UI.icon name="loop" />{@cycles}</span>
831
      <span :if={@projects} class="team-row__metric"><UI.icon name="cube" />{@projects}</span>
832
    </div>
833
    """
834
  end
835
836
  @doc """
837
  One person as a row: who they are, what they may do, and where they belong.
838
839
  Two lines of identity rather than one. A display name is what a colleague
840
  recognises and a handle is what appears in a mention, and a directory that
841
  shows only one of them fails whichever question is being asked.
842
  """
843
  attr :name, :string, required: true
844
  attr :handle, :string, required: true
845
  attr :src, :string, default: nil
846
  attr :role, :string, default: nil
847
  attr :role_tone, :atom, values: [:neutral, :accent], default: :neutral
848
  attr :joined, :string, default: nil, doc: "already formatted joining date"
849
  attr :teams, :list, default: [], doc: "team identifiers"
850
  attr :presence, :atom, values: @presences, default: :none
851
  attr :navigate, :any, default: nil
852
  attr :class, :any, default: nil
853
  attr :rest, :global
854
855
  def member_row(assigns) do
856
    assigns =
857
      assigns
858
      |> assign(:shown_teams, Enum.take(assigns.teams, 2))
859
      |> assign(:extra_teams, max(length(assigns.teams) - 2, 0))
860
861
    ~H"""
862
    <div class={["member-row", @class]} {@rest}>
863
      <span class="member-row__identity">
864
        <.assignee name={@name} src={@src} presence={@presence} size={:lg} />
865
        <span class="member-row__names">
866
          <.link :if={@navigate} navigate={@navigate} class="member-row__name">{@name}</.link>
867
          <span :if={!@navigate} class="member-row__name">{@name}</span>
868
          <span class="member-row__handle">{@handle}</span>
869
        </span>
870
      </span>
871
872
      <span :if={@role} class="member-row__role" data-tone={@role_tone}>{@role}</span>
873
      <span :if={@joined} class="member-row__joined">{@joined}</span>
874
      <span :if={@teams != []} class="member-row__teams">
875
        <UI.icon name="group" />{Enum.join(@shown_teams, ", ")}
876
        <span :if={@extra_teams > 0}>
877
          +{@extra_teams}
878
        </span>
879
      </span>
880
    </div>
881
    """
882
  end
883
884
  # ── the fixed vocabularies ─────────────────────────────────────────────────
885
886
  # Five of the source's six status shapes exist in the vendored set. Triage is
887
  # opposing arrows in a disc, which `compare-arrows` says exactly; the dashed
888
  # gear it uses for backlog has no equivalent and `circle-dashed` carries the
889
  # same "not yet real" reading without vendoring a glyph for one state.
890
  defp category_icon(:triage), do: "compare-arrows"
891
  defp category_icon(:backlog), do: "circle-dashed"
892
  defp category_icon(:unstarted), do: "empty-circle"
893
  defp category_icon(:completed), do: "check-circle-filled"
894
  defp category_icon(:canceled), do: "x-circle-filled"
895
896
  defp priority_name(:none), do: "No priority"
897
  defp priority_name(:low), do: "Low priority"
898
  defp priority_name(:medium), do: "Medium priority"
899
  defp priority_name(:high), do: "High priority"
900
  defp priority_name(:urgent), do: "Urgent"
901
902
  # `:unknown` has no word because the row renders no health cell for it. A
903
  # project nobody has reported on should leave a gap in the column, not claim
904
  # "no update" as though that were a fourth health state.
905
  defp health_name(:on_track), do: "On track"
906
  defp health_name(:at_risk), do: "At risk"
907
  defp health_name(:off_track), do: "Off track"
908
909
  # A progress value arrives from a count of finished issues over a count of
910
  # issues, so it can be anything; the arc has to be drawable regardless.
911
  defp clamp(nil), do: 0
912
  defp clamp(value) when is_integer(value), do: value |> max(0) |> min(100)
913
  defp clamp(_), do: 0
914
end
lib/openagents_web/live/components_live.ex modified +698 -1

@@ -12,6 +12,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

12 12
  use OpenAgentsWeb, :live_view
13 13
14 14
  alias OpenAgentsWeb.ComponentCatalog
15
  alias OpenAgentsWeb.UI.Circle
15 16
  alias OpenAgentsWeb.UI.Graph
16 17
  alias OpenAgentsWeb.UI.Landing
17 18
  alias OpenAgentsWeb.UI, as: UI

@@ -22,6 +23,87 @@ defmodule OpenAgentsWeb.ComponentsLive do

22 23
    %{id: 3, owner: "OpenAgentsInc", repo: "arcade", state: "closed"}
23 24
  ]
24 25
26
  # Six issues spanning every status category, every priority, assigned and
27
  # unassigned. A demo that shows one happy row hides exactly the cases the
28
  # component was built to keep legible.
29
  @demo_issues [
30
    %{
31
      identifier: "OA-142",
32
      title: "Placement refuses a node whose capability manifest has expired",
33
      status_category: :started,
34
      status_label: "In progress",
35
      progress: 35,
36
      priority: :urgent,
37
      labels: [%{name: "Bug", tone: :danger}, %{name: "Cloud", tone: :info}],
38
      project: "Managed nodes",
39
      due: "Aug 22",
40
      created: "Aug 12",
41
      assignee: %{name: "Mason Carter", presence: :online}
42
    },
43
    %{
44
      identifier: "OA-138",
45
      title: "Bound every atom attribute so a bad call site fails at compile time",
46
      status_category: :completed,
47
      status_label: "Done",
48
      priority: :high,
49
      labels: [%{name: "Refactor", tone: :warning}],
50
      created: "Aug 9",
51
      assignee: %{name: "Priya Raman", presence: :away}
52
    },
53
    %{
54
      identifier: "OA-151",
55
      title: "Diff parser drops the enclosing-function hint on a truncated hunk",
56
      status_category: :triage,
57
      status_label: "Triage",
58
      priority: :medium,
59
      labels: [%{name: "Forge", tone: :success}],
60
      created: "Aug 18",
61
      assignee: nil
62
    },
63
    %{
64
      identifier: "OA-119",
65
      title: "Command palette should say which issue it is acting on",
66
      status_category: :unstarted,
67
      status_label: "Todo",
68
      priority: :low,
69
      labels: [%{name: "UI", tone: :primary}],
70
      project: "Component library",
71
      created: "Jul 30",
72
      assignee: %{name: "Tomas Lindqvist", presence: :offline}
73
    },
74
    %{
75
      identifier: "OA-102",
76
      title: "Investigate whether receipts can be signed on the node itself",
77
      status_category: :backlog,
78
      status_label: "Backlog",
79
      priority: :none,
80
      labels: [],
81
      created: "Jul 14",
82
      assignee: nil
83
    },
84
    %{
85
      identifier: "OA-097",
86
      title: "Second theme selector, superseded by the token ladder",
87
      status_category: :canceled,
88
      status_label: "Cancelled",
89
      priority: :none,
90
      labels: [%{name: "Design", tone: :neutral}],
91
      created: "Jul 2",
92
      assignee: %{name: "Ada Okafor", presence: :none}
93
    }
94
  ]
95
96
  @demo_people [
97
    %{name: "Mason Carter"},
98
    %{name: "Priya Raman"},
99
    %{name: "Tomas Lindqvist"},
100
    %{name: "Ada Okafor"},
101
    %{name: "Jun Watanabe"},
102
    %{name: "Fiona Bell"},
103
    %{name: "Ravi Menon"},
104
    %{name: "Lena Fischer"}
105
  ]
106
25 107
  # The catalog demonstrates the preferred vendored Apps SDK tier. Heroicons
26 108
  # remains an exceptional fallback with an empty product-use inventory.
27 109
  @openagents_icons ~w(sparkle compass folder document user bell play star)

@@ -194,7 +276,9 @@ defmodule OpenAgentsWeb.ComponentsLive do

194 276
     |> assign(:openagents_icons, @openagents_icons)
195 277
     |> assign(:demo_user, @demo_user)
196 278
     |> assign(:demo_swarm, @demo_swarm)
197
     |> assign(:demo_streams, @demo_streams)}
279
     |> assign(:demo_streams, @demo_streams)
280
     |> assign(:demo_issues, @demo_issues)
281
     |> assign(:demo_people, @demo_people)}
198 282
  end
199 283
200 284
  @impl true

@@ -286,6 +370,8 @@ defmodule OpenAgentsWeb.ComponentsLive do

286 370
  attr :rows, :list, default: []
287 371
  attr :openagents_icons, :list, default: []
288 372
  attr :demo_user, :map, default: nil
373
  attr :demo_issues, :list, default: []
374
  attr :demo_people, :list, default: []
289 375
290 376
  defp component_demo(%{item: %{slug: "openagents-input"}} = assigns) do
291 377
    ~H"""

@@ -1426,6 +1512,617 @@ defmodule OpenAgentsWeb.ComponentsLive do

1426 1512
    """
1427 1513
  end
1428 1514
1515
  defp component_demo(%{item: %{slug: "issue-status"}} = assigns) do
1516
    ~H"""
1517
    <div class="space-y-3">
1518
      <p class="text-sm text-base-content/60">
1519
        Six categories, five of them a vendored glyph and one — <code>:started</code>
1520
        — drawn in CSS from a percentage, because how far along the work is cannot come
1521
        out of a fixed icon set. Colour is assigned per category off the same ladder
1522
        <code>status_indicator/1</code>
1523
        uses, so activity is blue and completion is green everywhere in the product.
1524
      </p>
1525
      <div class="flex flex-wrap gap-x-6 gap-y-3">
1526
        <Circle.issue_status category={:triage} label="Triage" show_label />
1527
        <Circle.issue_status category={:backlog} label="Backlog" show_label />
1528
        <Circle.issue_status category={:unstarted} label="Todo" show_label />
1529
        <Circle.issue_status category={:started} label="In progress" progress={35} show_label />
1530
        <Circle.issue_status category={:started} label="In review" progress={70} show_label />
1531
        <Circle.issue_status category={:completed} label="Done" show_label />
1532
        <Circle.issue_status category={:canceled} label="Cancelled" show_label />
1533
      </div>
1534
      <p class="text-sm text-base-content/60">
1535
        The arc at four fractions of the same shape:
1536
      </p>
1537
      <div class="flex flex-wrap gap-4">
1538
        <Circle.issue_status
1539
          :for={pct <- [0, 25, 50, 80]}
1540
          category={:started}
1541
          label={"#{pct}% complete"}
1542
          progress={pct}
1543
        />
1544
      </div>
1545
    </div>
1546
    """
1547
  end
1548
1549
  defp component_demo(%{item: %{slug: "issue-priority"}} = assigns) do
1550
    ~H"""
1551
    <div class="space-y-3">
1552
      <p class="text-sm text-base-content/60">
1553
        The level reads from how much of the shape is lit, so the ordering survives
1554
        greyscale. No priority is three dashes rather than the bottom of the ramp,
1555
        because "nobody has decided" is not a degree of urgency, and urgent leaves the
1556
        ramp entirely so the eye cannot skip it.
1557
      </p>
1558
      <div class="flex flex-wrap gap-x-6 gap-y-3">
1559
        <Circle.issue_priority
1560
          :for={level <- [:none, :low, :medium, :high, :urgent]}
1561
          level={level}
1562
          show_label
1563
        />
1564
      </div>
1565
    </div>
1566
    """
1567
  end
1568
1569
  defp component_demo(%{item: %{slug: "issue-label"}} = assigns) do
1570
    ~H"""
1571
    <div class="space-y-3">
1572
      <p class="text-sm text-base-content/60">
1573
        The source gives each label its own hex value chosen at creation. Six tones off
1574
        the token ladder cannot tell eleven labels apart by colour, which is why the
1575
        word here is never optional — the dot groups, it does not identify.
1576
      </p>
1577
      <div class="flex flex-wrap gap-2">
1578
        <Circle.issue_label name="Bug" tone={:danger} />
1579
        <Circle.issue_label name="Feature" tone={:success} />
1580
        <Circle.issue_label name="Documentation" tone={:info} />
1581
        <Circle.issue_label name="Refactor" tone={:warning} />
1582
        <Circle.issue_label name="Design" tone={:primary} />
1583
        <Circle.issue_label name="Testing" tone={:neutral} />
1584
      </div>
1585
    </div>
1586
    """
1587
  end
1588
1589
  defp component_demo(%{item: %{slug: "assignee"}} = assigns) do
1590
    ~H"""
1591
    <div class="space-y-3">
1592
      <p class="text-sm text-base-content/60">
1593
        Unassigned is drawn rather than left blank: a gap in a column of faces reads as
1594
        a rendering failure, and "nobody has picked this up" is one of the more
1595
        actionable facts a triage view carries.
1596
      </p>
1597
      <div class="flex flex-wrap items-center gap-6">
1598
        <Circle.assignee name="Mason Carter" presence={:online} show_name />
1599
        <Circle.assignee name="Priya Raman" presence={:away} show_name />
1600
        <Circle.assignee name="Ada Okafor" show_name />
1601
        <Circle.assignee show_name />
1602
      </div>
1603
      <div class="flex flex-wrap items-center gap-6">
1604
        <Circle.assignee
1605
          :for={size <- [:sm, :default, :lg]}
1606
          name="Jun Watanabe"
1607
          size={size}
1608
          presence={:online}
1609
        />
1610
      </div>
1611
    </div>
1612
    """
1613
  end
1614
1615
  defp component_demo(%{item: %{slug: "assignee-stack"}} = assigns) do
1616
    ~H"""
1617
    <div class="space-y-3">
1618
      <p class="text-sm text-base-content/60">
1619
        Overlapping faces say "a group"; the count says how big. Six faces without a
1620
        count would claim the team has six people. Hover separates them so the
1621
        individuals stay reachable.
1622
      </p>
1623
      <div class="flex flex-col gap-4">
1624
        <Circle.assignee_stack people={@demo_people} limit={5} />
1625
        <Circle.assignee_stack people={Enum.take(@demo_people, 3)} />
1626
      </div>
1627
    </div>
1628
    """
1629
  end
1630
1631
  defp component_demo(%{item: %{slug: "issue-row"}} = assigns) do
1632
    ~H"""
1633
    <div class="space-y-3">
1634
      <p class="text-sm text-base-content/60">
1635
        Priority, identifier, and status lead in a fixed-width scan column, so they form
1636
        a straight edge down the list instead of shifting with each title. The
1637
        discretionary fields collect at the trailing edge and drop by width from the
1638
        least load-bearing inwards; the scan column and the title never drop.
1639
      </p>
1640
      <div class="-mx-6 border-y border-border">
1641
        <Circle.issue_row
1642
          :for={issue <- @demo_issues}
1643
          identifier={issue.identifier}
1644
          title={issue.title}
1645
          status_category={issue.status_category}
1646
          status_label={issue.status_label}
1647
          progress={issue[:progress]}
1648
          priority={issue.priority}
1649
          labels={issue.labels}
1650
          project={issue[:project]}
1651
          due={issue[:due]}
1652
          created={issue.created}
1653
          assignee={issue.assignee}
1654
        />
1655
      </div>
1656
      <p class="text-sm text-base-content/60">Selected, and with a destination on the title:</p>
1657
      <div class="-mx-6 border-y border-border">
1658
        <Circle.issue_row
1659
          identifier="OA-142"
1660
          title="Placement refuses a node whose capability manifest has expired"
1661
          navigate={~p"/components/issue-row"}
1662
          status_category={:started}
1663
          status_label="In progress"
1664
          progress={35}
1665
          priority={:urgent}
1666
          selected
1667
          assignee={%{name: "Mason Carter", presence: :online}}
1668
        />
1669
      </div>
1670
    </div>
1671
    """
1672
  end
1673
1674
  defp component_demo(%{item: %{slug: "issue-card"}} = assigns) do
1675
    ~H"""
1676
    <div class="space-y-3">
1677
      <p class="text-sm text-base-content/60">
1678
        A card is not a row turned sideways. With width and no neighbours the title gets
1679
        two lines, the labels get their own band, and the assignee drops to the foot
1680
        where it reads as ownership of the card rather than one more trailing attribute.
1681
      </p>
1682
      <div class="grid gap-3 sm:grid-cols-2">
1683
        <Circle.issue_card
1684
          :for={issue <- Enum.take(@demo_issues, 4)}
1685
          identifier={issue.identifier}
1686
          title={issue.title}
1687
          status_category={issue.status_category}
1688
          status_label={issue.status_label}
1689
          progress={issue[:progress]}
1690
          priority={issue.priority}
1691
          labels={issue.labels}
1692
          project={issue[:project]}
1693
          created={issue.created}
1694
          assignee={issue.assignee}
1695
        />
1696
      </div>
1697
    </div>
1698
    """
1699
  end
1700
1701
  defp component_demo(%{item: %{slug: "issue-group"}} = assigns) do
1702
    ~H"""
1703
    <div class="space-y-3">
1704
      <p class="text-sm text-base-content/60">
1705
        The header sticks and carries a wash mixed from its own category, which is the
1706
        only thing that says where one group ends once the header has scrolled past its
1707
        rows. The wash is mixed over the canvas rather than laid on with alpha, so rows
1708
        passing underneath are covered instead of showing through.
1709
      </p>
1710
      <div class="-mx-6 max-h-80 overflow-y-auto border-y border-border">
1711
        <Circle.issue_group
1712
          :for={
1713
            {category, label} <- [
1714
              {:started, "In progress"},
1715
              {:unstarted, "Todo"},
1716
              {:backlog, "Backlog"}
1717
            ]
1718
          }
1719
          label={label}
1720
          category={category}
1721
          count={Enum.count(@demo_issues, &(&1.status_category == category))}
1722
        >
1723
          <:glyph>
1724
            <Circle.issue_status category={category} label={label} progress={35} />
1725
          </:glyph>
1726
          <:actions>
1727
            <UI.text_button aria-label={"Add an issue to #{label}"}>
1728
              <UI.icon name="plus" />
1729
            </UI.text_button>
1730
          </:actions>
1731
          <Circle.issue_row
1732
            :for={issue <- Enum.filter(@demo_issues, &(&1.status_category == category))}
1733
            identifier={issue.identifier}
1734
            title={issue.title}
1735
            status_category={issue.status_category}
1736
            status_label={issue.status_label}
1737
            progress={issue[:progress]}
1738
            priority={issue.priority}
1739
            labels={issue.labels}
1740
            created={issue.created}
1741
            assignee={issue.assignee}
1742
          />
1743
          <UI.empty
1744
            :if={Enum.all?(@demo_issues, &(&1.status_category != category))}
1745
            title="Nothing here"
1746
          >
1747
            No issue is in this state.
1748
          </UI.empty>
1749
        </Circle.issue_group>
1750
      </div>
1751
    </div>
1752
    """
1753
  end
1754
1755
  defp component_demo(%{item: %{slug: "issue-board"}} = assigns) do
1756
    ~H"""
1757
    <div class="space-y-3">
1758
      <p class="text-sm text-base-content/60">
1759
        Columns side by side, each scrolling on its own — scrolling the board as one
1760
        surface would push every header off the top to read the bottom of one column.
1761
        The source adds drag-and-drop over this layout; the layout is the part worth
1762
        having on a server-rendered page.
1763
      </p>
1764
      <div class="h-96">
1765
        <Circle.issue_board class="h-full">
1766
          <Circle.issue_group
1767
            :for={
1768
              {category, label} <- [
1769
                {:started, "In progress"},
1770
                {:unstarted, "Todo"},
1771
                {:completed, "Done"}
1772
              ]
1773
            }
1774
            layout={:board}
1775
            label={label}
1776
            category={category}
1777
            count={Enum.count(@demo_issues, &(&1.status_category == category))}
1778
          >
1779
            <:glyph>
1780
              <Circle.issue_status category={category} label={label} progress={35} />
1781
            </:glyph>
1782
            <Circle.issue_card
1783
              :for={issue <- Enum.filter(@demo_issues, &(&1.status_category == category))}
1784
              identifier={issue.identifier}
1785
              title={issue.title}
1786
              status_category={issue.status_category}
1787
              status_label={issue.status_label}
1788
              progress={issue[:progress]}
1789
              priority={issue.priority}
1790
              labels={issue.labels}
1791
              created={issue.created}
1792
              assignee={issue.assignee}
1793
            />
1794
          </Circle.issue_group>
1795
        </Circle.issue_board>
1796
      </div>
1797
    </div>
1798
    """
1799
  end
1800
1801
  defp component_demo(%{item: %{slug: "filter-chip"}} = assigns) do
1802
    ~H"""
1803
    <div class="space-y-3">
1804
      <p class="text-sm text-base-content/60">
1805
        Subject, operator, value, each its own segment. That division is what lets <code>is</code>
1806
        become <code>is not</code>
1807
        without the filter being removed and rebuilt, and it is what the source's filter
1808
        library spends most of its code on.
1809
      </p>
1810
      <div class="flex flex-wrap gap-2">
1811
        <Circle.filter_chip
1812
          subject="Status"
1813
          operator="is any of"
1814
          value="In progress, Todo"
1815
          icon="circle"
1816
        />
1817
        <Circle.filter_chip subject="Assignee" operator="is" value="Mason Carter" icon="user" />
1818
        <Circle.filter_chip
1819
          subject="Label"
1820
          operator="is not"
1821
          value="Documentation"
1822
          icon="tag"
1823
          on_remove={JS.hide(to: {:closest, ".filter-chip"})}
1824
        />
1825
      </div>
1826
    </div>
1827
    """
1828
  end
1829
1830
  defp component_demo(%{item: %{slug: "filter-bar"}} = assigns) do
1831
    ~H"""
1832
    <div class="space-y-3">
1833
      <p class="text-sm text-base-content/60">
1834
        The applied filters, the control that adds one, and a way to drop them all. The
1835
        source hides this row entirely until a filter exists and keeps the entry point
1836
        in the toolbar, which is right — an empty filter bar is a permanent reminder of
1837
        a feature nobody is using. Rendering nothing when there are no chips stays the
1838
        caller's decision.
1839
      </p>
1840
      <div class="-mx-6">
1841
        <Circle.filter_bar on_clear={JS.hide(to: {:closest, ".filter-bar"})}>
1842
          <:add>
1843
            <UI.text_button><UI.icon name="filter" /> Filter</UI.text_button>
1844
          </:add>
1845
          <Circle.filter_chip
1846
            subject="Status"
1847
            operator="is any of"
1848
            value="In progress, Todo"
1849
            icon="circle"
1850
            on_remove={JS.hide(to: {:closest, ".filter-chip"})}
1851
          />
1852
          <Circle.filter_chip
1853
            subject="Priority"
1854
            operator="is"
1855
            value="Urgent"
1856
            icon="bar-chart"
1857
            on_remove={JS.hide(to: {:closest, ".filter-chip"})}
1858
          />
1859
        </Circle.filter_bar>
1860
      </div>
1861
    </div>
1862
    """
1863
  end
1864
1865
  defp component_demo(%{item: %{slug: "view-tabs"}} = assigns) do
1866
    ~H"""
1867
    <div class="space-y-3">
1868
      <p class="text-sm text-base-content/60">
1869
        Pills rather than underlined tabs: these switch a filter, not a page, and an
1870
        underline promises a bigger change than actually happens. The current view
1871
        carries <code>aria-current</code>, so its state is not colour alone.
1872
      </p>
1873
      <Circle.view_tabs label="Issue views">
1874
        <:tab label="Active" navigate={~p"/components/view-tabs"} selected />
1875
        <:tab label="Backlog" navigate={~p"/components/view-tabs"} />
1876
        <:tab label="All issues" navigate={~p"/components/view-tabs"} />
1877
      </Circle.view_tabs>
1878
    </div>
1879
    """
1880
  end
1881
1882
  defp component_demo(%{item: %{slug: "issue-toolbar"}} = assigns) do
1883
    ~H"""
1884
    <div class="space-y-3">
1885
      <p class="text-sm text-base-content/60">
1886
        What you are looking at on the left, what you can do to it on the right. The
1887
        source splits this into two stacked rows; stacking two toolbars gives the same
1888
        arrangement without forcing a surface that has only options to carry an empty
1889
        navigation strip.
1890
      </p>
1891
      <div class="-mx-6">
1892
        <Circle.issue_toolbar>
1893
          <:leading>
1894
            <Circle.view_tabs label="Issue views">
1895
              <:tab label="Active" navigate={~p"/components/issue-toolbar"} selected />
1896
              <:tab label="Backlog" navigate={~p"/components/issue-toolbar"} />
1897
            </Circle.view_tabs>
1898
          </:leading>
1899
          <:actions>
1900
            <UI.text_button aria-label="Search issues"><UI.icon name="search" /></UI.text_button>
1901
            <UI.text_button aria-label="Notifications"><UI.icon name="bell" /></UI.text_button>
1902
          </:actions>
1903
        </Circle.issue_toolbar>
1904
        <Circle.issue_toolbar>
1905
          <:leading>
1906
            <span class="text-xs text-muted-foreground">6 issues</span>
1907
          </:leading>
1908
          <:actions>
1909
            <UI.text_button><UI.icon name="filter" /> Filter</UI.text_button>
1910
            <UI.text_button><UI.icon name="settings-slider" /> Display</UI.text_button>
1911
          </:actions>
1912
        </Circle.issue_toolbar>
1913
      </div>
1914
    </div>
1915
    """
1916
  end
1917
1918
  defp component_demo(%{item: %{slug: "command-palette"}} = assigns) do
1919
    ~H"""
1920
    <div class="space-y-3">
1921
      <p class="text-sm text-base-content/60">
1922
        Press
1923
        <UI.kbd>⌘</UI.kbd>
1924
        <UI.kbd>K</UI.kbd>
1925
        or use the button. A native <code>&lt;dialog&gt;</code>
1926
        supplies the focus trap, the backdrop, and <UI.kbd>Esc</UI.kbd>; the one
1927
        colocated hook adds the shortcut, incremental filtering, and arrow-key
1928
        selection, which are the four things markup cannot express. Every command is a
1929
        real button and works without the hook.
1930
      </p>
1931
      <UI.button data-command-target="demo-command-palette">Open the palette</UI.button>
1932
      <Circle.command_palette
1933
        id="demo-command-palette"
1934
        context="OA-142 · Placement refuses an expired manifest"
1935
      >
1936
        <Circle.command_group heading="Issue">
1937
          <Circle.command_item label="Assign to…" icon="user-add" keys={["A"]} />
1938
          <Circle.command_item label="Change status…" icon="circle" keys={["S"]} />
1939
          <Circle.command_item label="Set priority…" icon="bar-chart" keys={["P"]} />
1940
          <Circle.command_item label="Change or add labels…" icon="tag" keys={["L"]} />
1941
          <Circle.command_item label="Set due date…" icon="calendar" keys={["⇧", "D"]} />
1942
        </Circle.command_group>
1943
        <Circle.command_group heading="Copy">
1944
          <Circle.command_item label="Copy issue ID" icon="clipboard" keys={["⌘", "."]} />
1945
          <Circle.command_item label="Copy issue URL" icon="link" keys={["⌘", "⇧", ","]} />
1946
          <Circle.command_item label="Copy branch name" icon="branch" keys={["⌘", "⇧", "."]} />
1947
        </Circle.command_group>
1948
        <Circle.command_group heading="Go to">
1949
          <Circle.command_item label="My issues" icon="user" keys={["G", "I"]} />
1950
          <Circle.command_item label="Projects" icon="cube" keys={["G", "P"]} />
1951
          <Circle.command_item label="Teams" icon="members" keys={["G", "T"]} />
1952
        </Circle.command_group>
1953
      </Circle.command_palette>
1954
    </div>
1955
    """
1956
  end
1957
1958
  defp component_demo(%{item: %{slug: "command-group"}} = assigns) do
1959
    ~H"""
1960
    <div class="space-y-3">
1961
      <p class="text-sm text-base-content/60">
1962
        Headings keep a palette of forty commands readable, and they are what makes
1963
        filtering legible: a group whose commands have all been filtered out hides
1964
        itself rather than leaving a heading over a gap. Shown here outside a dialog so
1965
        the structure is visible.
1966
      </p>
1967
      <div class="command-palette__list rounded-lg border border-border">
1968
        <Circle.command_group heading="Issue">
1969
          <Circle.command_item label="Assign to…" icon="user-add" keys={["A"]} />
1970
          <Circle.command_item label="Change status…" icon="circle" keys={["S"]} />
1971
        </Circle.command_group>
1972
        <Circle.command_group heading="Copy">
1973
          <Circle.command_item label="Copy issue ID" icon="clipboard" keys={["⌘", "."]} />
1974
        </Circle.command_group>
1975
      </div>
1976
    </div>
1977
    """
1978
  end
1979
1980
  defp component_demo(%{item: %{slug: "command-item"}} = assigns) do
1981
    ~H"""
1982
    <div class="space-y-3">
1983
      <p class="text-sm text-base-content/60">
1984
        A glyph, a name, and the keys that reach it directly. The chips are
1985
        documentation rather than bindings — the palette does not install them. Showing
1986
        them anyway is how a person stops needing the palette, which is the point of
1987
        having one. The label doubles as the filter key.
1988
      </p>
1989
      <div class="command-palette__list rounded-lg border border-border">
1990
        <Circle.command_item label="Assign to…" icon="user-add" keys={["A"]} />
1991
        <Circle.command_item label="Copy issue URL" icon="link" keys={["⌘", "⇧", ","]} />
1992
        <Circle.command_item label="Create new issue" icon="plus" />
1993
      </div>
1994
    </div>
1995
    """
1996
  end
1997
1998
  defp component_demo(%{item: %{slug: "project-row"}} = assigns) do
1999
    ~H"""
2000
    <div class="space-y-3">
2001
      <p class="text-sm text-base-content/60">
2002
        Projects are read across rather than down — the question is which one is behind,
2003
        not what any one of them is called — so the trailing fields sit in fixed columns
2004
        that line up between rows. Health is a word: "at risk" and "off track" are
2005
        different claims, and no reader should have to learn which shade of amber means
2006
        which.
2007
      </p>
2008
      <div class="-mx-6 border-y border-border">
2009
        <Circle.project_row
2010
          name="Managed nodes"
2011
          health={:on_track}
2012
          priority={:high}
2013
          lead={%{name: "Priya Raman"}}
2014
          target="Sep 30"
2015
          issues={24}
2016
          status_category={:started}
2017
          status_label="In progress"
2018
          percent={62}
2019
          labels={[%{name: "Cloud", tone: :info}]}
2020
        />
2021
        <Circle.project_row
2022
          name="Component library"
2023
          navigate={~p"/components"}
2024
          health={:at_risk}
2025
          priority={:medium}
2026
          lead={%{name: "Ada Okafor"}}
2027
          target="Aug 29"
2028
          issues={11}
2029
          status_category={:started}
2030
          status_label="In progress"
2031
          percent={78}
2032
        />
2033
        <Circle.project_row
2034
          name="Taproot settlement"
2035
          health={:off_track}
2036
          priority={:urgent}
2037
          target="Oct 14"
2038
          issues={7}
2039
          status_category={:triage}
2040
          status_label="Triage"
2041
        />
2042
        <Circle.project_row
2043
          name="Second theme selector"
2044
          priority={:none}
2045
          issues={0}
2046
          status_category={:canceled}
2047
          status_label="Cancelled"
2048
        />
2049
      </div>
2050
    </div>
2051
    """
2052
  end
2053
2054
  defp component_demo(%{item: %{slug: "team-row"}} = assigns) do
2055
    ~H"""
2056
    <div class="space-y-3">
2057
      <p class="text-sm text-base-content/60">
2058
        The identifier sits beside the name rather than instead of it, because it is the
2059
        prefix on every issue key the team produces — <code>OA-142</code>
2060
        is only findable if somebody can connect <code>OA</code>
2061
        to a team.
2062
      </p>
2063
      <div class="-mx-6 border-y border-border">
2064
        <Circle.team_row
2065
          name="Core"
2066
          identifier="OA"
2067
          glyph="◆"
2068
          navigate={~p"/components/team-row"}
2069
          joined
2070
          members={@demo_people}
2071
          projects={6}
2072
          cycles={3}
2073
        />
2074
        <Circle.team_row
2075
          name="Cloud"
2076
          identifier="CLD"
2077
          glyph="▲"
2078
          members={Enum.take(@demo_people, 4)}
2079
          projects={3}
2080
          cycles={2}
2081
        />
2082
        <Circle.team_row
2083
          name="Design"
2084
          identifier="DSN"
2085
          members={Enum.take(@demo_people, 2)}
2086
          projects={1}
2087
        />
2088
      </div>
2089
    </div>
2090
    """
2091
  end
2092
2093
  defp component_demo(%{item: %{slug: "member-row"}} = assigns) do
2094
    ~H"""
2095
    <div class="space-y-3">
2096
      <p class="text-sm text-base-content/60">
2097
        Two lines of identity rather than one. A display name is what a colleague
2098
        recognises and a handle is what appears in a mention; a directory that shows
2099
        only one of them fails whichever question is being asked.
2100
      </p>
2101
      <div class="-mx-6 border-y border-border">
2102
        <Circle.member_row
2103
          name="Mason Carter"
2104
          handle="mason.carter"
2105
          navigate={~p"/components/member-row"}
2106
          role="Admin"
2107
          role_tone={:accent}
2108
          joined="Mar 2024"
2109
          teams={["OA", "CLD", "DSN"]}
2110
          presence={:online}
2111
        />
2112
        <Circle.member_row
2113
          name="Priya Raman"
2114
          handle="priya.raman"
2115
          role="Member"
2116
          joined="Jun 2024"
2117
          teams={["OA"]}
2118
          presence={:away}
2119
        />
2120
        <Circle.member_row name="forge-bot" handle="forge.bot" role="Application" joined="Jan 2025" />
2121
      </div>
2122
    </div>
2123
    """
2124
  end
2125
1429 2126
  # The breadcrumb names the section a component lives in, so the trail matches
1430 2127
  # the sidebar the reader navigated through.
1431 2128
  defp section_title_for(slug) do
test/openagents_web/components/circle_test.exs added +247

@@ -0,0 +1,247 @@

1
defmodule OpenAgentsWeb.UI.CircleTest do
2
  @moduledoc """
3
  The parts of the issue surfaces a screenshot cannot check.
4
5
  Three failure modes are covered here because all three are invisible by eye:
6
  a state indicator that carries its meaning only in colour, an indicator that
7
  announces itself twice when its word is already on screen, and an arc whose
8
  fill silently stops tracking the number it is drawn from.
9
  """
10
11
  use ExUnit.Case, async: true
12
13
  import Phoenix.LiveViewTest, only: [render_component: 2]
14
15
  alias OpenAgentsWeb.UI.Circle
16
17
  defp query(html, selector) do
18
    html
19
    |> LazyHTML.from_fragment()
20
    |> LazyHTML.query(selector)
21
    |> LazyHTML.to_tree()
22
  end
23
24
  defp status(overrides) do
25
    render_component(
26
      &Circle.issue_status/1,
27
      Keyword.merge([category: :started, label: "In progress"], overrides)
28
    )
29
  end
30
31
  defp priority(level, show_label) do
32
    render_component(&Circle.issue_priority/1, level: level, show_label: show_label)
33
  end
34
35
  defp row(overrides) do
36
    render_component(
37
      &Circle.issue_row/1,
38
      Keyword.merge(
39
        [
40
          identifier: "OA-142",
41
          title: "A title",
42
          status_category: :started,
43
          status_label: "In progress",
44
          progress: 35,
45
          priority: :high
46
        ],
47
        overrides
48
      )
49
    )
50
  end
51
52
  defp chip(on_remove) do
53
    render_component(&Circle.filter_chip/1,
54
      subject: "Status",
55
      operator: "is",
56
      value: "Done",
57
      on_remove: on_remove
58
    )
59
  end
60
61
  describe "issue_status/1" do
62
    test "the arc is drawn from the percentage rather than from a fixed set" do
63
      for percent <- [0, 35, 100] do
64
        assert query(
65
                 status(progress: percent),
66
                 ~s{.issue-status__arc[style="--issue-arc: #{percent}"]}
67
               ) != []
68
      end
69
    end
70
71
    # A progress value is a count over a count, so it can arrive as anything.
72
    # The arc still has to be drawable: an absent or out-of-range value must
73
    # not produce a gradient stop the browser refuses.
74
    test "an absent or out-of-range percentage still draws" do
75
      for {given, drawn} <- [{nil, 0}, {-10, 0}, {140, 100}] do
76
        assert query(
77
                 status(progress: given),
78
                 ~s{.issue-status__arc[style="--issue-arc: #{drawn}"]}
79
               ) != []
80
      end
81
    end
82
83
    test "the glyph announces the state when no word is beside it" do
84
      assert query(status(category: :completed, label: "Done"), ~s{[aria-label="Done"]}) != []
85
    end
86
87
    test "the glyph goes quiet once the word is visible" do
88
      rendered = status(category: :completed, label: "Done", show_label: true)
89
90
      assert query(rendered, "[aria-label]") == []
91
      assert query(rendered, ~s{[aria-hidden="true"]}) != []
92
      assert rendered =~ "Done"
93
    end
94
95
    test "every category resolves to a shape" do
96
      for category <- [:triage, :backlog, :unstarted, :started, :completed, :canceled] do
97
        rendered = status(category: category, label: "Any", progress: 50)
98
99
        assert query(rendered, ~s{.issue-status[data-category="#{category}"]}) != []
100
        assert query(rendered, ".issue-status__arc, .issue-status__glyph") != []
101
      end
102
    end
103
  end
104
105
  describe "issue_priority/1" do
106
    test "the level is carried by an attribute, so it survives greyscale" do
107
      for level <- [:none, :low, :medium, :high] do
108
        assert query(priority(level, false), ~s{.issue-priority[data-level="#{level}"]}) != []
109
      end
110
    end
111
112
    test "urgent leaves the ramp and is drawn as its own mark" do
113
      assert query(priority(:urgent, false), ".issue-priority__bars") == []
114
      assert query(priority(:urgent, false), ".issue-priority__alarm") != []
115
    end
116
117
    test "the indicator names the level when no word is beside it" do
118
      assert query(priority(:high, false), ~s{[aria-label="High priority"]}) != []
119
      assert query(priority(:high, true), "[aria-label]") == []
120
    end
121
  end
122
123
  describe "assignee/1" do
124
    test "unassigned is drawn and named rather than left blank" do
125
      rendered = render_component(&Circle.assignee/1, [])
126
127
      assert query(rendered, ~s{.assignee__empty[aria-label="Unassigned"]}) != []
128
    end
129
130
    test "presence is decorative, because the row already names the person" do
131
      rendered =
132
        render_component(&Circle.assignee/1,
133
          name: "Mason Carter",
134
          presence: :online,
135
          show_name: true
136
        )
137
138
      assert query(rendered, ".assignee__presence[aria-label]") == []
139
      assert query(rendered, ~s{.assignee__presence[data-presence="online"]}) != []
140
    end
141
  end
142
143
  describe "assignee_stack/1" do
144
    test "the count says how many did not fit" do
145
      people = for n <- 1..8, do: %{name: "Person #{n}"}
146
      rendered = render_component(&Circle.assignee_stack/1, people: people, limit: 5)
147
148
      assert length(query(rendered, ".avatar")) == 5
149
      assert rendered =~ "+3"
150
    end
151
152
    test "a stack that fits carries no count" do
153
      rendered = render_component(&Circle.assignee_stack/1, people: [%{name: "Ada"}])
154
155
      assert query(rendered, ".assignee-stack__count") == []
156
    end
157
  end
158
159
  describe "issue_row/1" do
160
    test "the title is a link only when it has somewhere to go" do
161
      assert query(row(navigate: "/issues/1"), ~s{a.issue-row__title[href="/issues/1"]}) != []
162
      assert query(row(navigate: nil), "a.issue-row__title") == []
163
      assert query(row(navigate: nil), "span.issue-row__title") != []
164
    end
165
166
    test "an unassigned row still draws the assignee position" do
167
      assert query(row(assignee: nil), ".assignee__empty") != []
168
    end
169
  end
170
171
  describe "filter_chip/1" do
172
    test "the remove control names the filter it drops" do
173
      assert query(
174
               chip(Phoenix.LiveView.JS.hide()),
175
               ~s{button[aria-label="Remove the Status filter"]}
176
             ) != []
177
    end
178
179
    test "a chip with no command offers no dead control" do
180
      assert query(chip(nil), "button") == []
181
    end
182
  end
183
184
  describe "view_tabs/1" do
185
    test "the current view is stated, not merely coloured" do
186
      rendered =
187
        render_component(&Circle.view_tabs/1,
188
          label: "Issue views",
189
          tab: [
190
            %{__slot__: :tab, label: "Active", navigate: "/a", selected: true, inner_block: nil},
191
            %{__slot__: :tab, label: "Backlog", navigate: "/b", inner_block: nil}
192
          ]
193
        )
194
195
      assert query(rendered, ~s{a[aria-current="page"][href="/a"]}) != []
196
      assert query(rendered, ~s{a[href="/b"][aria-current]}) == []
197
    end
198
  end
199
200
  describe "command_item/1" do
201
    test "the filter key is the label, folded so typing matches it" do
202
      rendered =
203
        render_component(&Circle.command_item/1, label: "Copy Issue URL", keys: ["⌘", "."])
204
205
      assert query(rendered, ~s{[data-command-label="copy issue url"]}) != []
206
      assert length(query(rendered, "kbd")) == 2
207
    end
208
  end
209
210
  describe "the stylesheet" do
211
    setup do
212
      section =
213
        "assets/css/openagents.css"
214
        |> File.read!()
215
        |> String.split("/* ── Issues ──")
216
        |> List.last()
217
218
      %{section: section}
219
    end
220
221
    # The whole point of the port was to avoid a second palette. A literal hex
222
    # value in this section would be one of Circle's thirteen status colours
223
    # arriving by the back door.
224
    test "the Issues section paints only with tokens", %{section: section} do
225
      literals =
226
        ~r/(?<![-\w])#[0-9a-fA-F]{3,8}\b/
227
        |> Regex.scan(section)
228
        |> List.flatten()
229
        |> Enum.reject(&(&1 == "#08090a"))
230
231
      assert literals == [], """
232
      The Issues section names colours directly instead of resolving to a
233
      token: #{Enum.join(literals, ", ")}
234
235
      The one permitted literal is the backdrop scrim, which mixes against the
236
      darkest ink on purpose.
237
      """
238
    end
239
240
    test "each category that departs from the default grey has a tint", %{section: section} do
241
      for category <- ~w(triage backlog started completed canceled) do
242
        assert section =~ ~s{[data-category="#{category}"]},
243
               "no tint rule for the #{category} category"
244
      end
245
    end
246
  end
247
end

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