-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDESIGN.html
More file actions
4249 lines (4243 loc) · 245 KB
/
Copy pathDESIGN.html
File metadata and controls
4249 lines (4243 loc) · 245 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en-US">
<head>
<title>clasm</title>
<link rel="stylesheet" href="https://caltechlibrary.github.io/css/site.css">
<link rel="stylesheet" href="https://media.library.caltech.edu/cl-webcomponents/css/code-blocks.css">
<script type="module" src="https://media.library.caltech.edu/cl-webcomponents/copyToClipboard.js"></script>
<script type="module" src="https://media.library.caltech.edu/cl-webcomponents/footer-global.js"></script>
</head>
<body>
<header>
<a href="https://library.caltech.edu"><img src="https://media.library.caltech.edu/assets/caltechlibrary-logo.png" alt="Caltech Library logo"></a>
</header>
<a href="#main-content" class="visually-hidden">skip to main content</a>
<nav>
<ul>
<li><a href="/">All Library Apps</a></li>
<li><a href="index.html">Home</a></li>
<li><a href="LICENSE">LICENSE</a></li>
<!-- <li><a href="INSTALL.html">INSTALL</a></li> -->
<li><a href="user_manual.html">User Manual</a></li>
<li><a href="about.html">About</a></li>
<li><a href="https://github.com/caltechlibrary/clasm">Code Repository</a></li>
</ul>
</nav>
<section id="main-content">
<h1 id="aws-tools-awsops-design">AWS Tools — awsops — Design</h1>
<blockquote>
<p><strong>2026-07-01: Retargeted to Go.</strong> See
<code>DECISIONS.md</code> (“Retarget implementation from Bash to Go”)
for why. <code>ec2_ami_manager.bash</code> remains in this repo,
unchanged, as the working reference for the behavior this document
describes, until the Go version reaches parity and is verified against
real AWS.</p>
<p><strong>2026-07-02: Domain-picker redesign.</strong> Scope is
expanding beyond EC2/AMI to Key Management, S3 (including static website
hosting), and CloudFront. The single flat main menu is replaced by a
domain picker (Compute / Key Management / S3 / CloudFront) with a
domain-scoped submenu underneath — see “Navigation: Domain Picker”
below. This is additive: Compute’s existing 12 features (below) are
unchanged in behavior, only regrouped under one submenu. Real-AWS
verification of Compute (Phase 16) continues in parallel with this
redesign — see <code>PLAN.md</code>. See <code>DECISIONS.md</code>,
“Redesign navigation as a domain picker; add Key Management, S3, and
CloudFront domains”.</p>
<p><strong>2026-07-08: Bash retired.</strong> Phase 16’s real-AWS
verification (<code>TEST_PLAN_REAL_AWS.txt</code>, 112/112 checks) is
complete. <code>ec2_ami_manager.bash</code>, <code>ami_copy.bash</code>,
<code>ami_copy_basic_steps.md</code>, and <code>tests/*.bats</code> have
been deleted from this repo; <code>awsops</code> is now the sole
implementation and the working reference for Compute domain behavior.
See <code>DECISIONS.md</code>, “Retire ec2_ami_manager.bash,
ami_copy.bash, and the Bash test suite”.</p>
</blockquote>
<h2 id="overview">Overview</h2>
<p>An interactive Go CLI for administering AWS EC2 instances and AMIs
for this team’s infrastructure, across two regions (us-west-1, us-west-2
— narrowed from an original four; see <code>DECISIONS.md</code>, “Narrow
configured regions to us-west-1/us-west-2”). The tool is general-purpose
— nothing in its mechanisms (tagging, backup archival, cloud-init
inspection) is RDM-specific (see <code>DECISIONS.md</code>, “Name the
CLI binary <code>awsops</code>”) — but this team’s Invenio RDM
deployments are its primary use case today, and several features (the
Postgres/OpenSearch/Redis crash-consistency guidance, the
backup-directory convention) are grounded in operational facts observed
on those instances. Beyond EC2/AMI lifecycle management, the tool is
meant to help with ongoing <em>administration</em> of these instances
(e.g. backup hygiene, inspecting deployed configuration) and to speed up
and de-risk development, test, and deployment workflows more broadly —
not just be a thin wrapper over
<code>RunInstances</code>/<code>CreateImage</code>/<code>DeregisterImage</code>.
The core EC2/AMI feature set and UX below are unchanged from the Bash
version — only the implementation language and AWS access layer change;
everything from “Show/Export Cloud-Init” onward is new scope that came
out of this design review.</p>
<p>This team’s AWS footprint splits into two broad concerns: deploying
and operating Invenio RDM instances (Compute: EC2/AMI, plus the SSH key
pairs they launch with) and publishing static websites (S3 buckets as
origin, CloudFront serving and caching in front of them). The tool’s
navigation now reflects that split directly — see “Navigation: Domain
Picker” below — rather than growing a single ever-longer menu.</p>
<h2 id="non-goals">Non-Goals</h2>
<p><code>awsops</code> is an interactive replacement for ad hoc, day-2
AWS Console work — “what’s running right now, let me
tag/start/stop/snapshot/back up this specific thing” — with this team’s
safety gates and domain knowledge (crash-consistency guidance, backup
hygiene, the Project/ Environment tagging convention) built in. It is
deliberately <strong>not</strong>:</p>
<ul>
<li><strong>A declarative infrastructure-as-code tool.</strong> It
doesn’t define desired state, diff against reality, or reconcile drift.
Terraform, Pulumi, and AWS CDK already solve that problem well; if this
team ever wants version-controlled, reproducible environment
definitions, one of those is the right tool, not a <code>awsops</code>
feature to grow toward.</li>
<li><strong>An AMI-baking pipeline.</strong> Packer (and AWS EC2 Image
Builder) already automate “base AMI + cloud-init/provisioning script →
new AMI.” The deferred “Bake AMI from cloud-init” idea (below) is v1’s
primitives composed by hand, not a competing pipeline tool.</li>
<li><strong>A general-purpose AWS CLI replacement.</strong> It wraps a
curated, opinionated subset of operations this team actually performs,
not the full breadth of any single AWS service’s API.</li>
</ul>
<p>Scope decisions in this document (curated instance-type lists over
full API listings, a fixed Project/Environment tagging vocabulary rather
than free-form policy, no “pick a different AMI” recovery path once one
is committed) follow from staying inside this lane — see
<code>DECISIONS.md</code> for the specific trade-offs each one made.</p>
<h2 id="configuration">Configuration</h2>
<p><code>awsops</code> reads its own operational settings — never AWS
credentials or profile selection, which remain entirely the AWS SDK’s
responsibility via its standard chain (<code>~/.aws/credentials</code>,
<code>~/.aws/config</code>, environment variables, SSO; see
“Assumptions” #1, unchanged) — from an optional YAML file at
<code>~/.awsops</code> (overridable with
<code>-config <path></code>). See <code>DECISIONS.md</code>, “Add
a <code>~/.awsops</code> YAML config file for awsops’ own operational
settings”.</p>
<ul>
<li><strong>Entirely optional.</strong> If the file doesn’t exist at the
resolved path (default or <code>-config</code>-specified), built-in
defaults apply and the tool behaves exactly as it always has — no config
file is required to run <code>awsops</code>.</li>
<li><strong>Fails loudly on a real mistake.</strong> If the file exists
but is malformed YAML, <code>awsops</code> exits with a clear parse
error rather than silently falling back to defaults — a botched config
that’s silently ignored could mask a typo (e.g. a misspelled region)
behind confusing “why isn’t my region showing up” behavior.</li>
<li><strong>Per-field defaults, not all-or-nothing.</strong> If the file
exists and parses but a given setting is absent or empty, that setting’s
own built-in default applies. A config file only needs to mention what
it actually wants to override; it never needs to restate
everything.</li>
<li><strong>A single flat struct, not a versioned schema.</strong>
<code>internal/config.Config</code> has one YAML-tagged field per
setting. Adding a new setting later means adding a field, a default
constant, and wiring it into whatever consumes it — no migration
machinery, which would be over-engineering for a
single-operator-maintained local dotfile (not a multi-tenant service
config).</li>
</ul>
<h3 id="regions"><code>regions</code></h3>
<div class="sourceCode" id="cb1"><pre
class="sourceCode yaml"><code class="sourceCode yaml"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="fu">regions</span><span class="kw">:</span></span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> us-west-1</span></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> us-west-2</span></span></code></pre></div>
<p>Defaults to <code>[us-west-1, us-west-2]</code> if unset or the file
doesn’t exist (see <code>DECISIONS.md</code>, “Narrow configured regions
to us-west-1/us-west-2”). These are the regions every region-fanned-out
feature (instance/AMI listing, key pair listing, official Ubuntu AMI
lookup, and eventually Key Management once it ships) iterates over.</p>
<h3 id="backup_directories"><code>backup_directories</code></h3>
<div class="sourceCode" id="cb2"><pre
class="sourceCode yaml"><code class="sourceCode yaml"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="fu">backup_directories</span><span class="kw">:</span></span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> </span><span class="fu">pattern</span><span class="kw">:</span><span class="at"> </span><span class="st">"rdm-*"</span></span>
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">directory</span><span class="kw">:</span><span class="at"> /opt/rdm_sql_backups</span></span>
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> </span><span class="fu">pattern</span><span class="kw">:</span><span class="at"> </span><span class="st">"newt-*"</span></span>
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">directory</span><span class="kw">:</span><span class="at"> /opt/newt/backups</span></span></code></pre></div>
<p>An ordered list of glob patterns (<code>path.Match</code> syntax:
<code>*</code>, <code>?</code>, <code>[...]</code>), matched against the
picked instance’s Name tag, first match wins. Feature 11 (Backup Archive
& Trim) uses the matching rule’s directory to pre-fill its “Backup
directory” prompt — still an editable value, never a silent default,
consistent with that prompt’s other fields. No match (including an
untagged instance with a blank Name) leaves the prompt with no default,
exactly like today. See <code>DECISIONS.md</code>, “Configure
per-instance backup directories by Name pattern”. Built to accommodate,
not yet implementing, further settings this same file would naturally
hold: per-domain defaults once S3/CloudFront ship (e.g. a default
bucket), or overrides for the curated instance-type/Ubuntu-release lists
if those ever need site-specific tuning.</p>
<h2 id="user-experience-flow">User Experience Flow</h2>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│ awsops — AWS Operations CLI │
├─────────────────────────────────────────────────────────────────┤
│ Pick a domain: │
│ 1) Compute (EC2 & AMI) │
│ 2) Key Management │
│ 3) S3 (Buckets & Static Websites) │
│ 4) CloudFront │
│ 5) Exit │
└─────────────────────────────────────────────────────────────────┘</code></pre>
<p>Picking a domain drops into that domain’s own listing + menu loop.
The Compute domain (below) keeps today’s exact shape:</p>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│ awsops — Compute (EC2 & AMI) │
│ Regions: us-west-1, us-west-2 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ===== CURRENT EC2 INSTANCES ===== │
│ ID Name State AMI ID Region │
│ i-012345... web-server running ami-abc123... us-east-1 │
│ i-67890... db-server stopped ami-def456... us-west-2 │
│ │
│ ===== AVAILABLE AMIs (owned by account) ===== │
│ AMI ID Name Creation Date Region │
│ ami-abc123... base-ubuntu-2404 2026-01-15 us-east-1 │
│ ami-def456... app-server-v2 2026-02-20 us-west-2 │
│ ami-ghi789... custom-ami 2026-03-10 us-east-1 │
│ │
│ ===== COMPUTE MENU ===== │
│ 1) Show resource lists │
│ 2) Create EC2 instance from AMI │
│ 3) Create EC2 instance from cloud-init YAML │
│ 4) Start EC2 instance │
│ 5) Stop EC2 instance │
│ 6) Terminate EC2 instance │
│ 7) Manage tags for an instance or AMI │
│ 8) Create AMI from EC2 instance (running or stopped) │
│ 9) Remove AMI │
│ 10) Show/export cloud-init for an instance or AMI │
│ 11) Archive stale backups to S3 and trim disk space │
│ 12) Back to domain picker │
│ │
└─────────────────────────────────────────────────────────────────┘</code></pre>
<p>(Illustrative — the real listing also includes Project, Environment,
Public IP, and Private IP columns; see Feature 1 and Feature 12 below.)
Key Management, S3, and CloudFront follow the same listing-then-menu
pattern; their specific listings and menus are documented under their
own feature sections below rather than repeated here.</p>
<h2 id="navigation-domain-picker">Navigation: Domain Picker</h2>
<p>On startup, before any resource listing or menu, the tool shows the
domain picker above. Picking a domain fetches and displays that domain’s
resources, then shows a domain-scoped numbered menu — its own “Refresh”
and “Back to domain picker” entries, in addition to that domain’s
actions — and returns to that same domain’s listing after each action
completes. “Back to domain picker” returns to the picker; “Exit” from
inside any domain menu exits the whole tool, not just that domain, so an
operator working in S3 doesn’t have to back out twice.</p>
<p>Domain-specific notes: - <strong>Compute</strong> fans its resource
listing out across all four configured regions (Feature 1), unchanged
from today. - <strong>Key Management</strong> also fans out across the
configured regions — key pairs are a per-region resource. -
<strong>S3</strong> buckets share a single global namespace but each has
a home region; the listing shows that region per bucket, the same style
as Compute’s per-resource region column today. -
<strong>CloudFront</strong> is a genuinely global service (its
control-plane API is always <code>us-east-1</code>, regardless of where
origins live) — its listing is not region-fanned-out at all, the one
domain that behaves differently here.</p>
<p>This structure is additive and mechanical: each domain’s menu loop
and resource-listing call were already separable pieces of Compute’s
existing single-menu implementation (see “Architecture” below), so
introducing the domain picker is a refactor of
<code>internal/ui</code>/<code>internal/workflow</code>’s menu wiring,
not a rewrite of any of Compute’s existing workflows.</p>
<h2 id="color-output">Color Output</h2>
<p>When color is enabled (respects <code>NO_COLOR</code> and falls back
to plain text on a non-TTY, <code>ui.ColorEnabled()</code>), two things
are colorized: - The STATE column in the instance listing
(running=green, stopped/terminated=red, pending/stopping=yellow). -
Every pick-list prompt’s header line (e.g. “Select an instance to
start”), printed in bold <em>before</em> the numbered list it introduces
– so picking the wrong main-menu action (e.g. Start instead of Stop) is
visible immediately, without reading through the list first. See
DECISIONS.md, “Highlight PickList’s prompt header when color is
enabled”.</p>
<h2
id="terminal-ui-architecture-menus-actions-lists-and-managers-design-addendum-2026-07-10">Terminal
UI Architecture: Menus, Actions, Lists, and Managers (Design Addendum,
2026-07-10)</h2>
<p><strong>Status: designed 2026-07-10, implementation starting with the
S3 domain.</strong> Supersedes “S3 Resource List Display — Paged,
Accessible- Compatible” above
(<code>internal/ui.PagedTable</code>/<code>DisplayBuckets</code> are
retired, not extended) and the 0.0.1-era framing of huh as merely “the
leading candidate for the next release” (DECISIONS.md, “0.0.1 scope:
ship on termlib as-is…”). Full rationale and rejected alternatives:
DECISIONS.md, “Deprecate termlib; standardize on huh/bubbletea before
0.0.2.”</p>
<p><strong>Motivation.</strong> clasm exists to replace ad hoc AWS
Console clicking and one-off Bash scripts with something a whole team
can use fluidly, without each person memorizing a different command
sequence per screen – otherwise it offers no real advantage over writing
Bash against the AWS CLI directly. That only works if every screen,
however different its purpose, looks and behaves like part of the same
tool. <code>termlib</code> (a stepping-stone library used to figure out
the menu/action shape this tool needed, not the destination) is being
removed entirely before 0.0.2 in favor of standardizing on
<code>huh</code> and <code>bubbletea</code> exclusively.</p>
<p><strong>Taxonomy.</strong> Every navigation path in clasm passes
through one or more <em>connectors</em> (never a destination themselves)
to reach one of three <em>destinations</em>:</p>
<p>Connectors: - <strong>Guide menu</strong> — a <code>huh.Select</code>
today (works well, simple to teach), or a small <code>bubbletea</code>
screen later if a menu ever needs more than a flat pick-one, over a
small, fixed set of options (e.g. the S3 domain’s 6 actions). Routes the
operator toward a destination below. - <strong>Picker</strong> — chooses
<em>one instance</em> of a fetched, variable-length resource collection
(a specific S3 bucket, EC2 instance, AMI, key pair, …) to feed into an
action wizard or manager. Distinct from a guide menu because the option
list is dynamic and can be long enough to need scrolling/filtering, not
a small fixed menu. See “Picker tier” below.</p>
<p>Destinations: - <strong>Action wizard</strong> — a short prompt
sequence (huh fields, or termlib prompts as they’re migrated off) that
gathers parameters and executes one thing (Create Bucket, Delete Bucket,
…). - <strong>List</strong> — a read-only, scrollable display of a
resource collection (S3 buckets, EC2 instances, AMIs, key pairs). Was
<code>internal/ui.PagedTable</code> (plain sequential prints); becomes a
<code>bubbletea</code> component (below) so it shares real chrome with
the manager tier instead of approximating it with static text. -
<strong>Manager</strong> — a persistent, stateful <code>bubbletea</code>
screen for ongoing interactive work against a resource. The S3 object
manager (<code>internal/filemanager</code>) is the only one today.</p>
<p><strong>Shared chrome: <code>internal/tui</code>.</strong> The file
manager’s box-drawing/ legend/scrolling code
(<code>internal/filemanager/view.go</code>) is already implemented as
pure functions with no dependency on <code>filemanager.Model</code> —
<code>topBorder</code>, <code>bottomBorder</code>, <code>divider</code>,
<code>splitDivider</code>, <code>mergeDivider</code>,
<code>boxLine</code>, <code>boxRow2</code>, <code>padOrTruncate</code>,
<code>runeLen</code>, <code>stripANSI</code>,
<code>truncateVisible</code>, <code>scrollWindow</code>,
<code>styleRow</code>. These move, unchanged, into a new
<code>internal/tui</code> package, and <code>internal/filemanager</code>
imports them instead of keeping its own copy — one implementation, not
two that can drift apart. <code>internal/ui</code>
(<code>PickList</code>,
<code>DisplayInstances</code>/<code>Images</code>/<code>KeyPairs</code>,
<code>Confirm</code>, color helpers) stays in place for as long as
termlib-based call sites remain; it shrinks over the course of the
termlib removal rather than being replaced in one step.</p>
<p><strong>List tier: a new <code>internal/tui</code>
component.</strong> Replaces
<code>internal/ui.PagedTable</code>/<code>DisplayBuckets</code>. A
single bordered box (no split panes), a frozen header row, a scrollable
body reusing the same cursor-centered <code>scrollWindow</code> logic
the file manager’s panes use, sized to the real terminal via
<code>tea.WindowSizeMsg</code> (not a fixed or
computed-from-<code>termlib</code> page size —
<code>tea.WindowSizeMsg</code> is sent to <code>Update</code> once when
the program starts and again on every resize, except on Windows, which
has no <code>SIGWINCH</code>; an initial size still arrives there, just
no live updates), a legend bar at the bottom, rendered inline (no
<code>tea.WithAltScreen</code>, matching every other screen in this
app). Quitting (<code>q</code>) returns to the menu it was opened from —
for “List S3 Buckets” that’s the S3 menu, not
<code>ErrBackToDomainPicker</code> (which backs out of the whole S3
domain, one level further up).</p>
<p><strong>Picker tier: a new <code>internal/tui</code>
component.</strong> The user’s own framing: “this UI should feel the
same whether I select a bucket, an AMI or an EC2 instance” – resource
<em>selection</em> is exactly the kind of screen <code>huh.Select</code>
would otherwise handle, but <code>huh.Select</code>’s own rendering is
visually distinct from the bordered-box/legend-bar chrome the List and
Manager tiers use, which would make the S3 domain alone show two
different visual languages depending on whether a screen shows a
resource or picks one. <code>internal/tui.PickerModel</code> reuses the
exact same chrome as <code>ListViewModel</code>
(<code>TopBorder</code>/<code>BoxLine</code>/<code>Divider</code>/<code>ScrollWindow</code>/
<code>StyleRow</code>/<code>BottomBorder</code>) – same box, same scroll
behavior – but adds selection: <code>Enter</code> chooses the row under
the cursor and returns it, <code>q</code>/<code>ctrl+c</code> cancels. A
dedicated <code>PickerModel</code> rather than a
<code>Selectable bool</code> flag on <code>ListViewModel</code>,
matching this project’s existing preference for small, purpose-built
components over one component doing everything (the same reasoning
already used to keep the List tier itself separate from
<code>filemanager.Model</code>).</p>
<p>Like <code>ListViewModel</code>, <code>PickerModel</code> works on
pre-rendered rows and returns an <em>index</em>, not a typed value, so
<code>internal/tui</code> doesn’t need Go generics – each caller maps
the chosen index back into its own typed slice
(<code>buckets[idx]</code>, <code>instances[idx]</code>, …), the same
pattern <code>pickS3MenuItem</code> already uses for
<code>s3MenuItems</code>.</p>
<p><strong>Filtering, included from the start</strong> (the user’s own
request: “this allows someone to go directly to the thing they want if
they know the name or part of the name”): <code>/</code> enters
filter-typing mode (matching the keybinding table below, and
<code>huh.Select</code>’s own default <code>/</code> binding – not an
always-on type-ahead, since <code>j</code>/<code>k</code> must stay
unambiguous navigation keys), narrows visible rows by case-insensitive
substring match against each row’s rendered text, <code>Enter</code>
commits and keeps navigating the narrowed list, <code>Esc</code> clears
it – the same shape as <code>internal/filemanager</code>’s pane filter
and <code>ui.PickList</code>’s own existing substring-filter convention
(<code>filterByLabel</code>), just applied to a real chrome-consistent
box instead of a plain numbered list or huh’s default field styling.</p>
<p><strong>The map: every current resource-selection call site.</strong>
<code>internal/ui.PickList</code> is used in ~40 places today; most are
guide-menu-shaped (a small, fixed set of actions – “Choose an option,”
“Add/Update/Remove,” Instance-vs- AMI kind pickers) and are NOT Picker
candidates, they stay as menu-tier
<code>PickList</code>/<code>huh.Select</code>. The ones below select
<em>one instance of a fetched resource collection</em> and are the
Picker tier’s actual scope, listed here as the “clear map of specific
instances using the common model” the user asked for – S3 buckets are
the pilot (Phase 20.4); everything else is deliberately not scheduled
yet (see “Not decided yet” below), listed so the eventual conversions
have a concrete checklist to work from rather than needing to be
rediscovered later:</p>
<p>This was the original, preliminary map, written when Phase 20.4 was
the only conversion underway. It’s superseded by the “Full conversion
punch list” immediately below, which is the one kept current – statuses
here are left as a historical snapshot except for the terminal state
(every row below is now done); see the fuller table for the actual phase
each one landed in and, where it differs, which tier it was actually
reclassified into (e.g. “Storage class (transition)” and “Instance type
(curated list)” turned out to be small fixed option sets and became Menu
tier, not Picker tier).</p>
<table>
<colgroup>
<col style="width: 25%" />
<col style="width: 25%" />
<col style="width: 25%" />
<col style="width: 25%" />
</colgroup>
<thead>
<tr>
<th scope="col">Resource</th>
<th scope="col">Domain</th>
<th scope="col">Current call site(s)</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>S3 bucket</td>
<td>S3</td>
<td><code>bucket_website.go:43</code>,
<code>bucket_lifecycle.go:530</code>,
<code>bucket_delete.go:31</code></td>
<td><strong>done, Phase 20.4</strong></td>
</tr>
<tr>
<td>S3 lifecycle rule</td>
<td>S3</td>
<td><code>bucket_lifecycle.go:107,447,491</code></td>
<td><strong>done, Phase 20.12 (Picker tier)</strong></td>
</tr>
<tr>
<td>Storage class (transition)</td>
<td>S3</td>
<td><code>bucket_lifecycle.go:261,364</code></td>
<td><strong>done, Phase 20.11 (reclassified: Menu tier)</strong></td>
</tr>
<tr>
<td>EC2 instance</td>
<td>Compute</td>
<td><code>backup_archive.go:77</code>,
<code>create_ami_from_instance.go:94</code>,
<code>show_cloud_init.go:35</code>, <code>power_state.go:42,113</code>,
<code>terminate_instance.go:50</code>,
<code>manage_tags.go:135</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>AMI</td>
<td>Compute</td>
<td><code>launch_from_cloud_init.go:31</code>,
<code>launch_instance.go:55</code>, <code>show_cloud_init.go:60</code>,
<code>manage_tags.go:154</code>, <code>remove_ami.go:61</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>Subnet</td>
<td>Compute</td>
<td><code>launch_prompts.go:43</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>Instance type (curated list)</td>
<td>Compute</td>
<td><code>launch_prompts.go:169</code></td>
<td><strong>done, Phase 20.11 (reclassified: Menu tier)</strong></td>
</tr>
<tr>
<td>IAM instance profile / role</td>
<td>Compute</td>
<td><code>create_instance_profile.go:71,106</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>Region</td>
<td>S3, Key Management</td>
<td><code>bucket_create.go:26</code>,
<code>keymgmt_common.go:25</code></td>
<td><strong>done, Phase 20.11 (reclassified: Menu tier)</strong></td>
</tr>
<tr>
<td>Key pair</td>
<td>Key Management</td>
<td><code>create_key_pair.go:94</code>,
<code>keypair_delete.go:47</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
</tbody>
</table>
<p><strong>Full conversion punch list (2026-07-10).</strong> The map
above covers only Picker candidates. Per the user’s request for “a clear
map of specific instances using the common model” spanning all three
targets, here is every current
<code>ui.PickList</code>/<code>ui.Display*</code> call site in the
codebase, classified by which tier it converts to. Nothing here is
scheduled beyond what’s already marked done — this is a checklist to
work from, not a committed roadmap (see “Not decided yet” below).</p>
<p><em>Menu tier (→ <code>huh.Select</code>, small fixed option sets —
not fetched, not long enough to need scrolling/filtering):</em></p>
<table>
<colgroup>
<col style="width: 33%" />
<col style="width: 33%" />
<col style="width: 33%" />
</colgroup>
<thead>
<tr>
<th scope="col">Menu</th>
<th scope="col">Call site(s)</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>S3 domain menu</td>
<td><code>s3_menu.go</code> (<code>pickS3MenuItem</code>)</td>
<td><strong>done, Phase 20.2/20.7</strong></td>
</tr>
<tr>
<td>Lifecycle rule action (Add/Edit/Remove/View)</td>
<td><code>bucket_lifecycle.go</code>
(<code>pickLifecycleAction</code>)</td>
<td><strong>done, Phase 20.9</strong></td>
</tr>
<tr>
<td>Domain picker</td>
<td><code>domain_menu.go:60</code></td>
<td><strong>done, Phase 20.10</strong></td>
</tr>
<tr>
<td>Compute main menu</td>
<td><code>menu.go:85</code></td>
<td><strong>done, Phase 20.10</strong></td>
</tr>
<tr>
<td>Key Management menu</td>
<td><code>keymgmt_menu.go:59</code></td>
<td><strong>done, Phase 20.10</strong></td>
</tr>
<tr>
<td>Instance-vs-AMI kind (show/export cloud-init)</td>
<td><code>show_cloud_init.go:22</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>Instance-vs-AMI kind (manage tags)</td>
<td><code>manage_tags.go:119</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>Tag Add/Update/Remove action</td>
<td><code>manage_tags.go:171</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>Select a tag to update/remove (small, in-memory, per-resource)</td>
<td><code>manage_tags.go:196,212</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>Bucket-purpose enum (Website/Backup/Internal)</td>
<td><code>bucket_create.go:71</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>Region (configured list, S3)</td>
<td><code>bucket_create.go:26</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>Region (configured list, Key Management)</td>
<td><code>keymgmt_common.go:25</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>Instance type (curated static list + “Other”)</td>
<td><code>launch_prompts.go:169</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>Storage class, guided backup flow (curated 4)</td>
<td><code>bucket_lifecycle.go:296</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>Storage class, generic editor (full enum)</td>
<td><code>bucket_lifecycle.go:399</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>AZ-incompatibility remediation choice</td>
<td><code>instance_type_az_check.go:144</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
<tr>
<td>ENA-incompatibility remediation choice</td>
<td><code>instance_type_ena_check.go:66</code></td>
<td><strong>done, Phase 20.11</strong></td>
</tr>
</tbody>
</table>
<p><em>Picker tier (→ <code>tui.Picker</code>, fetched/variable-length
resource collections):</em></p>
<table>
<colgroup>
<col style="width: 33%" />
<col style="width: 33%" />
<col style="width: 33%" />
</colgroup>
<thead>
<tr>
<th scope="col">Resource</th>
<th scope="col">Call site(s)</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>S3 bucket</td>
<td><code>bucket_website.go</code>/<code>bucket_lifecycle.go</code>/<code>bucket_delete.go</code>
(<code>pickBucket</code>)</td>
<td><strong>done, Phase 20.4</strong></td>
</tr>
<tr>
<td>EC2 instance</td>
<td><code>backup_archive.go:77</code>,
<code>create_ami_from_instance.go:94</code>,
<code>show_cloud_init.go:35</code>, <code>power_state.go:42,113</code>,
<code>terminate_instance.go:50</code>,
<code>manage_tags.go:135</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>AMI</td>
<td><code>launch_from_cloud_init.go:31</code>,
<code>launch_instance.go:55</code>, <code>show_cloud_init.go:60</code>,
<code>manage_tags.go:154</code>, <code>remove_ami.go:61</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>Subnet</td>
<td><code>launch_prompts.go:43</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>IAM instance profile (fetched, + none/create-new)</td>
<td><code>create_instance_profile.go:71</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>IAM role (fetched, to attach)</td>
<td><code>create_instance_profile.go:106</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>Key pair (fetched, + create-new)</td>
<td><code>create_key_pair.go:94</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>Key pair (fetched, to delete)</td>
<td><code>keypair_delete.go:47</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
<tr>
<td>S3 lifecycle rule (view/edit/remove)</td>
<td><code>bucket_lifecycle.go:142,482,526</code></td>
<td><strong>done, Phase 20.12</strong></td>
</tr>
</tbody>
</table>
<p><em>List tier (→ <code>tui.ListView</code>, read-only resource
displays — the <code>ui.Display*</code> family):</em></p>
<table>
<thead>
<tr>
<th scope="col">Listing</th>
<th scope="col">Function</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>S3 buckets</td>
<td><code>ui.DisplayBuckets</code></td>
<td><strong>done, Phase 20.6</strong></td>
</tr>
<tr>
<td>EC2 instances</td>
<td><code>ui.DisplayInstances</code></td>
<td><strong>done, Phase 20.13</strong></td>
</tr>
<tr>
<td>AMIs</td>
<td><code>ui.DisplayImages</code></td>
<td><strong>done, Phase 20.13</strong></td>
</tr>
<tr>
<td>Key pairs</td>
<td><code>ui.DisplayKeyPairs</code></td>
<td><strong>done, Phase 20.13</strong></td>
</tr>
</tbody>
</table>
<p><code>ListViewModel</code> gained the same <code>/</code>-filter
behavior as <code>PickerModel</code> (Phase 20.14) – see “Filtering,
included from the start” above, which was written for Picker but always
intended for both per the keybinding table below; the two models now
share a <code>filterState</code> helper
(<code>internal/tui/filter.go</code>) rather than each keeping its own
copy.</p>
<p><strong>Keybinding conventions</strong> (DECISIONS.md, “TUI
keybinding conventions”):</p>
<table>
<colgroup>
<col style="width: 33%" />
<col style="width: 33%" />
<col style="width: 33%" />
</colgroup>
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Action</th>
<th scope="col">Where</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>q</code></td>
<td>Back to the parent screen</td>
<td>Everywhere</td>
</tr>
<tr>
<td><code>↑</code>/<code>↓</code>, <code>k</code>/<code>j</code></td>
<td>Navigate / scroll</td>
<td>Menus, pickers, lists, managers</td>
</tr>
<tr>
<td><code>Enter</code></td>
<td>Select / confirm / submit</td>
<td>Menus, pickers, lists, wizards</td>
</tr>
<tr>
<td><code>Esc</code></td>
<td>Cancel the <em>in-progress</em> action only — never closes a
screen</td>
<td>Wizards, in-progress input</td>
</tr>
<tr>
<td><code>/</code></td>
<td>Filter</td>
<td>Menus, pickers, lists, managers</td>
</tr>
<tr>
<td>Legend bar</td>
<td>Always visible at the bottom of every screen, showing that screen’s
actual keys</td>
<td>Every screen</td>
</tr>
</tbody>
</table>
<p>Menus (still <code>huh.Select</code> for now) can’t show a custom
footer entry: huh’s help line is built solely from the focused field’s
own <code>KeyBinds()</code>, and <code>SelectKeyMap</code> has no
quit/back entry to add one to without forking huh. <code>q</code> is
bound at the <code>Form</code> level instead
(<code>Form.WithKeyMap</code>, adding <code>"q"</code> alongside the
default <code>"ctrl+c"</code> on <code>KeyMap.Quit</code>), which
already resolves to the same <code>huh.ErrUserAborted</code> path
<code>RunS3Menu</code>’s <code>mapS3MenuPickerErr</code> maps to
<code>ErrBackToDomainPicker</code> — no new dispatch logic needed. Since
that won’t appear in huh’s own footer, a short static hint line is
printed above the menu instead (e.g. “(q to go back)”), fully within
this project’s own control. Picker, list, and manager tiers, which fully
own their rendering, show <code>q</code> in a real legend bar
instead.</p>
<p><strong>Accessibility.</strong> Screen-reader/non-TTY accessible
rendering is not a requirement for clasm going forward — it’s an
internal tool for Library staff managing AWS resources, not
public-facing (distinct from the Frontend Guidelines’ A11y requirement
for browser-side Web Components elsewhere in this workspace, which this
doesn’t affect). The prior session’s huh-accessible-mode
pipe-testability investigation (DECISIONS.md, 2026-07-10, “huh fields
are pipe-testable…”) remains factually accurate but is no longer
load-bearing for design decisions; testing shifts to
<code>teatest</code> (already proven against
<code>internal/filemanager</code>’s <code>Model</code>) for anything
built as a real <code>bubbletea</code> component.</p>
<p><strong>Superseded 2026-07-13</strong> by “Removing termlib: Action
Wizards and Output” immediately below, which is now the committed plan
for exactly this remaining work.</p>
<h2
id="removing-termlib-action-wizards-and-output-design-addendum-2026-07-13">Removing
termlib: Action Wizards and Output (Design Addendum, 2026-07-13)</h2>
<p><strong>Status: designed 2026-07-13, not yet implemented.</strong>
Closes out the “Not decided yet” paragraph above by giving the remaining
~40 <code>termlib</code> call sites (every action wizard, plus
<code>internal/ui</code>’s lower-level helpers) a committed conversion
plan, per DECISIONS.md, “Remove termlib entirely: input via huh, output
via <code>io.Writer</code>.” Menu/Picker/List tiers are unaffected —
they’re already fully converted (Phase 20.2-20.14).</p>
<p><strong>Surface audit.</strong> Every remaining <code>termlib</code>
symbol was traced to its actual call sites (not just its imports) across
the ~44 files that still reference it:</p>
<table>
<colgroup>
<col style="width: 33%" />
<col style="width: 33%" />
<col style="width: 33%" />
</colgroup>
<thead>
<tr>
<th scope="col">Symbol</th>
<th scope="col">Refs</th>
<th scope="col">Actual usage</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>termlib.Terminal</code></td>
<td>109</td>
<td>Only
<code>.Printf</code>/<code>.Println</code>/<code>.Refresh()</code> are
ever called anywhere in this codebase — no cursor movement, no color
state (<code>Move</code>/<code>Clear</code>/<code>SetFgColor</code>/etc.
are unused). It’s used purely as a buffered <code>io.Writer</code>.</td>
</tr>
<tr>
<td><code>termlib.LineEditor</code></td>
<td>83</td>
<td>Only <code>.Prompt()</code> is ever called. History
(<code>AppendHistory</code>/<code>SetHistory</code>/<code>History</code>),
tab-completion (<code>Completer</code>), <code>$EDITOR</code>
composition, and multi-line input (Ctrl+J) are all unused — no call site
needs anything beyond single-line text entry with Ctrl+C/Ctrl+D
handling.</td>
</tr>
<tr>
<td><code>termlib.PadRight</code> / <code>Truncate</code></td>
<td>40 / 12</td>
<td>Column formatting, <code>internal/ui/display.go</code> only.</td>
</tr>
<tr>
<td><code>termlib.Bold</code> / <code>Reset</code> / <code>Green</code>
/ <code>Red</code> / <code>Yellow</code></td>
<td>4 / 5 / 2 / 1 / 1</td>
<td>ANSI constants, <code>internal/ui/color.go</code>
(<code>Highlight</code>) and <code>display.go</code>
(<code>stateColor</code>) only.</td>
</tr>
<tr>
<td><code>termlib.FormatDuration</code></td>
<td>2</td>
<td>10-line <code>m:ss</code>/<code>h:mm:ss</code> formatter,
<code>progress_ticker.go</code> and
<code>create_ami_from_instance.go</code>.</td>
</tr>
<tr>
<td><code>termlib.New</code> / <code>NewLineEditor</code></td>
<td>6 / 3</td>
<td>Constructors — <code>cmd/clasm/main.go</code> and tests only.</td>
</tr>
<tr>
<td><code>termlib.ErrInterrupted</code></td>
<td>4</td>
<td>Ctrl+C sentinel from <code>LineEditor.Prompt</code>, checked in
<code>isExitSignal</code>/<code>mapMenuPickerErr</code>-style error
mapping.</td>
</tr>
</tbody>
</table>
<p>Only three files call <code>le.Prompt()</code> directly:
<code>internal/ui/prompt.go</code> (<code>Prompt</code>),
<code>internal/ui/picklist.go</code> (<code>PickList</code> — see
below), and <code>internal/workflow/confirm.go</code>
(<code>Confirm</code>/<code>ConfirmDestructive</code>). Every other file
that imports <code>termlib</code> merely threads
<code>t</code>/<code>le</code> through its own signature to reach one of
these three, or to call <code>t.Println</code>/ <code>t.Printf</code>
directly for status/error text. This means
<code>le *termlib.LineEditor</code> disappears from every signature in
the codebase once these three functions are rebuilt — there is no other
direct caller to migrate.</p>
<p><strong><code>internal/ui.PickList</code> is dead code.</strong>
Every real call site was already converted to
<code>huh.Select</code>/<code>tui.Picker</code> in the Phase 20.2-20.13
punch list; only comments still reference it
(<code>internal/tui/picker.go</code>, <code>object_browser.go</code>,
<code>s3_menu.go</code>). <code>internal/ui/picklist.go</code> and
<code>picklist_test.go</code> are deleted outright, not migrated.</p>
<p><strong>Mapping: termlib construct → replacement.</strong></p>
<ul>
<li><strong><code>ui.Prompt</code></strong> (free-text input, optional
default + validator; ~30 call sites) → rebuilt on
<code>huh.NewInput()</code>, following the same split-into-testable-core
pattern already used for every Menu/Picker conversion (Phase 20.2 etc.):
a thin public wrapper plus an
<code>input io.Reader, output io.Writer</code>-accepting core that tests
drive via huh’s accessible-mode pipe path.
<code>WithDefault</code>/<code>WithValidator</code> map to
<code>huh.Input.Value(&s)</code> with a default pre-fill and
<code>.Validate(func(string) error)</code> — huh already re-prompts on a
validator error without any surrounding loop needed.</li>
<li><strong><code>Confirm</code></strong> (y/n, re-prompt on
unrecognized input) → <code>huh.NewConfirm()</code>. The
re-prompt-on-bad-input loop disappears entirely: a toggle can’t produce
unrecognized input.</li>
<li><strong><code>ConfirmDestructive</code></strong> (type-to-confirm,
single attempt, mismatch cancels rather than re-prompting) →
<code>huh.NewInput()</code> with <em>no</em> validator (a validator
would make huh re-prompt until correct, changing the single-attempt
semantics); the exact-match check runs after the field returns, same as
today. The instructional text currently printed via
<code>t.Printf</code> before the prompt becomes the field’s
<code>.Description()</code>.</li>
<li><strong>Plain status/error output</strong>
(<code>t.Println</code>/<code>t.Printf</code> for things like
“Exiting.”, “Error: %s”, the progress ticker’s periodic elapsed-time
line, <code>loadUserData</code>’s “looks like an existing file” note) →
the <code>*termlib.Terminal</code> parameter becomes a plain
<code>io.Writer</code>; <code>t.Println</code>/ <code>t.Printf</code>
become <code>fmt.Fprintln</code>/<code>fmt.Fprintf</code>;
<code>t.Refresh()</code> calls are deleted outright (nothing buffers
anymore, so there’s nothing to flush). In the ~9 files where
<code>t</code>/<code>le</code> were pure pass-through (never called
directly, only forwarded to a callee), the parameter is dropped or
renamed to <code>w io.Writer</code> depending on whether that file’s own
callees still need one.</li>
<li><strong><code>progress_ticker.go</code></strong> — mechanical parity
only: <code>*termlib.Terminal</code> → <code>io.Writer</code>,
<code>termlib.FormatDuration</code> → a local reimplementation (same
<code>m:ss</code>/<code>h:mm:ss</code> rounding). No new bubbletea
spinner component in this pass — explicitly deferred to a later
chrome-improvement pass (see TODO.md) rather than mixed into a pure
removal.</li>
<li><strong><code>termlib.Bold</code>/<code>Reset</code>/<code>Green</code>/<code>Red</code>/<code>Yellow</code>,
<code>PadRight</code>/<code>Truncate</code></strong> → reimplemented
locally in <code>internal/ui</code> as the same small set of ANSI
constants and rune-aware pad/truncate helpers actually used (~20 lines
total). No new dependency (e.g. <code>lipgloss</code>) introduced in
this pass — deferred to the later chrome-standardization pass so this
removal stays scoped to “delete termlib,” not “restyle everything.”</li>
<li><strong><code>termlib.ErrInterrupted</code></strong> → once every
input path runs through huh, Ctrl+C during input surfaces as
<code>huh.ErrUserAborted</code>, which the Menu-tier conversions already
map (<code>mapMenuPickerErr</code>, <code>huhCancelledIsNil</code>).
Each remaining <code>errors.Is(err, termlib.ErrInterrupted)</code> check
is replaced with the equivalent <code>huh.ErrUserAborted</code> check at
its call site, not a blanket rename — some of these sites may find the
check is already redundant once the underlying prompt is gone.</li>
<li><strong><code>cmd/clasm/main.go</code></strong> —
<code>termlib.New(out)</code>/<code>termlib.NewLineEditor(...)</code>
construction is deleted; <code>os.Stdout</code> is passed directly
wherever an <code>io.Writer</code> is still needed.</li>
</ul>
<p><strong>Sequencing.</strong> Unlike the Menu/Picker/List conversions
(each independent, one call site at a time), this refactor changes a
type threaded through nearly every <code>internal/workflow</code>
function signature — Go requires the whole module to compile together,
so it can’t ship as 40 independent single-file changes. See PLAN.md
Phase 20.15 (foundational helpers) and 20.16 (mechanical propagation,
domain by domain) for the ordered work breakdown.</p>
<h2
id="chrome-standardization-a-shared-lipgloss-palette-design-addendum-2026-07-13">Chrome
Standardization: A Shared lipgloss Palette (Design Addendum,
2026-07-13)</h2>
<p><strong>Status: designed 2026-07-13, not yet implemented.</strong>
With termlib gone (Phase 20.15/20.16), every screen in clasm is now
either a <code>huh</code> field or a <code>bubbletea</code> component —
but they don’t yet look like one system. <code>huh</code>’s default
theme (<code>ThemeCharm</code>) renders a colorful indigo/fuchsia/ cream
card with a thick colored left border; <code>internal/tui</code>’s List/
Picker/Manager chrome (<code>box.go</code>/<code>style.go</code>) is
plain ASCII box-drawing with no color at all beyond the cursor row’s
reverse-video and the instance-state column’s green/red/yellow. An
operator moving from a Menu-tier <code>huh.Select</code> into a Picker
or List sees two unrelated visual languages depending on which tier they
happen to be in, not a deliberate design.</p>
<p><strong>A single shared accent, not a repaint.</strong> Rather than
inventing a new palette, this reuses the one color <code>huh</code>’s
own default theme already established and has been on screen since Phase
20.2: the adaptive indigo <code>ThemeCharm</code> uses for focused
titles/borders (<code>#5A56E0</code> light / <code>#7571F9</code> dark —
already light/dark-terminal-aware via
<code>lipgloss.AdaptiveColor</code>). Two pieces:</p>
<ol type="1">
<li><strong><code>tui.Theme() *huh.Theme</code></strong> — built from
<code>huh.ThemeBase()</code> (structural styling only: spacing,
borders-as-shapes, no color) with <em>only</em> the indigo accent
applied to focused titles, borders, and the selected-option marker (bold
+ indigo, mirroring exactly what <code>ThemeCharm</code> already does
for those same elements) — deliberately omitting
<code>ThemeCharm</code>’s fuchsia highlight, cream backgrounds, and
green/red confirm-button colors. A single accent suits an internal ops
tool better than a five-color rainbow; this is a restrained subset of
<code>ThemeCharm</code>, not a new invention.</li>
<li><strong><code>internal/tui/box.go</code>’s border/title
rendering</strong> (<code>TopBorder</code>, <code>BottomBorder</code>,
<code>Divider</code>, <code>SplitDivider</code>,
<code>MergeDivider</code>) styled with the same indigo + bold via