# 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.reef`** — `cmd_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` / `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.

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).
