|
root / docs / superpowers / specs / 2026-05-12-zyginit-visual-pass-design.md
2026-05-12-zyginit-visual-pass-design.md markdown 419 lines 22.6 KB

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

  1. Unified identity. Same [Z] sigil, six-color palette, and four-state glyph set on every visible surface: boot console, shutdown console, zygctl status.
  2. 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.
  3. 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.
  4. 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.
  5. Hammerhead-tem-safe. No animation faster than the main event loop wake-up cadence, no terminal queries (CSI 6n etc.), 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 a tier_name TOML 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; zygctl prints 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 list banner. list is the catalog command, used in scripts more than status — 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_COLOR detection
  • 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 — invoke ui_init immediately after setup_pid1_console. Wrap dep-graph load with ui_boot_start. Wrap each tier loop with ui_tier_start / ui_tier_done. Call ui_boot_complete after all tiers. Add ui_tick to the main poll loop. Bracket shutdown with ui_shutdown_start / ui_shutdown_complete.
  • src/supervisor.reef — replace ~8 lifecycle println calls (started, stopped, failed, stop-timeout, scheduled-for-restart, entered-maintenance) with ui_event_* calls. Warning/debug prints stay as-is.
  • src/socket.reefcmd_status and cmd_list delegate to ui_render_status / ui_render_list. New PLAIN modifier on the wire forces banner-off + escape-free output.
  • tools/zygctl/src/main.reef — pre-flight: detect isatty(STDOUT_FILENO) + NO_COLOR + TERM. Send STATUS PLAIN or STATUS to 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; PLAIN is an additive modifier.
  • zygctl list keeps 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 — / → - → \ → | — index g_tick mod 4.
  • Stopping state: frames advance in reverse — | → \ → - → / — index (N_FRAMES - 1) - (g_tick mod 4).
  • g_tick is incremented by ui_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 → RUNNING in 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 in c.accent; empty in c.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 paints Login: 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> down instead 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 the STOPPED state (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 / poweroff depending 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: SERVICE is dynamic (max name length, clamped 8–24); STATE is fixed 10 chars including glyph; PID 6, CTID 6, UPTIME 9, NOTES is 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 / stoppedinfo; failederr; 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.

  1. Tier names — bare tier N. No tier_name TOML field. (Rejected: too much schema and config-churn for limited value.)
  2. zygctl replace — does not render the rich UI. Replace is a developer/operator action, not a lifecycle one; stays plain-line per existing behavior.
  3. 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.
  4. Reload (zygctl reload) — plain lines, as today.
  5. zygctl log <name> — pass-through of the per-service log file. No color, no decoration.
  6. zygctl list — keeps existing minimal shape. (No banner; type chip is the only color application.)
  7. 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.
  8. Final card timing — no timer; final card persists until console-login's ttymon paints Login: underneath. If console-login is 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.reef skeleton; detect_rich_mode(); palette / glyph / sigil primitives; plain-mode pass-through.
  • Phase 2. Boot rolling tape + header + progress bar; integrate with main.reef boot path.
  • Phase 3. Spinner + ui_tick integration; verify forward/reverse rotation.
  • Phase 4. Shutdown mirror + final cards (boot + shutdown).
  • Phase 5. zygctl status server-side rendering + STATUS PLAIN wire modifier; client-side detect_rich_mode and dispatch.

Each phase ships independently with the existing build/deploy procedure (Linux dev for stubs + tests, then hh-prototest for visual smoke).