Skip to content

Commit eefa369

Browse files
authored
Polish the live-smoke ledger: grid slots, dark focus ring, system monitor states (#128)
* Keep declared grid column slots when children run short - gridColumnCount no longer clamps the declared column count to the child count, so a filtered grid's children keep their column-slot width instead of stretching across the freed row - pin the rule in the widget layout tests and in both soundboard e2e batteries (Zig example + TS port), which search the album grid down to one match and hold the tile at its natural width * Desaturate the accent focus ring in dark appearance - canvas.accentOverrides now takes the resolved scheme and derives the dark focus ring at half the accent's HSL saturation (canvas.accentFocusRing), matching the built-in packs' per-scheme ring moves - the Zig soundboard theme states the same derivation so both authoring tiers land the identical ring; pins in the token tests and both soundboard suites * Polish the system monitor footer and empty state in both tiers - label the footer sample time UTC: local rendering would need a journaled tz channel to keep replay byte-identical, so the honest label wins for this sweep - clear the transient 'terminate request delivered' notice on the next applied sample (failure notes still stick); pinned in both e2e batteries - hint the top-128-by-CPU search scope in the no-match empty state, derived from the sampler cap * Note the per-scheme accent ring in the soundboard-ts README - the theme_accent bullet now names the dark scheme's desaturated focus-ring derivation * Floor the dark accent ring at 3:1 and harden grid row math - accentFocusRing's dark step now lifts the desaturated ring's HSL lightness until it holds 3:1 (WCAG non-text) against the default dark background whenever the accent itself cleared that bar, and never below the accent's own contrast when it did not — halving saturation alone dropped #008000 from 3.9:1 to ~2.6:1; a hue-sweep test pins the floor, and the changelog fragment now states the accentOverrides ColorScheme parameter as a deliberate pre-1.0 break with the .light migration line. - gridRowCount ceil-divides as 1 + (count - 1) / columns so an unclamped huge declared column count no longer overflows the additive form in safe builds; both the layout and intrinsic-size paths already share the helper, and a unit test covers maxInt columns and zero children. * Floor the accent ring on the lightest adjacent dark tone and finish the grid row sweep - accentFocusRing's dark floor now measures against the lightest tone controls commonly sit on (house surface_subtle #262626) instead of the background, so rings drawn on cards and muted chrome clear 3:1 too — green's ring rises from ~2.72:1 to 3.49:1 on the dark surface, and the never-invent escape hatch caps at the accent's own contrast against that same reference - Route intrinsicGridChildrenSize's row count through gridRowCount: the additive ceil-div still panicked when a parent intrinsically measured a nonempty grid with maxInt columns (the earlier fix only covered the placement path) - Extend the hue sweep to assert 3:1 per adjacent tone across both packs and add the nested-grid intrinsic regression (verified to panic before the fix) * Gate the transient-note clear on a sample launched after the kill - A ps sample already in flight when the kill confirmed collected its rows before the signal, so its ps_done must not retire the delivered notice; both cores now bump a sample generation at launch and clear only when an applied sample's generation exceeds the kill_done stamp (pure Msg-driven state, replay-deterministic) - Pin the race in both batteries: the stale in-flight sample applies with the notice surviving into the rendered footer (kill exit and stale ps exit drained in one batch), and the first sample launched after the kill retires it - Rework the live kill-note pins to drive real launches through the cadence so delivered-clears and failure-persists keep their semantics under the generation gate
1 parent 4d83ce2 commit eefa369

23 files changed

Lines changed: 793 additions & 44 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
fix: **Dark-scheme accent focus rings settle down — and stay visible**: a `theme_accent` (or `canvas.accentOverrides`) now derives its dark-appearance focus ring at half the accent's saturation instead of the raw brand hue, contrast-floored at 3:1 (WCAG non-text) against the lightest dark tone controls commonly sit on (the house muted surface `#262626` — rings draw outside controls, so clearing the lightest adjacent container tone clears the page background and card surface too, in both shipped packs) — desaturation alone can cost a deep accent the bar it cleared (`#008000` fell from 3.9:1 to ~2.6:1 on the background; the floor lifts it back over 3:1 on background and card surface alike) — so the soundboard search field's ring no longer glares neon in dark mode; `canvas.accentFocusRing` exports the derivation so hand-authored token sets (the Zig soundboard's theme) state the identical ring.
2+
- **Breaking**: `canvas.accentOverrides` now takes the resolved `ColorScheme` alongside the accent — a deliberate break while the toolkit is pre-1.0, so the one function under the natural name states the scheme it layers over; pass `.light` to reproduce the previous output exactly.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fix: **Grids keep their declared column slots**: children fewer than a grid's declared `columns` now keep the column-slot width and fill the leading slots, instead of stretching across the freed row — a search that narrows the soundboard album grid below its column count leaves image-forward tiles at their natural size.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
improvement: **Search empty state names its scope**: the system monitor's no-match state now says search only sees the top 128 processes by CPU, so a miss on a quiet process reads as scope, not absence (both tiers).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fix: **"terminate request delivered" retires itself**: the system monitor's delivery notice now clears on the next applied sample instead of sitting in the footer forever; failure notes keep sticking (both tiers).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
fix: **System monitor footer says UTC**: the sample-time stamp renders from the journaled clock in UTC, and the footer now labels it "UTC" instead of passing it off as local time — local rendering would need a journaled timezone channel to stay replay-byte-identical, so the label is the honest fix (both the Zig example and the TS port).

examples/soundboard-ts/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The generated wiring detects each channel from an export in `core.ts` (export ex
2929
- **`envMsgs`**`NATIVE_SDK_MUSIC_URL_BASE` arrives as one journaled Msg at install: a non-empty base replaces the stream host wholesale, an empty base means local-only (a failed play then shows the "assets not prepared" notice, the Zig original's launch split). The core itself never reads the environment; replay carries the recorded value.
3030
- **`app.zon .assets.images`** — the committed covers register on the installing frame with id = album id, the `ImageId` the markup avatar bindings reference. The art is JPEG: live hosts decode it through the platform codec; the null test platform's strict PNG-subset decoder refuses it honestly and every cover degrades to initials (which the suite pins).
3131
- **`chromeMsg`** — window-chrome geometry (the tall hidden-inset titlebar app.zon declares) lands before the first view build: the markup header IS the titlebar — the drag surface, a leading spacer sized to the traffic lights, height matched to the band so its controls share the lights' centerline (the Zig original's treatment).
32-
- **`app.zon .theme` + `.theme_accent`** — the geist pack under the Zig original's pink accent identity (`#df2670`, theme.zig's pink_800): the wiring layers `canvas.accentOverrides` over the pack — accent + white knockout ink, the focus ring, the seek slider's filled range — and high-contrast requests skip it (accessibility beats brand), the original's tokens_fn rule through the manifest.
32+
- **`app.zon .theme` + `.theme_accent`** — the geist pack under the Zig original's pink accent identity (`#df2670`, theme.zig's pink_800): the wiring layers `canvas.accentOverrides` over the pack — accent + white knockout ink, the focus ring (raw pink in light, `canvas.accentFocusRing`'s desaturated, contrast-floored step in dark so the ring never glares neon on the dark palette yet keeps the 3:1 non-text bar), the seek slider's filled range — and high-contrast requests skip it (accessibility beats brand), the original's tokens_fn rule through the manifest.
3333

3434
## Where this port still deliberately differs from the Zig soundboard
3535

examples/soundboard/src/tests.zig

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,16 +1057,23 @@ test "the system appearance drives the custom tokens live" {
10571057
// palettes, so these assertions follow the one source of truth).
10581058
try testing.expectEqualDeep(theme.light_colors, main.tokensFromModel(&app_state.model).colors);
10591059

1060-
// The accent override actually landed: the filled-primary pair, the
1061-
// focus ring, and the slider's filled range all carry the same pink
1062-
// step in BOTH schemes — the scrubber is the one slot the pack
1063-
// states its own hue for, so it is pinned separately from `accent`.
1060+
// The accent override actually landed: the filled-primary pair and
1061+
// the slider's filled range carry the same pink step in BOTH
1062+
// schemes — the scrubber is the one slot the pack states its own
1063+
// hue for, so it is pinned separately from `accent`.
10641064
const pink = canvas.Color.rgb8(223, 38, 112);
10651065
for ([_]canvas.ColorTokens{ theme.light_colors, theme.dark_colors }) |colors| {
10661066
try testing.expectEqualDeep(pink, colors.accent);
10671067
try testing.expectEqualDeep(canvas.Color.rgb8(255, 255, 255), colors.accent_text);
1068-
try testing.expectEqualDeep(pink, colors.focus_ring);
10691068
}
1069+
// The focus ring is stated per scheme: raw pink in light, the
1070+
// desaturated dark step in dark (canvas.accentFocusRing — the same
1071+
// derivation the manifest theme_accent channel applies, so the TS
1072+
// port's ring matches). Full-chroma pink would glare neon on the
1073+
// dark palette.
1074+
try testing.expectEqualDeep(pink, theme.light_colors.focus_ring);
1075+
try testing.expectEqualDeep(canvas.accentFocusRing(pink, .dark), theme.dark_colors.focus_ring);
1076+
try testing.expect(!std.meta.eql(pink, theme.dark_colors.focus_ring));
10701077
for ([_]native_sdk.ColorScheme{ .light, .dark }) |scheme| {
10711078
try testing.expectEqualDeep(pink, theme.tokens(scheme, false, false).controls.slider.active_background.?);
10721079
}
@@ -1948,6 +1955,39 @@ fn expectChunking(rows: usize, counts: []const usize, first_x: []const f32, colu
19481955
}
19491956
}
19501957

1958+
test "a search narrowed below the column count keeps natural tile size" {
1959+
const live = try LiveApp.start(true);
1960+
defer live.stop();
1961+
1962+
// Establish the four-column desktop fit, then search down to ONE
1963+
// matching album (the last album's title matches "channel" and
1964+
// nothing else does — see the typed-dispatch search test).
1965+
try resizeTo(live, main.window_width, main.window_height, 2);
1966+
const fit = view_mod.gridFit(main.window_width);
1967+
try testing.expect(fit.columns > 1);
1968+
try live.dispatch(.{ .search_edit = .{ .insert_text = "channel" } });
1969+
1970+
// The lone tile keeps the fit's natural tile width — image-forward
1971+
// covers never balloon across the freed row — and stays pinned to
1972+
// the leading content edge, leaving the trailing space empty.
1973+
var layout = try live.harness.runtime.canvasWidgetLayout(1, main.canvas_label);
1974+
var tiles: usize = 0;
1975+
for (layout.nodes) |node| {
1976+
if (node.widget.kind != .list_item or node.widget.semantics.role != .listitem) continue;
1977+
tiles += 1;
1978+
try testing.expectApproxEqAbs(fit.tile_width, node.frame.width, 0.5);
1979+
}
1980+
try testing.expectEqual(@as(usize, 1), tiles);
1981+
1982+
// Clearing the search restores the full four-wide chunking.
1983+
try live.dispatch(.{ .search_edit = .clear });
1984+
var counts: [8]usize = undefined;
1985+
var first_x: [8]f32 = undefined;
1986+
layout = try live.harness.runtime.canvasWidgetLayout(1, main.canvas_label);
1987+
const rows = gridRowCounts(layout, &counts, &first_x);
1988+
try expectChunking(rows, counts[0..rows], first_x[0..rows], fit.columns);
1989+
}
1990+
19511991
test "chrome geometry pads the header and matches its height to the tall band" {
19521992
var fx = model_mod.Effects.init(testing.allocator);
19531993
defer fx.deinit();

examples/soundboard/src/theme.zig

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,28 @@ const pink_ink = Color.rgb8(255, 255, 255);
3434
/// - `colors.focus_ring`: the pack spends its identity hue on focus;
3535
/// with the identity moved to pink, the ring follows so focus and
3636
/// accent chrome read as one system (pink on the page clears the 3:1
37-
/// non-text bar in both schemes).
37+
/// non-text bar in both schemes). The ring is stated PER SCHEME: the
38+
/// accent itself in light, `canvas.accentFocusRing`'s desaturated,
39+
/// contrast-floored step in dark — full-chroma pink glares neon
40+
/// against the dark palette, and the derivation is the same one the
41+
/// manifest `theme_accent` channel applies, so both authoring tiers
42+
/// land the identical ring.
3843
/// - `controls.slider.active_background`: the pack's slider table states
3944
/// its own hue for the filled range rather than deriving from the
4045
/// accent channel, so the seek scrubber needs the same move stated
4146
/// once more or it would keep the pack's stock hue.
42-
const accent_overrides = canvas.DesignTokenOverrides{
43-
.colors = .{
44-
.accent = pink_800,
45-
.accent_text = pink_ink,
46-
.focus_ring = pink_800,
47-
},
48-
.controls = .{
49-
.slider = .{ .active_background = pink_800 },
50-
},
51-
};
47+
fn accentOverridesFor(color_scheme: canvas.ColorScheme) canvas.DesignTokenOverrides {
48+
return .{
49+
.colors = .{
50+
.accent = pink_800,
51+
.accent_text = pink_ink,
52+
.focus_ring = canvas.accentFocusRing(pink_800, color_scheme),
53+
},
54+
.controls = .{
55+
.slider = .{ .active_background = pink_800 },
56+
},
57+
};
58+
}
5259

5360
pub fn tokens(scheme: native_sdk.ColorScheme, high_contrast: bool, reduce_motion: bool) canvas.DesignTokens {
5461
const options = canvas.ThemeOptions{
@@ -64,7 +71,7 @@ pub fn tokens(scheme: native_sdk.ColorScheme, high_contrast: bool, reduce_motion
6471
// layer: white on the pink fill sits at ~4.5:1, well under the
6572
// loud-contrast bar, so the accent honestly bows out.
6673
if (high_contrast) return canvas.DesignTokens.theme(options);
67-
return canvas.DesignTokens.themeWithOverrides(options, accent_overrides);
74+
return canvas.DesignTokens.themeWithOverrides(options, accentOverridesFor(options.color_scheme));
6875
}
6976

7077
/// The resolved standard-contrast palettes, exported for the suite's

examples/system-monitor-ts/src/app.native

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,10 @@
9494
<panel padding="24" background="surface" radius="lg" border-color="border" label="No processes match">
9595
<column gap="6">
9696
<text>{emptyTitle}</text>
97-
<text size="sm" foreground="text_muted">Filter matches command names and pids.</text>
97+
<!-- The honest scope hint once samples exist: search only
98+
sees the top-128-by-CPU selection the sampler keeps
99+
(core.ts emptyHint). -->
100+
<text size="sm" foreground="text_muted">{emptyHint}</text>
98101
</column>
99102
</panel>
100103
</if>

examples/system-monitor-ts/src/core.ts

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
formatClock,
5151
intDiv,
5252
intDivRound,
53+
MAX_ROWS,
5354
pad2,
5455
parseHostInfo,
5556
parseMeminfo,
@@ -193,6 +194,24 @@ export interface Model {
193194
readonly tableScroll: number;
194195

195196
readonly note: Bytes;
197+
/// A note that clears itself on the next applied ps sample LAUNCHED
198+
/// after it was set (`noteStampGeneration`): the kill path's
199+
/// "terminate request delivered" is a moment, not a state — the
200+
/// following tick (whose rows are the delivery's visible consequence)
201+
/// retires it instead of letting it sit forever.
202+
readonly noteClearsOnSample: boolean;
203+
/// Generation counter for ps sample LAUNCHES (not applies), bumped in
204+
/// `sampling` when the spawns actually issue. Launches never overlap
205+
/// (`psInflight` gates them), so the value is stable from a sample's
206+
/// launch to its applied ps_done — at apply time it IS the applying
207+
/// sample's launch generation. Pure Msg-driven state, no clock
208+
/// anywhere near it, so replay walks the same values.
209+
readonly sampleGeneration: number;
210+
/// The generation current when the transient note was set: the note
211+
/// retires only with a sample launched AFTER this stamp. A sample
212+
/// already in flight at kill time collected its rows BEFORE the kill,
213+
/// so its pre-termination rows must never retire the notice.
214+
readonly noteStampGeneration: number;
196215

197216
/// Chrome overlay geometry (tall hidden-inset titlebar) from the
198217
/// chromeMsg channel: the header leads with a spacer this wide so its
@@ -295,6 +314,9 @@ export const viewUnbound = [
295314
"sortDescending",
296315
"pendingKill",
297316
"note",
317+
"noteClearsOnSample",
318+
"sampleGeneration",
319+
"noteStampGeneration",
298320
] as const;
299321

300322
export function initialModel(): [Model, Cmd<Msg>] {
@@ -325,6 +347,9 @@ export function initialModel(): [Model, Cmd<Msg>] {
325347
pendingKill: null,
326348
tableScroll: 0,
327349
note: new Uint8Array(0),
350+
noteClearsOnSample: false,
351+
sampleGeneration: 0,
352+
noteStampGeneration: 0,
328353
chromeLeading: 0,
329354
headerHeight: HEADER_NATURAL_HEIGHT,
330355
},
@@ -472,6 +497,17 @@ export function emptyTitle(model: Model): Bytes {
472497
return concat3(asciiBytes('No matches for "'), model.search.bytes, asciiBytes('"'));
473498
}
474499

500+
/// The empty state's second line: the honest scope hint once samples
501+
/// exist — search only sees the top-MAX_ROWS-by-CPU selection the
502+
/// sampler keeps, so a miss may simply be a quiet process outside it.
503+
export function emptyHint(model: Model): Bytes {
504+
if (model.samplesTaken === 0) return asciiBytes("Filter matches command names and pids.");
505+
return emDashJoin(
506+
asciiBytes(`Search sees the top ${MAX_ROWS} processes by CPU`),
507+
asciiBytes("filter matches command names and pids."),
508+
);
509+
}
510+
475511
// --------------------------------------------------- derived: status bar
476512

477513
/// The status-bar line: sample facts, then any activity note.
@@ -488,7 +524,7 @@ export function statusLine(model: Model): Bytes {
488524
} else {
489525
line = dotJoin(
490526
asciiBytes(`${model.processCount} processes`),
491-
concat2(asciiBytes("sampled at "), formatClock(model.sampledAtDayMs)),
527+
concat3(asciiBytes("sampled at "), formatClock(model.sampledAtDayMs), asciiBytes(" UTC")),
492528
);
493529
if (model.paused) line = dotJoin(line, asciiBytes("paused"));
494530
if (model.ticksSkipped > 0) line = dotJoin(line, asciiBytes(`${model.ticksSkipped} ticks skipped`));
@@ -533,8 +569,16 @@ function appliedPsSample(model: Model, sample: PsSample): Model {
533569
model.procHistory.length >= HISTORY_LEN
534570
? [...model.procHistory.slice(1), sample.processCountFloat]
535571
: [...model.procHistory, sample.processCountFloat];
572+
// A transient note ("terminate request delivered") retires with the
573+
// first sample LAUNCHED after it — those rows show the delivery's
574+
// outcome. A sample already in flight when the note was stamped
575+
// collected its rows before the kill, so it applies without retiring
576+
// the notice (the generation gate).
577+
const retiresNote = model.noteClearsOnSample && model.sampleGeneration > model.noteStampGeneration;
536578
return {
537579
...model,
580+
note: retiresNote ? new Uint8Array(0) : model.note,
581+
noteClearsOnSample: model.noteClearsOnSample && !retiresNote,
538582
psInflight: false,
539583
processCount: sample.processCount,
540584
uptimeSeconds: sample.uptimeSeconds,
@@ -567,11 +611,29 @@ function appliedMemSample(model: Model, sample: MemSample): Model {
567611
/// spawn commands themselves are built inline at each return site —
568612
/// commands live in update's return path only (NS1017).
569613
function sampling(model: Model): Model {
570-
return { ...model, psInflight: true, memInflight: true };
614+
return {
615+
...model,
616+
psInflight: true,
617+
memInflight: true,
618+
sampleGeneration: model.sampleGeneration + 1,
619+
};
571620
}
572621

573622
function withNote(model: Model, note: Bytes): Model {
574-
return { ...model, note: note };
623+
return { ...model, note: note, noteClearsOnSample: false };
624+
}
625+
626+
/// A `withNote` that retires itself on the next applied ps sample
627+
/// launched after this moment (see `Model.noteClearsOnSample`). The
628+
/// generation stamp keeps a sample already in flight — whose rows
629+
/// predate whatever this note reports — from retiring it early.
630+
function withTransientNote(model: Model, note: Bytes): Model {
631+
return {
632+
...model,
633+
note: note,
634+
noteClearsOnSample: true,
635+
noteStampGeneration: model.sampleGeneration,
636+
};
575637
}
576638

577639
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
@@ -788,7 +850,11 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
788850
];
789851
}
790852
case "kill_done": {
791-
if (msg.code === 0) return withNote(model, asciiBytes("terminate request delivered"));
853+
// Transient: the next LAUNCHED sample's rows ARE the outcome, so
854+
// the delivery notice retires with them instead of sitting in the
855+
// footer forever (a sample already in flight predates the kill
856+
// and cannot).
857+
if (msg.code === 0) return withTransientNote(model, asciiBytes("terminate request delivered"));
792858
return withNote(
793859
model,
794860
emDashJoin(asciiBytes(`kill failed (code ${msg.code}`), asciiBytes("not your process?)")),

0 commit comments

Comments
 (0)