Revision history for Selkie

0.11.1  2026-08-03T17:44:49+01:00
    - Use latest Notcurses::Native to improve multiplexer support
    - [2026-08-03] Table's cursor row can no longer be styled out of
      existence. A row-style that pinned both colours and was already
      bold — a budget grid's group headers, a banner row — merged over
      the cursor base to exactly the style it merged over the body
      base, so the cursor row rendered byte-for-byte like its
      neighbours and the cursor was simply invisible. On the cursor row
      the highlight background is now authoritative: theme
      text-highlight's own bg (the base bg when the slot has none)
      wins over whatever the row style asked for, while the row's
      foreground and flags still survive — an error-red row under the
      cursor is still red, still bold, and now visibly under the
      cursor. Where even that comes out identical (palettes whose
      highlight background IS the raised-surface colour a header row
      uses — Catppuccin, Gruvbox, Nord and Solarized all collapse those
      two steps into one) the theme's `selection` pair takes over for
      that one row, taking its foreground too if the row's own would
      vanish against it. effective-row-style stays public and now
      invokes the row-style callback exactly once per call whatever the
      cursor state, so all of this is assertable without a terminal.

0.11.0  2026-08-03T11:57:56+01:00
    - [2026-08-02] Two of the framework's own overlays can now fade
      themselves, both strictly opt-in and both off by default:
      Selkie::App.new(:animate-backdrop) makes show-modal ramp a
      BackdropScrim modal's scrim up over ~120 ms, and
      :animate-toast makes the toast bar resolve in over ~100 ms and
      dissolve out over the last ~200 ms of its life. Off is the
      important half — with the flags unset no tween is built, the
      tween group is never allocated, its per-frame ticker is never
      registered, and every existing consumer composites the identical
      cells it always did. Both flags are also runtime-settable
      (set-animate-backdrop / set-animate-toast) so a "reduce motion"
      preference can turn them off mid-session, and turning the toast
      one off cancels a fade in flight rather than freezing a
      half-transparent bar on screen. What ramps is colour, never
      alpha: alpha is a two-bit enum, so the scrim's AlphaBlend is held
      fixed while its RGB walks from the theme's base background (a
      50/50 mix of the surface with itself — no visible dim) to the
      modal-scrim slot. Modal gains set-scrim-style /
      clear-scrim-style / effective-scrim-style / scrim-channels'
      override, backdrop-fade-endpoints (pure, so a palette's ramp can
      be asserted without a frame), fade-backdrop-in and
      cancel-backdrop-fade; Toast gains enable-fade / disable-fade,
      render-style, and Instant arguments on show and tick so its
      lifetime is drivable from a test clock. There is deliberately no
      modal fade-OUT: close-modal destroys the modal synchronously, so
      an out-fade would have to keep painting into a widget that has
      already surrendered its planes. Every tween is owned by the
      widget it animates and cancelled by destroy, by set-backdrop, and
      by the next show — a fade can never outlive its plane. Soaked at
      20 000 open/close/flash cycles (460 000 group ticks, spesh
      enabled and again with MVM_SPESH_NODELAY): no leaks, refcount
      back at zero after every cycle, flat RSS.
    - [2026-08-02] Widgets can be disabled: set-disabled greys the
      subtree in place and drops it from focus. Not via
      ncplane_greyscale — that walks the framebuffer converting every
      cell, and a channel marked "use the default" reads back as 0,0,0,
      so it stamps explicit black over every default-coloured cell and
      defeats the base-cell fallthrough (verified on a live plane; the
      first attempt rendered disabled panels as black bars). Instead
      greyscale-plane walks cells and 1×1-stains only what changed,
      preserving default-ness, wide glyphs, and never-written cells;
      Style.greyscale uses notcurses's exact Rec. 601 truncating
      arithmetic, swept against real ncplane_greyscale output over
      7550 colours with zero mismatches. Disabled subtrees drop out of
      focusable-descendants, App.focus refuses them, mouse clicks no
      longer *focus* them, and the focus invariant re-homes focus off
      a widget disabled while focused. Two documented limits: Images
      don't grey (sprixels are pixel data), and a disabled widget does
      not yet swallow mouse clicks — greyed but still clickable until
      that semantic gets its own decision. Style.channels is the new
      public "style → notcurses channel word" packer that base cells,
      the modal scrim, and the greyscale path all share.
    - [2026-08-02] Text and the box containers learn alignment. The new
      Selkie::Align module defines TextAlign (left / center / right,
      left being exactly what Text always did) and CrossAlign (fill /
      start / center / end). Text.align x-offsets each line rather than
      space-padding it, so centred text over a scrim or gradient
      doesn't stamp opaque background where the base cell should show
      through; the same offset applies in render-region, so ScrollView
      content centres too. VBox/HBox gain align-items, and a child can
      override with align-self plus an optional cross-sizing (a plain
      Sizing resolved against the parent's cross extent). Defaults are
      bit-identical to the old fill-everything layout. One deliberate
      rule: a child whose cross extent resolves to zero is parked but
      keeps its main-axis slot — a narrow terminal must not silently
      reflow the stack. Widths are counted in characters, as
      everywhere else in Selkie; CJK/emoji caveat in the Pod.
    - [2026-08-02] Gradients. Selkie::Gradient wraps
      ncplane_gradient / ncplane_stain / ncplane_gradient2x1 behind a
      four-corner Gradient value class with horizontal / vertical /
      corners / uniform factories, and — the part that actually earns
      the wrapper — for-region, which collapses degenerate geometry
      the way notcurses itself interpolates it (top row, left column,
      never an invented average), because the raw natives refuse a
      one-row region with mismatched corners by painting nothing and
      returning -1. gradient-fill writes glyph + colour (call it
      before text), gradient-stain recolours existing glyphs (after
      text, and it skips never-written cells — pad the row with
      spaces first), gradient-fill-hires doubles vertical resolution
      with ▀ half-blocks on UTF-8 terminals. Oversized extents clamp
      to the plane instead of refusing; negative origins throw rather
      than silently meaning "at the cursor". GradientFill is the
      trivial decorative-pane widget over the same call.
    - [2026-08-02] Tweens and an animation budget. Selkie::Tween is a
      wall-clock interpolator — ease (linear / quad in / out / in-out),
      lerp-rgb, lerp-style, a Tween class whose tick takes an explicit
      Instant so tests never sleep, and a TweenGroup that drops
      completed members. Wall-clock matters: under the idle ladder a
      throttled tween takes bigger steps but still lands exactly on
      1.0 and fires on-complete once. Colours lerp; booleans, alpha
      modes, and mixed defined/undefined colours snap at the halfway
      point, because notcurses alpha is a two-bit enum and "fade"
      means interpolating RGB, not opacity. App.tweens hands out one
      lazily-wired group whose activity pins the render loop at the
      hot budget (begin-animation / end-animation, refcounted and
      thread-safe) so an animation started from a background handler
      isn't ticked at 4 Hz — and costs nothing when idle. Every
      on-update must mark something dirty; the Pod's examples all do.
    - [2026-08-02] TabBar grows badges and active-tab variants. Each
      tab can carry a badge (set-badge / clear-badge, Int badges clamp
      to 99+ via the default badge-formatter), rendered inside the tab
      cell so mouse hit-testing tracks it — render and tab-index-at-col
      now share one !tab-display width computation, which is the real
      fix; the two had duplicate formulas waiting to drift. Active-tab
      styles: brackets (the default, byte-identical), underline, and
      pill (filled with tab-active; a pill stays lit when the bar is
      unfocused, because unlike brackets it has no glyph to fall back
      on). FocusColor mode drops the ▶ prefix and marks focus with the
      new tab-focus-accent theme slot instead — note the slot defaults
      to border-focused, whose bg replaces a pill's fill; give it a bg
      of its own if you combine pill with FocusColor.
    - [2026-08-02] Checkbox glyphs are configurable: checked-glyph /
      unchecked-glyph (defaults [x] / [ ], byte-identical) plus
      set-glyphs. Keep the two the same width or the label jitters on
      toggle; ☑/☐ render double-width in some terminal/font combos,
      which is why ASCII stays the default. RadioGroup has the same
      hardcoding and is a deliberate follow-up, not changed here.
    - [2026-08-02] Table showed one row fewer than it held. The body
      height was computed from `self.rows`, which inside this class is
      Table's own row-data accessor shadowing Selkie::Widget's
      plane-height accessor of the same name — so the body was always
      exactly one shorter than the data regardless of how tall the
      plane was. Three symptoms, all silent: the last row was
      unreachable even in a plane with room to spare; the overflow
      test degenerated to `elems > elems - 1`, always true, so the
      scrollbar was always drawn and always took a column away from
      the last flex column; and PageUp / PageDown jumped by the row
      count instead of a screenful. The height now comes from the
      Widget role explicitly, and is exposed as the new plane-free
      `body-height` accessor — the number to add 1 to when sizing a
      table that should show everything without scrolling. No API
      changed; existing tables gain their missing row and, where they
      fit, their missing column.
    - [2026-08-02] Styles carry alpha, and widgets can own their plane's
      base cell. The new Selkie::Alpha module defines AlphaMode —
      AlphaOpaque, AlphaBlend, AlphaTransparent, AlphaHighContrast —
      plus alpha-constant to reach the matching NCALPHA_* value. Read
      its Pod before reaching for any of this: notcurses stores alpha in
      two bits per channel, so those four values are the entire space.
      There is no 30% opacity and no alpha to fade out; one AlphaBlend
      layer over an opaque one is an exact (truncating) 50/50 mix of
      the two colours, and a darker overlay means a darker colour, not
      more alpha. Anything that wants a smooth transition interpolates
      RGB.
      Selkie::Style gains fg-alpha and bg-alpha, undefined by default
      and resolving through effective-fg-alpha / effective-bg-alpha to
      AlphaOpaque — the same "undefined means don't care" convention fg
      and bg already use, so every existing style renders exactly as it
      did. merge treats them like the colours rather than the flags:
      an alpha the override sets wins, an alpha it leaves undefined
      inherits, and because AlphaOpaque is a value like any other an
      override can pin one element opaque over a translucent base.
      AlphaHighContrast is foreground-only — notcurses returns -1 and
      drops the write for a background — so constructing a Style with
      bg-alpha => AlphaHighContrast now throws at the construction site
      instead of failing silently several layers away.
      Widget.apply-style pushes both alpha modes to the plane, but only
      when they differ from the last modes it pushed: a per-widget cache
      keeps the all-opaque case — which is every widget that never
      mentions alpha — free of any additional native calls. init-plane
      records the fresh plane's known-opaque state; adopt-plane, which
      borrows a plane with a history, drops the cache to unknown so the
      first apply-style states both channels explicitly. Anything that
      sets the plane's alpha behind Selkie's back should call the new
      reset-style-cache afterwards.
      Finally, the plane's base cell — what ncplane_erase fills with and
      what shows wherever render never writes — is now behind two
      overridable hooks: base-style (default: the theme's base slot) and
      base-egc (default: a single space). Returning '' from base-egc
      leaves the cell with no glyph at all, which is gcluster 0, which
      is the sentinel notcurses's glyph search reads as "keep looking
      further down the pile" — pair it with a blended or transparent
      base-style and a widget becomes a see-through overlay instead of
      an opaque rectangle. base-channels exposes the packed 64-bit word
      the pair produces, plane-free, for callers and tests. With the
      defaults, that word and the base EGC are bit-for-bit what Selkie
      has always emitted: 0x40C0C0C0401A1A2E and ' ' for the built-in
      theme, pinned by t/87-alpha so no future change to this path can
      move a pixel unnoticed.
    - [2026-08-02] Modal gains backdrop modes and an optional frame.
      `backdrop` takes a BackdropMode — exported by
      Selkie::Widget::Modal itself, not by `use Selkie` — and picks what
      the dialog does with the screen it covers. BackdropOpaque is the
      default and is byte-for-byte what Modal has always drawn.
      BackdropScrim leaves the screen visible and tints it through the
      new modal-scrim theme slot: the scrim plane has no glyph of its
      own (an empty base EGC is gcluster 0, so notcurses's glyph search
      falls through to the text underneath) and both its channels blend,
      so colours come back mixed 50/50 with the scrim colour. A deeper
      dim is a darker scrim colour, never "more alpha" — there is no
      fractional alpha (see Selkie::Alpha). Caveat, documented under
      "Scrim and images": a scrim ERASES a sprixel beneath it, because
      blending sets the cell's blend count and notcurses's paint_sprixel
      wipes the cell outright, so use BackdropOpaque over Image content.
      BackdropNone draws no backdrop plane at all, which fixes a latent
      bug: `dim-background => False` used to show a flat rectangle
      rather than the screen behind it, because the modal's own
      full-screen plane carries an opaque base cell that occluded
      everything on its own. Its base cell now goes fully transparent
      with no glyph in both see-through modes, so the screen genuinely
      shows through. dim-background still works and still defaults to
      True; it is now derived from backdrop rather than stored beside
      it, so the two cannot disagree, and an explicit backdrop wins if
      you pass both. set-backdrop switches modes at runtime.
      `framed => True` wraps the dialog in a Selkie::Widget::Border —
      the home for a title and a key-hint strip, so a dialog can drop
      the hand-rolled header and footer Text rows it would otherwise
      need. frame-style, frame-glyphs, frame-title, frame-title-align,
      frame-bottom-title, frame-bottom-title-align and frame-padding
      (default 1) configure it; set-frame-title / set-frame-bottom-title
      update it live, and `.frame` hands you the Border for anything
      else. The frame is chrome, not content: `.content` still answers
      YOUR widget, focusable-descendants is unchanged, and the Tab cycle
      never lands on the frame. It does eat interior space, though —
      two rows and columns of edge plus the padding on each side — so
      the new plane-free content-extent(rows, cols) is what a content
      layout should be sized against, and modal-rect(rows, cols) gives
      the dialog rectangle. handle-resize cascades the interior, not the
      dialog rectangle, so a framed modal's flex spacers land where they
      should. Four theme slots come with it, each defaulting to an
      existing one so turning framing on changes layout and never
      palette: modal-frame (= border), modal-title (= overlay-title),
      modal-key (= overlay-key) and modal-scrim.
      Border grew the mechanism the frame uses: style-slot,
      focused-style-slot, title-slot and bottom-title-slot name the
      theme slots a frame and its two titles paint from. Names are
      resolved through Theme.slot on every render, so a Border
      configured this way follows a live theme swap where a pushed
      set-style-override would not; app-specific %.custom slots work
      too. The defaults ('border' / 'border-focused', and no slot of
      their own for the titles) make exactly the native calls Border
      always made. Pointing both style slots at one name opts a Border
      out of focus-reactive colouring without touching
      focus-from-store — which is what Modal's frame does, since a
      modal is a focus trap and something inside it is focused
      essentially always.
      Also fixed on the way past: Modal.render threw outright on a
      terminal smaller than the 3x10 dialog floor (a negative origin
      coerced to UInt). The origin now clamps at 0.
    - [2026-08-02] VBox and HBox gain gap; Border gains padding. `gap`
      reserves cells between children — set it in the constructor or
      with set-gap — and replaces the empty-Text-spacer idiom outright:
      a gap is not a widget, so it costs no plane and paints nothing
      (the container's own plane base shows through, which is what
      makes it read as breathing room rather than a strip of a
      different colour). Gutters are strictly between children: no
      leading or trailing gap, and none beside a child allocated zero
      cells, so collapsing a child with Sizing.fixed(0) — the hidden-
      child idiom — takes its gutter with it instead of leaving a
      double gap. Selkie::Layout::Allocate does the arithmetic:
      allocate-along-axis picks up a :gap named argument and runs its
      three passes over the content box (the axis total minus the newly
      exported gap-reserve), so Sizing.percent now means percent-of-
      content-box the way it does inside a CSS flex container. Split
      deliberately has no gap — it separates its panes with a divider
      it draws and drags itself.
      Border's `padding` insets the content from the frame: a uniform
      constructor shorthand that fans out to padding-top / -right /
      -bottom / -left (an explicitly-passed edge wins, so
      `padding => 1, padding-top => 0` is the titled-panel case), plus
      set-padding and set-padding-edges afterwards. The content box is
      exposed as the plane-free `inner-rect(rows, cols)` for callers
      and tests. Padding can squeeze that box to nothing on either
      axis, and a zero row or column count is a resize notcurses
      refuses — which would leave the content plane at its previous,
      larger geometry, painting straight through the frame — so Border
      parks the content for as long as either extent is zero and
      restores it on the first render with room, mirroring VBox's
      handling of zero-allocation children. Titles are unaffected by
      padding; they live on the frame.
      gap => 0 and padding => 0 are the defaults and are bit-identical
      to the previous layout, allocation for allocation: every golden
      in this distro and in App-Cantina and App-Mindmoor is unchanged.
    - [2026-08-02] Border gains glyph sets and title placement. The
      new Selkie::BorderStyle module carries a BorderKind enum
      (BorderSingle, BorderRounded, BorderDouble, BorderHeavy,
      BorderAscii), a TitleAlign enum (TitleLeft, TitleCenter,
      TitleRight), and the six-glyph BorderGlyphs value class those
      kinds resolve to via a cached .for(). Border picks a kind with
      border-style, or takes an explicit BorderGlyphs table via
      border-glyphs / set-border-glyphs which wins over the kind
      (effective-glyphs is the resolution both the painter and callers
      use); it also gains title-align, a second bottom-title with its
      own bottom-title-align (the key-hint slot), and title-prefix /
      title-suffix around both. Note that border-style is about glyphs
      only — set-style-override and the border / border-focused theme
      slots still own the colours, and the two compose. Selkie
      deliberately does NOT downgrade to ASCII on locale or terminal
      detection, which would make rendering (and every snapshot)
      environment-dependent; BorderAscii is the escape hatch you
      choose. Defaults reproduce the previous frame cell-for-cell —
      every existing golden in this distro and in App-Cantina is
      byte-identical — with one deliberate exception: a title long
      enough to be truncated to the full interior width used to be
      painted at column 2, running its last character over the
      top-right corner glyph, and is now pulled back to column 1 so
      the corner survives. Internally the frame painter is one private
      !paint-frame shared by the initial draw and the post-content
      redraw that covers image sprixel bleed (the :edges-only pass,
      which now re-stamps the bottom title after repainting the edge
      it sits on), and each horizontal run is a single putstr instead
      of one FFI call per column.
    - [2026-08-01] Shutdown tty restore is now spawn-free and
      step-isolated. The old restore shelled out to `stty` — if the
      process's working directory had been deleted or renamed while
      the app ran, posix_spawn failed with ENOENT, the resulting
      X::Proc::Unsuccessful broke on a thread-pool thread, escaped
      the caller-side try, and propagated out of shutdown — skipping
      notcurses_stop and the escape-sequence backstop and leaving
      the terminal raw in the alt-screen. Capture/restore now use
      in-process tcgetattr/tcsetattr NativeCall bindings treating
      the termios snapshot as an opaque blob (no child process, no
      cwd dependence, portable across glibc/musl/macOS; Windows
      keeps its /dev/tty-existence early-return), and both restore
      calls in shutdown are wrapped in !try-log like every other
      step so nothing can strand the backstop chain again.

0.10.0  2026-07-08T17:38:27+01:00
    - [2026-07-08] TextInput decorations: new pull-based
      highlight-provider (per-span buffer coloring via the new
      Selkie::Widget::TextInput::HighlightSpan value class) and
      suggest-provider (fish-style ghost completion tail painted after
      the caret; Right arrow at end-of-buffer accepts it via
      insert-text, one on-change emission). Both hooks are consulted
      at render time so they can never desync from the buffer
      (including set-text-silent store-syncs), and are ignored on
      masked inputs. New theme slot input-ghost (defaults to the
      placeholder foreground, italic); per-widget ghost-style
      override. Styled snapshot 34 pins the span/ghost/caret fg
      channels.
    - [2026-07-03] Trace-mode events are now written by a dedicated
      writer thread: producers enqueue snapshotted event Hashes on a
      Channel lock-free, and one thread owns JSON serialization + file
      I/O. Span capture on hot paths no longer pays serialization +
      the shared lock on the calling (usually main) thread — a real
      capture measured ~3ms/frame of instrumentation before this.
      shutdown/reset close the channel, JOIN the writer, then write
      the JSON tail, so the file is complete and valid once shutdown
      returns; producers racing shutdown are safely dropped. New
      Selkie::Trace pod documents the modes and the span-discipline
      rule below.
    - [2026-07-03] Span discipline: create spans AFTER any cheap
      early-return guard. Image.destroy-blit-plane / !load / !unload /
      render / set-file all hoisted their no-op guards above span
      creation — parked image cards re-enter destroy-blit-plane on
      every layout pass (VBox/HBox park zero-sized children each
      render), which made it the single largest span source in a real
      capture: 138k events, 98.4% recording nothing. Store's
      subscription digest span likewise no longer fires for
      identity-short-circuited checks, and drain-async gained a span
      so shutdown time is attributable.
    - [2026-07-03] Extend the mouse dispatcher's duplicate-press
      suppression to every widget: a press repeating the previous press
      (same button, same cell) within a 40ms floor window is dropped even
      when the target has no debounce-ms of its own. A terminal-driver
      double-fire can no longer double-activate plain widgets or register
      as a phantom double-click (click-count 2 from one physical click)
      in ListView/Table/TextInput. Widgets exposing debounce-ms keep
      their configured window.
    - [2026-07-03] Stop treating wheel events (buttons 4/5) as clicks in
      the mouse dispatcher: they no longer enter the duplicate-press
      window (flick-scrolling over a Button/Checkbox/Select was throttled
      to ~8 events/s), no longer seed drag capture (a wheel "press" has
      no release, so one scroll permanently captured motion routing), no
      longer advance click-count, and no longer dismiss
      dismiss-on-click-outside modals from outside. Click-to-focus on
      scroll is preserved.
    - [2026-07-03] Add per-row styling to Selkie::Widget::Table: a
      C<row-style> callback (constructor arg or C<set-row-style>)
      receives each row hash and returns a Selkie::Style to overlay —
      override colors win, style flags OR with the cursor highlight.
      Style resolution is extracted into the public, headless-testable
      C<effective-row-style>.
    - [2026-07-03] TextInput/MultiLineInput no longer swallow ctrl+x
      when there is no selection: the cut handler returns False so the
      chord bubbles to global keybinds (previously a focused input
      silently ate app-level ctrl+x bindings — e.g. a queue modal
      opener — whenever nothing was selected).

0.9.1  2026-06-23T01:38:56+01:00
    - Refactor Selkie::App into a documented facade backed by internal
      implementation roles/modules for dispatch, error logging, focus/tree
      state, hit testing, idle-budget selection, overlay/tree walking, render
      loop state, screen/modal lifecycle, terminal setup/cleanup, and terminal
      escape sequences. The new Selkie::App::Internal::* modules are listed in
      META6.json, and t/82-app-facade.rakutest guards the public App surface.
    - Re-enable generated API docs during distribution builds, expand the API
      index categories, add generated docs for the new App internals, and add
      docs/api-surface-1.0.md as the first API-freeze inventory.
    - Add defaulted theme slots for focused controls, selections, overlay
      titles/keys, modal backdrops, toasts, and password-strength bands; migrate
      production widgets away from hardcoded colors and update
      Selkie::Widget::PasswordStrength to render from theme slots.
    - Add Selkie::Store built-in effects db-replace and db-delete for exact
      subtree replacement/deletion, document db as deep-merge, and ensure
      unknown effect names hit the unknown-effect guard instead of crashing
      before it can run.
    - Improve Kitty/tab sprixel recovery: user-idle, not background store
      traffic, controls refresh timing, and the render loop can refresh
      sprixels on idle intervals without waiting for the next keypress.
    - Fix internal mouse dispatch to call the hit-test helper directly instead
      of relying on a type-object App facade lookup.
    - Fix paste batching to coerce `.^can('insert-text')` to a real Bool before
      returning from the focused-input capability check.
    - Fix viewport-cropped Images so active-modal occlusion destroys any
      existing blit and skips loads/blits when the Image is outside the modal
      tree, preventing card images from painting over fullscreen modal previews.
    - Gate Selkie::Widget.set-viewport diagnostic output behind
      SELKIE_DIAGNOSTIC_LOG, SELKIE_WARN_LOG, or SELKIE_DIAGNOSTICS_STDERR=1 so
      production stderr stays quiet by default.
    - Fix Selkie::Trace JSON event writing and numeric serialization so
      non-finite values become null and event commas are emitted atomically with
      each event write.
    - Add tools/check-release-hygiene.raku to catch ignored package-root
      artifacts before release validation.
    - Remove confirmed private dead code from Store, MultiLineInput, and
      ViewportedCardList, with updated tests for theme slots, store effects,
      mouse dispatch, tree/modal helpers, terminal cleanup, sprixel refresh,
      trace JSON, and the App facade.

0.9.0  2026-06-18T12:11:18+01:00
    - Selkie::Widget::ViewportedCardList: visible rows now get a
      `prepare-visible` callback on their item widget, when present,
      before the card root is rendered. This gives heavyweight widgets
      a framework-level hook to lazily request or materialise resources
      only after the list has determined that the card intersects the
      viewport, while still allowing the prepared content to render in
      the same frame if it is ready.
    - perf: ViewportedCardList now maintains prefix-summed item
      heights and binary-searches the first visible row instead of
      walking from item zero on every render. Offscreen items that
      leave the viewport are parked, and a new `visible-index-count`
      accessor exposes the current visible set size for tests and
      diagnostics.
    - fix: Selkie::Store's event queue is now lock-protected. dispatch()
      is called from worker threads (the async fx fires on-success /
      on-failure from its worker; apps push progress events from their
      own `start {}` pipelines), and the drain's copy-then-clear could
      wipe a push that landed between the two steps — events were
      silently lost under load. dispatch() now pushes under a Lock and
      !process-queue swaps the queue out under the same lock; the lock
      is never held while handlers run, so re-dispatch (the dispatch
      fx, __effect-error) cannot deadlock. dispatch() is now documented
      as the one store method that is safe from any thread. New test:
      t/79-store-dispatch-thread-safety.
    - battery: the idle ladder now tracks USER activity (input, resize,
      toast visibility) and STORE activity (events / writes processed
      by tick) on separate clocks. Store-only activity is floored at
      30 Hz instead of resetting the loop to the hot rate — a
      background worker dispatching progress every couple of seconds
      (asset generation, network status refreshes) used to pin the
      loop at 60 Hz for its whole lifetime. User input still snaps
      straight back to hot. The tier math moved from a closure inside
      run() to the package sub Selkie::App::pick-frame-budget so it's
      unit testable; new test t/80-idle-ladder-budget.
    - battery: the idle-remainder sleep is now chunked at 100 ms
      (SLEEP-CHUNK-MAX) instead of one 16 ms frame. The 16 ms chunking
      kept the thread waking ~60 times/s even at the 4 Hz deep-idle
      tier — the ladder reduced work per wake but not the OS wakeup
      count, which is what power accounting actually punishes. Deep
      idle now wakes ~12 times/s. Trade-off: the first SIGWINCH after
      deep idle is noticed within 100 ms instead of 16 ms (one-time;
      processing it is user activity, which snaps the ladder back to
      hot). Keystroke latency is unchanged — input is only read at
      the top of the loop, after the full budget, regardless of how
      the sleep is sliced.
    - perf: removed the temporary SELKIE_PERF_LOG loop instrumentation
      (a %*ENV lookup plus timer reads every iteration at 60 Hz). It
      existed to root-cause long-running background-work UI lag, which
      has been found and fixed.
    - fix: the Kitty tab-switch sprixel refresh threshold is now
      measured against user idle specifically. It was measured against
      combined activity, so background store traffic (a streaming
      response, worker progress) suppressed the refresh — switching
      Kitty tabs away and back during a long-running job left Images
      blank until the worker went quiet.
    - perf: Selkie::Widget::Image.set-rgba now retains the caller's Buf
      by reference instead of copying it into a CArray with an
      element-wise Raku loop (e.g. ~640K iterations for a 400×400 image,
      ~4.2M for 1024×1024 — once per call). !load hands the Buf's storage
      straight to ncvisual_from_rgba via nativecast(Pointer, $buf) —
      notcurses copies internally, so the buffer only has to stay
      reachable. New contract: the passed Buf must be treated as
      immutable (it's retained, not copied); sharing one buffer across
      many Image widgets is now cheap and safe. Removes the per-widget
      copy cost when re-rendering many Images.
    - Selkie::Widget::Image: new in-memory RGBA ingress alongside the
      existing file path. `set-rgba(Buf, :width!, :height!, :id)` blits
      decoded pixels straight from memory via `ncvisual_from_rgba`
      (rows = height, rowstride = width × 4, cols = width) — for images
      you already have as pixels (decoded yourself, generated, or held in
      memory) and don't want to round-trip through a file. The caller
      produces the RGBA buffer and hands it over; Image retains it for the
      clip-only reload-before-rescale path. Everything downstream of
      `$!visual` is shared with the file path.
    - Image render cache now keys on a small `source-id` string (the
      path, the caller's `:id`, or a gen token) instead of the file
      path, so a re-render NEVER compares two multi-MB pixel buffers
      (`$!last-file` → `$!last-source-id`). New `source-id` accessor.
    - Tests: t/76-image-rgba-source (headless state machine), t/77-image-
      rgba-blit (non-square blit pins the ncvisual_from_rgba parameters).

0.8.0  2026-05-20T18:53:56+01:00
    - BREAKING: `Selkie::Store.subscribe`, `subscribe-path-callback`,
      `subscribe-computed`, and `subscribe-with-callback` now default
      to structural change detection (the previous opt-in
      `:deep-equality-check` behaviour). The default flag is removed;
      pass `:identity-check-only` to opt into the old fast-path
      `===`-then-`eqv` comparison when the watched value is known to
      be a fresh allocation per write. Migration: drop
      `:deep-equality-check` at every call site (the safe behaviour
      is now the default). Add `:identity-check-only` only where the
      hot path can prove the watched value is fresh per call. This
      fixes a long-standing silent suppression of ancestor-fire
      subscriptions after deep `assoc-in` writes — the
      "chat doesn't update until I press a key" class of bug.
    - BREAKING: effect payloads must now be `Associative`. The
      dispatcher used to wrap bare scalars as `{ value => $x }`
      automatically, which silently reshaped the payload and produced
      hours of debugging when a handler expected a Hash and got
      `{ value => 0 }` instead. Now `!run-effect` throws with a
      clear message and routes the throw to `__effect-error` so the
      dispatch loop survives. Migration: wrap scalar payloads at
      the call site, e.g. `(my-fx => { value => 42 })`, or pass an
      empty Hash for no-payload effects.
    - behavior fix: `Selkie::Widget.!destroy-plane` now unsubscribes
      the widget from the store before tearing the plane down. Pre-
      fix, subscriptions held a widget reference, GC never collected
      the widget, `DESTROY`/`destroy` never fired, and notcurses
      planes leaked on every modal cycle / screen swap / dynamic
      widget rebuild. The store's `unsubscribe-widget` is the single
      writer; calling it again from a Container's `clear`/`remove`
      cascade is a no-op (idempotent). Subclass `destroy` overrides
      that already end with `self!destroy-plane` (the documented
      convention — Border, Modal, Image all comply) pick this up
      automatically. New `Selkie::Store.subscription-count` accessor
      for tests.
    - behavior fix: effect-handler exceptions are now isolated. A
      buggy `register-fx` handler used to tear out of `!run-effect`,
      out of `tick`, and into `Selkie::App.run`'s outer CATCH —
      one bad handler crashed the whole TUI. The framework now
      catches handler exceptions, logs them, and routes them to a
      new `__effect-error` event so apps can register a handler
      that surfaces failures (toast, modal, telemetry). Event-
      handler exceptions in `register-handler` callbacks get the
      same isolation. A re-entrance guard prevents infinite
      recursion if the `__effect-error` handler itself throws. The
      framework ships a default `__effect-error` handler that logs
      to the store's debug-log stream.
    - behavior fix: `register-fx('async')` now tracks every Promise
      it spawns. New `Selkie::Store.drain-async(:$timeout)` waits
      for in-flight workers to complete (bounded), flips the store
      into shutting-down mode so further async dispatches no-op,
      and clears the tracking list. `Selkie::App.shutdown` calls
      it before notcurses teardown so a long-running worker
      (LLM inference, etc.) can't complete post-`notcurses_stop`
      and dispatch into handlers whose native handles are gone.
      Async-effect failures now route through the CATCH and include
      the full backtrace; pre-fix the Promise was leaked entirely
      and its broken-promise diagnostic went to stderr behind the
      alt-screen.
    - robustness fix: shutdown-time exceptions are no longer
      silently swallowed by bare `try { }`. Each step (modal
      destroys, screen-manager destroy, drain-async,
      `notcurses_stop`) now runs through `App.!try-log(<step>, …)`
      which catches inside, preserves the isolation invariant
      (each step independent of the others), and writes a tagged
      diagnostic to `$*ERR` (captured by `!install-error-log`
      when active). Pre-fix, a shutdown failure left the user
      with a wedged terminal and zero clue which step blew up.
      `t/70-terminal-cleanup.rakutest` updated to assert the new
      pattern; existing tests still pass.
    - robustness fix: `Selkie::Layout::Split` no longer crashes on
      1-cell, 0-cell, or extreme-ratio axes. The boundary math
      moved out of `!layout-split` into a pure-math helper
      `compute-split-sizes(UInt $total, Rat $ratio --> Hash)` that
      clamps before subtraction and bails on 0-total. Pre-fix, a
      tmux pane resize to 1×N threw `X::TypeCheck::Assignment`
      out of `render()` (UInt rejected the wrapped-negative Int
      assignment) and killed the whole frame. `t/74-split-extreme-
      sizes.rakutest` covers 1×N, N×1, 1×1, 0×0, and extreme
      ratios.
    - new tests: `t/72-effect-error-isolation.rakutest`,
      `t/73-widget-destroy-unsubscribes.rakutest`,
      `t/74-split-extreme-sizes.rakutest`,
      `t/75-store-async-drain.rakutest`. `t/63-deep-equality-check`
      rewritten for the flipped default and the new
      `:identity-check-only` flag.
    - `examples/viewported-card-list.raku` no longer reaches into the
      sibling Notcurses-Native checkout for sample images. The 10
      referenced images are vendored verbatim under `examples/data/`
      (~1.2 MB, see that dir's README for attribution) so the example
      runs on a fresh Selkie install with no sibling repo present.
      Companion change to Notcurses-Native 0.4.0 dropping its
      `vendor/notcurses/` tree.

    - Test cleanup: `t/22-viewport.rakutest`'s safe-coord regression
      subtest no longer leaks its expected corruption diagnostic to
      stderr during `prove6` / `zef install`. The previous `quietly`
      wrapper had no effect (the diagnostic uses `note`, which goes
      straight to `$*ERR` and bypasses `quietly`'s warn-only scope);
      we now redirect `$*ERR` to `$*SPEC.devnull` for just the
      rejected call. Cosmetic — the contract being tested is
      unchanged.

0.7.4  2026-05-19T17:10:03+01:00
    - Selkie::Store: subscription mutations made from inside the
      per-tick subscription walks (`!check-subscriptions` and
      `!flush-push-subs`) are now deferred and applied after the
      walk via a `LEAVE` block. Earlier in this release cycle the
      same call site was patched with a `.keys.List -> Str:D $id`
      snapshot, which proved insufficient — App::Cantina's
      character-change path cascades ~120 `unsubscribe` calls
      from a single callback firing (ChatMessage / ImageMessage
      per-widget subs cleaned up via `Container.clear` →
      `unsubscribe-widget`), and the live `%!subscriptions` hash
      shrinking under that load left the loop variable undefined
      on the next iteration ("Hash keys must be concrete strings
      (got )" mid-tick). Neither `.keys.List` (likely fused away
      by spesh) nor `Str:D` on a pointy-block parameter (loosely
      enforced) caught it.
      The new mechanism establishes a hard invariant:
      `%!subscriptions` and `%!push-subs-by-key` are never
      mutated mid-walk. `unsubscribe` queues into a private
      `$!pending-unsubscribes` SetHash when called during a walk;
      the LEAVE block at the end of each walk drains the queue
      via a new private `!do-unsubscribe`. During the walk,
      queued ids are skipped in the dispatch loop, so the
      observable contract is unchanged (a sub unsubscribed by
      callback X in tick N does not fire later in tick N).
      Re-subscribing an id that's queued for removal cancels the
      queue and tears the old entry down cleanly before the new
      registration. The walks themselves now snapshot keys via a
      typed eager Array (`my Str:D @ids = %!subscriptions.keys`)
      that spesh cannot fuse away and that throws loudly on any
      non-Str:D key as a diagnostic backstop. Pod "Mutation
      safety" section rewritten to describe the actual mechanism.
    - Selkie::Widget::MultiLineInput: `set-text` and `set-text-silent`
      now normalise CRLF and lone CR to LF before splitting. A stray
      `\r` inside a logical line made `ncplane_putstr_yx` reset the
      cursor to column 0 mid-row, threw off `.chars`-based wrap
      counts, and broke scroll. The bug surfaced loading CCv3
      character cards (often authored on Windows) into the Cantina
      character editor's description box.
    - Selkie::Widget::Button: new constructor parameter
      `:debounce-ms` (UInt, default 0 = off). When non-zero, click
      events emitted within the window of the previous emit are
      suppressed. Default of 0 preserves existing behaviour for
      every consumer; opt in where mis-fires matter (e.g. Cantina's
      "+ Add LoRA" row builder).
    - Selkie::App: terminal-tab-switch sprixel recovery. New attribute
      `$.sprixel-refresh-idle-threshold` (Num seconds) controls how
      long the loop tolerates input-idle before forcing every Image
      in the tree to re-emit its sprixel on the next render. Defaults
      to 5e0 on Kitty (detected via `KITTY_WINDOW_ID` or `TERM`
      starting with `xterm-kitty`) and 0e0 (disabled) elsewhere. The
      bug it fixes: Kitty's per-tab compositor restores the cell
      buffer on tab return but NOT the direct-placement graphics
      notcurses emitted, so any Image widget whose owning widget
      hasn't dirtied since the switch stays visually blank — most
      visible in App::Cantina's character-select pane (AvatarList),
      which rarely re-renders during normal use. Other Image-bearing
      widgets recover incidentally because they're already dirtying
      on chat-pane scrolls / emotion-pane changes. The "next input
      after long idle" signal is the cheapest detector we have:
      Kitty does emit focus events under CSI ?1004, but notcurses's
      input parser doesn't surface them as keyable events, and
      reading raw stdin in parallel with notcurses isn't workable.
    - Selkie::App: new public method `force-refresh-sprixels`. Walks
      the live tree (active screen + modal stack + toast) and on
      every Selkie::Widget::Image calls `destroy-blit-plane` (which
      also clears the geometry cache so the next render takes the
      cache-miss path) and `mark-dirty`. Apps can call it directly
      from a manual-refresh keybind, or after an operation known to
      bypass the auto-refresh idle signal.
    - Selkie::App: new public class method
      `detect-sprixel-bug-prone-terminal` returning Bool. Exposed
      so the detection logic is unit-testable without an active
      notcurses instance — Selkie::App.detect-sprixel-bug-prone-terminal
      runs cleanly in plain Raku.
    - t/71-sprixel-refresh.rakutest (new): Kitty detection across
      KITTY_WINDOW_ID / TERM signals, the tree-walking helper's
      behavior on a mock mixed-widget tree (Images marked dirty,
      non-Image widgets untouched, undef tree safe), and source-
      level structural invariants tying the threshold into TWEAK
      and the run loop.

0.7.3  2026-05-16T04:21:07+01:00
    - META6: bump Notcurses::Native dep from 0.3.2+ to 0.3.4+ to
      pick up the new prebuilt platform matrix. 0.3.4 adds macOS
      x86_64 (Intel Mac / Hackintosh, Catalina 10.15+), Linux
      x86_64-musl and aarch64-musl prebuilts (Alpine 3.13+, Postmarket
      OS, Void, etc.), rebases the Linux glibc lanes onto
      manylinux_2_28 (glibc 2.28 floor; RHEL 8+ / Ubuntu 18.10+ /
      Debian 10+), and ships every prebuilt with accelerated codec
      libraries (libdav1d for AV1, libvpx for VP8/9, libopus). A
      CI release-gate probe dlopens the bundled libavcodec and
      decodes PNG/JPEG/BMP fixtures end-to-end before publish.
    - docs/Readme.rakudoc: updated the prebuilt platform list +
      "falling back to source build" notes to match the new matrix.

0.7.2  2026-05-13T18:16:11+01:00
    - Selkie::App: escape-sequence backstop on shutdown. After
      notcurses_stop + the stty termios restore, shutdown now writes
      a comprehensive "exit terminal protocol" sequence direct to
      /dev/tty: Kitty kbd protocol pop ×3, mouse tracking off (every
      encoding variant), bracketed paste off, focus events off,
      modify-other-keys off, cursor shown, SGR reset, alt-screen
      exit. Same direct-to-/dev/tty pattern as set-title.

      Smoking-gun motivation: iTerm2 3.5+ advertises Kitty keyboard
      protocol support, notcurses pushes it at init, but the pop
      emitted by notcurses_stop doesn't take — after the app exits
      every keystroke arrives at the shell as `CSI codepoint u`
      (e.g. typing 'a' shows `3;5;3u`) and the terminal looks stuck
      in raw mode. The pop is the load-bearing fix; the other
      disables ride along because all are idempotent and any future
      protocol leak notcurses_stop misses is now covered too.

      build-terminal-cleanup-sequence is a public class method so
      the sequence can be verified headlessly (t/70-terminal-cleanup);
      !emit-terminal-cleanup is the private writer that wraps it.

0.7.1  2026-05-12T17:16:11+01:00
    - Selkie::Store, Selkie::App: env-gated performance instrumentation.
      Setting SELKIE_PERF_LOG enables two diagnostic spurts to
      /tmp/selkie-perf.log:
      (a) Store!values-equal logs any eqv comparison whose fallback
          path (after the === identity shortcut) takes >20 ms, with
          the type names + WHICH of both operands. Surfaces the
          subscriptions whose computed values land in a deep walk
          (Array/Hash/Buf) on every store tick.
      (b) Selkie::App's run-loop logs per-phase timing (frame
          callbacks, store tick, render-frame) for any iteration
          whose total exceeds 50 ms. Localises lag to a specific
          phase so a slow input cycle is attributable.
      Both branches early-return on unset env, so the overhead in
      production is one Bool check per call. Pair with the consumer's
      own enable-debug() call on Selkie::Store to capture dispatch /
      effect / subscription fires alongside the timings. Designed to
      be left in tree as a permanent opt-in profiler.

0.7.0  2026-05-12T00:43:46+01:00
    - Selkie::EffectiveBounds: value class returned by
      `Widget.effective-bounds` representing the on-screen rectangle a
      widget may safely paint into, computed as the intersection of its
      plane with every ancestor's plane and the terminal viewport. Lets
      sprixel-bearing widgets like Image size their blit-plane to the
      visible region only, preventing pixel overflow past ancestor
      borders.
    - Selkie::Tree: framework-level tree-walking helpers
      (`mark-widgets-in-rect-dirty`, `current-active-modal`) that bridge
      widgets with the wider tree without requiring a circular import
      on Selkie::App. Selkie::App registers provider closures at init
      so widgets can reach live tree roots + modal state via free subs.
    - Selkie::Layout::Allocate: shared three-pass sizing algorithm
      (fixed → percent → flex) extracted from VBox + HBox. Layout
      math now lives in one place; per-container code only handles
      positioning along the cross axis.
    - Selkie::Widget::FocusableByDefault: composable role that defaults
      `focusable` to True at construction. Replaces the repeated
      three-line `new` override in Button, Checkbox, TextInput,
      ListView, RadioGroup, MultiLineInput, CardList, and the new
      ViewportedCardList.
    - Selkie::Widget::ViewportedCardList: row-scrolled selectable list
      of cards where the viewport can start mid-card. Unlike CardList
      (which scrolls in card-sized steps), this scrolls by individual
      rows via PgUp/PgDown/wheel while Up/Down still walks selection
      one card at a time. Designed for tall mixed-height content with
      Image children that need partial-clip rendering as cards slide
      past the viewport edge.
    - Selkie::Widget: added `clip-to-ancestors` attribute (default
      True) controlling whether `effective-bounds` includes ancestor
      clipping or only terminal-viewport clipping — portal-style
      overlays can set it False to escape container bounds. Added
      `effective-bounds()` method returning a Selkie::EffectiveBounds
      for safe sprixel-painting regions. Added `park-y()` (returns
      10_000) standardising off-screen positioning across all widgets.
      Added `!park-children(@kids)` helper consolidating park
      iteration for Container and CardList.
    - Selkie::App: integrated app-level coordination services for
      sprixel cleanup and modal occlusion. `!collect-tree-roots`
      exposes the live widget tree (active screen + modal stack +
      toast) for `mark-widgets-in-rect-dirty`. `!mark-all-images-dirty`
      propagates modal mount/unmount/toast state so `Image.render`'s
      occlusion check fires reliably. `!park-images-in-rect` pushes
      Images underneath the toast strip off-viewport to prevent
      sprixel bleed-through. `!capture-tty-state` /
      `!restore-tty-state` snapshot+restore terminal TTY state as a
      backstop when notcurses shutdown can't (signal-killed runs).
      Registers provider closures with Selkie::Tree and
      Selkie::EffectiveBounds during init. Crash-restore taps on
      SIGABRT/SIGTERM/SIGHUP/SIGQUIT ensure the terminal is cleaned
      up before process exit on fatal signals.
    - Selkie::Store: added `:deep-equality-check` flag (default False)
      on all subscribe variants. When set, change detection uses
      structural `eqv` rather than the fast `===` identity check, and
      values are snapshotted (deep-cloned for nested Hashes/Arrays) at
      every fire. Fixes the silent-suppression trap when assoc-in
      mutates a Hash in place — the reference stays the same across
      the write, so the `===` check fires no listener. Opt-in: enable
      only for subscriptions watching mutable containers updated
      in-place. Leaf values and fresh-per-call computes don't need it.
    - Selkie::Widget::CardList: major refactor. New
      `:min-display-height` parameter on `add-item` (default 1)
      controls the minimum visible height before a non-selected
      partially-clipped card is parked entirely instead of shown as
      a sliver. Selected cards always render regardless of height.
      Park decisions now run in a pre-pass before layout so the
      planes that won't fit are off-screen by the time render runs,
      eliminating sprixel-ghost churn from rapid scroll. Removed
      per-Image cache invalidation; sprixel reliability now comes
      from deterministic parking + fresh blits. Replaced boilerplate
      `focusable` override with the FocusableByDefault role.
    - Selkie::Widget::Image: full rewrite of the rendering model and
      its Pod6. Visibility is now driven purely by notcurses-queried
      plane positions and ancestor-intersection checks via
      Selkie::EffectiveBounds, never the Widget-level position cache
      (which can desync from notcurses across Container.park cascades
      and direct ncplane_move_yx calls). Sprixel is emitted only when
      the plane is fully contained in every ancestor and the terminal
      viewport; otherwise the blit-plane is destroyed and the render
      skips. Adds a `clip-only` mode that pre-scales the visual to
      cell-aligned natural dims and uses an additive
      `render-viewport-crop` API for partial-clip rendering inside
      row-scrolling containers. Adds file-target debug logging via
      `SELKIE_IMAGE_DEBUG` env var. The new Pod6 explains the
      rendering model, the notcurses-vs-Widget-cache divergence, park
      semantics, pixel-bleed protection, and the Sixel vertical
      granularity workaround in detail.
    - Selkie::Widget::Border: render path for small borders (< 3×3)
      properly resizes the content plane even when the border edges
      can't be drawn, preventing cell bleed past the border into
      adjacent siblings. Added explicit Pod6 for `set-content`,
      `set-title`, `set-has-focus`, `has-focus`.
    - Selkie::Widget::Modal: documented `set-content`'s `:destroy`
      flag and the park-on-swap-without-destroy pattern. Added Pod6
      for `on-close`, `focusable-descendants`, `handle-resize`, and
      `destroy`.
    - Selkie::Widget::ListView: applied FocusableByDefault role.
      Expanded Pod6 for `items`, `cursor`, `selected`, `on-select`,
      `on-activate`, `set-items`, `select-index`.
    - Selkie::Widget::MultiLineInput: applied FocusableByDefault role.
      Expanded Pod6 for `text`, `set-text`, `set-text-silent`,
      `clear`, `on-submit`, `on-change`, `set-focused`, `is-focused`,
      `desired-height`, `line-count`, `cursor-row`, `cursor-col`.
      Documented `on-copy` / `on-cut` supply semantics.
    - Selkie::Container: park() now uses `!park-children` and the
      shared `park-y` method, eliminating the hardcoded 10_000
      literals scattered through the codebase.
    - Selkie::Layout::VBox / Selkie::Layout::HBox: refactored to call
      `allocate-along-axis` from Selkie::Layout::Allocate, removing
      ~50 lines of duplicated three-pass allocation math each.
      Children that collapse to zero rows/cols are now parked
      off-viewport so their previous-size planes don't paint stale
      cells over adjacent siblings.
    - Selkie::Layout::Split: added Pod6 for `handle-resize`,
      `focusable-descendants`, and `destroy`.
    - Selkie::ScreenManager: clarified Pod6 for the App-level
      forwarding methods (`add-screen` / `switch-screen` /
      `screen-names`) so it's obvious which call paths each invokes.
    - Selkie::Widget::Toast / Select / RadioGroup / TextInput /
      Checkbox / TabBar / Table: doc-only Pod6 expansions covering
      methods that already existed but weren't fully documented.
    - Image.render: when `clip-only` is True, defer the blit entirely
      to render-viewport-crop instead of running emit-blit. emit-blit
      creates the sprixel as a child of Image's own plane, which sits
      in the row-scrolling container's backing chain and gets parked
      off-screen at end-of-frame. render-viewport-crop creates a
      different sprixel as a child of the container's own plane —
      so the two planes live in different parent chains, and the
      destroy in render-viewport-crop's prologue would target a
      stale `$!blit-plane` rather than the live one. The leftover
      sprixel survives in notcurses's tracking, moves with the
      backing chain as it parks, and emits a one-row ghost over
      the container's bottom border the moment the card scrolls
      fully off the top of the viewport. With clip-only set,
      render-viewport-crop is the single canonical blit pathway.
    - Image.destroy-blit-plane: capture the blit plane's screen
      rectangle before destroying and call
      Selkie::Tree::mark-widgets-in-rect-dirty so widgets owning
      cells under the destroyed sprixel get re-rendered. Documented
      in Selkie::Tree as the expected pattern for sprixel-bearing
      widgets; was missing from Image.
    - t/61-effective-bounds.rakutest (new): unit tests for
      Widget.effective-bounds — single and multi-ancestor clipping,
      clip-to-ancestors=False overrides, terminal viewport
      intersection.
    - t/62-tree-helpers.rakutest (new): tests for
      Selkie::Tree::mark-widgets-in-rect-dirty's tree walk —
      widgets in/out of the marked rectangle are dirtied correctly
      and the walk respects children/content patterns.
    - t/63-deep-equality-check.rakutest (new): exercises Store's
      `:deep-equality-check` flag across all four subscribe
      variants. Verifies in-place Hash mutation is detected when the
      flag is set and suppressed when it's off (the default).
    - t/64-layout-allocate.rakutest (new): unit tests for
      `allocate-along-axis` fixed / percent / flex passes, rounding
      remainder handling, and edge cases (empty list, single item,
      total fixed exceeds available, etc.).
    - t/65-viewported-card-list.rakutest (new): ViewportedCardList
      selection movement, row scrolling, mouse-wheel interaction,
      PgUp/PgDown handling.
    - t/68-image-clip-only-math.rakutest (new): pure-function tests
      for clip-only source scaling and sub-region math
      (compute-clip-only-blit).
    - t/69-image-clip-only-blit.rakutest (new): integration test
      driving compute-clip-only-blit + ncvisual_blit end-to-end on
      a synthetic 8×8 gradient and asserting the rendered cells
      correspond to the requested source sub-region. Pairs with the
      Notcurses-Native 0.2.7 patch (ncvisual_blit_internal
      pre-cropping) — together they fix ViewportedCardList rendering
      wrong rows when an image is partially clipped at the top of
      the viewport. Bumps minimum Notcurses-Native version to 0.2.7
      implicitly via the BINARY_TAG bump
      (binaries-notcurses-3.0.17-r3).
    - t/23-card-list.rakutest: expanded with three new subtests —
      add-item parameter signature check, min-display-height
      clipping scenarios, and park iteration covering every card
      root via !park-children.
    - t/fixtures/test-4x4.png (new): tiny PNG fixture mirrored from
      Notcurses-Native for use in image-pipeline tests that need a
      real file path. Selkie tests do not get %?RESOURCES access to
      sibling distributions' resources, so the file is bundled here.
    - xt/snapshots/29-cardlist-min-height-leading-park.raku +
      golden/29-…: CardList with three heterogeneous-height cards
      where the selected middle card can't fit fully in the
      viewport. The leading card's min-display-height exceeds its
      partial-visible height; parking pre-pass folds it entirely so
      layout compacts upward without producing broken slivers.
    - xt/snapshots/30-cardlist-min-height-trailing-park.raku +
      golden/30-…: symmetric test parking the trailing card.
    - xt/snapshots/31-cardlist-min-height-default-renders-sliver.raku +
      golden/31-…: cards with default min-display-height=1 still
      render positive slivers (preserves pre-existing behaviour).
    - xt/snapshots/32-cardlist-min-height-selected-exempt.raku +
      golden/32-…: selected card always renders regardless of
      min-display-height (selected cards are never parked by the
      threshold check).
    - xt/snapshots/33-viewported-cardlist-row-scroll.raku +
      golden/33-…: ViewportedCardList with tall content cards,
      demonstrating row-level scrolling, selection staying
      independent of scroll, and Image children rendering safely at
      partial-clip boundaries.
    - examples/viewported-card-list.raku (new): comprehensive
      ViewportedCardList demo with row scrolling, independent
      selection movement, and Image cards interspersed with text
      cards inside scrollable layouts.
    - META6.json: registered new modules — Selkie::EffectiveBounds,
      Selkie::Tree, Selkie::Layout::Allocate,
      Selkie::Widget::FocusableByDefault,
      Selkie::Widget::ViewportedCardList.
    - README: updated example count (8 → 9); added
      ViewportedCardList demo and HelpOverlay / PasswordStrength
      widget descriptions to the widget-by-widget reference.

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<notcurses_mice_enable> 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<MOUSE SUPPORT>
      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<dismiss-on-click-outside>
      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<click-count> (1 / 2 / 3 for single /
      double / triple presses, 0 for everything else) and a
      C<with-click-count> 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=<class> id=<n> y-bits=<n> x-bits=<n>
      parent=<class> parent-y-bits=<n> parent-x-bits=<n>]` 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. Mindmoor 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
      Mindmoor: `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/<name>.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)
