zyginit Visual Pass — Design
Date: 2026-05-12
Status: Approved (brainstorm complete); implementation plan to follow
Scope: Unified visual identity across the boot console, zygctl, and shutdown. New src/ui.reef module owns palette, glyphs, sigil, TTY/TERM detection, ANSI rendering, rolling tape, progress bar, and spinner. Existing println lifecycle sites in main.reef / supervisor.reef are routed through it. Graceful fallback to ASCII glyphs on dumb terminals and to plain line-by-line output when non-TTY, NO_COLOR, or supervisor mode.
1. Goals
- Unified identity. Same
[Z]sigil, six-color palette, and four-state glyph set on every visible surface: boot console, shutdown console,zygctl status. - Restrained brand. No banner ASCII art, no friendly tier names, no animations beyond a single spinner glyph. The boot screen feels structured and tidy, not theatrical.
- Make tiers visible. zyginit is the only init that exposes dependency tiers as a first-class concept. The boot UI uses them as the structural backbone of progress reporting.
- Graceful degrade. Truecolor on Hammerhead framebuffer, 16-color baseline on serial consoles, ASCII glyphs on dumb terminals, plain machine-parseable lines when non-TTY or
NO_COLOR. - Hammerhead-tem-safe. No animation faster than the main event loop wake-up cadence, no terminal queries (
CSI 6netc.), no concurrent writers to/dev/console. zyginit owns the screen.
2. Non-goals
- No splash screen / Plymouth-equivalent. Tem is polled I/O — text-mode only.
- No friendly tier names. Tiers are displayed as bare
tier N. Adding atier_nameTOML field was considered and rejected. - No mouse, no Unicode line-drawing reliance. Box-drawing chars (
──divider) are the only Unicode used in the frame; everything else has an ASCII fallback. - No new process or binary. Rendering happens in-process inside zyginit;
zygctlprints the daemon's pre-rendered response verbatim. - No restructure of the zyginit ↔ zygctl module boundary. They stay separate Reef projects with separate
reef.toml. - No
zygctl listbanner.listis the catalog command, used in scripts more thanstatus— stays minimal. - No animation under heavy load. Spinner ticks are event-driven (one per main-loop wake-up), not timer-based.
3. Architecture
3.1 New module: src/ui.reef
Single new file. Owns:
- Palette + truecolor / 16-color SGR selection
- Glyph set + ASCII fallback
- TTY / TERM /
NO_COLORdetection - ANSI cursor positioning helpers
- Header + rolling tape + progress bar state
[Z]sigil- Spinner frame index
3.2 Public surface (Reef pseudo-signatures)
// Lifecycle
proc ui_init(rich_mode: int)
proc ui_boot_start(num_svc: int, num_tiers: int, runlevel: string)
proc ui_tier_start(tier: int)
proc ui_tier_done(tier: int)
proc ui_event_starting(name: string)
proc ui_event_started(name: string, dur_ms: int)
proc ui_event_failed(name: string, exit_code: int, dur_ms: int)
proc ui_event_stopping(name: string)
proc ui_event_stopped(name: string, dur_ms: int)
proc ui_boot_complete(stats: BootStats)
proc ui_shutdown_start(reason: string)
proc ui_shutdown_complete(reason: string, elapsed_ms: int)
// Spinner
proc ui_tick() // advance spinner frame, redraw active rows
fn ui_spinner_frame(reverse: bool): string
// On-demand rendering (for zygctl status/list)
fn ui_render_status(table: ServiceTable, want_banner: int, plain: int): string
fn ui_render_list(table: ServiceTable): string
3.3 Call-site changes
src/main.reef— invokeui_initimmediately aftersetup_pid1_console. Wrap dep-graph load withui_boot_start. Wrap each tier loop withui_tier_start/ui_tier_done. Callui_boot_completeafter all tiers. Addui_tickto the main poll loop. Bracket shutdown withui_shutdown_start/ui_shutdown_complete.src/supervisor.reef— replace ~8 lifecycleprintlncalls (started, stopped, failed, stop-timeout, scheduled-for-restart, entered-maintenance) withui_event_*calls. Warning/debug prints stay as-is.src/socket.reef—cmd_statusandcmd_listdelegate toui_render_status/ui_render_list. NewPLAINmodifier on the wire forces banner-off + escape-free output.tools/zygctl/src/main.reef— pre-flight: detectisatty(STDOUT_FILENO)+NO_COLOR+TERM. SendSTATUS PLAINorSTATUSto the daemon accordingly. Print the response verbatim.
3.4 What stays unchanged
- Service stdout/stderr capture to
/var/log/zyginit/<name>.log(per-service log files) is unchanged. - Existing socket protocol commands keep their text shape;
PLAINis an additive modifier. zygctl listkeeps its existing minimal one-line-per-service shape.- The supervisor state machine (8 states), restart policies, contract handling — none of it changes.
4. Visual elements
4.1 Palette
Six tokens. Truecolor on TERM=sun-color or *-256color; 16-color baseline otherwise.
| Token | Truecolor | 16-color fallback | Used for |
|---|---|---|---|
c.frame |
#6E7B8B steel grey |
white |
header text, column labels |
c.accent |
#006A6F teal |
cyan |
[Z] sigil, dividers, "elapsed", progress-bar filled |
c.ok |
#2E8B57 sea green |
green |
● online / running |
c.warn |
#FFBF00 amber |
yellow |
◉ / spinner — starting / stopping |
c.fail |
#C8201F signal red |
red |
✕ failed / maintenance |
c.mute |
default fg + \e[2m (dim attr) |
dim |
· pending / stopped, progress-bar empty |
4.2 Glyphs
| State | Unicode | ASCII (TERM=dumb or ZYGINIT_ASCII=1) |
Color |
|---|---|---|---|
| online / running | ● |
# |
c.ok |
| starting | spinner (see 4.3) | spinner | c.warn |
| stopping | spinner reversed | spinner reversed | c.warn |
| failed / maintenance | ✕ |
X |
c.fail |
| pending / stopped | · |
. |
c.mute |
Box-drawing divider: ──────. Same character in rich and ASCII modes; tem renders it from the sun-color font.
4.3 Spinner
Frame set: [ "/", "-", "\\", "|" ] (same in rich and ASCII modes).
- Starting state: frames advance forward —
/ → - → \ → |— indexg_tick mod 4. - Stopping state: frames advance in reverse —
| → \ → - → /— index(N_FRAMES - 1) - (g_tick mod 4). g_tickis incremented byui_tick(), which is called once per main-loop wake-up. No separate timer thread. Spinner advances on any signal, contract event, socket activity, or poll timeout (200ms boot-mode default).- Services that transition
STARTING → RUNNINGin under one tick never visibly spin; their row appears with●directly. No special case. - Spinner is colored
c.warn.
4.4 Sigil
[Z] rendered in c.accent, prefixed to every banner (boot, shutdown, zygctl status). No other sigil variants.
5. Layouts
All layouts assume 80×24 minimum. Wider terminals leave whitespace; narrower terminals trigger truncation (see §7.2).
5.1 Boot rolling tape — during boot
Total: 18 rows. 4 header + 12 tape + 2 progress.
[Z] zyginit 0.1.2 · multi-user elapsed 6.2s
17 services · tier 5 · 12 done · 1 failed
failed: network exit=1
──────────────────────────────────────────────────────────────
0.49 ● swap
1.04 ● filesystem
1.08 ● identity
1.36 ● sysconfig
1.55 ● dlmgmtd
1.78 ● ipmgmtd
2.10 / network (starting)
...
──────────────────────────────────────────────────────────────
█████████████░░░░░░░░░░░░░░░░░░░░░░░ 12/17 70%
Header (rows 1–3) repainted on tier transition and on any change to fail count. The failed: line shows up to one currently-failed service; on a second failure it becomes failed: 2 services and the user reads zygctl status for detail. If zero failures, the line shows failed: none.
Tape (rows 5–16) is a 12-line rolling window. New event lines append at the bottom; oldest scrolls off the top. Each line is <elapsed-since-boot> <glyph> <name> (<state-note>) where state-note is (starting) / (stopping) for in-progress services and absent for terminal states.
Progress bar (row 18):
- 36-cell width
- Filled
█inc.accent; empty░inc.mute - Counter:
<online + failed> / <total>— failures DO count toward the bar's "done" tally so it can reach 100%. The header tracks failure visibility separately. - Percent:
floor(done / total * 100) - ASCII fallback:
[#################### ] 12/17 70%— 30-cell width inside brackets
5.2 Boot final card — boot complete → login
When the last tier completes, header and tape are erased and the final card is painted:
[Z] zyginit 0.1.2 · multi-user boot 8.3s
17 services — 16 online, 1 failed
failed: network exit=1
→ zygctl log network to see why
──────────────────────────────────────────────────────────────
slowest: network 5.02s filesystem 0.55s sshd 0.37s
- "boot 8.3s" replaces "elapsed 6.2s" in the header.
- Up to 3 slowest services listed on the bottom line.
- Progress bar is removed (its information is now in the second line).
- Card stays visible until
console-login's ttymon paintsLogin:underneath it. No timer, no clear. - If there are zero failures, the
failed:and hint lines are replaced with a blank line +all services online.
5.3 Shutdown mirror — during shutdown
Mirrors §5.1 with shape adjustments:
[Z] zyginit 0.1.2 · stopping for reboot elapsed 1.4s
17 services · tier 7 · 13 down · 0 failed
──────────────────────────────────────────────────────────────
0.04 · sshd
0.16 · console-login
0.31 · cron
0.48 | syslogd (stopping)
...
──────────────────────────────────────────────────────────────
░░░░░░░░░░░░░░░░░░░░██████████████████ 13/17 down 76%
- Header subtitle uses
<N> downinstead of<N> done. - Failures during shutdown are services that returned non-zero from their stop method or hit the stop-timeout → SIGKILL escalation; still surfaced in the
failed:line. - Reverse spinner: stopping services render
| \ - /. - Progress bar fills from right to left, signaling "winding down". Filled cells are still
c.accent; the bar's "done" count is services that have reached theSTOPPEDstate (success or kill).
5.4 Shutdown final card
[Z] zyginit 0.1.2 · halt complete halt 2.1s
17 services stopped cleanly
──────────────────────────────────────────────────────────────
invoking uadmin(A_SHUTDOWN, AD_BOOT)
- Variant for non-clean:
17 services stopped (2 force-killed). - Subtitle uses
halt/reboot/poweroffdepending on reason. - Card stays visible until the forked uadmin child causes the kernel to reset the screen.
5.5 zygctl status — banner + table
[Z] zyginit 0.1.2 · multi-user · 17 services · 16 online, 1 failed
──────────────────────────────────────────────────────────────
SERVICE STATE PID CTID UPTIME NOTES
root-fs ● online (oneshot)
crypto ● online (oneshot)
dlmgmtd ● running 1234 42 2h15m
network ✕ failed exit=1 restarts=3
sshd ● running 1280 51 2h15m
- Banner: same shape as boot/shutdown, no "elapsed" since this is a snapshot.
- Single-service form (
zygctl status sshd) returns only the table row — no banner. - Plain mode: no banner, no escapes, columns space-aligned, single trailing newline per row.
- Column widths:
SERVICEis dynamic (max name length, clamped 8–24);STATEis fixed 10 chars including glyph;PID6,CTID6,UPTIME9,NOTESis rest-of-line.
5.6 zygctl list — unchanged shape
Stays minimal:
$ zygctl list
root-fs (oneshot)
crypto (oneshot)
dlmgmtd (daemon)
network (oneshot)
sshd (daemon)
...
Reason: list is used in scripts more than status. Adding a banner adds noise. Color is applied to the type chip only when isatty.
6. Render policy
Decided once at startup by ui_init() via detect_rich_mode(). Stored in module state. No mid-run mode flips.
detect_rich_mode() -> int:
if env("ZYGINIT_NO_UI") set → MODE_PLAIN
if env("NO_COLOR") set → MODE_PLAIN (https://no-color.org)
if getpid() != 1 → MODE_PLAIN (supervisor mode)
if !isatty(STDOUT_FILENO) → MODE_PLAIN
let term = env("TERM")
if term in {"", "dumb"} → MODE_PLAIN
if env("ZYGINIT_ASCII") set → MODE_RICH_ASCII (glyphs → /-\| ASCII)
if term contains "256color"
or term == "sun-color" → MODE_RICH_TRUE (truecolor SGR)
else → MODE_RICH_16 (16-color SGR baseline)
| Mode | Header & tape | Color | Glyphs | Spinner | Progress bar |
|---|---|---|---|---|---|
MODE_PLAIN |
Plain lines | none | text labels | none | none |
MODE_RICH_ASCII |
Drawn | 16-color | ASCII (#, o/spinner, X, .) |
yes | bracket form |
MODE_RICH_16 |
Drawn | 16-color SGR | Unicode (●, ◉/spinner, ✕, ·) |
yes | block form |
MODE_RICH_TRUE |
Drawn | 24-bit SGR | Unicode (●, ◉/spinner, ✕, ·) |
yes | block form |
zygctl runs its own detect_rich_mode() against its stdout. When client detects MODE_PLAIN, it sends STATUS PLAIN / LIST PLAIN to the daemon; the daemon renders banner-free, escape-free output for that one response.
Plain mode line shape (machine-parseable):
<elapsed-s> <level> <event> <name> [k=v ...]
6.21 info started crypto dur_ms=120
7.14 info starting network
7.20 err failed network exit=1 dur_ms=4023
11.42 info stopping sshd
11.55 info stopped sshd dur_ms=130
Level mapping: started / starting / stopping / stopped → info; failed → err; supervisor warnings (stop-timeout, entered-maintenance) → warn. Used by integration tests and any external log scraper. Same content as the rich-mode tape, no escapes.
7. Error handling
7.1 Write failures
If a write(2) to /dev/console returns an error, ui.reef sets a sticky g_ui_failed flag and downgrades to MODE_PLAIN for the rest of the session. PID 1 must not crash because the screen got weird.
7.2 Terminal width
Width is assumed 80 cols. No CSI 6n queries (fragile on /dev/console). Layouts fit 80×24 with margin.
If COLUMNS env var is set (zygctl client, set by the user's shell), zygctl-side rendering uses it; the daemon doesn't see it. The daemon renders for 80 cols always.
Service names longer than the SERVICE column width are truncated with … (Unicode) or .. (ASCII).
7.3 Concurrent writers
Per Hammerhead's tem note ("/dev/console write path is serialized") — zyginit owns the screen during boot and shutdown. Per-service stdout/stderr already redirect to per-service log files in the supervisor's child-setup; that path is unchanged. Any rogue code that writes directly to /dev/console would interleave — same as today.
7.4 Service starts faster than one tick
The row appears with ● directly. No spinner ever shown for that service. Correct behavior; no special case needed.
7.5 Boot fails before ui_init
If setup_pid1_console or kernel handover fails, ui_init never runs. All output falls back to whatever the pre-existing println path does, which is the current shape — flat lines to /dev/console. No regression.
8. Data flow
8.1 Boot
main.reef:
setup_pid1_console()
ui_init(detect_rich_mode())
load services + build depgraph
ui_boot_start(num_svc, num_tiers, runlevel)
for each tier:
ui_tier_start(tier)
for each service in tier:
supervisor.start_service(...)
→ fork/exec, set state=STARTING
→ ui_event_starting(name) // appends spinner row to tape
while tier has services in STARTING:
poll() returns (signal/contract/socket/timeout 200ms)
ui_tick() // advance spinner, redraw spinner rows
handle contract events:
service became RUNNING → ui_event_started(name, dur_ms)
service exited 0 (oneshot) → ui_event_started(name, dur_ms)
service failed → ui_event_failed(name, exit, dur_ms)
ui_tier_done(tier) // header repaint
ui_boot_complete(stats) // header + tape erased, final card painted
enter steady-state event loop
Steady-state: no UI updates. Console keeps the final card frozen until console-login's ttymon writes underneath it.
8.2 Shutdown (symmetric)
main.reef on SIGTERM / socket shutdown command:
ui_shutdown_start(reason) // "stopping for reboot" / "halt" / "poweroff"
for each tier in REVERSE order:
for each service in tier (reverse):
supervisor.stop_service(...)
→ state=STOPPING, signal sent
→ ui_event_stopping(name) // reverse-spinner row
poll-and-tick until tier drained
ui_tier_done(tier)
ui_shutdown_complete(reason, elapsed_ms) // final card
fork child → uadmin // existing flow unchanged
8.3 zygctl status
zygctl client:
rich = detect_rich_mode()
if rich == MODE_PLAIN:
send "STATUS PLAIN"
else:
send "STATUS"
print response verbatim
zyginit socket handler:
cmd_status(table, arg, plain_flag):
if plain_flag or arg != "":
return ui_render_status(table, want_banner=0, plain=1)
return ui_render_status(table, want_banner=1, plain=0)
// when plain=0, the function reads g_render_mode from ui module state
The server already knows its own render mode from ui_init; the PLAIN keyword from the client just forces plain output for that one response (covers the case where zygctl is on a TTY but redirecting output, or vice versa).
9. Testing
| Layer | Where | How |
|---|---|---|
detect_rich_mode() |
Linux dev | Unit test with env-var / isatty fixtures across all 5 mode outcomes |
| Rendering (plain) | Linux dev | Snapshot tests: feed event sequence, assert exact string output |
| Rendering (rich) | Linux dev | Snapshot tests: feed events, assert escape sequences against golden files |
ui_spinner_frame(reverse) |
Linux dev | Unit: assert frame string for g_tick in {0,1,2,3,4,5,6,7} and both reverse settings |
| Boot tape rolling | Linux dev | Feed 30 events into a 12-line tape, assert visible-window content |
| Progress bar | Linux dev | Unit: assert fill width and percent for (done, total) pairs |
| Integration tests | Linux dev | Existing 44 tests get ZYGINIT_NO_UI=1 to force plain mode for diff stability |
zygctl STATUS PLAIN |
Linux dev | Add to existing socket integration tests |
| Framebuffer visual | hh-prototest |
Manual smoke + capture; one golden screenshot per layout (boot tape, boot final, shutdown mirror, shutdown final, zygctl status) |
| Serial console | hh-prototest serial |
Visual confirmation that 16-color path renders identically modulo color depth |
| Sub-tick service | Linux dev | A oneshot that exits before first ui_tick; assert row goes straight to ● (no spinner glyph) |
| Write failure | Linux dev | Mock write() to return error mid-boot; assert downgrade to plain and no crash |
10. Open boundary cases
These were considered and resolved during brainstorming; documented here so the implementation plan inherits the decisions.
- Tier names — bare
tier N. Notier_nameTOML field. (Rejected: too much schema and config-churn for limited value.) zygctl replace— does not render the rich UI. Replace is a developer/operator action, not a lifecycle one; stays plain-line per existing behavior.- Runlevel transitions (
zygctl single/multi,init s/init 3) — do not render the rich UI. These are mid-session events; spawning a rich UI mid-session would interleave with whatever is already on the console. Plain lines, as today. - Reload (
zygctl reload) — plain lines, as today. zygctl log <name>— pass-through of the per-service log file. No color, no decoration.zygctl list— keeps existing minimal shape. (No banner; type chip is the only color application.)- Failure visibility in progress bar — failed services count toward the bar's "done" tally so the bar can reach 100%. Failures are visible in the header's
failed:line, not by holding the bar back. - Final card timing — no timer; final card persists until
console-login's ttymon paintsLogin:underneath. Ifconsole-loginis disabled or fails to start, the card persists indefinitely. This is fine.
11. Phased delivery (preview for implementation plan)
The implementation plan will likely split this into ~5 phases. Sketch:
- Phase 1. New
src/ui.reefskeleton;detect_rich_mode(); palette / glyph / sigil primitives; plain-mode pass-through. - Phase 2. Boot rolling tape + header + progress bar; integrate with
main.reefboot path. - Phase 3. Spinner +
ui_tickintegration; verify forward/reverse rotation. - Phase 4. Shutdown mirror + final cards (boot + shutdown).
- Phase 5.
zygctl statusserver-side rendering +STATUS PLAINwire modifier; client-sidedetect_rich_modeand dispatch.
Each phase ships independently with the existing build/deploy procedure (Linux dev for stubs + tests, then hh-prototest for visual smoke).