summaryrefslogtreecommitdiff
path: root/src/runtime_config.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /src/runtime_config.zig
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz
pardes-1551e409c31992437cb2fa864f576d45c8433801.zip
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/runtime_config.zig')
-rw-r--r--src/runtime_config.zig568
1 files changed, 568 insertions, 0 deletions
diff --git a/src/runtime_config.zig b/src/runtime_config.zig
new file mode 100644
index 00000000..806cb766
--- /dev/null
+++ b/src/runtime_config.zig
@@ -0,0 +1,568 @@
+//! Runtime choices in one plain, owned record.
+//!
+//! `Setting` is compile-time metadata for the builtin registry; `State` is the
+//! data it mutates and the Config report reads. Neither contains callbacks.
+const std = @import("std");
+const panel_animation = @import("panel_animation.zig");
+
+/// Tagline glyphs retain body-cell geometry, so allowing a face larger than
+/// the body would clip into neighbouring cells. Zero would make the role
+/// invisible. Keep the runtime command on the same 1...100 contract as the
+/// build-time default in config.zig.
+pub const tagline_percent_min: u8 = 1;
+pub const tagline_percent_max: u8 = 100;
+
+pub fn Text(comptime capacity: usize) type {
+ return struct {
+ bytes: [capacity]u8 = @splat(0),
+ len: std.math.IntFittingRange(0, capacity) = 0,
+
+ pub fn get(value: *const @This()) []const u8 {
+ return value.bytes[0..value.len];
+ }
+
+ pub fn set(value: *@This(), text: []const u8) bool {
+ if (text.len > capacity) return false;
+ @memcpy(value.bytes[0..text.len], text);
+ value.len = @intCast(text.len);
+ return true;
+ }
+
+ pub fn clear(value: *@This()) void {
+ value.len = 0;
+ }
+ };
+}
+
+pub const State = struct {
+ theme: usize = 0,
+ colors: bool = true,
+ wrap: bool = true,
+ tag_bottom: bool = false,
+ debug: bool = false,
+
+ /// Empty requested text means config.default_shell. Resolution belongs to
+ /// the native host (the core deliberately has no filesystem), so retain
+ /// the executable it actually chose separately and mark a changed request
+ /// pending until the next terminal spawn acknowledges it.
+ shell: struct {
+ requested: Text(255) = .{},
+ // Fixed across native and freestanding builds: runtime State is one
+ // data schema, and web has no std.fs PATH_MAX to derive this from.
+ effective: Text(4095) = .{},
+ pending: bool = true,
+ } = .{},
+
+ /// The shell acknowledges a font before `effective` changes. A rejected
+ /// request therefore remains queryable without claiming it is on screen.
+ font: struct {
+ // 4095 leaves room for the NUL in the macOS C bridge without a
+ // second, subtly different limit at that boundary.
+ requested_path: Text(4095) = .{},
+ requested_name: Text(255) = .{},
+ effective_name: Text(255) = .{},
+ pending: bool = false,
+ effective_size_hundredths: u16 = 0,
+ effective_size_unit: FontSizeUnit = .unknown,
+ // Pardes.init copies config.gui_tagline_font_percent here. Keeping
+ // the compiled choice in the live record makes Config truthful while
+ // avoiding the config -> builtins -> runtime_config import cycle.
+ tagline_percent: u8 = 100,
+ } = .{},
+
+ panel_transition: panel_animation.Transition = .off,
+ scene_effects: panel_animation.SceneEffect = .{},
+
+ pub fn toggleTransition(state: *State, effect: panel_animation.Transition) void {
+ std.debug.assert(effect != .off);
+ state.panel_transition = if (state.panel_transition == effect) .off else effect;
+ }
+};
+
+pub const FontSizeUnit = enum { unknown, pixels, points };
+
+/// Replace the requested font tuple atomically. Both fixed strings are
+/// preflighted before either changes, so a rejected long name cannot leave a
+/// new path paired with stale metadata.
+pub fn requestFont(state: *State, path: []const u8, name: []const u8) bool {
+ if (path.len > state.font.requested_path.bytes.len or
+ name.len > state.font.requested_name.bytes.len) return false;
+ std.debug.assert(state.font.requested_path.set(path));
+ std.debug.assert(state.font.requested_name.set(name));
+ state.font.pending = true;
+ return true;
+}
+
+/// Backend facilities are plain data supplied once by the platform-facing
+/// registry. The same value gates command generation, leader paths, source
+/// queries, and the Config report, so "unsupported" cannot mean four subtly
+/// different things at those four call sites.
+pub const Capabilities = struct {
+ font_picker: bool,
+ panel_transitions: bool,
+ scene_shaders: bool,
+ /// A smaller tagline face is also supported by the browser, which does
+ /// not own a native font picker. Keep this fact deliberately independent.
+ tagline_font_size: bool,
+};
+
+pub const Capability = std.meta.FieldEnum(Capabilities);
+
+pub const Toggle = enum { colors, wrap, tag_bottom, debug };
+pub const Scene = std.meta.FieldEnum(panel_animation.SceneEffect);
+
+pub const Action = union(enum) {
+ toggle: Toggle,
+ shell,
+ theme,
+ font,
+ tagline_size,
+ transition: panel_animation.Transition,
+ scene: Scene,
+};
+
+pub const Setting = struct {
+ word: []const u8,
+ action: Action,
+ availability: ?Capability = null,
+
+ pub fn takesArg(setting: Setting) bool {
+ return switch (setting.action) {
+ .shell, .theme, .font, .tagline_size => true,
+ else => false,
+ };
+ }
+
+ pub fn enabled(setting: Setting, capabilities: Capabilities) bool {
+ const capability = setting.availability orelse return true;
+ return switch (capability) {
+ inline else => |field| @field(capabilities, @tagName(field)),
+ };
+ }
+};
+
+/// This table is both the generated-setting builtin input and the Config
+/// report order. Adding mutable config without adding a query row is therefore
+/// impossible unless it is deliberately kept out of this user-facing state.
+pub const settings = [_]Setting{
+ .{ .word = "Colors", .action = .{ .toggle = .colors } },
+ .{ .word = "Wrap", .action = .{ .toggle = .wrap } },
+ .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } },
+ .{ .word = "Debug", .action = .{ .toggle = .debug } },
+ .{ .word = "Theme", .action = .theme },
+ .{ .word = "Shell", .action = .shell },
+ .{ .word = "Font", .action = .font, .availability = .font_picker },
+ .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker },
+ .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions },
+ .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions },
+ .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions },
+ .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions },
+ .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions },
+ .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders },
+ .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders },
+ .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders },
+};
+
+pub fn find(name: []const u8) ?Setting {
+ for (settings) |setting| if (std.mem.eql(u8, setting.word, name)) return setting;
+ return null;
+}
+
+/// The table is also the sole action-to-command vocabulary. Compile-time
+/// callers use this for picker rows; the Config report uses it for the active
+/// transition. No parallel enum or transition-name switch can drift.
+pub fn findAction(action: Action) ?Setting {
+ for (settings) |setting| if (std.meta.eql(setting.action, action)) return setting;
+ return null;
+}
+
+fn actionCount(comptime action: Action) comptime_int {
+ var count = 0;
+ for (settings) |setting| count += @intFromBool(std.meta.eql(setting.action, action));
+ return count;
+}
+
+comptime {
+ @setEvalBranchQuota(20_000);
+ for (settings, 0..) |setting, i| {
+ if (setting.word.len == 0) @compileError("runtime setting has an empty command word");
+ for (settings[i + 1 ..]) |later| if (std.mem.eql(u8, setting.word, later.word))
+ @compileError("duplicate runtime setting command word: " ++ setting.word);
+ switch (setting.action) {
+ .font, .tagline_size => if (setting.availability != .font_picker)
+ @compileError("native font settings must use the font-picker capability"),
+ .transition => if (setting.availability != .panel_transitions)
+ @compileError("panel effects must use the panel-transition capability"),
+ .scene => if (setting.availability != .scene_shaders)
+ @compileError("scene effects must use the scene-shader capability"),
+ else => if (setting.availability != null)
+ @compileError("unconditional settings cannot carry a backend capability"),
+ }
+ }
+ for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1)
+ @compileError("runtime toggle must occur exactly once: " ++ @tagName(field));
+ if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or
+ actionCount(.tagline_size) != 1)
+ @compileError("Shell, Theme, Font, and TaglineSize actions must each occur exactly once");
+ for (std.enums.values(panel_animation.Transition)) |effect| {
+ const expected: comptime_int = @intFromBool(effect != .off);
+ if (actionCount(.{ .transition = effect }) != expected)
+ @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect));
+ }
+ for (std.enums.values(Scene)) |effect| {
+ if (actionCount(.{ .scene = effect }) != 1)
+ @compileError("scene effect must occur exactly once: " ++ @tagName(effect));
+ if (@FieldType(panel_animation.SceneEffect, @tagName(effect)) != bool)
+ @compileError("scene effect fields must be booleans: " ++ @tagName(effect));
+ }
+}
+
+/// Apply settings whose operation is independent of themes, font discovery,
+/// or a shell. Those three remain explicit at the generated builtin's edge.
+pub fn applySimple(state: *State, setting: Setting, argument: ?[]const u8) bool {
+ switch (setting.action) {
+ .toggle => |field| switch (field) {
+ .colors => state.colors = !state.colors,
+ .wrap => state.wrap = !state.wrap,
+ .tag_bottom => state.tag_bottom = !state.tag_bottom,
+ .debug => state.debug = !state.debug,
+ },
+ .shell => {
+ const value = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ if (value.len == 0 or !state.shell.requested.set(value)) return false;
+ state.shell.pending = true;
+ },
+ .tagline_size => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ const percent = std.fmt.parseInt(u16, text, 10) catch return false;
+ if (percent < tagline_percent_min or percent > tagline_percent_max) return false;
+ // Parse and validate before the sole write: malformed commands
+ // cannot partially alter the live configuration.
+ state.font.tagline_percent = @intCast(percent);
+ },
+ .transition => |effect| state.toggleTransition(effect),
+ .scene => |effect| switch (effect) {
+ inline else => |field| {
+ const value = &@field(state.scene_effects, @tagName(field));
+ value.* = !value.*;
+ },
+ },
+ .theme, .font => return false,
+ }
+ return true;
+}
+
+/// The runtime facts which do not belong to mutable `State`, supplied by the
+/// core when it materialises +Config. Slices are borrowed for this one write.
+pub const ReportContext = struct {
+ startup_config_path: ?[]const u8,
+ platform: []const u8,
+ theme_name: []const u8,
+ compiled_default_shell: []const u8,
+ /// Null outside the SDL GUI. The borrowed label comes from effect_sources,
+ /// which knows whether its embedded GLSL import is live or paired prebuilt.
+ gui_shader_source_mode: ?[]const u8 = null,
+ hover_delay_frames: ?u16,
+ native_images: bool,
+ capabilities: Capabilities,
+ state: *const State,
+};
+
+fn onOff(value: bool) []const u8 {
+ return if (value) "on" else "off";
+}
+
+fn shown(text: []const u8) []const u8 {
+ return if (text.len == 0) "(none)" else text;
+}
+
+/// Name the setting which selected the one active transition. This keeps the
+/// report vocabulary identical to the generated builtin registry.
+fn transitionSettingName(transition: panel_animation.Transition) []const u8 {
+ if (transition == .off) return "off";
+ return findAction(.{ .transition = transition }).?.word;
+}
+
+/// Write the complete live Config report without allocation. Setting-backed
+/// rows follow `settings` order; host facts follow them as a compact footer.
+pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void {
+ const state = context.state;
+ var wrote_transition = false;
+ for (settings) |setting| switch (setting.action) {
+ .toggle => |field| {
+ const value = switch (field) {
+ .colors => state.colors,
+ .wrap => state.wrap,
+ .tag_bottom => state.tag_bottom,
+ .debug => state.debug,
+ };
+ try out.print("{s}: {s}\n", .{ setting.word, onOff(value) });
+ },
+ .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }),
+ .shell => {
+ const chosen = state.shell.requested.get();
+ try out.print(
+ "{s} requested (new panes): {s}{s}\n" ++
+ "{s} effective (last spawn): {s}\n" ++
+ "{s} pending: {s}\n",
+ .{
+ setting.word,
+ if (chosen.len == 0) context.compiled_default_shell else chosen,
+ if (chosen.len == 0) " (default)" else "",
+ setting.word,
+ shown(state.shell.effective.get()),
+ setting.word,
+ onOff(state.shell.pending),
+ },
+ );
+ },
+ .font => if (!setting.enabled(context.capabilities))
+ try out.print("{s}: unsupported\n", .{setting.word})
+ else
+ try out.print(
+ "{s} requested: {s}\n" ++
+ "{s} requested path: {s}\n" ++
+ "{s} effective: {s}\n" ++
+ "{s} pending: {s}\n" ++
+ "{s} effective size: {d}.{d:0>2} {s}\n",
+ .{
+ setting.word,
+ shown(state.font.requested_name.get()),
+ setting.word,
+ shown(state.font.requested_path.get()),
+ setting.word,
+ shown(state.font.effective_name.get()),
+ setting.word,
+ onOff(state.font.pending),
+ setting.word,
+ state.font.effective_size_hundredths / 100,
+ state.font.effective_size_hundredths % 100,
+ @tagName(state.font.effective_size_unit),
+ },
+ ),
+ .tagline_size => if (!context.capabilities.tagline_font_size)
+ try out.print("{s}: unsupported\n", .{setting.word})
+ else if (!setting.enabled(context.capabilities))
+ try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent })
+ else
+ try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }),
+ .transition => {
+ if (wrote_transition) continue;
+ wrote_transition = true;
+ if (!setting.enabled(context.capabilities))
+ try out.writeAll("Panel transition: unsupported\n")
+ else
+ try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)});
+ },
+ .scene => |effect| {
+ if (!setting.enabled(context.capabilities)) {
+ try out.print("{s}: unsupported\n", .{setting.word});
+ continue;
+ }
+ const enabled = switch (effect) {
+ inline else => |field| @field(state.scene_effects, @tagName(field)),
+ };
+ try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) });
+ },
+ };
+
+ if (context.startup_config_path) |path|
+ try out.print("Startup config: {s}\n", .{path})
+ else
+ try out.writeAll("Startup config: no per-user config path\n");
+ try out.print(
+ "Platform: {s}\n" ++
+ "Compiled default shell: {s}\n",
+ .{ context.platform, context.compiled_default_shell },
+ );
+ if (context.gui_shader_source_mode) |mode|
+ try out.print("GUI shader source: {s}\n", .{mode});
+ if (context.hover_delay_frames) |frames|
+ try out.print("Look hover delay: {d} frames\n", .{frames})
+ else
+ try out.writeAll("Look hover delay: off\n");
+ try out.print("Native images: {s}\n", .{onOff(context.native_images)});
+}
+
+test "setting names are unique and argument metadata follows actions" {
+ for (settings, 0..) |setting, i| {
+ try std.testing.expect(setting.word.len > 0);
+ for (settings[i + 1 ..]) |later|
+ try std.testing.expect(!std.mem.eql(u8, setting.word, later.word));
+ try std.testing.expectEqual(switch (setting.action) {
+ .shell, .theme, .font, .tagline_size => true,
+ else => false,
+ }, setting.takesArg());
+ }
+}
+
+test "simple setting application mutates only its plain field" {
+ var state: State = .{};
+ try std.testing.expect(applySimple(&state, find("Colors").?, null));
+ try std.testing.expect(!state.colors);
+ try std.testing.expect(applySimple(&state, find("Shell").?, " fish\n"));
+ try std.testing.expectEqualStrings("fish", state.shell.requested.get());
+ try std.testing.expect(state.shell.pending);
+ try std.testing.expect(applySimple(&state, find("PanelAscii").?, null));
+ try std.testing.expectEqual(panel_animation.Transition.ascii, state.panel_transition);
+ try std.testing.expect(applySimple(&state, find("PanelAscii").?, null));
+ try std.testing.expectEqual(panel_animation.Transition.off, state.panel_transition);
+ try std.testing.expect(applySimple(&state, find("Crt").?, null));
+ try std.testing.expect(state.scene_effects.crt);
+}
+
+test "tagline size validates before mutating live state" {
+ const setting = find("TaglineSize").?;
+ var state: State = .{};
+
+ for ([_][]const u8{ "1", " 82\n", "100" }) |argument| {
+ try std.testing.expect(applySimple(&state, setting, argument));
+ try std.testing.expectEqual(try std.fmt.parseInt(u8, std.mem.trim(u8, argument, " \t\r\n"), 10), state.font.tagline_percent);
+ }
+
+ state.font.tagline_percent = 67;
+ for ([_]?[]const u8{ null, "", "0", "101", "-1", "50%", "999999999999999999999" }) |argument| {
+ try std.testing.expect(!applySimple(&state, setting, argument));
+ try std.testing.expectEqual(@as(u8, 67), state.font.tagline_percent);
+ }
+}
+
+test "font request tuple rejects atomically" {
+ var state: State = .{};
+ try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old"));
+ var too_long: [256]u8 = @splat('x');
+ try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long));
+ try std.testing.expectEqualStrings("/fonts/old.ttf", state.font.requested_path.get());
+ try std.testing.expectEqualStrings("Old", state.font.requested_name.get());
+}
+
+test "Config report observes every simple setting and all live context" {
+ var state: State = .{};
+ var storage: [4096]u8 = undefined;
+ const context: ReportContext = .{
+ .startup_config_path = "/tmp/pardes/config",
+ .platform = "gui",
+ .theme_name = "acme",
+ .compiled_default_shell = "/bin/sh",
+ .gui_shader_source_mode = "live GLSL compiled during this build",
+ .hover_delay_frames = 18,
+ .native_images = true,
+ .capabilities = .{
+ .font_picker = true,
+ .panel_transitions = true,
+ .scene_shaders = true,
+ .tagline_font_size = true,
+ },
+ .state = &state,
+ };
+
+ for (settings) |setting| {
+ switch (setting.action) {
+ .theme, .font => continue,
+ else => {},
+ }
+ const argument: ?[]const u8 = switch (setting.action) {
+ .shell => "fish",
+ .tagline_size => "73",
+ else => null,
+ };
+ try std.testing.expect(applySimple(&state, setting, argument));
+
+ var out: std.Io.Writer = .fixed(&storage);
+ try writeReport(&out, context);
+ const report = storage[0..out.end];
+ const expected = switch (setting.action) {
+ .toggle => |field| switch (field) {
+ .colors => "Colors: off\n",
+ .wrap => "Wrap: off\n",
+ .tag_bottom => "Tagbottom: on\n",
+ .debug => "Debug: on\n",
+ },
+ .shell => "Shell requested (new panes): fish\n",
+ .tagline_size => "TaglineSize: 73%\n",
+ .transition => |transition| switch (transition) {
+ .off => unreachable,
+ .slide => "Panel transition: PanelSlide\n",
+ .zoom => "Panel transition: PanelZoom\n",
+ .dissolve => "Panel transition: PanelDissolve\n",
+ .ascii => "Panel transition: PanelAscii\n",
+ .vertical => "Panel transition: PanelVertical\n",
+ },
+ .scene => |effect| switch (effect) {
+ .crt => "Crt: on\n",
+ .ripple => "Ripple: on\n",
+ .glitch => "Glitch: on\n",
+ },
+ .theme, .font => unreachable,
+ };
+ try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
+ }
+
+ try std.testing.expect(state.font.requested_name.set("Wanted Mono"));
+ try std.testing.expect(state.font.requested_path.set("/fonts/wanted.ttf"));
+ try std.testing.expect(state.font.effective_name.set("Effective Mono"));
+ state.font.pending = true;
+ state.font.effective_size_hundredths = 1375;
+ state.font.effective_size_unit = .points;
+ state.font.tagline_percent = 82;
+
+ var out: std.Io.Writer = .fixed(&storage);
+ try writeReport(&out, context);
+ const report = storage[0..out.end];
+ for ([_][]const u8{
+ "Theme: acme\n",
+ "Font requested: Wanted Mono\n",
+ "Font requested path: /fonts/wanted.ttf\n",
+ "Font effective: Effective Mono\n",
+ "Font pending: on\n",
+ "Font effective size: 13.75 points\n",
+ "TaglineSize: 82%\n",
+ "Startup config: /tmp/pardes/config\n",
+ "Platform: gui\n",
+ "Compiled default shell: /bin/sh\n",
+ "GUI shader source: live GLSL compiled during this build\n",
+ "Look hover delay: 18 frames\n",
+ "Native images: on\n",
+ }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
+
+ var defaults: State = .{};
+ var defaults_context = context;
+ defaults_context.startup_config_path = null;
+ defaults_context.platform = "tty";
+ defaults_context.gui_shader_source_mode = null;
+ defaults_context.hover_delay_frames = null;
+ defaults_context.native_images = false;
+ defaults_context.capabilities = .{
+ .font_picker = false,
+ .panel_transitions = true,
+ .scene_shaders = false,
+ .tagline_font_size = false,
+ };
+ defaults_context.state = &defaults;
+ out = .fixed(&storage);
+ try writeReport(&out, defaults_context);
+ const defaults_report = storage[0..out.end];
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null);
+
+ defaults_context.platform = "web";
+ defaults_context.capabilities.panel_transitions = false;
+ defaults_context.capabilities.tagline_font_size = true;
+ out = .fixed(&storage);
+ try writeReport(&out, defaults_context);
+ const web_report = storage[0..out.end];
+ try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null);
+}