Rand Stats

Selkie

zef:apogee
Revision history for Selkie

0.6.0  2026-04-30T17:03:19+01:00
    - First-class mouse support across the whole framework. Selkie::App
      enables button + drag events on construction (via
      C with the existing v1 button-event mask)
      and the input loop now routes mouse events through a new
      coordinate-based dispatcher alongside the keyboard path. Apps and
      consumer widgets get the behaviour for free — no opt-in flag,
      no per-app wiring; every prebuilt widget reacts to the pointer.
      Display-only widgets (Text, RichText, Image, Border, ProgressBar,
      Spinner, Toast, Legend, charts) deliberately don't react — Border
      passes clicks through to its content. The new C
      pod section in `lib/Selkie.rakumod` is the user-facing reference.

    - `Selkie::App.!dispatch-mouse`: coordinate-based dispatch for
      mouse events. Resolves the deepest widget under the cursor via
      `widget-at-in`, applies drag-capture so motion / release stay
      routed to the press's target (scrollbar drags, text-selection
      drags don't break when the cursor leaves the widget), performs
      click-to-focus on focusable presses, annotates presses with
      double / triple multiplicity, then bubbles up the parent chain
      using the same consume-or-bubble rule as keyboard events.
      Modal isolation matches the keyboard path — clicks outside the
      active modal are dropped (or trigger a synthesized close on
      modals that opted in via the new C
      flag). Click-to-focus is skipped when the click hits the
      already-focused widget so set-focused(False) → set-focused(True)
      churn doesn't tear down focus-bound state (Select dropdowns,
      caret styles) before the click handler observes it.

    - `Selkie::App.widget-at-in($root, $y, $x)`: public hit-test
      against an arbitrary root, returning the deepest widget whose
      on-screen rectangle contains the given absolute cell. Two-phase
      resolution — first pass walks the whole tree looking for any
      widget whose `claims-overlay-at` returns True (catches widgets
      that paint outside their nominal rect, like an open `Select`
      dropdown), then a standard depth-first containment walk.
      Children are walked in reverse so later-added (visually on-top)
      siblings win at the same point, matching render order. Type
      object when nothing claims the point. Companion to the existing
      `widget-attached` — tests can drive the hit-test logic without
      a live App / notcurses instance.

    - `Selkie::Event` gained C (1 / 2 / 3 for single /
      double / triple presses, 0 for everything else) and a
      C builder. `Selkie::App` computes the count
      from inter-press timing (300 ms window) and the press cell —
      strict same-cell rule, so sloppy drags don't manufacture
      spurious double-clicks. Multiplicity is tracked per-button so
      chord-style presses don't reset each other's counters.

    - `Selkie::Event::MouseHandler` is the new registration record
      produced by the on-* mouse methods on `Selkie::Widget`. Surfaced
      via `Widget.mouse-handlers` so future help overlays can list
      registered mouse interactions alongside keyboard binds. Two
      free subs `mouse-event-kinds(Selkie::Event)` and
      `mouse-event-button(Selkie::Event)` classify a mouse event into
      the handler kind(s) it should fan out to and extract the
      1-indexed button number — the dispatcher fans presses out to
      both `'click'` and `'mouse-down'`, motion-while-held to
      `'drag'`, releases to `'mouse-up'`, and scroll wheel to
      `'scroll'`.

    - `Selkie::Widget` gained the matching registration API:
      `on-click(&handler, :button = 1, :description)`,
      `on-scroll(&handler, :description)`,
      `on-drag(&handler, :button = 1, :description)`,
      `on-mouse-down(&handler, :button = 1, :description)`,
      `on-mouse-up(&handler, :button = 1, :description)`. `:button(0)`
      catches any button (used internally by `on-scroll`, useful for
      low-level mouse-down handlers). Accompanying coordinate helpers
      `local-row(Selkie::Event)` / `local-col(Selkie::Event)` translate
      absolute screen coords into widget-local cells (returning -1 for
      out-of-bounds, so callers can guard with a single check), and
      `contains-point(Int $y, Int $x)` exposes the same hit-test the
      framework uses internally. Widgets with zero viewport dimensions
      never contain any point — that's how unmounted / parked widgets
      drop out of dispatch without consulting plane handles.

    - `Selkie::Widget.claims-overlay-at(Int $y, Int $x)`: opt-in
      override for widgets that paint over the layout flow. Default
      returns False; `Select` overrides it so an open dropdown
      captures clicks even though its parent layout's bounds end at
      the closed-display row.

    - `Selkie::Widget.handle-event` default now routes `MouseEvent`s
      through any handlers registered via the on-* methods before
      falling through to the keybind table. Existing `handle-event`
      overrides that already had a mouse switch (CardList, ListView,
      RadioGroup, Table, ScrollView, TextStream, Select)
      explicitly call `self!dispatch-mouse-handlers($ev)` after their
      built-in scroll-wheel branches so the registration API still
      composes with their custom logic.

    - `Selkie::Widget::Modal` gained `:dismiss-on-click-outside`
      (default False). When True, a primary mouse click outside the
      modal's content rectangle dismisses the modal — the framework
      calls `close-modal`, restoring pre-modal focus. Default False
      matches the keyboard focus-trap behaviour: stray clicks in the
      dimmed backdrop are ignored. Confirm-style modals stay safe
      (a Yes/No decision shouldn't be silently abandoned by a stray
      click); informational overlays opt in.

    - `Selkie::Widget::HelpOverlay` flips the new modal flag on by
      default — clicking anywhere outside the help panel closes it
      (standard help / about / tooltip-style popup convention). The
      embedded Close button still works (Enter, Space, click), and so
      does Esc.

    - Built-in widget mouse behaviours:
      * `Button` — primary click activates (same path as Enter/Space).
      * `Checkbox` — primary click toggles.
      * `TabBar` — primary click activates the tab under the cursor;
        clicking the already-active tab re-emits `on-select`.
      * `RadioGroup` — single-click commits the row as the new
        selection (mouse intent is unambiguous, so no cursor-then-
        Enter dance); scroll-wheel still moves the cursor.
      * `ListView` — single-click moves the cursor to the row;
        double-click fires `on-activate` (matching Enter); scroll-wheel
        moves the cursor.
      * `Table` — header click cycles sort on sortable columns; body
        single-click selects the row; double-click fires `on-activate`;
        scroll-wheel moves the cursor. Column-from-x mirrors the
        renderer's column-widths pass exactly so hit-testing tracks
        the visible layout.
      * `Select` — click on the closed-display row toggles the
        dropdown; click on a dropdown row commits and closes; click
        elsewhere closes. `claims-overlay-at` extends the hit-test
        rect over the dropdown so those clicks reach the Select
        even though the parent layout doesn't know about the
        overdraw. Scroll-wheel scrolls the open dropdown.
      * `CardList` — click selects the card under the cursor. Card
        heights are heterogeneous, so the new `!card-index-at-row`
        helper walks visible items from `scroll-top` accumulating
        heights until the click row falls inside one. Scroll-wheel
        still moves between cards.
      * `ScrollView`, `TextStream` — primary mouse-down on the
        scrollbar column jumps the thumb; drag tracks the cursor.
        Mapping is proportional (cursor row → scroll-offset such
        that thumb-y == cursor-row, clamped to thumb-track bounds).
        Drag captures keep the widget as the target even when the
        cursor leaves the scrollbar.
      * `TextInput` — click positions the caret; drag selects from
        the press anchor to the current cursor cell; double-click
        selects the word; triple-click selects the buffer; Ctrl+A
        selects all; Ctrl+C / Ctrl+X emit on the new `on-copy` /
        `on-cut` Supplies (cut also deletes); Backspace and Delete
        consume an active selection if present, typing replaces it.
        Selections are rendered with reverse-video and exposed via
        `has-selection`, `selection-range`, `selected-text`. Selkie
        does not own the system clipboard — apps wire OSC 52 /
        notcurses paste-buffer in their `on-copy` / `on-cut` taps.
        Shift+Left / Shift+Right now also extend the selection, not
        just word-jump the caret.
      * `MultiLineInput` — same selection model, extended to 2D.
        Click positions the caret; drag selects across rows; the
        highlight follows the wrapped layout (visual rows), not the
        raw logical offsets. Double-click selects the word;
        triple-click selects the entire current logical line. Drag
        captures keep selection extending past the visible plane —
        clamping to the visible cell while `!visual-to-logical` pins
        beyond-the-buffer rows to the last line / last column. Same
        Ctrl+A / Ctrl+C / Ctrl+X / on-copy / on-cut contract as
        TextInput. Drag arms the selection anchor lazily on first
        motion (anchoring at click time would turn every post-click
        keystroke into a 1-char selection).
      * `ConfirmModal`, `CommandPalette`, `FileBrowser` — clicks fall
        through to the embedded Button / ListView / TextInput, which
        handle them with their built-in behaviour. FileBrowser
        descends / selects on double-click.

    - `Selkie::Test::Keys.mouse-event` accepts `:click-count` and
      documents `NCKEY_MOTION` as a valid `:id`. Existing call sites
      keep working — defaults preserve the old single-press shape.
      `t/59-mouse-dispatch.rakutest` is the new in-process test
      coverage for hit-testing, the event-classification helpers, the
      Selkie::Event annotation API, the default Widget.handle-event
      mouse fan-out, and Modal's new dismiss-on-click-outside flag.
      Live App-instance flow (capture, click-to-focus, modal isolation)
      needs notcurses_init and is covered by manual smoke in
      App::Cantina; see TODO/Selkie.md for the v1 mouse plan.

    - `Selkie::Widget::ScrollView` follow-bottom rewritten around a
      persistent `$!follow-active` flag. The previous implementation
      computed "follow active" per render by checking
      `scroll-offset >= max-offset` against the OLD content-height,
      which proved fragile: CardList resizes mid-frame change
      `self.rows` (and so `max-offset`) before content-height is
      re-measured, and children with lazy `logical-height` (RichText)
      can briefly report stale heights between `set-content` and the
      next render. Either fragility flipped the per-frame check to
      False and silently disabled tail-follow for streaming bodies.
      The new flag is maintained at the single `scroll-to` funnel
      (which `scroll-by`, `scroll-page-by`, `scroll-to-end`,
      `scroll-to-start`, mouse wheel, and scrollbar drag all route
      through), so user intent is the only thing that toggles it.
      Render also re-engages follow when a content shrink or viewport
      grow involuntarily lands the user back at `max-offset` — typical
      cause is a sibling card collapsing past where the user previously
      parked. New `follow-active` accessor exposes the flag for apps
      that want a "follow-mode" indicator and for tests.

    - `Selkie::Widget::CardList` defense-in-depth for sprixel ghosting.
      Sprixel cleanup goes via the terminal wire as an escape sequence;
      rapid sprite churn on scroll / resize occasionally lets the new
      blit land before the old remove has flushed, leaving ghost
      avatars at the previous positions. CardList now snapshots
      scroll-top, selected, viewport dims, and per-item heights across
      renders, and parks every item up front when any of them change —
      forcing fresh sprixel IDs on the layout pass below and sidestepping
      the incremental-update reliability question entirely. Per-item-
      height tracking deliberately fires on streaming wraps (~1% of
      tokens, when a rendered line wraps and the card grows by a row)
      but stays silent on tokens that just append text within an
      existing row, so the snapshot comparison stays cheap on the hot
      path.

    - `Selkie::Widget::CardList.add-item` wires per-item widgets into
      the parent chain when their `parent` is undefined. Without this
      link, `self.theme` on a card walks past CardList and falls back
      to `Selkie::Theme.default`, so the first `init-plane` →
      `!sync-plane-base` on each card paints its base cell with the
      framework's default palette instead of the active theme — the
      card stays themed against the default background until the next
      explicit `set-theme` cascade overwrites `$!theme`. Setting
      parent here lets the theme inheritance walk find the live theme
      at the moment the plane is first created.

    - `Selkie::Widget::Select` rendering now resolves all colours
      through the active theme. The closed-display row uses
      `theme.input-focused` / `theme.input` instead of hardcoded
      RGB; the dropdown plane's base cell is painted from
      `theme.input.bg` (or `theme.base.bg`) so unwritten regions
      (right-edge padding past the longest item) carry the input bg
      rather than the terminal default; dropdown rows use
      `theme.text-highlight` / `theme.input` for cursor / non-cursor
      including bg + styles, instead of the previous `0x4A4A8A` /
      `0x3A3A5A` / `0x2A2A3E` hardcoded values. Custom themes that
      were already setting these slots picked up the wrong rendering
      for the dropdown before; they get the right one now.


    - `Widget::Button` now treats Left / Right arrow as focus
      cycle, mirroring Tab / Shift-Tab. Buttons in a modal's
      action row sit horizontally — most users reach for arrow
      keys before Tab, and stranding focus on a single button
      until they discover Tab is poor UX. Implementation
      dispatches the same `ui/focus-prev` / `ui/focus-next`
      store events as the global Tab keybinds, so the focus-chain
      semantics (focusable-descendants order, modal focus traps)
      stay identical. Modifier-held arrow keys (e.g. Shift+Right
      for word-jump in a text input) are deliberately NOT
      consumed by Button — only the bare-arrow case is. Three
      new subtests in `t/17-button.rakutest` pin the contract:
      bare-Right dispatches focus-next, bare-Left dispatches
      focus-prev, modifier-held arrow falls through.

    - `Widget.set-viewport`: the corruption-rejection note is now
      structured with bit-width diagnostics. When `safe-coord`
      rejects, we emit `[self= id= y-bits= x-bits=
      parent= parent-y-bits= parent-x-bits=]` instead
      of the generic "skipping corrupt coord" line. Captures the
      data needed across multiple in-the-wild occurrences to
      distinguish a single multiplicative event (constant width
      across runs) from a per-frame accumulator (growing width)
      from a cascade (parent's slot already corrupt). New file-
      local helper `coord-bit-width` uses `nqp::base_I` +
      `nqp::chars` so it stays in the bigint domain — calling
      `.base(2)` directly on the corrupt Int would re-trip the
      Scalar.STORE inlined unbox the gate is protecting against.
      No new public API surface; the v1-blocker upstream filing
      tracks removing this whole machinery once a fixed MoarVM
      ships (see TODO/Selkie.md).

    - `Selkie::Store.tick` is now safe against subscriptions whose
      callbacks call `unsubscribe` (their own id, or any sibling).
      `check-subscriptions` previously did `for %!subscriptions.kv ->
      $id, %sub` — `Hash.kv` resolves values lazily, so a callback
      that deleted another sub mid-tick caused the next pair-bind
      to typecheck-fail with "expected Associative but got Mu". The
      practical hit was App::ImageTagger's modal-lifecycle
      subscriptions: a callback that called `$!app.close-modal`
      destroyed the modal's widget tree → cascading
      `unsubscribe-widget` calls → next tick crashed
      non-deterministically. Now snapshots `%!subscriptions.keys`
      up front and `:exists`-guards each lookup, mirroring the
      pattern already used in `flush-push-subs`. Three new subtests
      in `t/19-store.rakutest` pin the contract: self-unsubscribe,
      sibling-unsubscribe, mass-unsubscribe-of-many.

0.5.3  2026-04-30T02:49:37+01:00
    - `Widget.set-viewport`: hardened against the long-standing
      MoarVM spesh corruption that intermittently crashed the
      renderer with "P6opaque: get_boxed_ref could not unbox for the
      representation 'P6bigint' of type Scalar" pointed at the
      attribute store. The previous workaround (free-sub
      `position-changed` for boxed-Int comparison) covered the read
      side; the assignment site `$!abs-y = $abs-y` was still
      crashing on Scalar.STORE's inlined unbox when an upstream
      spesh slot held a multi-thousand-bit bigint. New `safe-coord`
      free sub uses `nqp::isbig_I` to detect any value that doesn't
      fit in int64 — screen coordinates physically can't, so a
      bigger-than-int64 result is the corruption signal — and
      otherwise rebuilds a fresh boxed `Int` via
      `nqp::unbox_i` + `nqp::box_i` so the subsequent attribute
      STORE doesn't traverse the corrupted slot. Corrupt coords
      log to STDERR ("skipping corrupt coord (bigint exceeds
      int64); will retry next frame") and are skipped — the parent
      layout re-passes coords every frame, and most spesh
      corruption is local to a hot frame so clean values usually
      arrive next render. The recurring log line, when it fires,
      is the surest signal we have for an upstream-spesh repro
      attempt. Existing `t/22-viewport.rakutest` regression
      "tolerates pathological Int values" updated to reflect the
      new reject-and-recover semantics (was: "stores any-size
      value via boxed-Int dispatch", which was the half-truth that
      missed the assignment crash).

0.5.2  2026-04-30T00:41:27+01:00
    - `Selkie::App` modals now stack. Calling `show-modal` while a
      modal is already open pushes the new modal on top instead of
      replacing the existing one; `close-modal` pops the topmost modal
      and restores the previous modal as active with all its keybinds
      intact. The pre-modal focus target is per-stack-frame, so
      popping the inner modal returns focus to where it was inside
      the outer modal. Previously, opening a confirm dialog from
      inside an editor permanently destroyed the editor's keybinds
      (ESC stopped working) — that's now fixed at the framework level.
      Render path renders only the topmost modal (modals are opaque
      overlays); resize cascades dims through every modal in the
      stack so revealed modals have correct dims when popped.
    - `Selkie::Event::Keybind.parse`: the literal `+` key is now
      bindable. Previously `'+'`, `'shift++'`, `'ctrl++'`, etc. all
      died with `Unknown modifier: ` because the spec splitter used
      `+` as the modifier separator and pulled an empty key off the
      end. Detected upfront via a trailing-`++` check (and a plain
      `'+'` short-circuit) before the split, so the key parser sees
      a single `'+'` character and emits `'+'.ord` as the keybind id.
      KEYBIND SYNTAX docs updated; new `t/04-event.rakutest` subtest
      covers `'+'`, `'shift++'`, `'ctrl++'`, and `'ctrl+shift++'`.

0.5.1  2026-04-29T00:45:16+01:00
    - `Widget::TextInput` and `Widget::MultiLineInput` no longer
      swallow OS-composed alt characters. The "modified keys (except
      shift) bubble up for global keybinds" filter at the top of
      `handle-event` now only fires when the event's char matches its
      keysym (i.e. the modifier was held for chord intent, not OS
      composition). When `eff_text` differs from `id` — UK Mac
      Alt-3 → '#', US Mac Alt-2 → '™', and similar layout-driven
      compositions across non-US keyboards — the composed character
      is treated as typed input. Without this, those characters
      were untypeable inside any focused TextInput / MultiLineInput.
      Pure Alt+letter / Ctrl+letter chords (where char == keysym)
      still bubble exactly as before, so global keybinds keep
      working. Two regression tests cover the split.

0.5.0  2026-04-27T22:51:02+01:00
    - `Widget::TextInput` and `Widget::MultiLineInput` now treat
      Shift+Left / Shift+Right as word-jump and Shift+Backspace as
      delete-previous-word. Word boundaries are the standard
      `\w` / non-`\w` transition (Unicode-aware via Raku's regex).
      Shared helpers `next-word-pos` / `prev-word-pos` are exported
      from `TextInput` under the `:words` tag — `MultiLineInput`
      imports them and adds 2D handling: shift-left at column 0
      crosses to the end of the previous line; shift-right at end-
      of-line crosses to column 0 of the next; shift-backspace at
      column 0 falls through to ordinary line-join backspace.

    - `App.run` paste drain rewritten to batch consecutive printable-
      char events into a single `insert-text(Str)` call on the
      focused widget. The previous per-event dispatch path triggered
      an O(n) buffer rebuild for every character (substr + concat +
      concat), making a paste of n chars cost O(n²) total — fine
      for small bursts but pathological for the >5K-char range
      (10 000 words = ~60 KB pastes truncated halfway through a word
      because the drain ran out of wall-clock budget before
      processing the queue). The new path collects paste-eligible
      events (printable, no Ctrl / Alt / Super) into a List, joins
      once, and applies via the widget's batch insert — single
      buffer rebuild per paste, O(n) total. Wall-clock cap raised to
      200 ms to give the drain comfortable headroom on huge pastes
      while still bounding pathological infinite-input sources.

    - `Widget::TextInput.insert-text(Str)` and
      `Widget::MultiLineInput.insert-text(Str)` are the public batch-
      insert API the App's drain loop calls. TextInput strips control
      chars and newlines (single-line semantics); MultiLineInput
      splits on `\n` and lays the pasted content across multiple
      buffer lines with the cursor landing at the end of the last
      pasted segment. Application code can call them directly to
      programmatically populate an input.

    - `Widget::Image.render`: defensively cap the blit-plane footprint
      at the Image's own cell dimensions. Under `NCSCALE_SCALE` the
      geometry returned by `ncvisual_geom` should already fit the
      plane, but on certain combinations of blitter, terminal cell-
      pixel size, and image aspect ratio it can report `rcelly` /
      `rcellx` one cell over the plane. The blit-plane below was
      then created larger than its parent — notcurses doesn't clip
      child planes — and the sprixel painted onto the next widget's
      cells. App::Cantina users observed this as the avatar of one
      message ghosting through the bottom border of its card and
      onto the message below. The cap is a one-line guard:
      `($geom.rcelly min self.rows) max 1`.

    - `Widget::ScrollView` rendering rewritten for consistency. Three
      bugs fixed:

      1. Out-of-view children were `reposition`ed to `(viewport-h, 0)`
         — i.e. the BOTTOM EDGE of the ScrollView. The plane's rows
         then extended BELOW that point, painting ghost cells onto
         whatever widget rendered next in the parent layout. The
         visible symptom was an item below the ScrollView going
         blank (its cells overwritten) after a scroll. Now uses
         `Widget.park` (move to 10_000, 0) just like Image does.

      2. Children's wrap-width flipped between `self.cols` and
         `self.cols - 1` based on whether the scrollbar was needed,
         re-wrapping the body and producing one extra line that
         then DID need a scrollbar — feedback loop that clipped the
         bottom row under the scrollbar. Now reserves the scrollbar
         column unconditionally when `show-scrollbar` is True; the
         scrollbar itself is still drawn only on overflow but the
         content-width never changes mid-render.

      3. `scroll-by` was the only public scroll mutator that took a
         row-count, leaving callers like CardList computing pages
         themselves with their OWN row count (the chat pane's
         height) — way too big for the inner body's smaller
         viewport. Added `scroll-page-by(Int $direction)` that
         scrolls by the SCROLLVIEW'S OWN viewport-height in
         `+1` / `-1` page increments. CardList's PgUp/PgDown
         now delegates via the duck-typed
         `scroll-content-page-by(±1)` (with a fallback to the
         legacy `scroll-content-by(±self.rows)` for older cards).

    - `Widget::ScrollView.follow-bottom`: new opt-in Bool attribute
      (default False). When True, each render captures whether the
      scroll offset was at `max-offset` BEFORE content-height
      updates, and if so snaps the new offset to the new max so
      streamed content stays visible. Any user scroll-up disables
      the auto-pin naturally (the captured "at end" check fails
      next frame); scrolling back to the bottom re-enables it.
      Designed for log views and chat-message bodies where the
      newest content matters most. Silent passthrough when the
      flag is off.

    - SIGWINCH-driven resize wake. The main loop now installs its own
      SIGWINCH tap via Raku's `signal()` Supply and disables notcurses's
      built-in handler with `NCOPTION_NO_WINCH_SIGHANDLER`. The handler
      sets an atomic flag; the idle sleep is split into ~16ms chunks so
      a resize wakes the loop within roughly one frame instead of
      waiting for the full 250ms deep-idle budget. `!check-terminal-
      resize` now leads with `notcurses_refresh` to force a TIOCGWINSZ
      re-query (necessary because we own the signal handler now and
      notcurses no longer updates stdplane dims on its own). Closes
      the macOS-specific symptom where a terminal resize during idle
      wasn't picked up until the user pressed a key — most visible in
      App::Cantina, where stale avatar sprixels stayed painted at the
      old position. Cairn and any future Image-using consumer benefits
      too.

    - `Widget::CardList.bottom-anchor`: new opt-in Bool attribute
      (default False, preserving existing behaviour). When True and
      the visible cards (scroll-top through the last item) sum to
      less than the viewport height, render shifts every visible
      card down so the LAST item ends at the bottom of the viewport
      rather than leaving empty space below it. Designed for chat-
      style consumers (App::Cantina's ChatView) where new content
      arrives at the bottom and the user expects the latest message
      to be anchored there even when the whole conversation fits on
      screen. Pickers / file browsers / inventory lists keep the
      default top-aligned rendering.

0.4.6  2026-04-25T12:52:41+01:00
    - `Widget.set-viewport` now marks the widget dirty when its
      absolute position (abs-y / abs-x) changes. Most widgets
      render position-independent cells and absorb the extra render
      with no visible effect, but Image specifically depends on it:
      notcurses sprixels don't follow plane moves, and the teardown
      / re-blit logic that disposes of the stale sprixel only runs
      inside `Image.render`. Before this change, a parent layout
      that shifted a card's screen position without independently
      dirtying the subtree (e.g. CardList rebalancing visible cards
      after a resize, or a screen-level VBox sliding panes around
      when a sibling's fixed sizing changed) could leave the Image
      clean — `render` never fired, the blit-plane was never
      destroyed, and the sprixel ghosted at the old terminal
      coordinates while the Image's plane sat at the new ones.
      Surfaced in App::Cantina as a band of stale avatar pixels
      directly below the most recent message after sending /
      regenerating.

      The Image cache check that compares incoming abs-y / abs-x
      against the last-rendered values still does the actual
      gating — set-viewport only ensures the check gets to run.
      Locked in by `t/22-viewport.rakutest`'s new "set-viewport
      marks dirty on absolute-position change" subtest.

    - `Image.handle-resize` eagerly destroys the blit-plane on a
      real resize. The blit-plane is a child of `self.plane` sized
      to the scaled image dimensions; when the parent shrinks
      (e.g. the host card is bottom-clipped by CardList), notcurses
      doesn't auto-clip or auto-destroy the child, so a stale
      blit-plane could extend past the Image's new bounds and paint
      a sprixel into whatever sits below. The next `render` would
      catch the size mismatch in its cache check and tear the
      blit-plane down, but doing it inside handle-resize makes the
      cleanup atomic with the resize itself — no window in which
      an oversized blit-plane is reachable from the render pass.

    - Border learned `focus-from-store` (rw, default True). When
      False, the Border no longer registers the
      `border-focus-{WHICH}` subscription and its `render` no
      longer re-derives `has-focus` from `ui.focused-widget` —
      `set-has-focus` becomes the single source of truth, and its
      value survives arbitrary numbers of re-renders.

      Intended for Borders managed by a parent container whose
      selection semantics don't map onto "some descendant is
      focused". `CardList` is the driving case: a card's Border
      should stay highlighted for the *selected* card even when
      keyboard focus has moved elsewhere, and should not be wiped
      out when a sibling container triggers a cascade re-render.

      `CardList.add-item` now flips this flag off on the card's
      Border automatically, so existing CardList callers get the
      fix for free. Other containers that pass pre-built Borders
      through `:border` to add-item inherit it too.

    - App gained `set-error-log(Str $path)` for swapping the
      redirect destination after construction. Tears down the
      current fd-2 redirect, updates `$.error-log`, and reinstalls
      against the new path; passing `Str` (undefined) or an empty
      string disables redirection altogether.

      Motivated by apps whose log location is only known after
      some runtime event — e.g. App::Cantina doesn't know the
      selected profile until the login screen reports one, so the
      per-profile `{home}/{db}/error.log` path can't be provided
      to `Selkie::App.new`.

    - Image.render now caches its last-rendered (rows, cols,
      abs-y, abs-x, file) tuple and skips the destroy + re-blit
      fast path when nothing has changed since the previous
      frame. Ancestors cascading dirty through the tree (e.g. a
      sibling widget's sizing change propagating up a VBox and
      back down via render-children) previously re-blitted every
      visible Image per frame; at multi-keystroke-per-second
      input rates that produced visible sprite drop-outs and
      flicker as terminals failed to keep up with the remove+add
      sprixel sequences.

      Position is part of the cache key because notcurses
      sprixels don't automatically follow their parent plane
      when it's repositioned — same-dims-new-position would
      leave a ghost sprixel at the old terminal coordinates.
      Surfaced in App::Cantina as a discoloured band in the
      input bar below a message card that had just shrunk during
      swipe / regenerate.

      The cache is invalidated by set-file, clear-image, park,
      and !destroy-blit-plane so callers that genuinely want a
      re-blit still get one.

    - CardList.render now calls `set-viewport` on each visible
      card's root after the reposition + resize pair. VBox / HBox
      layout passes cascade abs-y / abs-x through their own
      set-viewport calls, but CardList manages its items in a
      separate `@!items` array and previously relied on
      reposition alone — which only updates the plane's relative
      y / x, not the widget's abs-y / abs-x. Downstream consumers
      that read abs-y (Image's blit cache, any future viewport-
      sensitive widget) otherwise saw stale coordinates after a
      scroll or height-change cascade. Observable before this fix
      as ghost sprixels painting the input bar below a shrinking
      message card during edit / swipe in App::Cantina.

    - Image now reads its blit position from notcurses directly
      (`ncplane_abs_y` / `ncplane_abs_x`) rather than from
      `Widget.abs-y` / `Widget.abs-x`. The notcurses calls are
      single C struct-field reads (O(1)) that reflect the plane's
      current absolute screen coordinates after any move,
      including moves caused by a repositioned ancestor plane.
      Widget.abs-y only updates when a parent layout cascades a
      set-viewport call, which several containers had historically
      got wrong (CardList, Modal, ScrollView were all audited);
      any future container that forgets the cascade would have
      caused the same class of ghost-sprixel bug. Reading from
      notcurses makes Image's move detection independent of
      Selkie-level cascade hygiene: its cache invalidates the
      moment notcurses reports a different position, regardless
      of whether Widget.abs-y was ever refreshed. Surfaced in
      App::Cantina as ghost avatar bands above and below a
      chat message card while editing non-bottom messages.

    - Modal.render and ScrollView.render now cascade
      set-viewport to their content / visible children after
      reposition + resize. Neither had done so previously, which
      left abs-y / abs-x stale for everything nested inside them.
      Image no longer depends on this (see the ncplane_abs_yx
      change above), but other abs-y consumers (focus detection,
      overlay positioning, future widgets) pick up cleaner
      coordinates as a result.

    - Image gained two test hooks: `blit-cache-valid` (public Bool
      accessor) and `populate-blit-cache-for-test` (test-only
      seeder). These make cache-invalidation semantics testable
      without a live notcurses plane; see
      `t/58-image-blit-cache.rakutest`.

    - Layout cascade simplified: `handle-resize` is now strictly a
      self-resize on VBox / HBox / Border / CardList. It no longer
      triggers `layout-children` or recurses into descendants.
      Layout / re-allocation / set-viewport propagation happens
      exactly once per frame — top-down through `render` →
      `layout-children` → `render-children`. Previously a parent
      container would run `layout-children` twice per frame (once
      via `handle-resize` from the parent's own Pass 3, then again
      via its own `render`), and CardList specifically would
      cascade `handle-resize(item-height, cols)` to every card —
      with the card's *full logical height*, even when the card
      was about to be clipped by `CardList.render`. That left a
      window where card planes were oversized relative to
      CardList's bounds, visible as cards bleeding past the list
      into adjacent widgets (the input bar in App::Cantina being
      the most reliable repro).

      Net behavioural change: one layout per container per frame,
      with allocations always reflecting the *display* dimensions
      the parent intended.

    - Widget gained two helpers for forcing a full-screen render
      pass: `mark-dirty-tree` (recursively marks this widget + all
      descendants dirty) and `mark-screen-dirty` (walks up to the
      root of the attached tree and calls mark-dirty-tree from
      there). Use when a state change has layout implications the
      default up-propagating mark-dirty can't express — for
      example, a widget resizing itself shifts every sibling's
      allocation and a partial cascade can leave planes at stale
      positions or sizes.

    - MultiLineInput.!update-sizing now calls `mark-screen-dirty`
      on a size transition instead of the previous `parent.mark-
      dirty`. A multi-line input growing / shrinking shifts every
      visible widget's allocation, and relying on the default
      cascade-down-in-render-children semantics had been producing
      persistent visual glitches (cards bleeding out of a
      shrinking CardList into an adjacent input, for one). The
      full-screen flag gives every widget a clean re-layout on the
      same frame with no cascade-correctness assumptions. Also
      unifies the edit and type code paths in App::Cantina
      (`set-text` for a bulk-load edit and `insert-char` for a
      keystroke both route through update-sizing, so both trigger
      the same redraw).

0.4.5  2026-04-21T02:34:52+01:00
    - Push-based path subscriptions. `Selkie::Store.subscribe(@path,
      $widget)` no longer walks every tick doing `get-in(|@path)`
      and comparing to a cached last-value. Instead, every write
      to the store (`assoc-in` or the deep-merge that backs the
      `db` effect) records the affected path in an internal dirty
      set; at the top of `tick`, the set is drained and only
      subscribers whose bound path overlaps with a written path
      are fired. "Overlap" means one path is a prefix of the other,
      covering three cases:
      * Exact match: write `foo.bar`, sub on `foo.bar` → fire.
      * Ancestor: write `foo.bar.baz`, sub on `foo` or `foo.bar`
        → fire (something in my subtree changed).
      * Descendant: write `foo` (replacing the subtree), sub on
        `foo.bar.baz` → fire (my state was written over).
      Unrelated subs pay zero cost. Idle ticks with no writes do
      zero subscription work at all. Value-change gating is kept:
      a push fire still runs `!values-equal` against the sub's
      last known value, so a no-op write (writing the same value
      back) doesn't spuriously fire the callback / dirty the
      widget — only real changes propagate.
    - `Selkie::Store.subscribe-path-callback($id, @path, &callback,
      $widget)`. Path-based subscription with a callback, no compute
      closure required — the path IS the watched expression. Fires
      `&callback($new-value)` on any real change (using the same
      exact / ancestor / descendant rule as `subscribe`) plus marks
      the owning widget dirty. Equivalent to `subscribe-with-callback`
      with a trivial `-> $s { $s.get-in(|@path) }` compute, but
      push-based — no per-tick closure cost. Use when a widget
      needs reconfiguration on change (`set-items`, `set-text`,
      etc.) rather than just re-rendering.
    - Multiple writes to the same path within one tick coalesce to
      one subscription fire — reducers that write a path multiple
      times during event processing produce one callback invocation,
      not many. Dedup happens at flush time against a per-tick
      "subs to fire" set keyed by subscription id.
    - `subscribe-computed` and `subscribe-with-callback` remain
      pull-based (per-tick walk + compute compare). They can depend
      on arbitrary computed values of the store, which the push
      index can't cheaply track without full reactive dependency
      tracking (Vue 3 / MobX-style). The pull walk is skipped on
      idle ticks where no events fired (existing 0.4.3 behaviour),
      so the overall idle cost is still near-zero.

0.4.4  2026-04-21T01:51:38+01:00
    - Fixed idle CPU pinning on macOS. `notcurses_get` with a
      16 ms timespec does not block on this platform — it returns
      in microseconds rather than sleeping for the requested
      duration. The main loop in `Selkie::App.run` treated it as
      a blocking wait, so it spun ~378 000 times per second
      instead of ~60, pinning a CPU core at ~100%. Measured on
      Cairn: `powermetrics` decayed power score dropped from
      ~29 327 (above WindowServer, ~164× Cubase 14) to ~58 — a
      ~500× reduction — after wiring an elapsed-based sleep
      fallback into the loop. The loop now computes the actual
      time spent in `notcurses_get` + dispatch + render and
      sleeps the remainder of its frame budget, so it genuinely
      runs at the target rate on every platform regardless of
      whether notcurses's timespec is honoured.
    - Input drain per frame. After the first `notcurses_get`
      returns an event, the loop now drains any remaining
      pending input via `notcurses_get_nblock` (bounded at 256
      events per frame) before rendering. Without this, a
      single-event-per-frame cadence at 60 Hz would turn a
      1 000-char paste into a ~17 s catch-up — fast typing
      would look laggy and bulk input would queue up visibly.
      The bound prevents a runaway input source from starving
      render + sleep; 256 events/frame is generous enough that
      realistic pastes and autorepeat drain in a single frame.
    - Activity-aware idle ladder. The main loop now ramps down
      its tick rate during extended idle so a backgrounded
      Selkie app doesn't burn battery as if it were in-focus:
      * 0-30 s since last activity: the hot rate (default 60 Hz).
      * 30-60 s: 30 Hz.
      * 60-120 s: 12 Hz.
      * 120 s+: 4 Hz.
      Snap-back from deep idle still feels near-instant because
      the deepest tier is 250 ms — the first keystroke after two
      minutes of silence lands within a quarter-second and the
      very next frame restores the hot rate. "Activity" is any
      input event, any resize detected by the terminal poll,
      any `Store.tick` that processed queued events (so
      registered subscriptions / handlers firing counts), or a
      toast visibility flip. Passive store reads don't bump the
      activity timestamp.
    - Configurable hot rate via `$.hot-hz` on `Selkie::App`.
      Defaults to 60 Hz. Apps doing terminal video (notcurses
      supports it), high-refresh animations, or live plot
      rendering can bump this higher — 120 Hz, 144 Hz, etc. —
      at construction: `Selkie::App.new(:hot-hz(120))`. The
      notcurses_get timespec and the active-tier frame budget
      both scale with this value.
    - Idle ladder is monotonically non-speeding. A `max($ideal,
      $hot)` clamp on each tier ensures that setting a slow
      hot-hz (e.g. 10 Hz for a low-motion tool on a battery-
      constrained device) doesn't paradoxically speed the app
      UP when it goes idle. The ladder can only decrease the
      tick rate from the hot rate, never increase it.
    - `Selkie::Store.tick` now returns `Bool`. Returns `True`
      when the event queue had events to process this tick
      (which may have fired subscription callbacks / registered
      handlers), `False` when idle. Existing callers that
      ignored the return value are unaffected; the new signal
      is used by the idle ladder to distinguish "store did real
      work" from "store was a no-op".
    - `Selkie::App.!check-terminal-resize` and
      `!maybe-check-terminal-resize` now return `Bool` — `True`
      when a dim change was detected and the UI re-flowed,
      `False` otherwise. Used by the idle ladder to treat a
      resize as an activity event.

0.4.3  2026-04-19T18:08:02+01:00
    - Idle CPU reduction. A completely idle Selkie app was waking
      the CPU ~60 times per second and doing real work on every
      wake — compositor render, subscription walk, syscalls.
      Three changes collapse idle work to near-zero without
      changing the loop's responsiveness when anything's actually
      happening:
      * `Selkie::App.!render-frame` now gates its
        `notcurses_render` call on whether any widget actually
        rendered this frame. A static screen produces zero
        compositor, diff, and pty-write work per tick.
      * `Selkie::Store.tick` skips `!check-subscriptions` when
        the event queue was empty and every subscription has
        been primed. The compute closures no longer run 60 Hz
        on idle; they run when events could plausibly have
        changed state. A new `$!subs-primed` flag is flipped on
        first check and invalidated whenever a subscription is
        registered, so late-added subscriptions still initialise
        correctly.
      * `Selkie::App.!check-terminal-resize` is now throttled to
        ~12 Hz via a new `!maybe-check-terminal-resize` wrapper.
        Polling is still required (notcurses absorbs SIGWINCH on
        macOS without queuing), but 83ms resize latency is
        imperceptible and the syscall stops being a measurable
        idle cost. Real `ResizeEvent`-driven checks remain
        immediate.
    - `Selkie::Widget::Toast.tick` now returns `Bool`. It returns
      `True` only on the tick where visibility flips from visible
      to invisible — the `!render-frame` gate uses this signal
      to force one more composite render so the already-painted
      toast is erased from the terminal. Existing callers that
      ignored the return value are unaffected.
    - Focus invariant. `Selkie::App` now maintains the rule that
      `$!focused` is always attached to the active input surface
      (modal or screen) whenever focusable widgets exist there.
      "Focus: nothing" is only a valid state when the surface
      genuinely has zero focusables. Apps don't need to manage
      focus across transitions themselves — the framework keeps
      it in a sensible place:
      * `switch-screen` saves the outgoing screen's focus in a
        new per-screen focus memory and restores the incoming
        screen's last-focused widget (or first focusable if the
        screen has never been visited, or its saved reference
        has gone stale). Switching back to a previously-visited
        screen now lands you where you were rather than nowhere.
      * `add-screen` discards any stashed focus for the name
        being re-registered — common pattern with overlay
        screens rebuilt each time they open.
      * `focus(undefined)` coerces to the first focusable on the
        active surface. Only a surface with zero focusables
        leaves focus undefined.
      * `close-modal` validates `$!pre-modal-focus` against the
        live tree before restoring. If the modal's action
        destroyed that widget, focus falls through to the first
        focusable on the active screen instead of dangling.
      * New `check-focus-invariant` method runs at the top of
        every event-loop iteration. If focus got detached
        between ticks (Container::remove, screen destruction,
        subscription tearing down a focus holder), it
        re-acquires before the next event dispatches into a
        stale reference.
      * New public `widget-attached(Widget, Root)` — walks
        `$w`'s parent chain; returns True iff it reaches
        `$root`. Type-object callable (no App instance needed)
        so tests can exercise the guard logic directly.
    - `Selkie::App.set-theme(Selkie::Theme:D $theme)` — swap the
      active theme at runtime. Updates the app's theme attribute,
      repaints the stdplane base cell, cascades `set-theme` to
      every registered screen's root widget (which in turn walks
      their subtrees), and marks every screen dirty. Note that
      consumers holding cached `Selkie::Style` values derived
      from a theme's slots still need to rebuild those manually —
      `set-theme` can't reach closures that copied style values
      at construction. The guarantee is "every plane's base cell
      and every widget's inherited theme updates"; cached styles
      at the consumer layer stay the consumer's responsibility.
    - `Selkie::ScreenManager.screen(Str:D $name)` — look up a
      registered screen's root widget by name. Returns the
      Container type object if no screen with that name exists.
      Companion to the existing `active-root` / `has-screen` /
      `screen-names` accessors; needed for `set-theme` to walk
      every screen regardless of which one is currently active.

0.4.2  2026-04-17T01:48:20+01:00
    - Fix SIGBUS on fast container rebuilds under themed apps (macOS).
      Two overlapping cleanup gaps conspired to leave widgets holding
      stale notcurses plane handles after their host container was
      torn down, so the next store tick re-rendered into freed
      memory:
      * `Container.!unsubscribe-tree` only recursed through
        `.children`, missing the widgets that Border / Modal hold
        under `.content`.
      * `Split` and `CardList` stash their kids in private slots
        (`$!first` / `$!second`, `@!items`) rather than `@!children`,
        so `.children` returned an empty list and the cascade
        skipped their subtrees entirely.
      Border and Modal now get walked via their `.content` accessor;
      `Split.children` exposes the two panes; `CardList.children`
      exposes each card's root + border. `container.clear` /
      `container.remove` now reliably unsubscribe every descendant
      before destroying, regardless of which container pattern they
      live under. Visible as bus errors during tab-switching in
      Border-wrapped, Split-hosted task lists.
    - Move themed plane-base painting off the hot render path. The
      per-widget `!sync-plane-base` call added in 0.4.1 ran from
      `apply-style`, i.e. every frame on every widget — a reliable
      UAF trigger when a widget whose plane had already been
      destroyed by a cascade was still reachable via a leaked
      subscription (see above). The sync now runs at
      `init-plane` / `set-store` / `set-theme` — the one-shot
      attach points — instead. Theme-background full-coverage is
      unchanged; the per-frame cost is now zero.

0.4.1  2026-04-17T00:56:00+01:00
    - Themed terminal background. `Selkie::App` now paints the notcurses
      standard plane's base cell from `$theme.base` when a theme is
      supplied, and `Selkie::Widget` syncs each per-widget plane's base
      cell from the resolved theme on init-plane / set-theme / each
      apply-style call. Together these give a theme background
      full-coverage across the terminal — including widget gaps,
      unwritten padding, and screen edges — instead of falling through
      to the terminal's own default.
    - New `Selkie::App.stdplane` public accessor so apps that need to
      paint a custom base cell (or otherwise reach the standard plane)
      can do so without reflection.
    - Docs updated: `Selkie::App` gained a "Theme background" section
      documenting the stdplane fill; `Selkie::Widget`'s "What you get
      for free" lists the per-plane base painting.

0.4.0  2026-04-16T16:03:44+01:00
    - Chart / plot widget family. Twelve new modules covering the
      common archetypes: Selkie::Widget::Sparkline (single-row inline
      chart, block glyphs), Selkie::Widget::Plot (streaming wrapper
      over notcurses ncuplot / ncdplot), Selkie::Widget::BarChart
      (vertical or horizontal, 1/8-cell precision via block glyphs),
      Selkie::Widget::Histogram (adapter that bins a numeric series
      and feeds it into BarChart), Selkie::Widget::Heatmap (2D grid
      coloured by value via a ramp), Selkie::Widget::ScatterPlot
      (braille dots at 2×4 sub-cell resolution), Selkie::Widget::
      LineChart (hand-rolled braille multi-series renderer for
      static data with fill-below + legend). Each widget supports
      both static construction (:data / :series / :values) and
      reactive binding (:store-path / :store-path-fn /
      subscribe-with-callback).
    - Selkie::Plot::Scaler — linear value→cell mapping primitive
      shared across chart widgets. Auto-clamps out-of-domain values,
      preserves NaN as undef, handles ±Inf, supports :invert for
      y-axes (cell 0 at the top of the plane, max value at row 0).
    - Selkie::Plot::Ticks — Heckbert nice-number algorithm for axis
      labels at {1, 2, 5} × 10ⁿ. Returns values + formatted labels
      with fixed decimal precision so sub-unit ticks line up
      visually ("0.000", "0.005", "0.010" rather than "0", "0.005",
      "0.01").
    - Selkie::Plot::Palette — named series palettes and color ramps.
      Default series palette is okabe-ito (8 colours, colourblind-
      safe); tol-bright (7), tableau-10 (10) also available. Default
      heatmap ramp is viridis (perceptually uniform, colourblind-
      safe); magma, plasma, coolwarm, grayscale also provided.
      Ramp sampling is linear interpolation in RGB space.
    - Selkie::Widget::Axis — labelled tick axis for chart edges,
      renders along top / bottom / left / right. Composes internally
      or exposes reserved-rows / reserved-cols for layout callers
      that want to size their chart body precisely.
    - Selkie::Widget::Legend — colour-swatch + label rows for chart
      series. Vertical (one row per series) or horizontal (single
      row, spaces between). Truncates labels with ellipsis when
      sizing-constrained. Theme-aware via new graph-legend-bg slot.
    - Theme: six new graph-* slots — graph-axis, graph-axis-label,
      graph-grid, graph-line, graph-fill, graph-legend-bg. All are
      non-required with defaults derived from existing slots
      (text-dim, divider, border-focused, base.bg), so themes
      predating the chart widgets keep working without modification.
      The default dark theme explicitly sets chart-tuned values for
      a distinct presentation.
    - All chart widgets render a centered "No data" placeholder in
      text-dim style when their input is empty. This is the expected
      startup state for monitoring dashboards — the widget is
      mounted before the first sample arrives. Each widget accepts
      :empty-message to customise the text (default "No data"; pass
      "" to suppress).
    - Test::Snapshot: new :capture-styles mode. Emits a format-
      marked output with parallel glyph + style grids and a legend
      keying each unique (fg, bg, stylemask) tuple to a letter.
      Heatmaps, multi-series LineChart, and multi-coloured BarChart
      can now be snapshot-tested without losing the colour semantics.
    - Test::Snapshot::Harness: auto-routes styled scenarios to
      xt/snapshots/golden-styled/ by detecting the format marker on
      the first line of subprocess stdout. Plain and styled
      scenarios coexist in the same directory — authors don't need
      to know which bin their golden goes in.
    - examples/charts.raku — reactive showcase of the whole chart
      family. Sparkline binds to a store path; LineChart rebuilds
      from a subscribe-with-callback over two latency paths
      (p50 stays ≤ p99 by construction); Plot streams its own
      native buffer. BarChart + Histogram + Heatmap + ScatterPlot
      stay static to demonstrate the different chart archetypes.
    - examples/dashboard.raku — new "History" column on the Servers
      table renders an inline sparkline-as-string per row. Proves
      the most demanding inline-chart-in-table case works cleanly
      (high instance count, no native handles per row).
    - tools/build-api-docs.raku: added -I lib to the subprocess
      invocation so modules that import other modules from the same
      distribution can resolve their deps before install. Previously
      any new module with an in-dist `use` line would fail doc
      generation with "Could not find X" and silently produce no
      docs/api/*.md.
    - Widget authors — two Raku gotchas surfaced during this work
      that are worth internalising: (1) `@data.all ~~ Pair` is true
      when a single-hash array flattened into a list of pairs, and
      `@x = { |@pairs }` produces a Block not a Hash; use `%(|@pairs)`
      instead. Affects any widget that accepts a list of structured
      entries. (2) `.map: { ... }` in a constructor arg list swallows
      trailing named arguments because Raku parses `.map: { ... },
      foo => bar` as `.map(: { ... }, foo => bar)`. Use explicit
      parens — `.map({ ... })` — in constructor arg lists.

0.3.0  2026-04-15T03:00:16+01:00
    - Add title helper
    - Auto install with no building for most platforms

0.2.4  2026-04-13T17:09:44+01:00
    - Widget::HelpOverlay: new modal that walks the focused widget +
      its ancestors, collects all `on-key` binds that carry a
      `:description`, and renders them grouped by widget class.
      Authors opt in by passing `:description` to `on-key` — undocumented
      binds (e.g. cursor-movement plumbing) stay out of the overlay.
    - Widget.on-key: new `:description` named arg. Stored on the
      Keybind so HelpOverlay can surface it. Existing call sites stay
      working (no description = bind doesn't appear in the overlay).
    - Widget: new public `keybinds()` accessor returning the list of
      registered Keybinds. Used by HelpOverlay; harmless to call from
      anywhere.
    - Event: Keybind gained `$.spec` (original spec string, e.g.
      "ctrl+h") and `$.description` fields. `Keybind.parse` accepts
      `:description`.
    - Widget::Button: new `set-label` method — replace the displayed
      label at runtime (e.g. "Save 3 Edited Entries"). The label
      attribute was previously immutable.
    - Widget::Text: new `set-style` method — swaps the style
      override at runtime and marks the widget dirty. Previously
      the style attribute was read-only after construction, so
      changing colour/emphasis required rebuilding the widget.
    - Widget: new `park()` method — explicit hook for moving a widget
      off-screen while preserving its state. Default implementation
      repositions the widget's plane to (10000, 0). Container override
      recurses through children + content. Image widget overrides to
      destroy its blit-plane (and thus the sprixel — Sixel/Kitty pixel
      data the terminal renders at an absolute screen position and
      won't clear just because the parent plane moved).
      Every place we previously parked widgets via plain `reposition`
      now calls `park` instead — App.switch-screen, App.add-screen,
      Border.set-content(:!destroy), Modal.set-content(:!destroy),
      and CardList's off-screen item parking. Without this, sprixels
      (Image widgets' pixel-graphics output) leaked between screens,
      tabs, and scroll positions, painting over the new content.
    - Widget::TabBar: focus visibility. Focused bars render a `▶ `
      chevron prefix and use `tab-active` styling on the active tab;
      unfocused bars show `  ` padding and dim the active tab to
      `tab-inactive`. Brackets `[ ]` always mark the active tab so
      users can tell WHICH tab is current even when the bar isn't
      driven. Critical for screens with multiple TabBars where the
      only previous focus cue was "nothing else has focus."
    - Theme: new `tab-active` / `tab-inactive` slots. Widget::TabBar
      previously reused text / text-highlight for inactive/active —
      a subtle bold + color change that users missed. The new
      dedicated slots default to a distinct bg colour on the active
      tab so it's unambiguously selected. Bracket decorators on the
      active tab are retained for low-colour environments and
      screenshots. Custom themes must add the two new slots (they're
      `is required`); see `examples/chat.raku` for a reference.

0.2.3  2026-04-13T12:31:11+01:00
    - App: disable IXON / IXOFF flow control after notcurses_init so
      Ctrl+Q (XON) and Ctrl+S (XOFF) reach the application as
      keystrokes instead of being eaten by the tty driver. notcurses's
      cbreak mode clears ECHO / ICANON / ICRNL but leaves IXON set,
      so on macOS Terminal.app the built-in Ctrl+Q quit keybind
      silently didn't work (Kitty disables IXON by default so it
      worked there). notcurses_stop restores the original termios at
      shutdown, so flow-control comes back on automatically when the
      app exits.
    - Widget::PasswordStrength: live strength meter bound to a
      TextInput via its `on-change` Supply. Five levels (weak → fair
      → good → strong → very strong), colored bar + label, simple
      length-plus-character-class heuristic. 12-subtest unit coverage
      on the scoring; snapshot coverage at each level.

0.2.2  2026-04-13T11:59:55+01:00
    - Test::Snapshot: terminal restore on END via notcurses_stop.
      Previously skipped, leaving Kitty keyboard protocol pushed and
      mouse tracking enabled — after a test run the parent terminal
      didn't accept Ctrl+C as SIGINT (transmitted as an escape
      sequence) and Enter arrived as ^M (ICRNL off), so shells /
      mi6 / prove6 couldn't accept input. Users had to close the
      terminal tab.
      Fix: redirect fd 1/2 to /dev/null in an INIT phaser (must be
      INIT, not module-level mainline — Raku's precomp-and-cache
      semantics meant mainline fd juggling never re-ran in the
      consumer process), rerouting Raku's $*OUT to a fd-backed
      handle pointing at the saved original stdout so TAP keeps
      flowing. Then call notcurses_stop in END; its stderr warnings
      ("signals weren't registered" etc) land in /dev/null rather
      than the TAP stream.
      Also: previous attempts used open(2) to get the /dev/null fd,
      but open is variadic on macOS and Raku's NativeCall can't
      reliably invoke variadic C functions; it silently returned -1
      and dup2 was a no-op. Switched to fopen + fileno (neither
      variadic), which works reliably.
      Plus an stty termios restore as a belt-and-suspenders layer.

0.2.1 2026-04-13T10:46:15+01:00
    - Add API docs.

0.2.0 2026-04-12T23:00:39+01:00
    - Poll-based resize detection. notcurses doesn't reliably emit
      NCKEY_RESIZE through the input queue on every platform (macOS
      absorbs SIGWINCH into render without queueing the event),
      so the App run loop now polls stdplane dims every frame
      (~60/s, negligible cost) and handles the resize synchronously
      when dims change. Previously, on platforms with no event
      delivery, the UI stayed broken until the next input — users
      thought the app had crashed and hit Ctrl-Q before recovery.
    - Resize now calls notcurses_refresh() to invalidate notcurses's
      internal frame-diff state. Without the refresh, cells whose
      composited value happens to match pre-resize (but whose
      underlying plane shifted) aren't re-emitted, leaving the
      terminal with duplicated chrome, missing column titles,
      vanished app header, triple-stacked borders, and random
      dropped letters. Refresh is the only way to force a full
      re-emit of every cell on the next render.
    - Resize protocol: new `handle-resize(rows, cols)` method on
      Widget — the explicit entry point for terminal-resize events.
      Cascades synchronously through containers (VBox, HBox, Split,
      Border, Modal, CardList) so every widget knows its new
      dimensions before the next render AND before frame-callbacks
      that read widget dimensions run. Previously, dims propagated
      via next-render layout, leaving a one-frame window where
      on-frame handlers saw stale values — visible as ghost-line
      artifacts in apps that re-layout based on widget dims (e.g.
      Cantina's ChatView.check-refresh). A matching `!on-resize()`
      hook fires whenever dims actually change; Text and RichText
      use it to rewrap their content.
    - ScreenManager.handle-resize propagates a resize to every
      registered screen, not just the active one. Switching to a
      previously inactive screen after a resize no longer shows
      stale dimensions.
    - Toast.resize-screen → Toast.handle-resize (aliased for back
      compat). Unifies the resize entry point across the framework.
    - ConfirmModal: long messages now wrap to modal width instead of
      clipping. Switched the message widget from Text (single-line) to
      RichText (wraps). At small modal heights the wrapped text can
      still overflow — bump `height-ratio` on the call site for prompts
      longer than ~60 chars.
    - Test::Snapshot::Harness: new module, factored out the
      fork-per-scenario harness so every Selkie app can drop
      `use Selkie::Test::Snapshot::Harness; run-snapshots;` into an
      xt/ file instead of copy-pasting 80 lines. Centralises the
      MVM_SPESH_DISABLE=1 subprocess env, the golden-file path
      layout, the diff format, and SELKIE_UPDATE_SNAPSHOTS handling.
    - Test::Snapshot: render-to-string now composites the pile via
      notcurses_render + reads from the rendered frame with
      notcurses_at_yx, so container widgets (Border, VBox, HBox,
      CardList, nested trees) snapshot correctly. Previously we read
      directly from the widget's plane, which only sees what was drawn
      on that single plane — child subplanes were invisible, so all
      container snapshots came out empty.
    - xt/02-snapshots.rakutest: fork-per-snapshot harness. Each
      scenario under xt/snapshots/*.raku runs in a fresh Raku process,
      captures stdout, and diffs against xt/snapshots/golden/.snap.
      Isolating each render sidesteps the MoarVM spesh bug that
      made in-process snapshotting of nested widgets flaky. Adding a
      new test case is just dropping a .raku file in xt/snapshots/.
    - CI: add Windows (MSYS2 UCRT64) to the GitHub Actions test matrix.
      Build-only on Windows since notcurses's upstream test harness is
      Unix-only.
    - README: document system dependencies (cmake, ncurses, deflate,
      unistring, ffmpeg/OpenImageIO) with per-OS install commands for
      Linux (Debian/Fedora), macOS, and Windows (MSYS2 UCRT64).
    - xt/: moved snapshot tests out of `t/` — they can't run in
      fork-constrained CI environments.
    - Test::Snapshot: golden-file snapshot testing. `snapshot-ok` renders
      a widget through a shared headless notcurses instance (notcurses
      only allows one init per process), reads cells back via
      `ncplane_at_yx`, and diffs against a stored .snap file. Set
      SELKIE_UPDATE_SNAPSHOTS=1 to accept new output.
    - Test::Keys, Test::Supply, Test::Store, Test::Focus, Test::Tree:
      a suite of helpers for widget tests. press-key / press-keys /
      type-text for event synthesis, collect-from / emitted-once-ok /
      emitted-count-is for Supply observation, mock-store /
      dispatch-and-tick / state-at for store-backed tests, with-focus
      block helper to gate focus-dependent behaviour, walk / find-widget
      / find-widgets / contains-widget-ok for tree introspection.
    - Existing test files migrated from per-file `sub key-event` to
      the new helpers.
    - Spinner widget: tiny animated loading indicator with built-in
      frame sets (BRAILLE, DOTS, LINE, CIRCLE, ARROW) or custom frames.
      Drive via tick() from a frame callback.
    - TabBar widget: horizontal tab strip with keyboard navigation
      (Left/Right/Home/End/Enter). sync-to-app integrates with
      ScreenManager — the bar's active tab follows active-screen.
    - CommandPalette widget: VS-Code-style fuzzy-filtered action
      launcher. Register commands with label + action callback; emits
      the activated Command on on-command Supply.
    - Table widget: scrollable tabular data with typed columns, header
      row, sort indicators, cursor navigation, custom cell rendering.
      Column widths use the same fixed/percent/flex sizing model as
      layouts.
    - Border.set-content / Modal.set-content accept :destroy flag (default
      True). Pass :!destroy to swap content without destroying the outgoing
      widget — useful for tab-style panes that cycle through persistent
      views.
    - ListView, RadioGroup, Select: set-items now preserves the current
      selection by value when the previously-selected label is still in
      the new list. Falls back to clamping the index on missing values.
      Resets only when the list becomes empty.
    - Selkie::App.on-key accepts :screen to scope a keybind to a named
      screen. Unset means truly global (e.g. Ctrl+Q quit).
    - Widget.once-subscribe / once-subscribe-computed: idempotent helpers
      that track per-id registration, so repeated set-store / reparent
      calls don't create duplicate subscriptions. Border now uses this.
    - Store.enable-debug: opt-in logging of every dispatched event, the
      effects its handlers return, and each subscription fire. Pass a
      custom log handle or let it default to $*ERR.

0.1.1  2026-04-12T04:00:39+01:00
    - Fix Github Actions
 
0.1.0  2026-04-12T03:54:47+01:00
    - Fix Border focus subscription crash: replaced `return False without
      $focused` (which targets the enclosing method, not the closure) with
      a ternary. Bug crashed Selkie::App into raw mode on first focus tick.
    - examples/: counter, settings, file-viewer, tasks, job-runner, chat —
      a curated set covering every widget and store pattern.
    - Checkbox widget: focusable toggle with [x]/[ ] indicator, on-change Supply
    - ProgressBar widget: determinate (0..1 value with percentage) and
      indeterminate (bouncing animation via tick) modes, customizable
      fill/empty characters
    - RadioGroup widget: single-selection from labeled options with (●)/( )
      indicators, keyboard navigation, scrollbar support, on-change Supply
    - Select widget: dropdown picker with child-plane overlay, open/close
      state management, cursor navigation, Esc to cancel, focus-loss auto-close
    - Initial framework with core widget tree architecture
    - Style and Theme system with semantic style slots
    - Event system with keybind parsing and modifier support
    - Layout widgets: VBox, HBox, Split
    - Content widgets: Text, TextStream, TextInput, ScrollView, Image
    - Virtual scrolling with render-only-visible-rows strategy
    - App lifecycle with event loop and dirty-tracking render cycle
    - Automatic memory management for all notcurses handles
    - ListView widget with keyboard navigation and selection Supply
    - Image widget supports set-file for dynamic image swapping
    - Image browser example with split pane file list + image preview
    - Fix callsame not dispatching to role methods in Widget, Container,
      Split — replaced with private helper methods (!apply-resize,
      !destroy-plane) that compose correctly across roles
    - Comprehensive test suite: Widget role, Container role, Text,
      TextStream, TextInput, ListView, ScrollView (137 tests total)
    - ScreenManager for named screen switching with scoped focus
    - Modal overlay widget with focus trapping and close Supply
    - RichText widget with inline-styled spans and span-aware word wrapping
    - MultiLineInput widget with 2D cursor, Shift+Enter for newlines,
      desired-height for dynamic layout, and submit/change Supplies
    - App integration: ScreenManager replaces single root, modal support
      with show-modal/close-modal, focus scoping to active screen/modal
    - Border widget with auto-focus highlighting via store subscription
    - Button widget with activation Supply
    - ConfirmModal: yes/no dialog with focus on no-button by default
    - CardList widget: cursor-navigated scrollable list of variable-height
      widgets with top/bottom clipping and scroll tracking
    - Reactive Store (re-frame style): centralized state with event dispatch,
      handlers, effects (:db, :dispatch, :async), path/computed/callback
      subscriptions, and per-frame tick processing
    - Widget/App store integration: auto-propagation through widget tree,
      convenience dispatch/subscribe methods, on-store-attached hook
    - Focus management via store: Tab/Shift-Tab cycling, modal focus
      scoping, Border self-manages highlight via focus subscription
    - Error recovery: App.run wraps event loop in CATCH for terminal restore
    - Viewport hierarchy: layouts pass absolute position and bounds to
      children for correct clipping
    - Toast widget: temporary overlay messages with auto-dismiss
    - Default keybinds: Tab, Shift-Tab, Esc (close modal), Ctrl-Q (quit)
    - FileBrowser modal: shell-style path completion with Tab, type to
      filter, Enter to navigate/select, configurable extensions and
      show-dotfiles parameter
    - TextInput: set-text-silent for programmatic updates without emitting,
      keybind check in default branch for registered keys (up/down etc)