|
root / docs / superpowers / specs / 2026-06-01-progress-in-divider-design.md
2026-06-01-progress-in-divider-design.md markdown 162 lines 11.5 KB

Progress Gauge in Header Divider — Design

Date: 2026-06-01
Status: Approved (brainstorm complete); implementation plan to follow
Scope: Relocate the boot/shutdown progress indicator from the last row of the screen into the existing horizontal divider in the boot header. Eliminates a long-standing "progress bar disappears and reappears" artifact and reclaims two rows of vertical space for the rolling tape. Symmetric treatment for shutdown.

1. Problem

The boot screen rendered by render_boot_screen() in src/ui.reef ends with a horizontal divider plus a separate progress bar row, occupying the bottom two rows of the screen. The progress bar is the very last row — the file's own comment at src/ui.reef:517-519 notes that the layout fits exactly g_screen_rows and cannot afford even a trailing newline.

Between zyginit's \e[2J\e[H redraws, any other writer to the console — a kernel printk, a daemon's startup stderr, a not-yet-suppressed driver message — scrolls the screen up. The last row is the first thing to scroll off. The user sees the progress bar disappear, then reappear on the next redraw, then disappear again. The artifact is endemic to placing dynamic content on the last row of a shared console.

2. Goals

  1. Stability under concurrent writers. Move the progress indicator out of the most-volatile screen row.
  2. No layout twitch at boot completion. The transition from live gauge to final boot-time label must not shift any surrounding glyph.
  3. Reclaim vertical space. Eliminate both the bottom cosmetic divider and the bottom progress row — give those rows to the rolling tape.
  4. Symmetric shutdown. Apply the same treatment to the shutdown screen; same bug exists there.
  5. No regressions in plain or ASCII mode. MODE_PLAIN continues to emit line-by-line events; MODE_RICH_ASCII continues to use only ASCII characters.

3. Non-goals

  • No new TOML schema fields. This is purely a console-rendering change.
  • No zygctl protocol changes. zygctl status is rendered by src/ui_render.reef and is not touched.
  • No palette, sigil, or glyph changes. The visual pass shipped in 0.1.3 stays as-is; only layout moves.
  • No animation changes. Spinner cadence, tape-row behavior, and tier-progression logic are unchanged.
  • No supervision or dependency-graph changes. Pure UI work.

4. Visual design

4.1 Before

  [Z] zyginit 0.2.2 · multi-user   elapsed 2.4s
  20 services · tier 3 · 8 done · 0 failed · 0 skipped
  failed: none
  ─────────────────────────────────────────       (top divider — cosmetic)
  [tape row 1]
  …
  [tape row N]
  ─────────────────────────────────────────       (bottom divider — cosmetic)
  ████░░░░░░░░░░░░░  8/20  31%                    (progress bar — last row, scrolls off)

4.2 After — boot in progress

  [Z] zyginit 0.2.2 · multi-user   elapsed 2.4s
  20 services · tier 3 · 8 done · 0 failed · 0 skipped
  failed: none
  ─────[████████░░░░░░░░░░  31%]───────────       (gauge inside divider)
  [tape row 1]
  …
  [tape row N+2]                                  (+2 rows reclaimed)

4.3 After — boot complete

The existing ui_boot_complete() at src/ui.reef:708 clears the screen and paints a separate "final card" (sigil, version, boot Xs, online/failed/skipped summary, optional failure hint, slowest-services list). The final card is unchanged by this work. The new divider gauge therefore runs only during the live boot screen; the boot post-label ([ boot 10.0s ]) is rendered in code but is only momentarily visible — within one frame, the final card replaces the boot screen.

The elapsed Xs field on header line 1 is dropped from the boot screen once g_done + g_skipped + g_failed_count == g_num_svc (i.e., the boot has visually completed even if the final-card transition hasn't fired yet). This avoids showing a stale elapsed 9.8s while the gauge reads 100% in the brief window before the final card.

4.4 Shutdown

Symmetric with boot. ui_shutdown_complete() at src/ui.reef:818 already paints a shutdown final card after the last service stops (sigil, reboot X.Ys, "invoking uadmin..." line). The shutdown final card is unchanged. The new divider gauge runs only during the live shutdown screen as services stop:

  ─────[█████████████░░░░░░░  65%]──────────       (during stop sequence)

Header content (status lines, failure summary) above the divider is unchanged.

5. Rendering rules

A new function fmt_divider_gauge(done: int, total: int): string replaces:

  • The make_divider() call at the end of fmt_header() (the top divider).
  • The bottom divider line in render_boot_screen().
  • The fmt_progress_bar() line in render_boot_screen().

Width: identical to today's make_divider()g_line_width - 16 cells of total divider width, with the leading two-space indent preserved. The brackets plus their inner content consume 28 cells ([ + 26 inner + ]); the remaining g_line_width - 44 cells are split equally between left and right padding (odd cell goes right).

Narrow-terminal fallback. If g_line_width < 46 (i.e., total_width < 30) the bracket pair plus at least one flanking dash on each side won't fit. In that case fmt_divider_gauge() degrades to a label-only divider — no bracket pair, just <lpad>── 31% ──<rpad>. This path is exercised only on artificially small consoles; the Hammerhead framebuffer and standard serial console widths (80+) always hit the full layout.

The layout inside the divider (full path) is:

  <lpad>[<content>]<rpad>

Where:

  • Brackets are literal [ and ], painted with the accent color.
  • lpad / rpad are chars in MODE_RICH, - chars in MODE_RICH_ASCII, painted accent. Padding is distributed equally on both sides; if total padding is odd, the extra cell goes to the right side.
  • content has a fixed inner width so the bracket positions never move between live and post-complete renders. Inner width = bar(20) + two-space gap + 4-char NNN% field = 26 cells.

5.1 Live content

<bar>  <pct>%
  • bar: 20 cells. In MODE_RICH: (accent) for filled, (mute) for empty. In MODE_RICH_ASCII: # for filled, space for empty. Filled count = (done * 20) / total, clamped to [0, 20].
  • pct: integer (done * 100) / total, clamped to [0, 100], right-aligned in a 3-cell field, followed by %. So " 3%", " 31%", "100%" all consume 4 cells.

5.2 Plain mode

MODE_PLAIN continues to emit one line per event via fmt_plain_event() and ignores the divider gauge entirely. fmt_divider_gauge() returns "" in plain mode, same as fmt_progress_bar() does today.

6. State changes

None. Both ui_boot_complete() (src/ui.reef:708) and ui_shutdown_complete() (src/ui.reef:818) already exist and paint final cards that replace the live screen. The new divider gauge is purely a transform on existing counters (g_done, g_skipped, g_num_svc), so no new g_* flags are required.

Header behavior:

  • fmt_header() drops the elapsed Xs field from line 1 when g_done + g_skipped + g_failed_count >= g_num_svc during the boot phase. This is a pure boolean check on existing counters; no new flag. During shutdown the elapsed field is left as-is (the shutdown final card carries the timer once complete).

7. Files touched

  • src/ui.reef — only file touched.

    • New: fmt_divider_gauge(done, total) — main rendering function. Includes the narrow-terminal fallback in §5.
    • Update: fmt_header() — conditional elapsed Xs (suppressed when g_done + g_skipped + g_failed_count >= g_num_svc and phase is boot); divider line now calls fmt_divider_gauge(g_done + g_skipped, g_num_svc).
    • Update: render_boot_screen() at src/ui.reef:499 — remove the bottom divider concatenation and the fmt_progress_bar() call. Trailing-newline accounting recomputed so total height still equals g_screen_rows.
    • Update: tape height calculation at src/ui.reef:682g_tape_height = g_screen_rows - 6 becomes g_screen_rows - 4 (header is 4 rows including the gauge divider; no trailing rows).
    • Update: scenario harness in ui_demo() — the existing progress_rich, progress_ascii, progress_full scenarios at lines 950-963 currently call fmt_progress_bar(); convert them to exercise fmt_divider_gauge() instead. Add fresh gauge_full_screen_boot scenario for full-layout visual check.
    • Delete: fmt_progress_bar() (lines 393-439), PROGRESS_WIDTH() (line 391). No remaining callers after the harness conversion.
  • src/main.reef — no changes. ui_boot_complete() and ui_shutdown_complete() already wired.

  • src/shutdown.reef — no changes.

  • src/ui_render.reef — no changes. zygctl status rendering is independent.

8. Backwards compatibility

  • MODE_PLAIN output — unchanged. Same line-per-event format consumers parse today.
  • MODE_RICH_ASCII output — gauge characters (#, space) and divider characters (-) are pure ASCII. No new Unicode dependencies.
  • MODE_RICH output — gauge characters (, ) and divider character () were already in use in the prior layout. No new code points.
  • Screen-height math — assumes g_screen_rows >= 5 (1 header line + 3 status lines + at least 1 tape row). Already an existing assumption; no change.
  • External consumers — none. zygctl status, scripted log parsers, and the syslog stream all read MODE_PLAIN or daemon log output, not the framebuffer rendering.

9. Testing

Manual on hh-prototest (the Hammerhead VM at 192.168.122.50):

  1. Cold boot. Confirm gauge fills as tiers progress; confirm post-boot label boot Xs appears after the last tier; confirm elapsed Xs disappears from header line 1.
  2. Boot under noise. Trigger a kernel printk during boot (e.g. a dladm configure with verbose driver). Confirm gauge stays visible across the scroll.
  3. Shutdown. zygctl halt. Confirm gauge inverse-fills; confirm down Xs label appears before reboot.
  4. Single-user boot. boot -s. Confirm gauge still renders correctly in the smaller service set.
  5. Plain mode. ZYGINIT_NO_UI=1, NO_COLOR=1, non-TTY redirect, or running non-PID-1. Confirm line-per-event output is unchanged.
  6. ASCII mode. TERM=dumb (or any non-color-capable terminal that doesn't trip the plain-mode fallback). Confirm # / space gauge renders inside the divider.

No unit tests added — the boot UI has historically been validated visually on the test VM. Existing integration tests in tests/integration/ continue to cover supervisor behavior independently.

10. Open questions

None. All decisions made during brainstorming:

  • Gauge style: block fill (/ in rich, #/space in ASCII) — matches the prior progress bar's character set.
  • elapsed Xs on header line 1: shown only while boot is in progress; dropped once all services have resolved (done/failed/skipped sum reaches total).
  • Boot completion: existing ui_boot_complete() final card kept as-is. The new gauge runs only during the live boot screen.
  • Shutdown: same treatment as boot. Existing ui_shutdown_complete() final card kept as-is. Gauge fills 0→100% as services stop, then the final card replaces the screen.