Design system¶
Every usm command — the launcher and the scripts it runs — renders through
usmo.ui, so twenty-seven separate programs read as one tool.
The vocabulary¶
| meaning | |
|---|---|
✓ |
something finished |
✗ |
something failed |
! |
worth reading, but not fatal |
→ |
a step starting, or a transition (old → new) |
● ○ |
present / absent — cached, mounted, enabled, running |
· |
separator between facts on one line |
Colour carries the same meaning everywhere:
| used for | |
|---|---|
| bold cyan | an identifier you could type back: a name, an id |
| dim | context you can skip: paths, counts, hints |
| green / yellow / red | the three status levels |
Nothing else is coloured. If a value isn't an identifier, a status or context, it stays plain.
ui.RUNNING / ui.STOPPED are the same two glyphs under the names a daemon
reads better with; a table uses the glyph (ui.state) and a detail row uses
the word (ui.status_text), and both take their colour from the same place.
Status lines¶
ui.ok("mounted ~/data") # ✓ mounted ~/data
ui.warn("SAS expires in 12m") # ! SAS expires in 12m (stderr)
ui.fail("mount failed") # ✗ mount failed (stderr)
ui.step("refreshing credential") # → refreshing credential
ui.hint("usm azsync ls to see all") # dim, secondary guidance
ui.title("azsync", subtitle="4 syncs")
fail and warn go to stderr so a command's result stays pipeable.
Everything is redacted on the way out — sig=, password=, token=,
api-key= and friends become ***. A credential cannot reach a terminal or
a log just because someone interpolated a URL into a message.
Tables¶
Headers are lowercase: they are dim context, not titles, and shouting
them competes with the bold-cyan identifiers in the rows. Short acronyms
(SAS, TCP, SSH) keep their case because lowercasing them makes them
harder to read, not easier.
A listing row is one line. Columns are dropped as the terminal narrows rather than every column being squeezed into uselessness — the detail view always has the full picture.
built = ui.table(
ui.Column("id", min_width=6),
ui.Column("source", hide_below=110), # first to go
ui.Column("destination", min_width=14, ratio=1),
ui.Column("SAS", justify="right", hide_below=80),
title="Syncs",
)
A table stops at its content, not at the right margin — a rule trailing off
across a wide window reads as a stray line, not as a table. It grows to at
most ui.MAX_TABLE_WIDTH (96) columns; past that the ratio column takes
the squeeze on its own, so identifiers and states stay readable while only
the long path or description ellipsizes. Pass max_width= for a different
cap, or expand=True for the rare table that really should fill the width.
hide_below is the terminal width under which a column disappears. Build
rows to match with ui.row_for, keyed by header:
for job in jobs:
built.add_row(*ui.row_for(columns, {
"ID": job.id,
"Source": ui.shorten_path(job.source),
"Destination": ui.short_blob_target(job.dest),
"SAS": ui.compact_duration(remaining),
}))
Detail views¶
The inverse: values wrap rather than truncate, because silently cutting a
path in half is worse than a second line. Group with SECTION.
ui.print_detail([
("source", ui.shorten_path(job.source)),
("destination", ui.short_blob_target(job.dest)),
ui.SECTION,
("status", ui.status("ok", "watching")),
("last sync", "ok, 2m ago"),
])
Formatting¶
Two duration formats, deliberately:
| for | ||
|---|---|---|
human_duration(9000) |
2h30m |
detail views, where width is free |
compact_duration(9000) |
2h |
table columns — never more than 4 chars |
Using the precise one in a column is what makes a row wrap to three lines.
| helper | |
|---|---|
human_bytes |
2.0KiB, 5.0MiB |
shorten_path |
/home/first.last@example.com/data → ~/data |
short_blob_target |
https://acct.blob.core.windows.net/c/a?sas → acct/c/a |
elide(text, n) |
trims, keeping the tail (paths are known by their end) |
plural(2, "sync") |
2 syncs |
joined(a, b, c) |
a · b · c |
Adopting it in a script¶
Declare usmo in the script's requirements:
Then import it like any library. usm --debug myscript installs the usmo
checkout you're standing in editably, so changes to the design system take
effect immediately — no release, no venv rebuild.
Cost¶
usmo.ui imports only the standard library; rich is pulled in the first
time something is actually rendered. A command that prints two status lines
never pays for the table engine. Column is a plain class rather than a
dataclass for the same reason — dataclasses alone costs ~9ms, and this
module is imported on every usm invocation.
Source¶
src/usmo/ui.py,
tested at 100% line coverage in
tests/test_ui.py —
including that the glyphs don't drift, that secrets never render, that a row
fits terminals from 50 to 200 columns, and that importing the module doesn't
drag in rich.