a73x

16676784

Add implementation plans for dirty row rendering, font config, HiDPI, text coverage, and visible selection

a73x   2026-04-10 08:09

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff --git a/docs/superpowers/plans/2026-04-08-dirty-row-rendering-implementation.md b/docs/superpowers/plans/2026-04-08-dirty-row-rendering-implementation.md
new file mode 100644
index 0000000..15cadc7
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-08-dirty-row-rendering-implementation.md
@@ -0,0 +1,674 @@
# Dirty-Row Rendering Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make terminal redraw cost proportional to changed rows by caching per-row instances and supporting partial instance-buffer uploads.

**Architecture:** Keep the existing single contiguous instance buffer and single draw call. Add CPU-side row and cursor caches in `src/main.zig`, use `ghostty-vt` dirty flags to decide what to rebuild, and extend `src/renderer.zig` with partial-range instance uploads plus full-upload fallback when buffer growth invalidates prior GPU contents.

**Tech Stack:** Zig 0.15, ghostty-vt render state dirty flags, Vulkan host-visible buffers, existing `renderer.Instance` pipeline.

---

## File Structure

- Modify: `src/main.zig`
  - Add row-cache data structures, rebuild planning helpers, cursor-cache tracking, and the new redraw/update flow.
- Modify: `src/renderer.zig`
  - Add partial instance-range upload support and tests for the buffer-growth fallback behavior.
- Test: `src/main.zig`
  - Add tests for rebuild planning, packing offsets, cursor-only invalidation, and dirty-flag lifecycle.
- Test: `src/renderer.zig`
  - Add tests for range calculations and fallback decisions without requiring a live Vulkan device.

### Task 1: Add failing tests for dirty-row planning helpers

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "planRowRefresh requests full rebuild for full dirty state" {
    const plan = planRowRefresh(.full, &.{ false, true, false }, .{
        .cursor_changed = false,
        .old_cursor_row = null,
        .new_cursor_row = null,
    });

    try std.testing.expect(plan.full_rebuild);
    try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}

test "planRowRefresh selects only dirty rows for partial state" {
    const plan = planRowRefresh(.partial, &.{ false, true, false, true }, .{
        .cursor_changed = false,
        .old_cursor_row = null,
        .new_cursor_row = null,
    });

    try std.testing.expect(!plan.full_rebuild);
    try std.testing.expect(plan.rows_to_rebuild.isSet(1));
    try std.testing.expect(plan.rows_to_rebuild.isSet(3));
    try std.testing.expect(!plan.rows_to_rebuild.isSet(0));
}

test "planRowRefresh handles cursor-only updates without unrelated rows" {
    const plan = planRowRefresh(.partial, &.{ false, false, false }, .{
        .cursor_changed = true,
        .old_cursor_row = 1,
        .new_cursor_row = 2,
    });

    try std.testing.expect(!plan.full_rebuild);
    try std.testing.expect(plan.cursor_rebuild);
    try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count());
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL with missing identifiers such as `planRowRefresh`.

- [ ] **Step 3: Write minimal helper types and planning implementation**

```zig
const RowRefreshContext = struct {
    cursor_changed: bool,
    old_cursor_row: ?usize,
    new_cursor_row: ?usize,
};

const RowRefreshPlan = struct {
    full_rebuild: bool,
    cursor_rebuild: bool,
    rows_to_rebuild: std.StaticBitSet(256),
};

fn planRowRefresh(
    dirty: vt.RenderDirty,
    row_dirty: []const bool,
    ctx: RowRefreshContext,
) RowRefreshPlan {
    var rows = std.StaticBitSet(256).initEmpty();
    if (dirty == .full) {
        return .{
            .full_rebuild = true,
            .cursor_rebuild = true,
            .rows_to_rebuild = rows,
        };
    }

    for (row_dirty, 0..) |is_dirty, i| {
        if (is_dirty) rows.set(i);
    }

    return .{
        .full_rebuild = false,
        .cursor_rebuild = ctx.cursor_changed,
        .rows_to_rebuild = rows,
    };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new planning tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add dirty-row refresh planning helpers"
```

### Task 2: Add failing tests for row packing and layout invalidation

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "repackRowCaches assigns contiguous offsets" {
    var rows = [_]RowInstanceCache{
        .{ .instances = try makeTestInstances(std.testing.allocator, 2), .gpu_offset_instances = 99, .gpu_len_instances = 0 },
        .{ .instances = try makeTestInstances(std.testing.allocator, 3), .gpu_offset_instances = 99, .gpu_len_instances = 0 },
    };
    defer for (&rows) |*row| row.instances.deinit(std.testing.allocator);

    var packed: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    defer packed.deinit(std.testing.allocator);

    const total = try repackRowCaches(std.testing.allocator, &packed, &rows, &.{});

    try std.testing.expectEqual(@as(u32, 0), rows[0].gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 2), rows[1].gpu_offset_instances);
    try std.testing.expectEqual(@as(u32, 5), total);
}

test "updateLayoutDirty becomes true when row instance count changes" {
    try std.testing.expect(markLayoutDirtyOnLenChange(2, 3));
    try std.testing.expect(!markLayoutDirtyOnLenChange(3, 3));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL with missing identifiers such as `repackRowCaches`.

- [ ] **Step 3: Write minimal packing helpers**

```zig
fn markLayoutDirtyOnLenChange(old_len: usize, new_len: usize) bool {
    return old_len != new_len;
}

fn repackRowCaches(
    alloc: std.mem.Allocator,
    packed: *std.ArrayListUnmanaged(renderer.Instance),
    rows: []RowInstanceCache,
    cursor_instances: []const renderer.Instance,
) !u32 {
    packed.clearRetainingCapacity();

    var offset: u32 = 0;
    for (rows) |*row| {
        row.gpu_offset_instances = offset;
        row.gpu_len_instances = @intCast(row.instances.items.len);
        try packed.appendSlice(alloc, row.instances.items);
        offset += @intCast(row.instances.items.len);
    }

    _ = cursor_instances;
    return offset;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new packing tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add dirty-row packing helpers"
```

### Task 3: Add failing tests for renderer partial-upload fallback decisions

**Files:**
- Modify: `src/renderer.zig`
- Test: `src/renderer.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "range upload falls back to full upload when capacity must grow" {
    const decision = planInstanceUpload(.{
        .current_capacity = 8,
        .offset_instances = 6,
        .write_len = 4,
    });

    try std.testing.expect(decision.needs_growth);
    try std.testing.expect(decision.force_full_upload);
}

test "range upload stays partial when capacity is sufficient" {
    const decision = planInstanceUpload(.{
        .current_capacity = 16,
        .offset_instances = 4,
        .write_len = 3,
    });

    try std.testing.expect(!decision.needs_growth);
    try std.testing.expect(!decision.force_full_upload);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/renderer.zig`
Expected: FAIL with `planInstanceUpload` undefined.

- [ ] **Step 3: Write minimal planning implementation in renderer**

```zig
const InstanceUploadRequest = struct {
    current_capacity: u32,
    offset_instances: u32,
    write_len: u32,
};

const InstanceUploadDecision = struct {
    needed_capacity: u32,
    needs_growth: bool,
    force_full_upload: bool,
};

fn planInstanceUpload(req: InstanceUploadRequest) InstanceUploadDecision {
    const needed = req.offset_instances + req.write_len;
    const needs_growth = needed > req.current_capacity;
    return .{
        .needed_capacity = needed,
        .needs_growth = needs_growth,
        .force_full_upload = needs_growth,
    };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/renderer.zig`
Expected: PASS for the new renderer planning tests.

- [ ] **Step 5: Commit**

```bash
git add src/renderer.zig
git commit -m "Add instance upload planning helpers"
```

### Task 4: Implement row and cursor cache data structures in main

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "RenderCache resizeRows preserves existing row allocations" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);

    try cache.resizeRows(std.testing.allocator, 3);
    try std.testing.expectEqual(@as(usize, 3), cache.rows.len);

    try cache.resizeRows(std.testing.allocator, 2);
    try std.testing.expectEqual(@as(usize, 2), cache.rows.len);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL with `RenderCache` undefined.

- [ ] **Step 3: Implement cache structs and lifecycle**

```zig
const RowInstanceCache = struct {
    instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
    gpu_offset_instances: u32 = 0,
    gpu_len_instances: u32 = 0,

    fn deinit(self: *RowInstanceCache, alloc: std.mem.Allocator) void {
        self.instances.deinit(alloc);
    }
};

const RenderCache = struct {
    rows: []RowInstanceCache = &.{},
    cursor_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
    packed_instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
    total_instance_count: u32 = 0,
    layout_dirty: bool = true,

    const empty: RenderCache = .{};

    fn resizeRows(self: *RenderCache, alloc: std.mem.Allocator, row_count: usize) !void { ... }
    fn deinit(self: *RenderCache, alloc: std.mem.Allocator) void { ... }
};
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for `RenderCache` lifecycle tests and prior helper tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add render cache data structures"
```

### Task 5: Extract row rebuild logic from the current full-frame path

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing test**

```zig
test "rebuildRowInstances emits expected instances for a colored glyph row" {
    // Reuse existing appendCellInstances expectations, but route through
    // rebuildRowInstances into a RowInstanceCache.
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL because `rebuildRowInstances` does not exist.

- [ ] **Step 3: Implement minimal row rebuild helper**

```zig
fn rebuildRowInstances(
    alloc: std.mem.Allocator,
    cache: *RowInstanceCache,
    row_idx: u32,
    row_cells: anytype,
    face: *font.Face,
    atlas: *font.Atlas,
    baseline: u32,
    default_bg: [4]f32,
) !bool {
    const old_len = cache.instances.items.len;
    cache.instances.clearRetainingCapacity();
    // Move the current per-row cell loop from runTerminal here.
    return markLayoutDirtyOnLenChange(old_len, cache.instances.items.len);
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new row rebuild test and existing append-order tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Extract row instance rebuild logic"
```

### Task 6: Add cursor cache handling and tests

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "rebuildCursorInstances produces one cursor quad when visible" {
    // Build a minimal cursor state and assert one instance is emitted.
}

test "cursor cache marks layout dirty when visibility changes instance count" {
    try std.testing.expect(markLayoutDirtyOnLenChange(1, 0));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL because `rebuildCursorInstances` is missing.

- [ ] **Step 3: Implement minimal cursor rebuild helper**

```zig
fn rebuildCursorInstances(
    alloc: std.mem.Allocator,
    cursor_instances: *std.ArrayListUnmanaged(renderer.Instance),
    cursor: vt.Terminal.RenderCursor,
    cell_w: u32,
    cell_h: u32,
    cursor_uv: font.GlyphUV,
) !bool { ... }
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new cursor-cache tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add cursor cache rebuild logic"
```

### Task 7: Implement renderer partial-range uploads

**Files:**
- Modify: `src/renderer.zig`
- Test: `src/renderer.zig`

- [ ] **Step 1: Write the failing test**

```zig
test "uploadInstanceRangeWrite computes byte offset from instance offset" {
    const write = planInstanceRangeWrite(3, 2);
    try std.testing.expectEqual(@as(vk.DeviceSize, 3 * @sizeOf(Instance)), write.byte_offset);
    try std.testing.expectEqual(@as(vk.DeviceSize, 2 * @sizeOf(Instance)), write.byte_len);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/renderer.zig`
Expected: FAIL because `planInstanceRangeWrite` is undefined.

- [ ] **Step 3: Implement partial-range upload support**

```zig
const InstanceRangeWrite = struct {
    byte_offset: vk.DeviceSize,
    byte_len: vk.DeviceSize,
};

fn planInstanceRangeWrite(offset_instances: u32, len_instances: u32) InstanceRangeWrite {
    return .{
        .byte_offset = @as(vk.DeviceSize, offset_instances) * @sizeOf(Instance),
        .byte_len = @as(vk.DeviceSize, len_instances) * @sizeOf(Instance),
    };
}

pub fn uploadInstanceRange(
    self: *Context,
    offset_instances: u32,
    instances: []const Instance,
) !bool {
    const decision = planInstanceUpload(.{
        .current_capacity = self.instance_capacity,
        .offset_instances = offset_instances,
        .write_len = @intCast(instances.len),
    });
    if (decision.force_full_upload) return true;

    const range = planInstanceRangeWrite(offset_instances, @intCast(instances.len));
    const mapped = try self.vkd.mapMemory(self.device, self.instance_memory, range.byte_offset, range.byte_len, .{});
    @memcpy(@as([*]Instance, @ptrCast(@alignCast(mapped)))[0..instances.len], instances);
    self.vkd.unmapMemory(self.device, self.instance_memory);
    return false;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/renderer.zig`
Expected: PASS for the new range-write and upload-planning tests.

- [ ] **Step 5: Commit**

```bash
git add src/renderer.zig
git commit -m "Add partial instance buffer uploads"
```

### Task 8: Integrate dirty-row cache flow into `runTerminal`

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing test**

```zig
test "applyRenderPlan requests full upload when layout changes" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);

    const result = applyRenderPlan(.{
        .layout_dirty = true,
        .rows_rebuilt = 1,
        .cursor_rebuilt = false,
    });

    try std.testing.expect(result.full_upload);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL because `applyRenderPlan` is undefined.

- [ ] **Step 3: Implement the main-loop integration**

```zig
// In runTerminal:
// 1. initialize RenderCache after grid creation
// 2. after term.snapshot(), compute RowRefreshPlan
// 3. rebuild only required rows/cursor
// 4. if layout dirty, repack + full upload
// 5. otherwise call uploadInstanceRange for changed rows/cursor
// 6. if uploadInstanceRange requests fallback, repack + full upload
// 7. clear dirty flags only after cache refresh succeeds
// 8. call drawCells with cache.total_instance_count
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the new integration helper tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig src/renderer.zig
git commit -m "Use dirty-row render cache in terminal loop"
```

### Task 9: Verify dirty-flag lifecycle and resize behavior

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

```zig
test "clearConsumedDirtyFlags clears flags only after successful refresh" {
    var rows = [_]bool{ true, false, true };
    clearConsumedDirtyFlags(.partial, &rows, true);
    try std.testing.expect(!rows[0]);
    try std.testing.expect(!rows[2]);
}

test "resize invalidates cache layout for full repack" {
    var cache = RenderCache.empty;
    defer cache.deinit(std.testing.allocator);
    cache.layout_dirty = false;
    invalidateCacheForResize(&cache);
    try std.testing.expect(cache.layout_dirty);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig test src/main.zig`
Expected: FAIL because helpers are undefined.

- [ ] **Step 3: Implement the helpers and wire resize invalidation**

```zig
fn clearConsumedDirtyFlags(dirty: vt.RenderDirty, row_dirty: []bool, success: bool) void { ... }
fn invalidateCacheForResize(cache: *RenderCache) void {
    cache.layout_dirty = true;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig test src/main.zig`
Expected: PASS for the dirty-flag lifecycle and resize invalidation tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Handle dirty flag clearing and resize invalidation"
```

### Task 10: Full verification

**Files:**
- Modify: none
- Test: `src/main.zig`, `src/renderer.zig`

- [ ] **Step 1: Run the full test suite**

Run: `zig build test`
Expected: PASS

- [ ] **Step 2: Run a manual responsiveness smoke test**

Run: `zig build run`
Expected:
- Terminal opens normally.
- Typing a single character does not trigger visible sluggishness.
- Cursor movement still renders correctly.
- Resize still redraws correctly.

- [ ] **Step 3: Run a scrolling/full-clear smoke test**

Run inside the terminal:

```sh
yes "row" | head -n 200
clear
printf 'done\n'
```

Expected:
- Scrolling remains correct.
- `clear` fully redraws the screen.
- Prompt remains usable afterward.

- [ ] **Step 4: Commit**

```bash
git add src/main.zig src/renderer.zig
git commit -m "Verify dirty-row rendering implementation"
```

## Self-Review

- Spec coverage:
  - Row caches: Tasks 4, 5, 8
  - Partial redraw planning: Tasks 1, 8
  - Full repack and offset packing: Tasks 2, 8
  - Partial uploads and fallback on growth: Tasks 3, 7, 8
  - Cursor-only handling: Task 6
  - Dirty-flag lifecycle: Task 9
  - Verification and smoke tests: Task 10
- Placeholder scan:
  - The exact integration steps are listed explicitly in Task 8.
  - No `TODO`/`TBD` markers remain.
- Type consistency:
  - `RowInstanceCache`, `RenderCache`, `RowRefreshPlan`, `planInstanceUpload`, and `uploadInstanceRange` are defined before later tasks rely on them.
diff --git a/docs/superpowers/plans/2026-04-09-font-config-implementation.md b/docs/superpowers/plans/2026-04-09-font-config-implementation.md
new file mode 100644
index 0000000..b7a569c
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-09-font-config-implementation.md
@@ -0,0 +1,242 @@
# Font Config Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Move terminal font family and font size into an `st`-style config module and use `Monaspace Argon` as the explicit configured font.

**Architecture:** Add a small `src/config.zig` module with compile-time constants, then thread that configuration into the existing Fontconfig lookup path and the main terminal startup path. Keep runtime behavior unchanged apart from explicit font selection and size sourcing, with no fallback logic.

**Tech Stack:** Zig 0.15, Fontconfig, FreeType, existing `font.zig` and `main.zig` startup path

---

## File Structure

- Create: `src/config.zig`
  - Holds user-editable font defaults in one place.
- Modify: `src/font.zig`
  - Reads the configured family and resolves it through Fontconfig without fallback.
- Modify: `src/main.zig`
  - Uses configured font size in the normal terminal path and helper/demo paths that currently hardcode `16`.
- Test: `src/font.zig`
  - Keeps lookup tests aligned with explicit configured-family behavior.
- Test: `src/main.zig`
  - Keeps any font-size-dependent helper tests aligned with config-backed size use if needed.

### Task 1: Add the ST-style config module

**Files:**
- Create: `src/config.zig`

- [ ] **Step 1: Add the config module**

```zig
pub const font_family = "Monaspace Argon";
pub const font_size_px: u32 = 16;
```

- [ ] **Step 2: Verify the file contents**

Run: `sed -n '1,40p' src/config.zig`
Expected:
- Shows only the two config constants.

- [ ] **Step 3: Commit**

```bash
git add src/config.zig
git commit -m "Add terminal font config module"
```

### Task 2: Wire configured font family into Fontconfig lookup

**Files:**
- Modify: `src/font.zig`
- Test: `src/font.zig`

- [ ] **Step 1: Add the failing test update**

Replace the old generic lookup test with a configured-family test:

```zig
test "lookupConfiguredFont returns a valid configured font path" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    try std.testing.expect(lookup.path.len > 0);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `rm -rf /tmp/zig-global-cache-font-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-font-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-font-plan zig build test --summary all`
Expected:
- FAIL because `lookupConfiguredFont` is undefined or call sites still reference `lookupMonospace`.

- [ ] **Step 3: Implement the configured-family lookup**

Update imports and lookup code in `src/font.zig`:

```zig
const config = @import("config");
```

```zig
pub fn lookupConfiguredFont(alloc: std.mem.Allocator) !FontLookup {
    if (c.FcInit() == c.FcFalse) return error.FcInitFailed;

    const pattern = c.FcPatternCreate() orelse return error.FcPatternCreate;
    defer c.FcPatternDestroy(pattern);

    _ = c.FcPatternAddString(pattern, c.FC_FAMILY, @ptrCast(config.font_family));
    _ = c.FcPatternAddInteger(pattern, c.FC_WEIGHT, c.FC_WEIGHT_REGULAR);
    _ = c.FcPatternAddInteger(pattern, c.FC_SLANT, c.FC_SLANT_ROMAN);

    _ = c.FcConfigSubstitute(null, pattern, c.FcMatchPattern);
    c.FcDefaultSubstitute(pattern);

    var result: c.FcResult = undefined;
    const matched = c.FcFontMatch(null, pattern, &result) orelse return error.FcFontMatchFailed;
    defer c.FcPatternDestroy(matched);

    var file_cstr: [*c]c.FcChar8 = null;
    if (c.FcPatternGetString(matched, c.FC_FILE, 0, &file_cstr) != c.FcResultMatch) {
        return error.FcGetFileFailed;
    }

    var index: c_int = 0;
    _ = c.FcPatternGetInteger(matched, c.FC_INDEX, 0, &index);

    const slice = std.mem.span(@as([*:0]const u8, @ptrCast(file_cstr)));
    const dup = try alloc.dupeZ(u8, slice);
    return .{ .path = dup, .index = index };
}
```

Update existing tests and helpers that call `lookupMonospace` to call `lookupConfiguredFont` instead.

- [ ] **Step 4: Run test to verify it passes**

Run: `rm -rf /tmp/zig-global-cache-font-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-font-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-font-plan zig build test --summary all`
Expected:
- PASS for the font module tests and any dependent tests.

- [ ] **Step 5: Commit**

```bash
git add src/font.zig src/main.zig build.zig
git commit -m "Resolve configured terminal font family"
```

### Task 3: Move font size to config-backed startup

**Files:**
- Modify: `src/main.zig`
- Modify: `src/font.zig`

- [ ] **Step 1: Add the failing test update**

Replace hardcoded `16` setup in the normal terminal path and demo helper path with config-backed size references:

```zig
const config = @import("config");
```

```zig
const font_size: u32 = config.font_size_px;
```

```zig
var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
```

- [ ] **Step 2: Run test to verify it fails**

Run: `rm -rf /tmp/zig-global-cache-size-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-size-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-size-plan zig build test --summary all`
Expected:
- FAIL until all hardcoded call sites are updated and the config module is imported where needed.

- [ ] **Step 3: Implement config-backed font size usage**

Update `src/main.zig`:

```zig
const config = @import("config");
```

Replace the startup hardcode:

```zig
const font_size: u32 = config.font_size_px;
```

Replace helper/demo face creation that currently uses `16`:

```zig
var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
```

Use `lookupConfiguredFont` in the same locations:

```zig
var font_lookup = try font.lookupConfiguredFont(alloc);
```

- [ ] **Step 4: Run test to verify it passes**

Run: `rm -rf /tmp/zig-global-cache-size-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-size-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-size-plan zig build test --summary all`
Expected:
- PASS with all tests green.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig src/font.zig src/config.zig
git commit -m "Read terminal font size from config"
```

### Task 4: Full verification

**Files:**
- Modify: none
- Test: `src/font.zig`, `src/main.zig`

- [ ] **Step 1: Run the full test suite**

Run: `rm -rf /tmp/zig-global-cache-font-verify && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-font-verify && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-font-verify zig build test --summary all`
Expected:
- PASS

- [ ] **Step 2: Run a build verification**

Run: `rm -rf /tmp/zig-global-cache-font-build && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-font-build && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-font-build zig build`
Expected:
- PASS

- [ ] **Step 3: Run a manual launch verification**

Run: `zig build run`
Expected:
- Terminal launches with `Monaspace Argon`.
- Terminal starts at configured font size.
- Startup fails loudly if the configured family is not available.

- [ ] **Step 4: Commit**

```bash
git add src/config.zig src/font.zig src/main.zig
git commit -m "Verify configured terminal font defaults"
```

## Self-Review

- Spec coverage:
  - ST-style config module: Task 1
  - Explicit `Monaspace Argon` family: Task 2
  - No fallback behavior: Task 2 and Task 4
  - Config-backed font size: Task 3
  - Validation with tests and manual launch: Task 4
- Placeholder scan:
  - No `TODO`, `TBD`, or deferred implementation markers remain.
- Type consistency:
  - The plan consistently uses `lookupConfiguredFont`, `config.font_family`, and `config.font_size_px`.
diff --git a/docs/superpowers/plans/2026-04-09-hidpi-support-implementation.md b/docs/superpowers/plans/2026-04-09-hidpi-support-implementation.md
new file mode 100644
index 0000000..5863bb1
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-09-hidpi-support-implementation.md
@@ -0,0 +1,1148 @@
# HiDPI Support Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make waystty render crisply on HiDPI Wayland outputs by binding `wl_output`, tracking per-surface buffer scale via `wl_surface.enter`/`leave`, rasterizing the font at the correct pixel size, and sizing the Vulkan swapchain in buffer pixels.

**Architecture:** A pure `ScaleTracker` struct owns the mapping from bound `wl_output`s to their advertised scales and the set of outputs the current surface has entered. `Connection` binds `wl_output` globals at registry time and drives the tracker on scale/done events. `Window` listens for `wl_surface.enter`/`leave` and exposes the tracker's computed `bufferScale()`. The main loop and the text‑compare loop both react to scale changes by (a) rebuilding `font.Face` at `px_size * scale`, (b) resetting the glyph atlas and render cache, (c) recreating the Vulkan swapchain at `surface_size * scale`, and (d) calling `wl_surface.set_buffer_scale(scale)`. Window dimensions are always surface‑coordinate; only the Vulkan extent, font rasterization size, and push‑constant `cell_size` switch to buffer pixels.

**Tech Stack:** Zig 0.15, `zig-wayland` (wl_compositor v6, wl_output v4), FreeType, Vulkan, existing `renderer.zig` / `font.zig` / `wayland.zig`.

---

## File Structure

- Create: `src/scale_tracker.zig`
  - Pure data struct: adds/removes outputs, updates per-output scale, tracks entered outputs for one surface, and computes `max(scale)` across entered outputs (default 1). Fully unit-testable with no Wayland calls.
- Modify: `src/wayland.zig`
  - Bind `wl_output` in `registryListener`; attach output listeners that push scale/done into the tracker.
  - Add an `Output` struct (handle + proxy listener context).
  - Extend `Window` with a pointer to the connection's `ScaleTracker` plus a generation counter incremented when `enter`/`leave` changes the effective scale.
  - Add a `Window.bufferScale()` helper.
- Modify: `src/font.zig`
  - Add `Atlas.reset()` that clears the cache + zeroes pixels + resets cursors + marks dirty.
  - Add `Face.reinit()` that deinits freetype state and re-opens at a new `px_size`.
- Modify: `src/main.zig`
  - Extract a `DisplayGeometry` helper (`{surface_w, surface_h, buffer_w, buffer_h, cell_w_px, cell_h_px, px_size}`) recomputed from the current scale.
  - Add a `rebuildForScale()` helper shared by `runTerminal` and `runTextCoverageCompare`.
  - Integrate scale changes into both loops' resize-handling blocks.
- Modify: `src/renderer.zig`
  - No structural changes; `recreateSwapchain(width, height)` continues to take buffer‑pixel dimensions. (`drawCells` already receives `cell_size` via push constants.)
- Test: `src/scale_tracker.zig` — unit tests for the tracker.
- Test: `src/wayland.zig` — wiring smoke test that the `Output` add/remove path feeds the tracker when exercised manually (fake events).
- Test: `src/font.zig` — test `Atlas.reset()` and `Face.reinit()` behavior.
- Test: `src/main.zig` — test that `buildTextCoverageCompareScene` still produces the same grid after the refactor (no regression).

### Commit cadence

One commit per task unless explicitly noted. Never squash visual‑verification work into the code commit.

### Manual verification plan

Manual verification happens at the end of the plan on the real dual-monitor setup:
- Run `./zig-out/bin/waystty --text-compare` on DP‑5 (scale 1.0): text should be crisp (baseline regression check).
- Drag the same window to DP‑4 (Studio Display, scale 2.0): text should remain crisp (the feature).
- Run `./zig-out/bin/waystty` on DP‑4: terminal should be crisp.
- Drag between outputs while typing: no glyph corruption; brief re-layout is OK.

---

### Task 1: Pure ScaleTracker data struct + tests

**Files:**
- Create: `src/scale_tracker.zig`
- Test: `src/scale_tracker.zig` (inline tests)

- [ ] **Step 1: Write the failing tests**

Create `src/scale_tracker.zig` with the tests first (no implementation yet):

```zig
const std = @import("std");

pub const ScaleTracker = struct {
    // implementation added in Step 3
};

test "new tracker reports default scale of 1" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "entered output scale is reflected in bufferScale" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(1);
    t.setOutputScale(1, 2);
    try t.enterOutput(1);
    try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
}

test "not-yet-entered output does not change bufferScale" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(7);
    t.setOutputScale(7, 3);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "bufferScale is max across entered outputs" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(1);
    try t.addOutput(2);
    t.setOutputScale(1, 1);
    t.setOutputScale(2, 2);

    try t.enterOutput(1);
    try t.enterOutput(2);
    try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
}

test "leaving an output drops its contribution" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(1);
    try t.addOutput(2);
    t.setOutputScale(1, 2);
    t.setOutputScale(2, 3);
    try t.enterOutput(1);
    try t.enterOutput(2);
    try std.testing.expectEqual(@as(i32, 3), t.bufferScale());

    t.leaveOutput(2);
    try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
}

test "removing an unknown output is a no-op" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();
    t.removeOutput(999);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "removeOutput also removes it from entered set" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(5);
    t.setOutputScale(5, 4);
    try t.enterOutput(5);
    try std.testing.expectEqual(@as(i32, 4), t.bufferScale());

    t.removeOutput(5);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "setOutputScale on unknown id is a no-op" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();
    t.setOutputScale(999, 5);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}
```

- [ ] **Step 2: Register the module and run tests to verify they fail**

Add to `build.zig` so the test step picks up the new file. First, register a module (it'll also be imported by `wayland_mod` in Task 2). Insert after the `config_mod` block (around line 10):

```zig
    const scale_tracker_mod = b.createModule(.{
        .root_source_file = b.path("src/scale_tracker.zig"),
        .target = target,
        .optimize = optimize,
    });
```

Then add a dedicated test module and register it with `test_step`. Insert after the `pty_tests` block (after line 102):

```zig
    // Test scale_tracker.zig
    const scale_tracker_test_mod = b.createModule(.{
        .root_source_file = b.path("src/scale_tracker.zig"),
        .target = target,
        .optimize = optimize,
    });
    const scale_tracker_tests = b.addTest(.{
        .root_module = scale_tracker_test_mod,
    });
    test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step);
```

Finally, wire the module into `wayland_mod` so Task 2 can `@import("scale_tracker")`. Add after `wayland_mod.linkSystemLibrary("xkbcommon", .{});` (around line 41):

```zig
    wayland_mod.addImport("scale_tracker", scale_tracker_mod);
```

Then run:

```bash
rm -rf /tmp/zig-global-cache-hidpi && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-hidpi && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: FAIL — tests reference `ScaleTracker.init`, `bufferScale`, `addOutput`, `setOutputScale`, `enterOutput`, `leaveOutput`, `removeOutput` which are undefined.

- [ ] **Step 3: Implement ScaleTracker**

Replace the stub in `src/scale_tracker.zig` with:

```zig
const std = @import("std");

pub const OutputId = u32;

pub const ScaleTracker = struct {
    alloc: std.mem.Allocator,
    scales: std.AutoHashMapUnmanaged(OutputId, i32),
    entered: std.AutoHashMapUnmanaged(OutputId, void),

    pub fn init(alloc: std.mem.Allocator) ScaleTracker {
        return .{
            .alloc = alloc,
            .scales = .empty,
            .entered = .empty,
        };
    }

    pub fn deinit(self: *ScaleTracker) void {
        self.scales.deinit(self.alloc);
        self.entered.deinit(self.alloc);
    }

    pub fn addOutput(self: *ScaleTracker, id: OutputId) !void {
        try self.scales.put(self.alloc, id, 1);
    }

    pub fn setOutputScale(self: *ScaleTracker, id: OutputId, scale: i32) void {
        if (self.scales.getPtr(id)) |slot| slot.* = scale;
    }

    pub fn removeOutput(self: *ScaleTracker, id: OutputId) void {
        _ = self.scales.remove(id);
        _ = self.entered.remove(id);
    }

    pub fn enterOutput(self: *ScaleTracker, id: OutputId) !void {
        try self.entered.put(self.alloc, id, {});
    }

    pub fn leaveOutput(self: *ScaleTracker, id: OutputId) void {
        _ = self.entered.remove(id);
    }

    pub fn bufferScale(self: *const ScaleTracker) i32 {
        var max_scale: i32 = 1;
        var it = self.entered.iterator();
        while (it.next()) |entry| {
            const id = entry.key_ptr.*;
            if (self.scales.get(id)) |s| {
                if (s > max_scale) max_scale = s;
            }
        }
        return max_scale;
    }
};
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: all 8 `ScaleTracker` tests PASS, plus all pre-existing tests remain passing.

- [ ] **Step 5: Commit**

```bash
git add src/scale_tracker.zig build.zig
git commit -m "Add pure ScaleTracker for wl_output scale tracking"
```

### Task 2: Bind wl_output in the registry and feed ScaleTracker

**Files:**
- Modify: `src/wayland.zig`

- [ ] **Step 1: Import the tracker and add an Output proxy struct**

Add near the top of `src/wayland.zig`, after the existing imports:

```zig
const ScaleTracker = @import("scale_tracker").ScaleTracker;
```

Add a new pub struct above `Globals`:

```zig
pub const Output = struct {
    wl_output: *wl.Output,
    name: u32,
    tracker: *ScaleTracker,
    pending_scale: i32 = 1,
};
```

- [ ] **Step 2: Extend Globals + Connection to own a ScaleTracker and an outputs list**

Update `Globals`:

```zig
pub const Globals = struct {
    compositor: ?*wl.Compositor = null,
    wm_base: ?*xdg.WmBase = null,
    seat: ?*wl.Seat = null,
    data_device_manager: ?*wl.DataDeviceManager = null,
};
```

Leave `Globals` alone; the outputs list and the tracker live on `Connection` because they need an allocator. Change `Connection`:

```zig
pub const Connection = struct {
    display: *wl.Display,
    registry: *wl.Registry,
    globals: Globals,
    alloc: std.mem.Allocator,
    scale_tracker: ScaleTracker,
    outputs: std.ArrayListUnmanaged(*Output),

    pub fn init(alloc: std.mem.Allocator) !Connection {
        const display = try wl.Display.connect(null);
        errdefer display.disconnect();

        const registry = try display.getRegistry();
        errdefer registry.destroy();

        var conn = Connection{
            .display = display,
            .registry = registry,
            .globals = Globals{},
            .alloc = alloc,
            .scale_tracker = ScaleTracker.init(alloc),
            .outputs = .empty,
        };

        registry.setListener(*Connection, registryListener, &conn);

        if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
        // Second roundtrip so each wl_output's initial scale/done events are received.
        if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;

        if (conn.globals.compositor == null) return error.NoCompositor;
        if (conn.globals.wm_base == null) return error.NoXdgWmBase;
        if (conn.globals.seat == null) return error.NoSeat;

        return conn;
    }

    pub fn deinit(self: *Connection) void {
        for (self.outputs.items) |out| {
            out.wl_output.release();
            self.alloc.destroy(out);
        }
        self.outputs.deinit(self.alloc);
        self.scale_tracker.deinit();
        self.display.disconnect();
    }

    // createWindow stays; see Task 3.
};
```

Note: `Connection.init` now takes an allocator. This breaks every call site — the next step fixes them.

- [ ] **Step 3: Rewrite registryListener to handle wl_output**

Replace `registryListener` with:

```zig
fn registryListener(
    registry: *wl.Registry,
    event: wl.Registry.Event,
    conn: *Connection,
) void {
    switch (event) {
        .global => |g| {
            const iface = std.mem.span(g.interface);
            if (std.mem.eql(u8, iface, std.mem.span(wl.Compositor.interface.name))) {
                conn.globals.compositor = registry.bind(g.name, wl.Compositor, 6) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(wl.DataDeviceManager.interface.name))) {
                conn.globals.data_device_manager = registry.bind(g.name, wl.DataDeviceManager, 3) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(xdg.WmBase.interface.name))) {
                conn.globals.wm_base = registry.bind(g.name, xdg.WmBase, 5) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(wl.Seat.interface.name))) {
                conn.globals.seat = registry.bind(g.name, wl.Seat, 9) catch return;
            } else if (std.mem.eql(u8, iface, std.mem.span(wl.Output.interface.name))) {
                const wl_out = registry.bind(g.name, wl.Output, 4) catch return;
                const out = conn.alloc.create(Output) catch {
                    wl_out.release();
                    return;
                };
                out.* = .{
                    .wl_output = wl_out,
                    .name = g.name,
                    .tracker = &conn.scale_tracker,
                };
                conn.outputs.append(conn.alloc, out) catch {
                    wl_out.release();
                    conn.alloc.destroy(out);
                    return;
                };
                conn.scale_tracker.addOutput(g.name) catch {};
                wl_out.setListener(*Output, outputListener, out);
            }
        },
        .global_remove => |g| {
            var i: usize = 0;
            while (i < conn.outputs.items.len) : (i += 1) {
                const out = conn.outputs.items[i];
                if (out.name == g.name) {
                    conn.scale_tracker.removeOutput(out.name);
                    out.wl_output.release();
                    conn.alloc.destroy(out);
                    _ = conn.outputs.swapRemove(i);
                    return;
                }
            }
        },
    }
}

fn outputListener(
    _: *wl.Output,
    event: wl.Output.Event,
    out: *Output,
) void {
    switch (event) {
        .scale => |s| {
            out.pending_scale = s.factor;
        },
        .done => {
            out.tracker.setOutputScale(out.name, out.pending_scale);
        },
        .geometry, .mode, .name, .description => {},
    }
}
```

- [ ] **Step 4: Fix call sites**

Update every call to `Connection.init()` in the codebase to pass the allocator. Run:

```bash
grep -rn "wayland_client.Connection.init" src/
```

Expected matches: `runTerminal`, `runTextCoverageCompare`, plus any smoke test functions (`runWaylandSmokeTest`, etc). For each, change `wayland_client.Connection.init()` to `wayland_client.Connection.init(alloc)`.

- [ ] **Step 5: Build and run full test suite**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: PASS. No new tests yet — this step just verifies we didn't break the build or existing tests.

- [ ] **Step 6: Manual smoke test — verify outputs get tracked**

Add a temporary debug print at the end of `Connection.init` (after the second roundtrip) that prints scales for each bound output:

```zig
for (conn.outputs.items) |out| {
    std.debug.print("wl_output name={d} scale={d}\n", .{ out.name, conn.scale_tracker.scales.get(out.name) orelse 0 });
}
```

Build and run:

```bash
zig build
./zig-out/bin/waystty --text-compare
```

Expected: two `wl_output name=... scale=...` lines printed before the window appears, one showing `scale=2` (DP‑4) and one showing `scale=1` (DP‑5).

**Remove the debug print before committing.**

- [ ] **Step 7: Commit**

```bash
git add src/wayland.zig src/main.zig
git commit -m "Bind wl_output globals into ScaleTracker"
```

### Task 3: Track surface enter/leave and expose bufferScale on Window

**Files:**
- Modify: `src/wayland.zig`
- Test: `src/wayland.zig` (inline test for tracker reaction to simulated enter/leave)

- [ ] **Step 1: Write a failing test for enter/leave plumbing**

Add this test at the bottom of `src/wayland.zig`:

```zig
test "Window.bufferScale reflects ScaleTracker entered outputs" {
    var tracker = ScaleTracker.init(std.testing.allocator);
    defer tracker.deinit();

    try tracker.addOutput(1);
    try tracker.addOutput(2);
    tracker.setOutputScale(1, 1);
    tracker.setOutputScale(2, 2);

    // Simulate the bits Window.bufferScale delegates to.
    try tracker.enterOutput(2);
    try std.testing.expectEqual(@as(i32, 2), tracker.bufferScale());

    tracker.leaveOutput(2);
    try std.testing.expectEqual(@as(i32, 1), tracker.bufferScale());
}
```

This test documents the contract for `Window.bufferScale()` and will pass trivially once the window wiring lands. It exists so the behavior has test coverage; the real integration test is the manual run at the end.

- [ ] **Step 2: Extend Window to hold tracker pointer and generation counter**

Update `Window`:

```zig
pub const Window = struct {
    alloc: std.mem.Allocator,
    surface: *wl.Surface,
    xdg_surface: *xdg.Surface,
    xdg_toplevel: *xdg.Toplevel,
    tracker: *ScaleTracker,
    scale_generation: u64 = 0,
    applied_buffer_scale: i32 = 1,
    configured: bool = false,
    should_close: bool = false,
    width: u32 = 800,
    height: u32 = 600,

    pub fn deinit(self: *Window) void {
        self.xdg_toplevel.destroy();
        self.xdg_surface.destroy();
        self.surface.destroy();
        self.alloc.destroy(self);
    }

    pub fn setTitle(self: *Window, title: ?[:0]const u8) void {
        self.xdg_toplevel.setTitle((title orelse "waystty"));
    }

    pub fn bufferScale(self: *const Window) i32 {
        return self.tracker.bufferScale();
    }
};
```

- [ ] **Step 3: Wire the wl_surface enter/leave listener in createWindow**

Replace `createWindow` with:

```zig
pub fn createWindow(self: *Connection, alloc: std.mem.Allocator, title: [*:0]const u8) !*Window {
    const compositor = self.globals.compositor orelse return error.NoCompositor;
    const wm_base = self.globals.wm_base orelse return error.NoXdgWmBase;

    const window = try alloc.create(Window);
    errdefer alloc.destroy(window);

    window.* = .{
        .alloc = alloc,
        .surface = try compositor.createSurface(),
        .xdg_surface = undefined,
        .xdg_toplevel = undefined,
        .tracker = &self.scale_tracker,
    };
    errdefer window.surface.destroy();

    window.surface.setListener(*Window, surfaceListener, window);

    window.xdg_surface = try wm_base.getXdgSurface(window.surface);
    errdefer window.xdg_surface.destroy();

    window.xdg_toplevel = try window.xdg_surface.getToplevel();

    window.xdg_toplevel.setTitle(title);
    window.xdg_toplevel.setAppId("waystty");

    window.xdg_surface.setListener(*Window, xdgSurfaceListener, window);
    window.xdg_toplevel.setListener(*Window, xdgToplevelListener, window);
    wm_base.setListener(*xdg.WmBase, wmBaseListener, wm_base);

    window.surface.commit();
    _ = self.display.roundtrip();

    return window;
}
```

Add the listener function next to the existing `xdgSurfaceListener`:

```zig
fn surfaceListener(
    _: *wl.Surface,
    event: wl.Surface.Event,
    window: *Window,
) void {
    switch (event) {
        .enter => |e| {
            window.handleSurfaceEnter(e.output);
            window.scale_generation += 1;
        },
        .leave => |e| {
            window.handleSurfaceLeave(e.output);
            window.scale_generation += 1;
        },
        .preferred_buffer_scale => {},
        .preferred_buffer_transform => {},
    }
}
```

The callback needs access to the `Connection.outputs` list to map from `*wl.Output` to a stable id. Add a pointer to the outputs list onto `Window`:

```zig
    tracker: *ScaleTracker,
    outputs: *std.ArrayListUnmanaged(*Output),
```

Populate it in `createWindow`:

```zig
    window.* = .{
        .alloc = alloc,
        .surface = try compositor.createSurface(),
        .xdg_surface = undefined,
        .xdg_toplevel = undefined,
        .tracker = &self.scale_tracker,
        .outputs = &self.outputs,
    };
```

And implement the lookup helpers on `Window`:

```zig
    pub fn handleSurfaceEnter(self: *Window, wl_out: *wl.Output) void {
        for (self.outputs.items) |out| {
            if (out.wl_output == wl_out) {
                self.tracker.enterOutput(out.name) catch {};
                return;
            }
        }
    }

    pub fn handleSurfaceLeave(self: *Window, wl_out: *wl.Output) void {
        for (self.outputs.items) |out| {
            if (out.wl_output == wl_out) {
                self.tracker.leaveOutput(out.name);
                return;
            }
        }
    }
```

- [ ] **Step 4: Run tests and build**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: all tests PASS including the new `Window.bufferScale reflects ScaleTracker entered outputs` test.

- [ ] **Step 5: Commit**

```bash
git add src/wayland.zig
git commit -m "Track wl_surface enter/leave on Window"
```

### Task 4: Atlas reset + Face reinit so rasterization can follow the scale

**Files:**
- Modify: `src/font.zig`
- Test: `src/font.zig`

- [ ] **Step 1: Write failing tests**

Append to `src/font.zig`:

```zig
test "Atlas.reset clears cache and starts fresh" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();

    var atlas = try Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    _ = try atlas.getOrInsert(&face, 'A');
    try std.testing.expect(atlas.cache.count() > 0);

    atlas.reset();
    try std.testing.expectEqual(@as(u32, 1), @as(u32, @intCast(atlas.cache.count() + 1))); // cache empty
    try std.testing.expectEqual(@as(u8, 255), atlas.pixels[0]);
    try std.testing.expect(atlas.dirty);

    // Re-inserting the same glyph should succeed after reset.
    _ = try atlas.getOrInsert(&face, 'A');
}

test "Face.reinit switches px_size and produces different cell metrics" {
    var lookup = try lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try Face.init(std.testing.allocator, lookup.path, lookup.index, 14);
    defer face.deinit();
    const small_cell = face.cellWidth();

    try face.reinit(lookup.path, lookup.index, 28);
    const large_cell = face.cellWidth();

    try std.testing.expect(large_cell > small_cell);
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: FAIL — `Atlas.reset` and `Face.reinit` are undefined.

- [ ] **Step 3: Implement Atlas.reset and Face.reinit**

Add to `Atlas` (next to `deinit`):

```zig
    pub fn reset(self: *Atlas) void {
        @memset(self.pixels, 0);
        self.pixels[0] = 255;
        self.cursor_x = 1;
        self.cursor_y = 0;
        self.row_height = 1;
        self.cache.clearRetainingCapacity();
        self.dirty = true;
    }
```

Add to `Face` (next to `deinit`):

```zig
    pub fn reinit(
        self: *Face,
        path: [:0]const u8,
        index: c_int,
        px_size: u32,
    ) !void {
        _ = c.FT_Done_Face(self.face);
        self.face = null;

        var new_face: c.FT_Face = null;
        if (c.FT_New_Face(self.library, path.ptr, index, &new_face) != 0) return error.FtNewFaceFailed;
        errdefer _ = c.FT_Done_Face(new_face);

        if (c.FT_Set_Pixel_Sizes(new_face, 0, px_size) != 0) return error.FtSetPixelSizesFailed;

        self.face = new_face;
        self.px_size = px_size;
    }
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: all tests PASS.

- [ ] **Step 5: Commit**

```bash
git add src/font.zig
git commit -m "Add Atlas.reset and Face.reinit for scale changes"
```

### Task 5: Wire dynamic scale into runTextCoverageCompare

This task integrates scale into the simpler of the two render loops first. It's the loop the user originally reported the bug in, so we verify the fix here before extending it to the full terminal.

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Extract a rebuild helper**

Near the top of `src/main.zig` (after `GridSize`), add:

```zig
const ScaledGeometry = struct {
    buffer_scale: i32,
    px_size: u32,
    cell_w_px: u32, // buffer pixels
    cell_h_px: u32, // buffer pixels
    baseline_px: u32,
};

fn rebuildFaceForScale(
    face: *font.Face,
    atlas: *font.Atlas,
    font_path: [:0]const u8,
    font_index: c_int,
    base_px_size: u32,
    buffer_scale: i32,
) !ScaledGeometry {
    const scale: u32 = @intCast(@max(@as(i32, 1), buffer_scale));
    const new_px = base_px_size * scale;
    try face.reinit(font_path, font_index, new_px);
    atlas.reset();
    return .{
        .buffer_scale = @intCast(scale),
        .px_size = new_px,
        .cell_w_px = face.cellWidth(),
        .cell_h_px = face.cellHeight(),
        .baseline_px = face.baseline(),
    };
}
```

- [ ] **Step 2: Update runTextCoverageCompare to respect scale**

Replace the body of `runTextCoverageCompare` with a version that:

1. Calls `rebuildFaceForScale` once up front at scale=1 to establish the base geometry.
2. Sets the surface size to `(panel_cols * variants) × rows` in surface coordinates.
3. After the first frame is rendered, re-reads `window.bufferScale()`; if it changed, calls `rebuildFaceForScale` again at the new scale, rebuilds the scene, calls `window.surface.setBufferScale(scale)`, commits, recreates the Vulkan swapchain at buffer pixels, re-uploads atlas and instances, and continues.

Concretely, replace lines 1759–1847 with:

```zig
fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init(alloc);
    defer conn.deinit();

    const window = try conn.createWindow(alloc, "waystty-text-compare");
    defer window.deinit();

    _ = conn.display.roundtrip();

    var font_lookup = try font.lookupConfiguredFont(alloc);
    defer font_lookup.deinit(alloc);

    var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
    defer face.deinit();

    var atlas = try font.Atlas.init(alloc, 2048, 2048);
    defer atlas.deinit();

    var geom: ScaledGeometry = .{
        .buffer_scale = 1,
        .px_size = config.font_size_px,
        .cell_w_px = face.cellWidth(),
        .cell_h_px = face.cellHeight(),
        .baseline_px = face.baseline(),
    };

    var scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
    defer scene.deinit(alloc);

    // Surface-coordinate window size (logical pixels).
    const surface_cell_w: u32 = geom.cell_w_px; // scale=1 initially
    const surface_cell_h: u32 = geom.cell_h_px;
    window.width = scene.window_cols * surface_cell_w;
    window.height = scene.window_rows * surface_cell_h;

    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width * @as(u32, @intCast(geom.buffer_scale)),
        window.height * @as(u32, @intCast(geom.buffer_scale)),
    );
    defer ctx.deinit();

    try ctx.uploadAtlas(atlas.pixels);
    atlas.dirty = false;
    try ctx.uploadInstances(scene.instances.items);

    const wl_fd = conn.display.getFd();
    var pollfds = [_]std.posix.pollfd{
        .{ .fd = wl_fd, .events = std.posix.POLL.IN, .revents = 0 },
    };
    var last_window_w = window.width;
    var last_window_h = window.height;
    var last_scale: i32 = geom.buffer_scale;

    while (!window.should_close) {
        _ = conn.display.flush();
        if (conn.display.prepareRead()) {
            pollfds[0].revents = 0;
            _ = std.posix.poll(&pollfds, 16) catch {};
            if (pollfds[0].revents & std.posix.POLL.IN != 0) {
                _ = conn.display.readEvents();
            } else {
                conn.display.cancelRead();
            }
        }
        _ = conn.display.dispatchPending();

        const current_scale = window.bufferScale();
        const scale_changed = current_scale != last_scale;
        const size_changed = window.width != last_window_w or window.height != last_window_h;

        if (scale_changed or size_changed) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);

            if (scale_changed) {
                geom = try rebuildFaceForScale(
                    &face,
                    &atlas,
                    font_lookup.path,
                    font_lookup.index,
                    config.font_size_px,
                    current_scale,
                );
                // Rebuild the scene against the fresh atlas.
                scene.deinit(alloc);
                scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);

                // The surface size is cells × (cell_px / scale). Since we raster at px_size*scale,
                // the surface_cell values are stable across scale changes.
                const new_surface_cell_w: u32 = geom.cell_w_px / @as(u32, @intCast(geom.buffer_scale));
                const new_surface_cell_h: u32 = geom.cell_h_px / @as(u32, @intCast(geom.buffer_scale));
                window.width = scene.window_cols * new_surface_cell_w;
                window.height = scene.window_rows * new_surface_cell_h;

                window.surface.setBufferScale(geom.buffer_scale);
                try ctx.uploadAtlas(atlas.pixels);
                atlas.dirty = false;
                try ctx.uploadInstances(scene.instances.items);
                last_scale = current_scale;
            }

            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);

            last_window_w = window.width;
            last_window_h = window.height;
        }

        drawTextCoverageCompareFrame(
            &ctx,
            &scene,
            geom.cell_w_px,
            geom.cell_h_px,
            .{ 0.0, 0.0, 0.0, 1.0 },
        ) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
                const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
                try ctx.recreateSwapchain(buf_w, buf_h);
                last_window_w = window.width;
                last_window_h = window.height;
                continue;
            },
            else => return err,
        };

        _ = conn.display.flush();
        std.Thread.sleep(16 * std.time.ns_per_ms);
    }

    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
}
```

Note: the atlas was bumped from 1024×1024 to 2048×2048 because at 2× scale a single panel of text may exceed the smaller atlas's vertical budget. If the build complains about uniform sizes, this is the first place to look.

- [ ] **Step 3: Run the test suite to confirm nothing regressed**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: PASS, including `buildTextCoverageCompareScene repeats the same specimen in four panels`.

- [ ] **Step 4: Manual verification on the 2× monitor**

```bash
zig build
./zig-out/bin/waystty --text-compare
```

- Move the window to DP‑4 (Apple Studio Display, scale 2.0). Expected: glyphs are crisp (no compositor upscale blur). The window's logical size should look the same as before — only the buffer density changes.
- Move the window to DP‑5 (Dell AW3225QF, scale 1.0). Expected: glyphs stay crisp. No visible re-layout flicker beyond the resize frame.

If the text still looks fuzzy on DP‑4 at this stage, the `window.surface.setBufferScale` call isn't taking effect before the first frame — check that `commit()` runs before the Vulkan draw, and that the swapchain was recreated at the doubled extent.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Honor wl_output buffer scale in text-compare mode"
```

### Task 6: Wire dynamic scale into runTerminal

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Add scale handling to the terminal loop**

In `runTerminal`, after the existing `last_window_w` / `last_window_h` / `render_pending` variables are declared, add:

```zig
    var geom: ScaledGeometry = .{
        .buffer_scale = 1,
        .px_size = config.font_size_px,
        .cell_w_px = cell_w,
        .cell_h_px = cell_h,
        .baseline_px = baseline,
    };
    var last_scale: i32 = geom.buffer_scale;
    _ = &last_scale; // touched inside the loop
```

Rename the outer immutable `cell_w` / `cell_h` / `baseline` to `cell_w_px`, `cell_h_px`, `baseline_px` and pull them from `geom` so that the scale‑rebuild path can mutate them. Leave the initial values wired through as before — this is a pure rename in all existing references.

- [ ] **Step 2: Detect scale changes inside the loop**

In `runTerminal`'s main loop, just before the existing size-change block (`if (window.width != last_window_w or window.height != last_window_h)`), add:

```zig
        const current_scale = window.bufferScale();
        if (current_scale != last_scale) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);

            geom = try rebuildFaceForScale(
                &face,
                &atlas,
                font_lookup.path,
                font_lookup.index,
                config.font_size_px,
                current_scale,
            );

            // Invalidate cached instances so glyphs get re-inserted into the fresh atlas.
            // `invalidateAfterResize()` already zeroes per-row GPU offsets, clears cursor/packed,
            // and marks layout_dirty. Marking the terminal full-dirty forces rebuildRowInstances()
            // to run for every row next frame.
            render_cache.invalidateAfterResize();
            term.render_state.dirty = .full;

            window.surface.setBufferScale(geom.buffer_scale);

            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);

            last_scale = current_scale;
            render_pending = true;

            cell_w_px = geom.cell_w_px;
            cell_h_px = geom.cell_h_px;
            baseline_px = geom.baseline_px;
        }
```

- [ ] **Step 3: Update the existing resize block to use buffer-pixel extents**

Where the existing code calls `ctx.recreateSwapchain(window.width, window.height)` inside `runTerminal`, change both call sites to:

```zig
            const buf_w = window.width * @as(u32, @intCast(geom.buffer_scale));
            const buf_h = window.height * @as(u32, @intCast(geom.buffer_scale));
            try ctx.recreateSwapchain(buf_w, buf_h);
```

Also update the grid-size computation. Because `cell_w_px` / `cell_h_px` are in buffer pixels and `window.width` / `window.height` are surface coordinates, `gridSizeForWindow` must divide by the *surface-pixel* cell size, which is `cell_w_px / buffer_scale`. Update the call:

```zig
            const surf_cell_w = cell_w_px / @as(u32, @intCast(geom.buffer_scale));
            const surf_cell_h = cell_h_px / @as(u32, @intCast(geom.buffer_scale));
            const new_grid = gridSizeForWindow(window.width, window.height, surf_cell_w, surf_cell_h);
```

- [ ] **Step 4: Run the test suite**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: PASS.

- [ ] **Step 5: Manual verification**

```bash
zig build
./zig-out/bin/waystty
```

- Launch with the cursor focused on DP‑4. Expected: crisp terminal at startup.
- Drag to DP‑5 and back. Expected: brief re-layout frame, then crisp text. Grid size (cols × rows) should stay stable because we compute grid from surface coordinates, not buffer pixels.
- Type during the drag. Expected: no crashes, no corrupted glyphs after the rebuild.

- [ ] **Step 6: Commit**

```bash
git add src/main.zig
git commit -m "Honor wl_output buffer scale in terminal render loop"
```

### Task 7: Final verification and cleanup

**Files:**
- Verify: all modified files

- [ ] **Step 1: Full test suite**

```bash
ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-hidpi zig build test --summary all
```

Expected: PASS.

- [ ] **Step 2: Lint for stale debug prints and TODOs**

```bash
grep -n "std.debug.print" src/wayland.zig src/main.zig
grep -n "TODO" src/scale_tracker.zig src/wayland.zig src/font.zig src/main.zig
```

Expected: no `std.debug.print` lines that weren't in the baseline; no new TODOs introduced by this plan.

- [ ] **Step 3: Four-point manual verification matrix**

| Binary | Start output | Drag target | Expected |
|---|---|---|---|
| `waystty --text-compare` | DP‑5 (1×) | — | Crisp at startup |
| `waystty --text-compare` | DP‑4 (2×) | — | Crisp at startup |
| `waystty --text-compare` | DP‑5 → DP‑4 | drag | Crisp after drag |
| `waystty` | DP‑4 (2×) | — | Crisp terminal, input works |

- [ ] **Step 4: Update the dev-display memory note**

Edit `/home/xanderle/.claude/projects/-home-xanderle-code-rad-waystty/memory/dev_display_setup.md` and flip the status from "waystty has no HiDPI handling yet" to "waystty binds wl_output and reacts to wl_surface.enter/leave; rebuilds font+atlas+swapchain on buffer scale changes." Keep the display layout facts as-is since the dual-monitor setup is still relevant for future rendering debugging.

- [ ] **Step 5: Final commit if any cleanup happened**

Only create this commit if the previous steps surfaced anything to clean up.

```bash
git add <touched files>
git commit -m "HiDPI support cleanup"
```
diff --git a/docs/superpowers/plans/2026-04-09-text-coverage-comparison-implementation.md b/docs/superpowers/plans/2026-04-09-text-coverage-comparison-implementation.md
new file mode 100644
index 0000000..12fe832
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-09-text-coverage-comparison-implementation.md
@@ -0,0 +1,403 @@
# Text Coverage Comparison Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a `--text-compare` mode that renders one specimen across four side-by-side coverage variants so text sharpness can be evaluated visually without changing the default terminal path.

**Architecture:** Reuse the existing Vulkan/font/atlas smoke-test path in `src/main.zig`, extend the renderer with a small fragment-side coverage control, and render four panels using the same atlas and instance format. Keep the default terminal rendering path unchanged; the comparison mode is an isolated inspection tool.

**Tech Stack:** Zig 0.15, Vulkan renderer in `src/renderer.zig`, GLSL fragment shader in `shaders/cell.frag`, Wayland window path in `src/main.zig`, configured font lookup in `src/font.zig`

---

## File Structure

- Modify: `src/main.zig`
  - Add the `--text-compare` CLI path, specimen layout helpers, and comparison-mode render loop.
- Modify: `src/renderer.zig`
  - Add a small configurable coverage parameter path that the fragment shader can read.
- Modify: `shaders/cell.frag`
  - Apply variant coverage shaping while preserving the current baseline behavior.
- Test: `src/main.zig`
  - Add tests for specimen layout and panel placement helpers.
- Test: `src/renderer.zig`
  - Add tests for the coverage-parameter packing/planning helpers that do not require a live Vulkan device.

### Task 1: Add coverage-variant planning helpers in the renderer

**Files:**
- Modify: `src/renderer.zig`
- Test: `src/renderer.zig`

- [ ] **Step 1: Write the failing tests**

Add tests for a small coverage parameter helper:

```zig
test "coverageVariantParams returns identity values for baseline" {
    const params = coverageVariantParams(.baseline);
    try std.testing.expectEqualDeep([2]f32{ 1.0, 0.0 }, params);
}

test "coverageVariantParams steepens non-baseline variants" {
    const mild = coverageVariantParams(.mild);
    const medium = coverageVariantParams(.medium);
    const crisp = coverageVariantParams(.crisp);

    try std.testing.expect(mild[0] > 1.0);
    try std.testing.expect(medium[0] > mild[0]);
    try std.testing.expect(crisp[0] > medium[0]);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `rm -rf /tmp/zig-global-cache-coverage-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-coverage-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-coverage-plan zig build test --summary all`
Expected:
- FAIL because `coverageVariantParams` and the enum are undefined.

- [ ] **Step 3: Add the minimal helper types and implementation**

Add a small enum and helper in `src/renderer.zig`:

```zig
pub const CoverageVariant = enum(u32) {
    baseline,
    mild,
    medium,
    crisp,
};

fn coverageVariantParams(variant: CoverageVariant) [2]f32 {
    return switch (variant) {
        .baseline => .{ 1.0, 0.0 },
        .mild => .{ 1.15, 0.0 },
        .medium => .{ 1.3, 0.0 },
        .crisp => .{ 1.55, -0.08 },
    };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `rm -rf /tmp/zig-global-cache-coverage-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-coverage-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-coverage-plan zig build test --summary all`
Expected:
- PASS for the new renderer helper tests.

- [ ] **Step 5: Commit**

```bash
git add src/renderer.zig
git commit -m "Add text coverage variant helpers"
```

### Task 2: Plumb coverage controls through push constants and fragment shader

**Files:**
- Modify: `src/renderer.zig`
- Modify: `shaders/cell.frag`
- Test: `src/renderer.zig`

- [ ] **Step 1: Write the failing test**

Add a test that checks the push-constant default remains baseline-safe:

```zig
test "PushConstants defaults preserve baseline coverage shaping" {
    const pc = PushConstants{
        .surface_size = .{ 800.0, 600.0 },
        .cell_size = .{ 8.0, 16.0 },
        .clear_color = .{ 0.0, 0.0, 0.0, 1.0 },
        .coverage_params = .{ 1.0, 0.0 },
    };

    try std.testing.expectEqualDeep([2]f32{ 1.0, 0.0 }, pc.coverage_params);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `rm -rf /tmp/zig-global-cache-push-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-push-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-push-plan zig build test --summary all`
Expected:
- FAIL because `PushConstants` does not yet contain `coverage_params`.

- [ ] **Step 3: Add push-constant and shader plumbing**

Extend `PushConstants` in `src/renderer.zig`:

```zig
pub const PushConstants = extern struct {
    surface_size: [2]f32,
    cell_size: [2]f32,
    clear_color: [4]f32,
    coverage_params: [2]f32,
};
```

Update `drawCells` to accept coverage params:

```zig
pub fn drawCells(
    self: *Context,
    instance_count: u32,
    cell_size: [2]f32,
    clear_color: [4]f32,
    coverage_params: [2]f32,
) !void
```

Set the push constants with that field populated.

Update `shaders/cell.frag`:

```glsl
layout(push_constant) uniform PushConstants {
    vec2 surface_size;
    vec2 cell_size;
    vec4 clear_color;
    vec2 coverage_params;
} pc;

float shape_coverage(float alpha) {
    float curved = pow(alpha, pc.coverage_params.x);
    return clamp(curved + pc.coverage_params.y, 0.0, 1.0);
}

void main() {
    float alpha = texture(glyph_atlas, in_uv).r;
    alpha = shape_coverage(alpha);
    out_color = mix(in_bg, in_fg, alpha);
}
```

Keep the baseline identity behavior by passing `{1.0, 0.0}` for normal paths.

- [ ] **Step 4: Run test to verify it passes**

Run: `rm -rf /tmp/zig-global-cache-push-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-push-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-push-plan zig build test --summary all`
Expected:
- PASS with shader compilation and the new push-constant test green.

- [ ] **Step 5: Commit**

```bash
git add src/renderer.zig shaders/cell.frag
git commit -m "Plumb text coverage controls through renderer"
```

### Task 3: Keep the default draw path on baseline coverage

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Add the failing compile-path update**

Update existing `drawCells` call sites to pass baseline coverage:

```zig
ctx.drawCells(instance_count, cell_size, clear_color, .{ 1.0, 0.0 })
```

- [ ] **Step 2: Run test to verify it fails**

Run: `rm -rf /tmp/zig-global-cache-baseline-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-baseline-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-baseline-plan zig build test --summary all`
Expected:
- FAIL until all `drawCells` call sites are updated.

- [ ] **Step 3: Update existing draw paths**

Update all current `ctx.drawCells(...)` calls in `src/main.zig` to use baseline params:

```zig
const baseline_coverage = .{ 1.0, 0.0 };
```

Pass `baseline_coverage` in:
- the normal terminal loop,
- `runDrawSmokeTest`,
- any other smoke/helper render loop.

- [ ] **Step 4: Run test to verify it passes**

Run: `rm -rf /tmp/zig-global-cache-baseline-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-baseline-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-baseline-plan zig build test --summary all`
Expected:
- PASS with the default rendering path behavior unchanged.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Keep default rendering on baseline coverage"
```

### Task 4: Add specimen layout helpers for comparison mode

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing tests**

Add tests for panel offsets and specimen instance generation planning:

```zig
test "comparisonPanelOrigins splits four panels left to right" {
    const origins = comparisonPanelOrigins(4, 80, 24);
    try std.testing.expectEqual(@as(f32, 0), origins[0][0]);
    try std.testing.expect(origins[1][0] > origins[0][0]);
    try std.testing.expect(origins[2][0] > origins[1][0]);
    try std.testing.expect(origins[3][0] > origins[2][0]);
}

test "specimenLines remains fixed and non-empty" {
    const lines = comparisonSpecimenLines();
    try std.testing.expect(lines.len >= 5);
    try std.testing.expect(lines[0].len > 0);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `rm -rf /tmp/zig-global-cache-layout-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-layout-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-layout-plan zig build test --summary all`
Expected:
- FAIL because the comparison helpers are undefined.

- [ ] **Step 3: Add the layout helpers**

Add focused helpers in `src/main.zig`:

```zig
const ComparisonVariant = struct {
    label: []const u8,
    coverage: [2]f32,
};

fn comparisonVariants() [4]ComparisonVariant {
    return .{
        .{ .label = "baseline", .coverage = renderer.coverageVariantParams(.baseline) },
        .{ .label = "mild", .coverage = renderer.coverageVariantParams(.mild) },
        .{ .label = "medium", .coverage = renderer.coverageVariantParams(.medium) },
        .{ .label = "crisp", .coverage = renderer.coverageVariantParams(.crisp) },
    };
}

fn comparisonSpecimenLines() []const []const u8 { ... }
fn comparisonPanelOrigins(panel_count: usize, panel_cols: u32, top_margin_rows: u32) [4][2]f32 { ... }
```

Keep the specimen text fixed to the approved five lines.

- [ ] **Step 4: Run test to verify it passes**

Run: `rm -rf /tmp/zig-global-cache-layout-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-layout-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-layout-plan zig build test --summary all`
Expected:
- PASS for the new comparison-layout tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add text comparison layout helpers"
```

### Task 5: Implement the `--text-compare` render mode

**Files:**
- Modify: `src/main.zig`

- [ ] **Step 1: Add the failing compile-path update**

Add a CLI branch:

```zig
if (args.len >= 2 and std.mem.eql(u8, args[1], "--text-compare")) {
    return runTextCoverageCompare(alloc);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `rm -rf /tmp/zig-global-cache-compare-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-compare-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-compare-plan zig build test --summary all`
Expected:
- FAIL because `runTextCoverageCompare` is undefined.

- [ ] **Step 3: Implement the comparison mode**

Add `runTextCoverageCompare(alloc)` in `src/main.zig` by reusing the draw-smoke structure:

```zig
fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
    // 1. Create Wayland connection, window, and Vulkan context.
    // 2. Load configured font and atlas.
    // 3. Build one instance list for the fixed specimen in each panel.
    // 4. Upload atlas and packed instances once.
    // 5. Render in a loop, selecting one panel coverage variant per draw.
}
```

Implementation notes:
- Use four separate draws per frame, one per panel.
- Re-upload only once; reuse the same instance buffer data.
- Keep the specimen identical in each panel and only vary `coverage_params`.
- Reserve enough horizontal spacing so panel text does not overlap.

- [ ] **Step 4: Run test to verify it passes**

Run: `rm -rf /tmp/zig-global-cache-compare-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-compare-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-compare-plan zig build test --summary all`
Expected:
- PASS

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add text coverage comparison mode"
```

### Task 6: Full verification

**Files:**
- Modify: none
- Test: `src/main.zig`, `src/renderer.zig`, `shaders/cell.frag`

- [ ] **Step 1: Run the full test suite**

Run: `rm -rf /tmp/zig-global-cache-compare-final && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-compare-final && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-compare-final zig build test --summary all`
Expected:
- PASS

- [ ] **Step 2: Run a build verification**

Run: `rm -rf /tmp/zig-global-cache-compare-build && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-compare-build && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-compare-build zig build`
Expected:
- PASS

- [ ] **Step 3: Run the comparison mode manually**

Run: `zig build run -- --text-compare`
Expected:
- One window opens.
- Four panels are visible.
- The specimen text matches across all panels.
- Baseline, mild, medium, and crisp variants are visually distinguishable.

- [ ] **Step 4: Commit**

```bash
git add src/main.zig src/renderer.zig shaders/cell.frag
git commit -m "Verify text coverage comparison mode"
```

## Self-Review

- Spec coverage:
  - New CLI comparison mode: Task 5
  - Reuse existing Vulkan/font/atlas path: Task 5
  - Four side-by-side panels with baseline + three variants: Tasks 4 and 5
  - Configured font family and size reuse: Tasks 3 and 5
  - Shader-only first pass: Tasks 1 and 2
  - Validation with build and manual comparison: Task 6
- Placeholder scan:
  - No `TODO`, `TBD`, or deferred “figure this out later” markers remain.
- Type consistency:
  - `CoverageVariant`, `coverageVariantParams`, `coverage_params`, `comparisonVariants`, and `runTextCoverageCompare` are named consistently across tasks.
diff --git a/docs/superpowers/plans/2026-04-09-visible-selection-implementation.md b/docs/superpowers/plans/2026-04-09-visible-selection-implementation.md
new file mode 100644
index 0000000..dabdeef
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-09-visible-selection-implementation.md
@@ -0,0 +1,631 @@
# Visible Selection Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add XTerm-style visible-grid text selection so users can drag-highlight text with the left mouse button and copy it with `Ctrl+Shift+C`.

**Architecture:** Keep selection as UI state in `src/main.zig`, sourced from visible-grid coordinates derived from Wayland pointer events. Extend `src/wayland.zig` to expose pointer events and clipboard ownership, then thread the normalized selection span into existing row rebuild and copy paths without moving selection logic into the VT wrapper.

**Tech Stack:** Zig 0.15, Wayland client bindings, `ghostty-vt`, existing `src/main.zig` render cache path, existing `src/wayland.zig` clipboard receive path

---

## File Structure

- Modify: `src/wayland.zig`
  - Add pointer setup/event queue and clipboard-source support for serving copied UTF-8 text.
- Modify: `src/main.zig`
  - Add visible selection state, pointer-to-grid mapping, selection-aware highlighting, copy extraction, and `Ctrl+Shift+C` copy handling.
- Modify: `src/vt.zig`
  - Only if needed to expose a tiny helper for extracting printable text from visible render cells.
- Test: `src/wayland.zig`
  - Add coverage for pointer queue primitives or clipboard-source helper logic that can be tested without a compositor.
- Test: `src/main.zig`
  - Add coverage for selection normalization, inclusion checks, visible-text extraction, copy shortcut detection, and resize clamping behavior.

### Task 1: Add selection primitives and tests in `main.zig`

**Files:**
- Modify: `src/main.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing selection-helper tests**

Add these tests near the existing pure-function tests in `src/main.zig`:

```zig
test "SelectionSpan.normalized orders endpoints in reading order" {
    const span = SelectionSpan{
        .start = .{ .col = 7, .row = 4 },
        .end = .{ .col = 2, .row = 1 },
    }.normalized();

    try std.testing.expectEqual(@as(u32, 2), span.start.col);
    try std.testing.expectEqual(@as(u32, 1), span.start.row);
    try std.testing.expectEqual(@as(u32, 7), span.end.col);
    try std.testing.expectEqual(@as(u32, 4), span.end.row);
}

test "SelectionSpan.containsCell includes the normalized endpoints" {
    const span = SelectionSpan{
        .start = .{ .col = 3, .row = 2 },
        .end = .{ .col = 1, .row = 1 },
    };

    try std.testing.expect(span.containsCell(1, 1));
    try std.testing.expect(span.containsCell(2, 1));
    try std.testing.expect(span.containsCell(0, 2));
    try std.testing.expect(span.containsCell(3, 2));
    try std.testing.expect(!span.containsCell(0, 0));
    try std.testing.expect(!span.containsCell(4, 2));
}

test "clampSelectionSpan clears fully offscreen spans and trims resized spans" {
    try std.testing.expect(clampSelectionSpan(.{
        .start = .{ .col = 5, .row = 30 },
        .end = .{ .col = 10, .row = 40 },
    }, 80, 24) == null);

    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 78, .row = 22 },
        .end = .{ .col = 99, .row = 30 },
    }, 80, 24).?;

    try std.testing.expectEqual(@as(u32, 78), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 22), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 79), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 23), clamped.end.row);
}

test "clampSelectionSpan preserves a larger span that collapses to one visible cell" {
    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 120, .row = 80 },
    }, 1, 1).?;

    try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 0), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 0), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 0), clamped.end.row);
    try std.testing.expect(clamped.containsCell(0, 0));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because `SelectionSpan` and `clampSelectionSpan` do not exist yet.

- [ ] **Step 3: Implement the minimal selection primitives**

Add these helpers in `src/main.zig` near the other render-loop helper structs/functions:

```zig
const GridPoint = struct {
    col: u32,
    row: u32,
};

const SelectionSpan = struct {
    start: GridPoint,
    end: GridPoint,

    fn normalized(self: SelectionSpan) SelectionSpan {
        if (self.start.row < self.end.row) return self;
        if (self.start.row == self.end.row and self.start.col <= self.end.col) return self;
        return .{ .start = self.end, .end = self.start };
    }

    fn containsCell(self: SelectionSpan, col: u32, row: u32) bool {
        const span = self.normalized();
        if (row < span.start.row or row > span.end.row) return false;
        if (span.start.row == span.end.row) {
            return row == span.start.row and col >= span.start.col and col <= span.end.col;
        }
        if (row == span.start.row) return col >= span.start.col;
        if (row == span.end.row) return col <= span.end.col;
        return true;
    }
};

fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan {
    if (cols == 0 or rows == 0) return null;
    const max_col = @as(u32, cols) - 1;
    const max_row = @as(u32, rows) - 1;
    const normalized = span.normalized();
    if (normalized.start.row > max_row) return null;
    if (normalized.start.row == normalized.end.row and normalized.start.col > max_col) return null;
    return SelectionSpan{
        .start = .{
            .col = @min(normalized.start.col, max_col),
            .row = @min(normalized.start.row, max_row),
        },
        .end = .{
            .col = @min(normalized.end.col, max_col),
            .row = @min(normalized.end.row, max_row),
        },
    };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the new selection-helper tests.

Note:
- `SelectionSpan` is an inclusive visible span and may legitimately collapse to one visible cell after resize.
- Click-without-drag emptiness will be handled later by `SelectionState` in Task 4 rather than by the span primitive itself.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig
git commit -m "Add visible selection helpers"
```

### Task 2: Add visible-text extraction and copy shortcut coverage

**Files:**
- Modify: `src/main.zig`
- Modify: `src/vt.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing extraction and copy tests**

Add the shortcut test next to `isClipboardPasteEvent`, and add focused extraction tests:

```zig
test "isClipboardCopyEvent matches Ctrl-Shift-C press" {
    try std.testing.expect(isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_C,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .press,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
    try std.testing.expect(!isClipboardCopyEvent(.{
        .keysym = c.XKB_KEY_C,
        .modifiers = .{ .ctrl = true, .shift = true },
        .action = .repeat,
        .utf8 = [_]u8{0} ** 8,
        .utf8_len = 0,
    }));
}
```

```zig
test "extractSelectedText trims trailing blanks on each visible row" {
    var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 6, .rows = 2 });
    defer term.deinit();

    term.write("abc   \r\ndef   ");
    try term.snapshot();

    const text = try extractSelectedText(
        std.testing.allocator,
        &term.render_state.row_data,
        SelectionSpan{
            .start = .{ .col = 0, .row = 0 },
            .end = .{ .col = 5, .row = 1 },
        },
    );
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("abc\ndef", text);
}

test "extractSelectedText respects partial first and last rows" {
    var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 6, .rows = 2 });
    defer term.deinit();

    term.write("abcdef\r\nuvwxyz");
    try term.snapshot();

    const text = try extractSelectedText(
        std.testing.allocator,
        &term.render_state.row_data,
        SelectionSpan{
            .start = .{ .col = 2, .row = 0 },
            .end = .{ .col = 3, .row = 1 },
        },
    );
    defer std.testing.allocator.free(text);

    try std.testing.expectEqualStrings("cdef\nuvwx", text);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because `isClipboardCopyEvent` and `extractSelectedText` do not exist yet, or because cell text extraction is not wired.

- [ ] **Step 3: Implement minimal visible-text extraction**

In `src/main.zig`, add:

```zig
fn isClipboardCopyEvent(ev: wayland_client.KeyboardEvent) bool {
    return ev.action == .press and
        ev.modifiers.ctrl and
        ev.modifiers.shift and
        ev.keysym == c.XKB_KEY_C;
}
```

Add a row/cell extraction path that walks `term.render_state.row_data` in reading order:

```zig
fn extractSelectedText(
    alloc: std.mem.Allocator,
    row_data: anytype,
    span: SelectionSpan,
) ![]u8 {
    const normalized = span.normalized();
    var out = std.ArrayList(u8).empty;
    defer out.deinit(alloc);

    const rows = row_data.items(.cells);
    var row_idx = normalized.start.row;
    while (row_idx <= normalized.end.row and row_idx < rows.len) : (row_idx += 1) {
        const row = rows[row_idx];
        const first_col: u32 = if (row_idx == normalized.start.row) normalized.start.col else 0;
        const last_col: u32 = if (row_idx == normalized.end.row) normalized.end.col else @intCast(row.items(.raw).len - 1);
        try appendSelectedRowText(alloc, &out, row, first_col, last_col);
        if (row_idx != normalized.end.row) try out.append(alloc, '\n');
    }

    return try out.toOwnedSlice(alloc);
}
```

If `src/vt.zig` needs a helper to expose printable text for a render-state cell, add only that helper, for example:

```zig
pub fn cellCodepoint(cell: ghostty_vt.RenderState.Cell) u21 {
    return cell.raw.codepoint();
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the new copy-shortcut and selected-text extraction tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig src/vt.zig
git commit -m "Add visible selection copy extraction"
```

### Task 3: Add Wayland pointer events and clipboard ownership plumbing

**Files:**
- Modify: `src/wayland.zig`
- Test: `src/wayland.zig`

- [ ] **Step 1: Write the failing Wayland helper tests**

Add focused tests for logic that can run without a compositor:

```zig
test "clampSurfacePointToGrid caps pointer coordinates to visible cells" {
    const point = clampSurfacePointToGrid(9999.0, 9999.0, 8, 16, 80, 24).?;
    try std.testing.expectEqual(@as(u32, 79), point.col);
    try std.testing.expectEqual(@as(u32, 23), point.row);
}

test "ClipboardSelection.init stores offered UTF-8 bytes" {
    const selection = ClipboardSelection.init("hello");
    try std.testing.expectEqualStrings("hello", selection.text);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because pointer/grid helpers and clipboard-source state do not exist yet.

- [ ] **Step 3: Implement pointer queue and clipboard source support**

In `src/wayland.zig`, add pointer types alongside `KeyboardEvent`:

```zig
pub const PointerEvent = union(enum) {
    enter: struct { surface_x: f64, surface_y: f64 },
    leave: void,
    motion: struct { surface_x: f64, surface_y: f64 },
    button_press: struct { button: u32 },
    button_release: struct { button: u32 },
};
```

Add a `Pointer` wrapper parallel to `Keyboard`:

```zig
pub const Pointer = struct {
    alloc: std.mem.Allocator,
    wl_pointer: *wl.Pointer,
    event_queue: std.ArrayList(PointerEvent),

    pub fn init(alloc: std.mem.Allocator, seat: *wl.Seat) !*Pointer { ... }
    pub fn deinit(self: *Pointer) void { ... }
};
```

Register the listener with `seat.getPointer()` and append `.enter`, `.leave`, `.motion`, `.button_press`, and `.button_release` events in `pointerListener`.

Extend `Clipboard` to hold source-side state and export text:

```zig
const ClipboardSelection = struct {
    text: []const u8,

    fn init(text: []const u8) ClipboardSelection {
        return .{ .text = text };
    }
};
```

Add a setter on `Clipboard`:

```zig
pub fn setSelectionText(self: *Clipboard, text: []const u8) !void { ... }
```

That method should:
- duplicate the copied text into owned storage
- create a `wl_data_source`
- offer `text/plain;charset=utf-8` and `text/plain`
- install a listener that writes the stored bytes to the requester fd on `.send`
- call `data_device.setSelection(...)`
- clean up the previous source on replacement or cancellation

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the new pure/helper tests in `src/wayland.zig`.

- [ ] **Step 5: Commit**

```bash
git add src/wayland.zig
git commit -m "Add Wayland pointer and clipboard source support"
```

### Task 4: Wire pointer-driven visible selection into the main loop and rendering

**Files:**
- Modify: `src/main.zig`
- Modify: `src/wayland.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing interaction/render tests**

Add tests for the state machine and selected color override:

```zig
test "SelectionState starts drag on left-button press and commits on release" {
    var state = SelectionState{};
    handlePointerSelectionEvent(&state, .{ .motion = .{ .surface_x = 24.0, .surface_y = 16.0 } }, 8, 16, 80, 24);
    handlePointerSelectionEvent(&state, .{ .button_press = .{ .button = BTN_LEFT } }, 8, 16, 80, 24);
    handlePointerSelectionEvent(&state, .{ .motion = .{ .surface_x = 56.0, .surface_y = 16.0 } }, 8, 16, 80, 24);
    handlePointerSelectionEvent(&state, .{ .button_release = .{ .button = BTN_LEFT } }, 8, 16, 80, 24);

    try std.testing.expect(state.active == null);
    try std.testing.expect(state.committed != null);
}

test "selectionColors overrides terminal colors for selected cells" {
    const selected = selectionColors(.{
        .fg = .{ 1.0, 1.0, 1.0, 1.0 },
        .bg = .{ 0.0, 0.0, 0.0, 1.0 },
    }, true);
    try std.testing.expectEqualDeep([4]f32{ 0.08, 0.08, 0.08, 1.0 }, selected.fg);
    try std.testing.expectEqualDeep([4]f32{ 0.78, 0.82, 0.88, 1.0 }, selected.bg);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because `SelectionState`, `handlePointerSelectionEvent`, and `selectionColors` do not exist yet.

- [ ] **Step 3: Implement pointer-driven selection and selection-aware rendering**

In `src/main.zig`, add UI state:

```zig
const SelectionState = struct {
    hover: ?GridPoint = null,
    anchor: ?GridPoint = null,
    active: ?SelectionSpan = null,
    committed: ?SelectionSpan = null,
};
```

Create a small helper that maps pointer coordinates to cells using surface-space cell dimensions:

```zig
fn surfacePointToGrid(
    surface_x: f64,
    surface_y: f64,
    cell_w: u32,
    cell_h: u32,
    cols: u16,
    rows: u16,
) ?GridPoint { ... }
```

Drain the pointer queue in the main loop after `dispatchPending()` and before rendering:

```zig
for (pointer.event_queue.items) |ev| {
    handlePointerSelectionEvent(&selection, ev, surf_cell_w, surf_cell_h, cols, rows);
}
pointer.event_queue.clearRetainingCapacity();
```

Thread the current committed-or-active span into row rebuilds:

```zig
const current_selection = activeSelectionSpan(selection, cols, rows);
```

Update `rebuildRowInstances` to accept `selection: ?SelectionSpan` and use `selectionColors(...)` when `selection.containsCell(col_idx, row_idx)` is true before calling `appendCellInstances`.

On grid resize:

```zig
selection.committed = if (selection.committed) |span| clampSelectionSpan(span, cols, rows) else null;
selection.active = if (selection.active) |span| clampSelectionSpan(span, cols, rows) else null;
selection.anchor = if (selection.anchor) |point| clampGridPoint(point, cols, rows) else null;
selection.hover = if (selection.hover) |point| clampGridPoint(point, cols, rows) else null;
```

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the selection interaction and render-color tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig src/wayland.zig
git commit -m "Highlight visible text selection"
```

### Task 5: Connect `Ctrl+Shift+C` to Wayland clipboard export

**Files:**
- Modify: `src/main.zig`
- Modify: `src/wayland.zig`
- Test: `src/main.zig`

- [ ] **Step 1: Write the failing copy integration test**

Add a small testable helper around the copy path:

```zig
test "copySelection returns false for empty visible selection" {
    var term = try vt.Terminal.init(std.testing.allocator, .{ .cols = 4, .rows = 1 });
    defer term.deinit();

    try std.testing.expect(!try copySelectionText(
        std.testing.allocator,
        null,
        &term,
        null,
    ));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `zig build test --summary all`
Expected:
- FAIL because the copy helper does not exist yet.

- [ ] **Step 3: Implement the explicit copy path**

In `src/main.zig`, factor the copy path into a helper:

```zig
fn copySelectionText(
    alloc: std.mem.Allocator,
    clipboard: ?*wayland_client.Clipboard,
    term: *vt.Terminal,
    selection: ?SelectionSpan,
) !bool {
    const cb = clipboard orelse return false;
    const span = selection orelse return false;
    const text = try extractSelectedText(alloc, &term.render_state.row_data, span);
    defer alloc.free(text);
    if (text.len == 0) return false;
    try cb.setSelectionText(text);
    return true;
}
```

Wire it into keyboard handling after `term.snapshot()` has produced current visible rows:

```zig
if (isClipboardCopyEvent(ev)) {
    _ = try copySelectionText(alloc, clipboard, term, activeSelectionSpan(selection, cols, rows));
    continue;
}
```

Keep the existing `Ctrl+Shift+V` paste path unchanged.

- [ ] **Step 4: Run test to verify it passes**

Run: `zig build test --summary all`
Expected:
- PASS for the new empty-selection copy helper test and all prior tests.

- [ ] **Step 5: Commit**

```bash
git add src/main.zig src/wayland.zig
git commit -m "Copy visible selection to clipboard"
```

### Task 6: Full verification

**Files:**
- Modify: none
- Test: `src/main.zig`, `src/wayland.zig`, `src/vt.zig`

- [ ] **Step 1: Run the full test suite**

Run: `rm -rf /tmp/zig-global-cache-selection-plan && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-selection-plan && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-selection-plan zig build test --summary all`
Expected:
- PASS

- [ ] **Step 2: Run a build verification**

Run: `rm -rf /tmp/zig-global-cache-selection-build && cp -a /home/xanderle/.cache/zig /tmp/zig-global-cache-selection-build && ZIG_GLOBAL_CACHE_DIR=/tmp/zig-global-cache-selection-build zig build`
Expected:
- PASS

- [ ] **Step 3: Run a manual Wayland verification**

Run: `zig build run`
Expected:
- Left-button drag highlights visible text.
- Right-to-left drag normalizes correctly.
- `Ctrl+Shift+C` copies the highlighted text.
- `Ctrl+Shift+V` paste still works.
- Resize does not crash or leave out-of-bounds selection state.

- [ ] **Step 4: Commit**

```bash
git add src/main.zig src/wayland.zig src/vt.zig
git commit -m "Verify visible text selection flow"
```

## Self-Review

- Spec coverage:
  - Visible-grid drag selection: Task 4
  - Persistent highlight after release: Task 4
  - `Ctrl+Shift+C` clipboard export: Task 5
  - Wayland clipboard ownership: Task 3
  - Resize-safe behavior: Task 1 and Task 4
  - Tests and manual verification: Tasks 1, 2, 3, 4, 5, and 6
- Placeholder scan:
  - No `TODO`, `TBD`, or deferred “figure it out later” steps remain.
- Type consistency:
  - The plan consistently uses `GridPoint`, `SelectionSpan`, `SelectionState`, `extractSelectedText`, `isClipboardCopyEvent`, and `setSelectionText`.