
# Changelog

All notable changes to Drafter are documented here.
Versions marked with ★ were published to Hex.pm.

## [0.5.0]

### Breaking changes

- **`Drafter.Event.Delegation` is removed** (`delegate_to_children/4`, `delegate_to_parent/3`, `delegate_to_siblings/4`, `broadcast_to_descendants/3`). Address a widget by id through `Drafter.WidgetPidRegistry`, as `Drafter.push_data/2` does.
- **`Drafter.Session.start_isolated/3` is now `start_isolated/2`.** Drop the `driver_config` argument and pass every option, `:driver_pid` included, in `opts`.
- **`:ease_in_out` (and `:ease`, its alias) is the quadratic curve.** Pass `:ease_in_out_cubic` for the previous motion.
- **`Drafter.Clipboard.paste/0` is governed by the paste setting alone.** With `clipboard: [copy: true, paste: false]` it now returns `{:error, :disabled}`; set `paste: true` to read the clipboard.
- **An app started as a session, such as one `Drafter.Server` runs over ssh or telnet, mounts with the props it was given**, not `%{}`. An app that ignored or worked around the empty map receives the real props in `mount/1`.
- **`Drafter.Widget.Chart.Pixel.mode/2` and `Drafter.Widget.Slider.Pixel.mode/1` can return `{:cells, set}`** besides `:pixel`, `:braille` and `:text` (type `t:Drafter.Widget.Chart.Pixel.drawing/0`), and `Chart.Pixel.modes/0` lists `:half`, `:quad`, `:sextant` and `:octant`. A custom widget matching on the result adds a `{:cells, set}` clause, drawn with `cells_strips/3`.
- **iTerm2 draws with its own inline images**, even when it answers the kitty graphics query. Set `FRENCH_CURVE_ITERM2=kitty` to draw with kitty graphics there.
- **A pointer motion report for a button that is not held is a `{:mouse, %{type: :move}}` event with no `:button` key**, not a `:drag`. Hover logic that read Ghostty's motion as `:drag` moves to `handle_hover/3` or a `:move` clause. `Drafter.Terminal.InputBuffer` gains a `:held` field, so a match against the whole struct literal no longer equals it.
- **`Drafter.Runtime.FrameClock.interval_for/1` raises `ArgumentError`** for `"0fps"`, a non-positive integer and any other term it cannot use, where it raised `ArithmeticError` or `FunctionClauseError`. Rescue `ArgumentError`.
- **`Drafter.Widget.RichLog` line numbers count from the first line of the log** in both directions. A test asserting on the numbers shown in a scrolled or reversed log updates its expected text.
- **A scatter or bubble chart is scaled by the y values of its points.** `{x, y}` tuples and `%{x:, y:}` maps are read as points; a chart that wants a fixed axis passes `:min_value` and `:max_value`.
- **`french_curve` is required at `~> 0.2`**, which builds its native code with `make` through `elixir_make`. Update a lock that pins an earlier release.
- **The `:scroll_optimization` option of `Drafter.run/2` and `Drafter.run_session/3` is removed.** Drop it from the call. Every scroll tick renders, paced like any other input, with images kept on screen. `c:Drafter.App.on_scroll_active/1` and `c:Drafter.App.on_scroll_idle/1` are still called.
- **The built-in image widgets export `image_raster/2` instead of `image/3`:** `Drafter.Widget.Chart`, `PieChart`, `Gauge`, `Slider` and `Digits`. `image/4` on `Drafter.Widget.Chart.Pixel` and on `Drafter.Widget.Slider.Pixel` is now `picture/3`, and `render/5` on `Drafter.Widget.Digits.Image` is now `picture/4`; each returns a `t:Drafter.Render.Picture.t/0`, encoded with `Drafter.Render.Picture.encode/3`.
- **`Drafter.Widget.Chart.update_props_from_mount/3` is the default one**, passing the props through.
- **An `:animated` line, area or step chart of one series rotates its points in every renderer**, pixel and braille included, where only the text renderer did.

### Added

- Each ssh session's start and end are logged at notice level: the user and their
  address, and at the end how long it lasted, whether the client closed it, how long
  before the end the client last sent input and output last went out, and the bytes
  sent.
  Each ssh connection's end is logged too, with ssh's reason: the network connection
  closed by the far side, or the disconnect message the client sent.

- A widget picture (`Drafter.Render.Picture`) for a kitty terminal that keeps images
  may ask for `format: :png`, sent as a PNG, and `swap: true`, kept under two image ids
  so each frame is stored under the one not on screen, placed, and only then the old one
  deleted, all as one synchronized update. Neither is on by default, and without them a
  picture is sent as before.

- A warning is logged when a terminal has taken no output for 5 seconds, frames being
  skipped meanwhile, and again when it takes output once more; and when an ssh session
  that does not end in time is killed, with where it was.

- `Drafter.Server.start_ssh/2` takes `:send_buffer`, the bytes of each connection's
  send buffer, and `:login_timeout`, the milliseconds a client has to log in.

- **`c:Drafter.Widget.image_raster/2`** — a widget returns a `t:Drafter.Render.Picture.t/0` (a `FrenchCurve.Raster`, the protocol to draw it with and the cell box it fills) or `nil`, and drafter encodes it, moves it with the widget and shows only its visible part when it is partly scrolled out of view. `Drafter.Widget.draws_images?/1` says whether a module draws images by either callback.
- **`Drafter.Render.Picture`** — encodes a widget's raster for `Drafter.Compositor.put_image/4`: stored once under its kitty id where the terminal keeps images, or the visible part alone for iTerm2 and sixel.
- **`Drafter.Compositor.place_image/4`** takes the window an image may be drawn in, and a region given to `put_image/4` may carry `:crop` naming a stored kitty image, which the compositor moves and crops with placements alone.
- **A changed row is written as the runs of cells that changed** (`Drafter.Render.RowDamage`), joined where they are close and written whole where most of it changed, rather than always as the whole row.

- **Game controllers** — `use Drafter.App, controller: true` delivers `{:controller, event}` to `handle_event/2`: connections, buttons and axes in the Xbox layout. The reading is done by the separate `gamepad` package, which an app that wants controllers adds to its deps; drafter itself gains no build step. Local sessions only. `Drafter.terminal_opts/1` reports the setting as `:controller`.
- **Block-character rendering for charts and sliders** — `renderer: :half | :quad | :sextant | :octant` (also as `DRAFTER_MODE` values and the `mode:` run option) draws the same raster the pixel path builds as block characters of that `FrenchCurve.Backend.Cells` glyph set, two colours per cell, through `Drafter.Widget.Chart.Pixel.cells_strips/3` and `Drafter.Widget.Slider.Pixel.cells_strips/3`. Octants (Unicode 16) match braille's resolution with solid pixels where the terminal or font draws them.
- **`Drafter.Widget.Chart.Pixel.cell_sets/0`** — the glyph sets accepted as modes, and the `t:Drafter.Widget.Chart.Pixel.drawing/0` type naming what `mode/2` returns.
- **`Drafter.Draw.Strip.from_cells/1`** — a strip from one row of `{glyph, fg, bg}` cells, with neighbouring cells in the same two colours joined into one segment.
- **`Drafter.Accounts.list/1`** — every account in registration order, each with its `:number`, username and props.
- **`examples/spark/35_isometric_town.exs`** — an isometric and a first-person raycast view of one town, drawn as pixels or in any block-character set, with mouse targeting and game controller input.

### Changed

- The compiled native code is built into and loaded from
  `priv/native/<system architecture>` (`Drafter.Native`) instead of `priv/`, so builds
  for macOS and Linux in one checkout no longer overwrite each other. `mix.exs` passes the
  folder to `make` as `PRIV_DIR`.

- **An image handed to the compositor with the same bytes and placement as the one it shows is not sent to the terminal again.** `Drafter.Compositor.put_image/4` keeps a digest of each region's bytes and bumps the region's version only when the bytes or placement differ.
- **The `x` and `y` of `c:Drafter.Widget.handle_press/3`, `handle_mouse_up/3` and `handle_drag/3` are documented as cells relative to the widget's rect**, which is what they are.
- **A pie chart's raster covers only the circle's bounding square**, so a large pie costs less to draw.

### Fixed

- A widget server stops when a process of the session it was started in ends, so a widget outliving its session no longer renders against a stopped theme manager and logs an error.
- Elixir syntax highlighting works on Elixir 1.20, whose tokenizer returns three elements
  where 1.19's returns two; on 1.20 every snippet came back uncoloured.

- `Drafter.Server.stop_ssh/1,2` returns `:ok` when the daemon ends by itself while it is
  being stopped, as it does once its port is closed and its last session ends; it exited
  with `:noproc` or `shutdown`.

- `End` and `PageDown` on an empty data table do nothing; they set the selection to row
  `-1`.

- `Drafter.Test.send_click/2,3` sends a press and then a release, as a terminal reports a
  click, so widgets that act on the press, such as an option list, can be clicked in tests.

- `examples/*/15_screens.exs`: the confirm modal reports `:cancelled` when it is closed
  with `Esc` or `No`, not only with `Enter`; `15_screens` and `widgets` show "none yet"
  instead of `nil` before anything is chosen.

- A theme or skin change redraws every widget at once (`Drafter.WidgetServer.restyle/1`). A
  widget whose props had not changed kept the old theme until something else redrew it,
  such as the header until its clock's next second.
  An app returned to after another one changed the theme is redrawn in the new theme.

- A local app stops when its terminal goes away: when stdin ends or fails, the loop
  that called `Drafter.Terminal.Driver.start_input/0` is sent `:drafter_end_session`.
  The input reader stopped silently, leaving the app running with no keys.

- `Drafter.Server.stop_ssh/1,2` ends each connected session before stopping the
  daemon: the client's terminal leaves the alternate screen with mouse and keyboard
  reporting off, a notice is printed (`stop_ssh/2`; default "The server closed this
  session."), and the connection closes. A client was left on its last frame.
  Stopping the application does the same for every ssh session ("The server
  stopped."). `Drafter.Server.stop_ssh_listening/1` closes the port alone, leaving
  connected sessions running; the message `:drafter_end_session` ends a session's
  app loop and every app beneath it.

- `use Drafter.App` appends its catch-all `handle_event/2` and `on_timer/2` clauses only when
  the module has no catch-all of its own, so a module that writes one compiles without a
  redundant-clause warning.
- **Images in a scrollable stay on screen while it scrolls.** A partly visible chart shows its visible rows, cropped from the full picture. On a kitty terminal a scroll sends placements and no pixels; on iTerm2 and sixel only the visible rows are encoded.
- **A widget scrolled into view is drawn on the tick it appears**, not 150 ms after scrolling stops.
- **A chart that is not animated keeps the same state while its data does not change**, so its picture is not drawn again on every render of the app. An animated chart redraws once per animation frame.
- **An image the compositor can no longer draw is taken off the terminal** with its clear sequence, rather than left where it was last drawn.
- **Text under an image is written when it changes, and the image is placed again over it in the same frame.**
- **Data pushed with `Drafter.push_data/2` or `push_data_many/2` is drawn without waiting for something else to redraw the widget**; a burst of pushes becomes one render.
- **Home then Page Down no longer crashes a scrollable widget.**
- **A widget created under a previously used id draws its image at its own height**, not clipped to the old widget's visible region.
- **A trait declaring `:click` in `handles/0` receives clicks.**
- **`Drafter.Pty.close/2` with `:kill_after` does not signal a reused process id.** It slept and then sent `SIGKILL` without checking the program was still there; it now probes the group with signal `0` first.
- **A widget scrolled into view draws its image without waiting for another redraw.**
- **`Drafter.focus/1` and `Drafter.blur/1` called in `on_ready/1` take effect in the first frame**, for an app started by `Drafter.run/2`, a `Drafter.CellSession`, or a pushed app, whatever input arrives meanwhile.
- **A widget is rendered once per frame, not twice.** An unchanged position no longer re-renders a widget before its new props arrive, so a widget with an expensive render no longer falls behind its input.
- **No image bytes reach the terminal after it is restored on exit.** A widget's image task finishing during shutdown could hand a picture to the compositor after `Drafter.Terminal.Driver.cleanup/0` had left the alternate screen, and the escape sequence printed as base64 on the normal screen. `Drafter.run/2` now flushes and stops the compositor before restoring the terminal.
- **A terminal that answers the startup probe without naming a protocol is judged by its environment.** `Drafter.Widget.Chart.Pixel.protocol/1` took `{:ok, nil}` from the probe as final, so an iTerm2 that did not answer the version query drew charts, sliders and every image widget that asks it as text; it now falls back to `FrenchCurve.Capability.detect/1` as it already did for a terminal never probed. `Drafter.Widget.Chart.Pixel.terminal/0` exposes the answer.
- **Tab after a click on a widget that handles presses but is not focusable no longer crashes the event router.**
- **A frame's image bytes are written inside synchronized-update markers of their own.** They stay a separate write after the text, so a slow terminal still delays nothing, but a terminal that draws images as cells (iTerm2) no longer shows the erased region between taking the old picture down and putting the new one up.
- **A widget switched off its picture by props shows its glyphs** instead of the withdrawn image.

## [0.4.0]

### Added

- **Key release events** — `use Drafter.App, key_release: true` turns on the kitty keyboard protocol in the terminal of every session of that app, locally and over SSH and telnet. A press is then followed by `{:key_down, key, modifiers}`, a release arrives as `{:key_up, key, modifiers}`, and `{:key_release_support, true}` is delivered once the terminal confirms it speaks the protocol; `Drafter.Session.Context.key_release?/0` reports the same. `key` is the unshifted key, so a press and its release name the same thing. The `{:key, ...}` stream is unchanged, and an app that does not opt in sees exactly what it saw before. `Drafter.Terminal.KittyKeyboard` holds the sequences and the parser.
- **`use Drafter.App, frame_pacing: :always`** — draws at most once per `refresh_rate/0` interval whatever caused the change, so a burst of events or timer ticks becomes one frame showing the latest state. The default `:animations` paces only animation frames, as before.
- **`Drafter.Accounts`** — a file-backed account store with PBKDF2 password hashes, per-account props, case-insensitive unique names, an equal cost for unknown names and wrong passwords, and a per-peer lockout after repeated failures. `Drafter.Server.start_ssh/2` takes `auth: {:accounts, server}` to check logins against it, and with `register_as: "new"` (the default) a client connecting as that user is shown `Drafter.Accounts.RegisterApp`, creates an account, and continues straight into the app under the new name. An account's props are merged into its sessions' mount props.
- **`Drafter.Server.start_ssh/2` takes `tunnel: true`** to accept `ssh -R` reverse forwards, and `auth:` entries of the form `{username, password, props}` whose map is merged into that user's mount props.
- **`use Drafter.App, cell_size: true`** — asks each session's terminal how many pixels a cell is (`CSI 16 t`); the answer arrives as `{:cell_size, {width, height}}` and `Drafter.Session.Context.cell_size/0` keeps it. `Drafter.Terminal.Reports` holds the query and its parser.
- **`Drafter.blur/1`** — takes focus away from a widget and keeps it away: a render no longer re-focuses the first focusable widget after an explicit blur, until something is focused again by the app, a click, or Tab.
- **`Drafter.set_refresh_rate/1`** — changes a running app's refresh rate; pacing and tick-based image throttles follow from the next frame.
- **`image_priority:` on any widget with an `image/3` callback** — the scheduler priority its image tasks run at (`:low`, `:normal`, `:high`), alongside the existing `image_throttle:`. The default stays `:low`, or `DRAFTER_GEN_PRIORITY`.
- **`Drafter.terminal_opts/1`** — the `:mouse_hover`, `:key_release` and `:cell_size` settings an app module declares, as transports read them.
- **The accounts file is text** — one Erlang term per account, read by `:file.consult/1`; a binary file from before is read and rewritten as text.
- **`start_ssh`'s `:ip` says exactly what to bind** — an address, `{0, 0, 0, 0}` for every IPv4 interface, `{0, 0, 0, 0, 0, 0, 0, 0}` for every IPv6 one, `:any` for both families, or a list of these bound by one daemon each on the same port (`{:ok, [pid]}`; `Drafter.Server.stop_ssh/1` stops either shape). A host reachable over IPv6 whose daemon was IPv4-only refused clients that resolved it to the IPv6 address.
- **`Drafter.Accounts` numbers its accounts and takes `default_props:`** — a function from an account's number (0, 1, 2, … in registration order; a file from before numbering is numbered in name order when loaded) to props every account has unless its own say otherwise, so a server can hand each account something of its own.
- **`label/2` takes runs** — a list of `{text, style}` pieces drawn side by side on one line as one widget, each in its own style over the label's; a line of many colours no longer needs a `horizontal` of many labels.

### Changed

- **SSH and telnet drivers write synchronously.** A frame is handed to the connection before the next is composed, so a client that cannot keep up sees fewer, complete frames instead of a growing backlog.
- **Key replies sent before the app loop starts are kept.** A session subscribes to its event manager before the terminal probe, so answers that arrive during the probe reach the app.
- **The telnet driver parses what arrived during the probe at once** rather than at the next keypress.

### Fixed

- **Release support is claimed only when the terminal reports event types.** Any reply to the kitty keyboard query counted as "releases are reported", so a terminal that speaks the protocol without event types (bit 2 of its flags) — Alacritty — left apps waiting for `key_up` events that never came, and held keys stuck. The reply's flags are read now; `{:key_release_support, false}` says the terminal answered without them.
- **Text beside an image no longer tears it.** A changed row an image lies on is written around the image, the cells it covers left alone, instead of over it with the image re-sent afterwards — on iTerm2 and sixel the rewrite erased a band of the image until the resend arrived, a flicker on every text change beside it, and the resend itself was the cost of a whole image per text change.
- **`unmount/1` is called** — once, with the app's last state, on every way an app stops: `{:stop, reason}` from a callback, the global quit key, the runtime's `:shutdown`, and a linked process exiting. It was documented and never called.
- **An arrow key with nothing focused reaches the app.** It used to focus the first focusable widget — a chat log or an input the app never asked to focus — and vanish. `Drafter.blur/1` now keeps focus away even when the widget it names is not focused or not drawn yet, and `Drafter.focus/1` on a widget not drawn yet takes effect once it appears. `scrollable/2`'s `focusable: false` is honoured, as its docs said.
- **A refused password is a denial, not an "Internal error".** With `auth: {:accounts, _}`, a wrong password or an unknown user crashed the daemon's password check on its first refusal (the failure count started as `:undefined`), so the client was disconnected with `Internal error` instead of `Permission denied, please try again`; only a correct password on the first try ever got in.
- **An SSH session ends when its input does** — a dropped connection reaches the app as `:shutdown`, as a dropped telnet connection already did, so the app's `unmount/1` runs and nothing it started (processes, connections) outlives the session.

- **Bound values are current for the next event.** A `bind:` update is applied before the following event is handled, so an app-level handler reading bound state after a burst of keystrokes sees all of them.
- **A focused text input no longer swallows keys it cannot type.** `:escape`, function keys and other named keys bubble to the app instead of being dropped.
- **`Drafter.Test.sync/1` waits at every hop.** It returns once the headless driver has forwarded every injected event and the frame the app drew for it is on the driver — `send_key/3` and the other input helpers with it — so a second key can no longer overtake the blur or focus change the first one asked for, and `HeadlessDriver.get_render_count/0` right after an input counts that input's frame. `Drafter.Compositor.sync/1` is new.
- **A lone Escape over SSH is delivered** after the input buffer's flush interval rather than waiting for the next byte.
- **SSH session cleanup writes to the session's channel**, not the server's `/dev/tty`, so mouse tracking and the keyboard protocol are turned off in the client's terminal.
- **The local driver writes every control sequence straight to `/dev/tty`.** Setup, mouse toggles, terminal queries and app writes no longer go through the BEAM stdio server, where they waited behind the stdin reader's blocking read until the terminal happened to send a byte; the graphics probe's queries therefore reach the terminal before the probe's deadline in every terminal, not only in those that answer something else first.
- **Terminal queries are sent after the local driver's stdin drains.** The kitty keyboard and cell size queries are written by `Drafter.Terminal.Driver.query_terminal/0` once input starts, so their replies are no longer discarded with the stale bytes a local session flushes before its first frame; `key_release?/0` and `cell_size/0` are answered in local sessions as they were over ssh.
- **A control sequence the parser does not know is consumed**, not delivered as the escape key followed by its bytes. A terminal's reply to a query drafter did not ask, or asked after a probe had finished, no longer reads as `Esc` plus typed characters.
- **Every byte for the terminal goes through `Drafter.Render.Writer`**, one process per session, so no escape sequence is ever cut by another write; a frame's image bytes follow its text, outside the synchronized update, and never block the compositor: a terminal slow to accept an image (iTerm2 decodes each inline image before reading further) delays neither text nor input handling, and while it is busy only the newest image is kept for it. Trace lines `V` record each image write.

## [0.3.0] - 2026-08-03

Upgrading from 0.2.x: see **Removed** and the first three **Changed** entries. An
application that only uses `Drafter.App`, the widget constructors and `Drafter.run/2`
needs no source changes; a custom widget that paints images or declares traits does.

### Added

- **`Drafter.Pty.close/2` takes `:kill_after`** — milliseconds to wait before sending `SIGKILL` to the program's process group, for a program that ignores the `SIGHUP` that releasing the descriptors sends. The signal goes to the group, so children the program started go with it. `close/1` sends nothing further.
- **`Drafter.Terminal.TermiosNif.load_error/0`** — why the native library is not in use, or `nil` when it loaded, carrying the path that was tried and the runtime's reason for refusing it. The loader leaves the Elixir fallbacks in place rather than stopping the module from loading, so a load failure otherwise showed up only as `:nif_not_loaded` at a later call. `Drafter.Pty.spawn/2` now returns `{:error, {:nif_unavailable, reason}}` in that case instead of raising.
- **`Drafter.ScrollMath.visible_range/3`, `scroll_by/4`, `page/4` and `to_oldest/2`** — the operations a widget rendering virtual rows needs, so a large row set is windowed without materialising what falls outside it.
- **`slider(opts)` widget** — a draggable value slider with a track, a fill and a thumb. Arrow keys move one step, `PageUp`/`PageDown` ten, `Home`/`End` jump to the ends of the range, and a press or drag anywhere on the track moves the thumb there (the gesture keeps tracking once the pointer leaves the widget). Supports `min`/`max`/`step`, `bind:` two-way binding, `on_change`, a label and a formatted readout that reserves the widest value in the range so the track never shifts, `:disabled`, per-part colour overrides, and `:horizontal` / `:vertical` orientation. An integer range keeps integer values; any other range works in floats rounded to the decimals the step implies. `Drafter.get_widget_value/1` and `set_widget_value/2` read and write its number.
- **Slider rendering through `french_curve`** — `renderer: :braille` draws the rounded track and disc thumb as braille cells, and a graphics protocol (`:auto`, `:pixel`, `:kitty`, `:iterm2`, `:sixel`) transmits it as a picture, falling back to braille where the terminal has none. The default `:text` renderer draws characters from the active skin, which now carries a `slider` glyph group.
- **A font catalogue for `digits(value, opts)`** — `font:` selects `:block` (7×5 box-drawing outlines, the default), `:compact` (5×3), `:tall` (8×4 half blocks), `:pixel` (4×4 quadrants) or `:braille` (4×4). Every built-in font covers digits, upper and lower case, and common punctuation, so swapping one for another never drops characters, and a character a font cannot draw renders as blanks of the font's widest glyph rather than raising. `Drafter.Widget.Digits.Font` exposes `height/1`, `glyph_width/2`, `text_width/2` and `supports?/2` for laying out around a headline. See the [Large Text guide](guides/large_text.md).
- **FIGlet fonts in `digits`** — `Drafter.Widget.Digits.Figlet.load/1` reads a `.flf` file and `Drafter.Widget.Digits.Font.register/2` names it, after which `digits("Vellum", font: :slant)` works like any built-in. FIGlet fonts are proportional, so measure with `text_width/2` rather than assuming a cell.
- **`digits` can render as a transmitted image** with `renderer:`, using the same graphics protocols as charts and sliders.
- **`31_font_catalogue.exs` and `32_slider.exs` examples.** The `widgets.exs` showcase and the theme sandbox gallery gained sliders too.
- **`Drafter.Clipboard`** — `copy/2` writes OSC 52 to the terminal the session is attached to, so a copy from an app served over SSH or telnet lands on the *client's* clipboard, and additionally writes the local clipboard through `pbcopy`, `clip`, `wl-copy`, `xclip` or `xsel` when one is on `PATH`. `paste/0` reads the clipboard of the machine the app process runs on. Both are configured by `clipboard:` in `Drafter.run/2` or `config :drafter` — `false` makes them no-ops returning `{:error, :disabled}`, and a keyword list sets the two directions separately (`clipboard: [copy: true, paste: false]`). Key bindings come from `:clipboard_keys`.
- **A widget receives pasted text** by declaring `handles: [:paste]` and implementing `c:Drafter.Widget.handle_paste/2`.
- **`Drafter.Pty`** — runs a program on a pseudoterminal and hands its byte stream to the caller. `spawn/2` allocates the pty, starts the program in a new session with the pty as its controlling terminal, and returns a handle whose ports deliver `{:data, bytes}` and `{:exit_status, status}` straight to the calling process, with no relay in between. `write/2` feeds standard input and `resize/3` sets the size, which makes the kernel deliver `SIGWINCH` to the program.
- **Pluggable character-width tables.** `Drafter.CharacterWidth` measures per grapheme cluster and is the single point every width measurement goes through — strip widths, truncation, wrapping, cursor placement and the compositor's column arithmetic. A host that owns the grid Drafter draws into can supply its own tables with `config :drafter, character_width: MyTables`; the setting is read with `Application.compile_env/3`, so the calls compile to direct dispatch.
- **`refresh_rate` accepts a frame-rate string** — `"30fps"`, `"7.5fps"`, `"unlimited"`, `:unlimited`, or a millisecond integer, in `Drafter.run/2` or the app's `c:Drafter.App.refresh_rate/0`. Anything else raises `ArgumentError` rather than pacing at a silently wrong rate.
- **`Drafter.Test.screen_text/1` and `screen_lines/1`** — what is on screen as plain text, so a test can assert on rendered output instead of only on state. `sync/1` blocks until the app has drained what it was sent.
- **Docking in layout** — a child carrying `dock: :top | :bottom | :left | :right` is taken out of the normal flow and given the full span of that edge; the remaining space is what its undocked siblings share. `footer` docks to the bottom without being asked.
- **Translucent colours.** A colour may carry an alpha component, which the compositor blends against what is already in the cell rather than replacing it.
- **`radio_set(options, opts)` takes `:width`**, which fixes the column width when `:cols` lays the options out in a grid.
- **`c:Drafter.Widget.image_active?/1`** — an optional callback saying whether a widget is painting a transmitted image right now.
- **`Drafter.Compositor.write_raw/1`** — writes bytes to the terminal through the compositor, so output from an embedded program interleaves with the frame rather than racing it.
- **`handles: [:click]`** is accepted as a spelling of `handles: [:press]`.
- **Terminal resize is driven by `SIGWINCH`**, so a window change is picked up as it happens rather than at the next poll.
- **The terminal is asked what graphics it supports instead of being guessed at.** At startup Drafter writes XTVERSION (`CSI > q`) and primary device attributes (`CSI c`) and reads the answers, which name the terminal and list its features. This settles kitty, iTerm2, WezTerm, ghostty and sixel support from the terminal itself, so it is right under a multiplexer, over ssh and over telnet alike — none of which carry the environment variables a guess depends on. A terminal that answers neither query within 250 ms falls back to the environment as before. `Drafter.Terminal.Probe` performs the exchange; `FrenchCurve.Capability.probe/0` and `from_probe/1` say what to send and what the answers mean.
- **`Drafter.Session.Context.terminal_env/0` and `terminal_protocol/0`** — the terminal a session is attached to, carried between processes by `capture/0` and `adopt/1` like the rest of the context, and falling back to the host's environment outside a session. `Drafter.run_session/3` sets both from `:terminal_env` and `:terminal_protocol` keys on the session context a transport builds.
- **Telnet negotiates TERMINAL-TYPE**, so a telnet client's `TERM` is known even when it does not answer the graphics probe. A client that answers neither starts anyway after 250 ms.
- **`Drafter.CellSession.take_lines/1` and `take_text/1`** — the cell grid as plain text, styling dropped and trailing blanks trimmed. The same view `Drafter.Test.screen_lines/1` gives of a headless app, so a host embedding a session can assert on or log what is displayed without flattening strips itself.
- **README sections for embedding and number formatting.** `Drafter.CellSession` and `Drafter.Format` were documented on the modules but named nowhere a reader would find them.
- **Two new guides** — [Large Text](guides/large_text.md), on choosing a `digits` font and how the fonts are built, and [Design Notes](guides/design_notes.md), on how the internals fit together, for anyone writing widgets or changing the framework.

### Changed

- **`Drafter.WidgetHierarchy` is documented.** `Drafter.Test.get_widget_hierarchy/1` returns it and the README describes its fields, but the module was marked internal, so the struct a test is handed had no reference to read.
- **Element examples render again in the published docs.** A doctest written inside an option list was absorbed into that list, and its result line — `{:label, "Ready", []}` and fourteen others — was parsed as a Markdown attribute list and dropped. The examples always ran; only the rendered page lost them.
- **`header(title, opts)` no longer shows a clock unless asked.** `:show_clock` now defaults to `false`, so a header draws a title and starts no timer of its own. Pass `show_clock: true` for the previous behaviour.
- **`option_list` draws its `▶` cursor only while focused**, and takes `:focused` as a mount option. Several lists can now sit side by side — arrow keys move between them and the highlight of an unfocused list stays put without competing for the eye. `Drafter.get_widget_state/1` reports the field.
- **`Drafter.Compositor.put_image/4`** takes the placement map a widget's `image/3` returns (`%{dx:, dy:, cols:, rows:, stamp:, place:}`) instead of nine positional arguments. Custom widgets that transmit images must update their calls.
- **Widget traits are declared with `use Drafter.Widget, traits: [...]`.** The Spark `traits do ... end` block is gone, and with it Drafter's dependency on a DSL compiler for widget definitions. A widget that used the block moves its trait list into the `use` options.
- **`use Drafter.App` appends its catch-all `handle_event/2` and `on_timer/2` clauses after a module's own clauses** rather than defining them ahead of an override. A module that already ends with its own catch-all keeps working; one that never wrote a catch-all now falls through to `{:noreply, state}` instead of raising `FunctionClauseError` on the first unnamed key. A module whose own catch-all is followed by more clauses will now see an unreachable-clause warning where the clauses were previously silently dropped.
- **A widget that draws characters costs nothing on the image path.** The renderer and the widget server ask `Drafter.Widget.image_active?/2` before placing or generating an image, so a chart, gauge, digits or slider in a `:text` or `:braille` mode never enters it. A widget that was painting and stops has its image withdrawn once at the transition rather than re-cleared every frame.
- **Widget value reads and writes go through `Drafter.WidgetValue`**, so a value read through the app loop and one read directly with `Drafter.get_widget_value/1` always agree.
- **Per-session services are resolved from the calling process rather than from a global name.** `Drafter.Session.Context` holds the association, with `capture/0` and `adopt/1` for carrying it to another process, and falls back to a globally registered process so a widget can still be rendered outside any session. Concurrent SSH and telnet sessions no longer see each other's widget registrations.
- **The framework's ETS tables are owned by a supervised process** for the lifetime of the application, so a crash in the process that happened to create one no longer takes the table with it.
- **Terminal input is buffered across reads**, so an escape sequence split across two reads — common on slow links and under multiplexers — is reassembled instead of being delivered as garbage keys.
- **`Drafter.Test`'s `send_*` functions block until the app has finished handling the input**, so a send and the assertion after it need no sleep between them.
- **The API documentation has been rewritten throughout**, covering every public callback, element constructor, option and return value, with executable examples. The README now documents `Drafter.run/2`'s options, the two `handle_event` arities and which events reach each, screen types and their option defaults, selector syntax, and headless testing. Several previously documented behaviours were wrong — notably `Ctrl+C` was never a global quit key; `Ctrl+Q` is, and `Ctrl+C` is delivered to the app and is the copy binding inside text widgets.

### Removed

- **`Drafter.Examples.*` and the `lib/drafter/examples/` tree.** Twenty-one demo modules compiled into the library and shipped to every application that depended on it, several of them scratch files (`button_debug`, `button_test`, `scrollable_test`, `input_debugger`) and most of them stale duplicates of the maintained scripts under `examples/`. The two with no script counterpart are now `examples/spark/33_css_styling.exs` and `examples/spark/34_breakpoints.exs`.
- **`Drafter.Util`.** `normalize_class/1` and `normalize_classes/1` moved to `Drafter.Style`, which is where the `:class` option they serve belongs; `safe_to_existing_atom/1` had no callers.
- **`Drafter.Transport.SSHChannel`.** An unused `:ssh_server_channel` implementation. The ssh transport runs an app through an OTP `shell:` function, which the runtime graphics probe reaches as readily as a custom channel would, so nothing needed it.
- **`Drafter.TreeDiff`, `Drafter.Widget.DirtyTracker`, `Drafter.Widget.Trait.Dsl` and `Drafter.Visualization.BinarySearch`.** These were internal to render-path bookkeeping and the trait DSL, and nothing replaces them at the call site: fingerprinting and dirty tracking now live in the compositor's per-row cache, and traits are declared in `use Drafter.Widget`.

### Fixed

- **Terminals that speak sixel are detected.** Konsole, foot and mlterm drew braille where they could have drawn images: Drafter carried its own copy of the terminal rules, and that copy knew no sixel terminal at all. Detection now goes through `FrenchCurve.Capability.detect/1`, which also recognises kitty and ghostty from `TERM` alone — a kitty session that does not export `KITTY_WINDOW_ID`, such as one inside a multiplexer, previously fell back to braille.
- **A remote session detects the connecting client's terminal rather than the host's.** Every ssh and telnet session read the environment of the machine the server runs on, so a server started under a plain shell served braille to every client whatever they were running, and a server started inside kitty sent kitty escape sequences to clients that could not draw them.
- **Switching a chart off a pixel renderer clears the graphic it already sent**, instead of leaving the old image on screen underneath the new glyphs, where the two appeared to alternate as rows repainted.
- **Escape sequences in pasted text never reach a widget's buffer.** A paste is sanitized where it is handed to the focused widget, so it applies to `text_input`, `text_area` and `file_picker` as well as to widgets declaring `handles: [:paste]`. Ordinary text and the newlines of a multi-line paste come through unchanged.
- **A widget rendered into a rect too small to draw in produces strips that fit it rather than raising** — reachable in a narrow split, a collapsed pane, or a small terminal.
- **A rapid second click is reported as consumed**, so a double click no longer leaks its second half to the widget underneath.
- **Dragging a `data_table` scrollbar** no longer also selects the row released on, no longer leaves the table stuck in drag mode after a plain click, and clamps at the ends instead of overscrolling. A table dragging its scrollbar captures the pointer, so the drag keeps tracking once the pointer leaves the table.
- **A scrollbar thumb round-trips**: the row a thumb is drawn at maps back to the offset that drew it, so dragging a thumb no longer drifts against the content.
- **A hovered `switch` is drawn differently**, rather than only recording that it is hovered.
- **A widget scrolled entirely off screen is marked invisible**, so it stops transmitting images that would be clipped away.
- **A slider's `:renderer` can change after mount**, so an app switching render modes at runtime moves its mounted sliders with it.
- **A telnet session registers its loop while it runs and leaves none behind once it closes**, and the last of several rapid keystrokes reaches the wire.

## [0.2.11] - 2026-06-29

### Added

- **`Drafter.set_widget_value/2`** — imperatively set a widget's value on demand (text widgets take a string, `checkbox` a boolean). The discrete counterpart to `get_widget_value/1`; routes through the widget's own `update/2` so text widgets reclamp the cursor. Lets a client own a self-managed input widget and write into it at specific moments rather than mirroring state on every keystroke.

### Changed

- **Per-keystroke render cost greatly reduced for editor-heavy UIs.** Incremental compositing now tracks dirty regions **per row** (a cache-key per strip) instead of per widget, so editing one line recomposites a single row instead of the whole widget; and a keystroke that changes neither app state nor layout now skips the component-tree reconcile via the render fast path.
- **`get_widget_value/1` and `get_widget_state/1` read the widget's `WidgetServer` directly** through the widget registry instead of round-tripping a message through the app loop. They are now deadlock-free and safe to call from inside app event handlers.

## [0.2.10] - 2026-03-27

### Added

- **Weighted scatter points** — scatter data points accept an optional weight component (`[x, y, weight]` or `{x, y, weight}`) where weight is a float 0.0–1.0. Higher weights produce denser braille dot clusters and brighter colors, providing visual density feedback for clustered data.
- **`fill_opacity` chart option** — controls the brightness of area fill relative to the series edge color, from `0.0` (invisible) to `1.0` (same as edge). Default `0.6`. Applies to `:area` and `:braille_area` chart types.
- **Braille area example** — `26_braille_area.exs` demonstrates stacked braille area charts with per-series opacity.
- **SSH anonymous authentication** — pass `auth: :anonymous` to `Drafter.Server` to allow anonymous SSH connections (still need password, but do not need to be registered).

### Changed

- **Adaptive color depth in braille area charts** — fill color now scales inversely with span thickness, producing more natural gradient fills for stacked series.

## [0.2.3] - 2026-03-24

### Added

- **SSH chat channels** — the `ssh_chat.exs` example now supports multiple chat rooms. `/join #channel` switches rooms, `/channels` lists active rooms, `/help` shows available commands.
- **`scrollable/2`: `focusable: false` option** — excludes a scrollable container from the tab/focus cycle on a per-instance basis.

### Fixed

- Various bug fixes and rendering improvements across SSH, layout, focus management, and screen lifecycle.

## [0.2.2] - 2026-03-24

### Added

- **CJK / multi-byte character input** — Japanese, Chinese, Korean, and emoji characters can now be typed into text inputs and text areas.
- **Braille area chart** — new `chart_type: :braille_area` renders smooth stacked area charts using braille characters with per-series color blending. Ideal for live metrics dashboards.
- **Collapsible focus behaviour** — children of collapsed sections are automatically excluded from keyboard navigation. Expanding a section makes its children navigable again.

### Fixed

- **Arrow key navigation** — arrow keys now correctly move focus between widgets. A regression had silently disabled all arrow-based navigation.
- **Text input race condition** — rapidly typed characters no longer get dropped due to async binding updates overwriting widget state.
- **Split pane resize crash** — dragging a pane to a very narrow width no longer crashes the box widget.
- **Action handlers in sub-apps** — custom action handlers registered before `Drafter.run` now work correctly when launched from the example gallery or any push-session context.

### Changed

- **NIF compilation** — switched from a custom build script to `elixir_make` with a standard Makefile. Run `mix deps.get` after upgrading.
- **Session isolation** — ScreenManager, ThemeManager, EventHandler, and Event.Manager are no longer started as global named processes. Each session creates its own instances, preventing state leakage between concurrent SSH/telnet sessions.
- **Event.Manager simplified** — removed the internal queue; events are dispatched directly in `handle_cast`, matching standard GenServer semantics.
- **SkinManager** — character set selection is now per-session instead of global, avoiding cross-session interference and `persistent_term` global GC.

### Removed

- **FocusRegistry** — unused global keybinding store that would have caused bugs with multiple SSH sessions.
- **Event.CustomRegistry** — unused runtime schema validation registry. Use `defstruct` and pattern matching instead.
- **Event.Processor** — stub module with unimplemented functions; all functionality lives in WidgetHierarchy.

## [0.2.0] - 2026-03-21

### ⚠ Breaking Changes

#### `Drafter.set_interval/2` — new unit-aware API

The second argument to `set_interval` is now a **unit atom** that also serves as
the timer ID passed to `on_timer/2`. Any app using `set_interval` must update
both the call site and the matching `on_timer` clause.

**Before:**
```elixir
def on_ready(state) do
  Drafter.set_interval(33, :my_timer)
  state
end

def on_timer(:my_timer, state), do: ...
```

**After — choose the unit that matches your intent:**
```elixir
def on_ready(state) do
  Drafter.set_interval(30, :fps)   # 30 fps  → ~33 ms interval
  # or
  Drafter.set_interval(500, :ms)   # 500 ms interval
  state
end

def on_timer(:fps, state), do: ...
# or
def on_timer(:ms, state), do: ...
```

The available units are:

| Unit | Meaning | Example |
|------|---------|---------|
| `:fps` | fires N times per second | `set_interval(30, :fps)` → ~33 ms |
| `:ms` | fires every N milliseconds | `set_interval(500, :ms)` → 500 ms |
| `:tick` | alias for `:ms` (backward compat) | `set_interval(500, :tick)` |

The timer ID used in `on_timer/2` is always the **unit atom** you passed, not a
separate name. If you need two independent timers, use two different unit atoms
(or combine `:tick` for one and `:fps`/`:ms` for the other):

```elixir
def on_ready(state) do
  Drafter.set_interval(30, :fps)
  Drafter.set_interval(1000, :ms)
  state
end

def on_timer(:fps, state), do: ...   # animation tick
def on_timer(:ms, state), do: ...    # slow poll
```

> **Apps using `send(self(), {:set_interval, ms, id})` directly** must switch to
> `Drafter.set_interval/2`. Direct sends bypass session isolation and can cause
> timers from one session to fire in the next.

### Added
- `run_examples.exs`: example gallery now starts `TreeSitterDaemon` automatically,
  so syntax-highlighted examples (e.g. `code_browser`) work without any extra flags
  when launched from the gallery

### Fixed
- Timer events from a finished session no longer bleed into the next session — stale
  `{:timer, _}` messages are drained from the process mailbox when a new session starts
- `Drafter.run/2` called with `syntax_highlighting: true` from inside a running app
  (gallery → sub-example) now correctly starts `TreeSitterDaemon`; previously the
  tree-sitter daemon was only started for the root `run/2` call, so sub-sessions
  always fell back to plain-text rendering
- `set_interval` called during `on_ready` is now captured synchronously and immune
  to race conditions when the gallery launches two examples in quick succession
- Gauge example `+`/`-` keyboard shortcuts now work (previously only the on-screen
  buttons worked); the `keybindings` hint was declared but the key handlers were missing

## [0.1.28] - 2026-03-20

### Added
- `run_examples.exs` — interactive example gallery; run with `elixir run_examples.exs`
  to browse and launch all bundled examples. Returns to the gallery after each example
  exits. Note: the SSH example terminates the launcher on `Ctrl+C` — this is expected.
- `Gauge` widget — semi-circular arc gauge rendered with braille characters; colour
  transitions through configurable low/mid/high thresholds as value increases.

  ```elixir
  gauge(value: 0.72)
  gauge(value: cpu_usage, label: "CPU", low_threshold: 0.6, high_threshold: 0.8)
  ```

- `CodeView`: `hex_view: true` prop — displays binary files as a hex dump instead of
  attempting text rendering. The `code_browser` example enables this automatically for
  non-text files.

### Fixed
- Calculator keyboard input dropped characters and missed button animations (PR #3,
  nshkrdotcom; follow-up fix for hierarchy state sync)
- Running multiple `Drafter.run/2` calls in sequence (e.g. via `run_examples.exs`)
  no longer causes leftover widget processes, stale renders, or corrupted input from
  a previous app bleeding into the next
- `{:stop, :normal}` returned from a widget `on_select` / `on_click` callback now
  correctly stops the application
- Tree-sitter "No language found" messages no longer bleed into the TUI output
- `StyleHelpers`: stylesheet detection broken for apps using `use Drafter.App` with
  CSS (fixes #1)
- `widgets.exs` example crashed on text input

## [0.1.22] - 2026-03-17
### Added
- `Chart`: `area_fill: :inverted` option for area charts — fills from the baseline upward (braille dots at bottom, empty space above); default behaviour (dots at top) is unchanged

### Fixed
- `Chart`: `area_fill` prop was silently overwritten to `:default` on every re-render because `ComponentRenderer` always passed a default value; now passes `nil` when unspecified so `update/2` preserves the mounted value

## [0.1.21] - 2026-03-16
### Added
- `ScrollableContainer`: `click_to_scroll: true` opt-in mode — scroll events are claimed by the parent container by default; `Ctrl+Click` inside the viewport toggles scroll-lock on that container (border highlights to show active state); clicking outside clears the lock. Nested scroll containers register themselves as exceptions at mount time so the per-event routing check only runs when exceptions exist (zero overhead when no nesting).

### Fixed
- `WidgetHierarchy`: `find_scroll_container_at` now consults `scroll_exceptions` — a non-`click_to_scroll` inner container (e.g. `DataTable`'s internal scroller) is skipped when an outer `click_to_scroll` container has it registered as an exception and is not scroll-locked; Ctrl+Click locking the outer container restores normal inner-scroll behaviour
- `app_event_loop`: first keypress after closing a modal no longer dropped — when `dispatch_event_sync` causes all screens to pop, `render_app` is called immediately to produce a fresh, consistent `widget_hierarchy`; previously the stale hierarchy caused a spurious `phash2` mismatch that set `consumed = true` and silently swallowed the event
- `ComponentRenderer`: auto-generated widget IDs are now namespaced by `app_module` (e.g. `ThemeSandbox_button_1` vs `InputModal_button_1`), eliminating ETS strip collisions between base-app and modal hierarchies that caused base-app widgets (e.g. the "Open Modal" button) to visually disappear when a modal was opened
- `Drafter.App`: `on_scroll_active/1` optional callback — fires once on the first scroll event of a gesture; return updated state (e.g. `%{state | scrolling: true}`)
- `Drafter.App`: `on_scroll_idle/1` optional callback — fires when the 150 ms debounce settles after the last scroll event; return updated state (e.g. flush pending data, clear scrolling flag)
- `Drafter.App`: `on_message/2` optional callback — receives any process message not recognised by the drafter event loop (PubSub, `send/2`, GenServer casts, etc.); return updated state. Previously all such messages were silently dropped.
- `Drafter.run/2` / `run_session/3`: `scroll_optimization: false` opt-out — disables the fast render/debounce path and triggers a full `render_app` on every scroll tick. Default is `true`.

```elixir
def on_scroll_active(state), do: %{state | scrolling: true}

def on_scroll_idle(state) do
  state = if state.pending_data, do: apply_pending_data(state), else: state
  %{state | scrolling: false, pending_data: nil}
end

def on_message({:data_refreshed, _uid, payload}, state), do: %{state | rows: payload.rows}

Drafter.run(MyApp, scroll_optimization: false)
```

### Fixed
- Modal focus isolation: base app widgets are now defocused (blurred) whenever a modal/screen is active — the button that opened the modal no longer retains focus styling or responds to keyboard events while the modal is open; focus is restored to the first focusable widget after the modal closes
- `ScreenManager`: `meaningful_hierarchy_change?` now compares widget state hashes (`phash2`), so text input changes inside modals are correctly detected as meaningful and the updated hierarchy is persisted
- `WidgetServer`: `event_sync` no longer calls `notify_render_needed` — the event loop renders after `event_sync` returns; calling it again was flooding the mailbox with one `{:widget_render_needed}` per scroll tick
- `WidgetHierarchy`: `update_widget` no longer blocks on `WidgetServer.get_state/1` after casting `update_props` — `update_props` is now a true fire-and-forget cast; ETS has the authoritative strips so rendering is unaffected
- `app_event_loop` / `shared_session_loop`: `{:widget_render_needed}` handler drains all pending notifications before doing a single `render_hierarchy`, eliminating N-fold duplicate composites when multiple widgets fire at once
- `app_event_loop` / `shared_session_loop`: `:scroll_debounce_render` handler drains all accumulated debounce messages before triggering one `render_app`, preventing update debt accumulation during slow/continuous scrolling
- `render_hierarchy` (fast scroll path): modals, popovers, and toasts are now correctly composited during scroll — previously the fast path painted only base app widgets, overwriting any open modal. Now reads screen and toast layers from stored ETS/hierarchy state with no `ComponentRenderer` re-run.
- Removed `sync_widget_states/1` — no longer needed; `render_hierarchy` reads strips directly from ETS

## [0.1.19] - 2026-03-16
### Changed
- `WidgetServer`: each widget owns its strip buffer via `WidgetStripCache` (ETS, public, `read_concurrency: true`) — rendering happens inside the widget's own GenServer process and results are written to ETS; `create_widget_layers_from_hierarchy` reads from ETS directly (no inter-process messaging, no round-trips)
- `WidgetServer`: `update_props` renders and writes to ETS when state changes but does **not** send `{:widget_render_needed}` — only autonomous widget state changes (events, timers) notify the event loop, eliminating redundant re-composites after `render_app`
- `WidgetServer`: `update_props` with identical resulting state is a no-op (no render, no ETS write)
- `ScrollableContainer`: scroll events use a fast render path — `render_hierarchy` re-clips ETS-cached strips without running `ComponentRenderer`; 150 ms debounce fires `render_app` once after scroll settles
- `MouseProcessor`: `mouse_move` while a button is held routes to the `mouse_down_widget` regardless of cursor position, enabling drag-out-of-bounds behaviour; `mouse_up` after drag-release outside the originating widget notifies the `mouse_down_widget` so it can clear drag state

### Added
- `WidgetStripCache`: ETS-backed strip store keyed by widget ID; lock-free reads from any process
- `ScrollableContainer`: click on scrollbar track jumps one viewport page toward the thumb
- `ScrollableContainer`: drag the scrollbar thumb — `mouse_down` on thumb begins drag, `mouse_move` continuously updates scroll offset, `mouse_up` ends drag

## [0.1.18] - 2026-03-15
### Fixed
- `DataTable`: click and Enter now toggle selection in both `:single` and `:multiple` modes — clicking or pressing Enter on an already-selected row deselects it; previously `change_selection/3` always set selection, while Space already toggled correctly via `action_toggle_selection`
- `DataTable`: arrow key navigation no longer inadvertently toggles selection in `:multiple` mode — `action_cursor_up/down` now pass `trigger_select: false` so moving the cursor never changes the selected set; only Enter, Space, and click change selection
- `Collapsible`: hidden children no longer receive mouse events — `find_widget_at` now excludes `hidden_widgets` from hit testing, preventing clicks intended for widgets beneath a collapsed section (e.g. a `DataTable` header) from being intercepted by invisible child widgets
- `Collapsible`: widget content (list) no longer renders over siblings below it — two root causes fixed:
  - `Collapsible.update/2` was resetting `content_height` to the default (10) on every re-render when only `content` was passed in `updated_props`, corrupting the stored height after the first render
  - `get_child_vertical_spec` / `get_preferred_height` ignored the `expanded:` and `content_height:` options when the widget was not yet in the hierarchy (first render), always returning height 1 and placing the next sibling at the wrong y position

## [0.1.17] - 2026-03-15
### Added
- `Digits`: `bg_data:` prop renders a braille line chart (4× vertical resolution per terminal row) behind the digit glyphs; `color:` sets the line colour; digits take priority where glyphs overlap braille dots
- `Sparkline`: `orientation: :horizontal` renders each data point as a left-to-right bar using left-aligned eighth-block characters (`▏▎▍▌▋▊▉█`)
- `Chart`: `pixel_style: :quadrant` option for line and scatter charts — uses quadrant block characters (`▖▗▘▝▚▞▛▜▟▙▀▄▌▐█`) at 2×2 pixel resolution per cell, giving larger/more visible dots than braille

## [0.1.16] - 2026-03-15
### Changed
- `Digits`: improved `B` glyph in both large and small sizes — more distinguishable from `8` and `6`; large uses flat `├` spine with `╲`/`╱` bump sides, small uses `╲` divider in the middle row

### Added
- `Rule`: new widget — horizontal/vertical divider line with optional embedded title, `title_align`, and `line_style` (`:solid`, `:double`, `:dashed`, `:thick`)
- `Tree`: `on_node_highlight:` callback fires whenever cursor moves to a new node; `Shift+←`/`Shift+→` navigates to previous/next sibling at the same depth
- `SelectionList`: `on_item_toggle:` callback fires with `{index, selected?}` on each individual item toggle; `Home`/`End` jump to first/last item; `Ctrl+A` toggles select-all / deselect-all in `:multiple` mode
- `MaskedInput`: `on_submit:` callback fires with the raw unmasked value on `Enter`
- `TextArea`: text selection (`Shift+Arrow`, `Ctrl+A`), copy/cut/paste (`Ctrl+C`/`X`/`V`), undo/redo (`Ctrl+Z`/`Y`), `read_only:`, `tab_behavior:` (`:focus` or `:indent`), `tab_size:`, `max_checkpoints:`, word navigation (`Ctrl+←`/`→`), page up/down, `highlight_cursor_line:`

## [0.1.15] - 2026-03-15
### Added
- `DataTable`: per-cell background colouring via `color_fn: (raw_value -> {r,g,b} | nil)` on column definitions; applied when the row is not selected
- `DataTable`: 3-state column sort cycle — click cycles ascending → descending → unsorted (restores original data order); `↕` indicator shown on all sortable-but-unsorted columns when `sortable: true`
- `DataTable`: table-level `sortable: false` option disables all sort indicators and click-to-sort
- `DataTable`: column width drag-resize — drag a column header to resize (when `locked: true`, the default); minimum 3 characters
- `DataTable`: column reorder — `Shift+←` / `Shift+→` moves the cursor column; drag a header while `locked: false` swaps columns live
- `DataTable`: `locked:` option — `true` (default) makes header-drag resize; `false` makes header-drag reorder
- `DataTable`: `on_layout_change:` callback — fires with `%{col_widths: [...], col_order: [...]}` after any resize or reorder
- `DataTable`: `col_widths:` and `col_order:` mount/update props to restore a previously saved layout
- `DataTable`: keyboard resize (`+`/`-`) fires `on_layout_change` after each step
- `DataTable`: `FocusRegistry` integration — footer key-binding bar updates dynamically when the table gains focus
- `FocusRegistry`: new `GenServer` tracking the focused widget's key bindings; consumed by `Footer` for dynamic display
- `EventRouter`: `{:key, key, mods}` events now dispatch to `handle_key/3` if exported, falling back to `handle_key/2`

## [0.1.14] - 2026-03-14
### Fixed
- Timer-driven re-renders skipped when `on_timer/2` returns state unchanged (`===`);
  applies to both `app_event_loop` and `shared_session_loop`. Eliminates redundant
  `render_app` / widget tree traversal on poll timers that find no new data.
- `{:widget_render_needed}` (fired by widget-internal timers such as the header clock)
  no longer triggers `ComponentRenderer.render_tree`. It now calls `render_hierarchy`
  which re-composites directly from the already-synced widget states, avoiding
  `update_widget` calls — and therefore `filter_list` — on every clock tick.

## [0.1.13] - 2026-03-14 *
### Added
- Multi-series line charts: pass a list of series (list of lists) to `chart_type: :line`
- Multi-series scatter charts: pass a list of point-lists to `chart_type: :scatter`
- `:clustered_bar` chart type — grouped multi-series bars with half-block resolution
- `:stacked_bar` chart type — series stack from baseline; supports mixed positive/negative values
- `:range_bar` chart type — each bar spans a `[low, high]` range
- Negative value support documented and verified across all chart types
- `multi_series_charts.exs` example demonstrating all new chart variants

### Fixed
- Area chart crash (`ArithmeticError`) when passed multi-series data; now dispatches to
  `render_multi_series` matching the same guard added to line chart

### Changed
- `Chart` moduledoc expanded with sections for negative values, multi-series API, and all bar types

## [0.1.11] - 2026-03-14 ★
### Added
- Scrollable viewport culling: off-screen children skipped during `render_component` calls,
  reducing GenServer traffic per frame for large scrollable lists

### Changed
- `count_component_slots/1` introduced to advance the ID counter for culled components,
  preserving auto-generated widget IDs for on-screen widgets

## [0.1.10] - 2026-03-14
### Fixed
- Chart axis labels: float concatenation crash in `format_axis_value/1` for values ≥ 1000

## [0.1.9] - 2026-03-14
### Fixed
- Binding resolution: `Checkbox` now reads `:checked` from opts at mount (was always `false`)
- `ComponentRenderer` checkbox update path now syncs `:checked` and `:on_change` on re-render
- `ComponentRenderer` `radio_set` update path now passes `:options` and `:selected` (was only
  `:on_change` and `:classes`, leaving options frozen after mount)
- `RadioSet.update/2` no longer resets `highlighted_index` on every timer-driven re-render

## [0.1.8] - 2026-03-14 ★
### Added
- Differential rendering in compositor: row-level dirty detection via `Strip.cache_key`
  (`:erlang.phash2` hash); unchanged rows skipped each frame, drastically reducing
  terminal output on static or partially-static screens
- Stale test cleanup: removed 10 test files referencing renamed/removed modules

### Fixed
- `TextInput`: scroll offset was double-subtracting border width, causing scroll to
  trigger 2 characters early
- `TextInput`: typed text no longer reset on re-render when widget has no `:bind` or
  `:value` prop

## [0.1.6] - 2026-03-14 ★
### Fixed
- `RadioSet`: options passed as raw tuples were not normalised at mount; now always
  stored as `%{id: _, label: _}` maps
- `RadioSet`: options not updating on re-render after first mount
- `RadioSet`: `highlighted_index` frozen after navigating before first selection

## [0.1.5] - 2026-03-14 ★
### Added
- `Collapsible` widget now supports interactive child widgets (buttons, inputs, etc.)
  inside the expanded body, not just plain text

### Fixed
- `Collapsible.update/2`: `content_height` no longer inherits stale value when content
  type changes between renders

## [0.1.4] - 2026-03-13 ★
### Fixed
- SSH: reverse entry bug introduced when SSH support was added
- Local startup issues with terminal initialisation

## [0.1.3] - 2026-03-13 ★
### Fixed
- Input handling cleanup following SSH integration

## [0.1.2] - 2026-03-13 ★
### Added
- Guide: Remote TUI over SSH/Telnet (`guides/remote_tui.md`)

## [0.1.1] - 2026-03-13 ★
### Added
- SSH and Telnet remote TUI support via `Drafter.Server`
- Remote client connects over standard SSH; full terminal interaction over the wire

### Fixed
- Theme switching between light and dark modes

## [0.1.0] - 2026-03-12 ★
### Added
- Initial public release
- Core framework: `Drafter.App` behaviour, widget lifecycle, event system
- Widget library: Label, Button, TextInput, TextArea, Checkbox, Switch, RadioSet,
  SelectionList, OptionList, MaskedInput, Link, DataTable, Tree, DirectoryTree,
  Chart, Sparkline, ProgressBar, LoadingIndicator, Pretty, Digits, Log, RichLog,
  Rule, Placeholder, Markdown, CodeView, Collapsible, TabbedContent, Card,
  Container, ScrollableContainer, Grid, Header, Footer
- Theming system with light/dark built-in themes and custom theme support
- Braille-dot chart rendering with line, area, bar, scatter, and candlestick types
- Layout engine: vertical, horizontal, scrollable containers with flex sizing
- Focus management: tab and arrow-key geometric navigation
- Multi-screen navigation stack with modal support
- Toast notification system with 9 positions and stack limiting
- Tree-sitter syntax highlighting integration (opt-in)
- Windows terminal support
- Dynamic actions and native alert/confirm dialogs
- Custom action handler API
