# Inkhaven 1.2.11 — in flight

1.2.10 was the "standalone editors" release —
`inkhaven config` and `inkhaven prompts-editor` both
shipped a dedicated TUI for their respective HJSON
files.  1.2.11 picks up the carryovers from 1.2.10
and the deferred items from 1.2.9.

This document is a living scratchpad — entries land
here as the work lands.  Each item starts as a one-
line plan; gets fleshed out when the corresponding
feature ships; gets a chord / config / hook callout
when relevant.

## Planned themes

### Carryovers from 1.2.10

  * **Apply prompts-editor critique back into the
    main TUI's flows.**  The reviewer-LLM can
    suggest a rewrite that survives Ctrl+B G into
    the editor — but the wider review-loop ends at
    Ctrl+S to disk.  Consider piping the same
    review-as-you-edit shape into the main TUI's
    `Ctrl+B 0` HJSON editor for inkhaven.hjson.

  * **HSL slider modal for the colour widget.**
    Hex + RGB-swatch ships today; an HSL slider
    overlay would make picking accessible colours
    easier without leaving the TUI.

  * **Actual file-picker for the path widget.**
    Text input + existence check ships today;
    integrate the main TUI's F3 file picker.

  * **More enum entries.**  The metadata table
    currently routes `typst_compile.engine` and
    `embeddings.model`.  Audit every `String`
    field in `Config` for fixed-set candidates
    (themes, output formats, stemmer languages).

  * **Build-time doc-comment extraction polish.**
    The current extractor handles `Option<T>` /
    `Vec<T>` / `HashMap<K, V>` descents; nested
    `Option<HashMap<K, V>>` shapes aren't yet
    handled.  Audit + fix.

  * **AI-driven sentence-rhythm rewrite** (deferred
    from 1.2.9).  A chord that sends the open
    paragraph to the LLM with a prompt asking it
    to break a MONOTONE verdict.  Pairs with
    Ctrl+B Shift+H the same way the AI
    show-don't-tell scan pairs with the regex
    overlay.

  * ~~**Per-language emotion + linking-verb lists**
    for the show-don't-tell detector~~ — shipped,
    see implementation log below.  Russian /
    French / German / Spanish now have curated
    built-in lists (~conservative dictionary-shape,
    no genre bias); per-project / per-genre tuning
    goes through the new
    `inkhaven show-dont-tell bootstrap <lang>`
    CLI which uses the configured LLM as a
    one-shot vocabulary curator.

  * ~~**Concordance: jump-to-occurrence on Enter**~~
    — shipped, see implementation log below.

### Split view — the deferred 1.3 work

1.3 has been "next release" since 1.2.5.  It keeps
slipping because each polish cycle finds enough
single-pane work to fill itself.  If 1.2.11 is
genuinely a quiet cycle, the split-view rework
lands here.

### Other candidates

  * Mouse-driven scene-break selection (drag from
    one break-line to the next selects the scene).
  * Concordance export — `inkhaven export
    concordance` for the CSV / JSON variant of
    the modal data.
  * Show-don't-tell theme audit — per-detector
    underline style override (DIM / BOLD /
    REVERSED / NONE), since the teal underline
    reads differently on different terminal
    palettes.

## Implementation log

### A1 — AI sentence-rhythm rewrite (`Ctrl+B Shift+M`)

Pairs with the existing `Ctrl+B Shift+H`
sentence-rhythm gauge.  Where the gauge says
MONOTONE, the rewrite asks the configured LLM to
mix short and long sentences while preserving the
author's voice, tone, named characters, dialogue,
em-dashes, and paragraph breaks.

Prompt resolution follows the standard inkhaven
pattern (`grammar-check` style precedence):

  1. Paragraph in the project's `Prompts` system
     book with slug or title
     `sentence-rhythm-rewrite` /
     `sentence rhythm rewrite`.
  2. Entry in the project's `prompts.hjson`
     library.
  3. Embedded multilingual fallback —
     `sentence_rhythm_rewrite_default_prompt(language)`
     in `src/tui/app.rs`.  Language-aware: the
     prompt explicitly names the project's
     `language` field so the rewrite lands in
     the same language (the model wouldn't
     translate but for the strong directive).

Behaviour:

  * `Ctrl+B Shift+M` in editor scope spawns the
    inference; the streaming response goes into
    the AI pane (the user can watch it land in
    real time).
  * The chord ALSO fires from inside the
    `Ctrl+B Shift+H` rhythm-gauge modal — the
    natural diagnose-then-rewrite path: open the
    gauge, see MONOTONE, press the chord, watch
    the rewrite stream.  The gauge dismisses
    automatically as the inference spawns
    (otherwise it would sit stuck on a stale
    verdict while the AI pane lit up behind it).
    Implemented two-part: (a) the rhythm-modal
    handler intercepts the meta-prefix
    (`Ctrl+B`) and arms `meta_pending` after
    closing the modal, so the normal meta
    dispatch resolves `Shift+M` →
    `AiRewriteRhythm` on the next key; (b)
    `start_sentence_rhythm_rewrite` also
    defensively dismisses any lingering
    `Modal::SentenceRhythm` so the rewrite UX
    is coherent regardless of entry point.
    Generalises: any meta-action is now
    reachable from inside the gauge.
  * When the stream completes, an AI diff modal
    pops **automatically** — the user doesn't
    need to press a chord to trigger review.
    Side-by-side: original on the left,
    rewrite on the right, ↑↓ / PgUp / PgDn /
    Home / End scroll.
  * `a` / `Enter` accept: a snapshot of the
    pre-rewrite buffer is created with
    annotation `Sentence rhythm rewrite` (so
    F6 surfaces it as a discoverable history
    entry), THEN the buffer is replaced.
  * `r` reject: modal closes, buffer untouched.

Implementation notes:

  * New `App.pending_rhythm_rewrite: bool` flag
    set by `start_sentence_rhythm_rewrite` and
    cleared by `pump_inference` on transition
    to Done (or Error).  When set + Done +
    non-empty response, `pump_inference`
    auto-opens the diff modal via the new
    `open_ai_diff_review_with_snapshot` helper.
  * `Modal::AiDiffReview` gains a
    `post_accept_snapshot: Option<String>`
    field.  When `Some`, the accept handler
    (in `ai_diff_review_handle_key`) calls
    the new `snapshot_open_paragraph_with_annotation`
    helper BEFORE `apply_ai_diff_accepted` so
    the pre-rewrite state is preserved.
  * The snapshot helper lives in
    `src/tui/app/snapshot_impl.rs` next to
    `commit_snapshot_annotation` and reuses the
    same `store.create_snapshot_annotated` API
    F5 already uses.

**AI diff pane wraps long lines.**  Until now the
side-by-side diff truncated content past the column
boundary — a single long sentence on either side
became unreadable.  The renderer now wraps each
diff row at column width, prepending the diff
marker (`- ` / `+ ` / `  `) on the first wrapped
row and indenting continuation rows by two columns
so a wrapped sentence reads as one visually
continuous block.  Left and right wrap
independently, but the shorter side is padded with
empty rows so paired diff entries stay vertically
aligned — the side-by-side guarantee is preserved.
Implementation: new `wrap_diff_row` helper in
`src/tui/app/render/modals.rs`; new
`Modal::AiDiffReview.wrapped_total` field written by
the renderer each frame and read by the key handler
to clamp Down / PgDn / End against the post-wrap
row count.  Scroll counts wrapped rows, not source
diff lines.

460 tests still pass.  Zero build warnings.

Docs:

  * `Documentation/KEYBINDING.md` row 86 added.
  * Action description in `src/tui/keybind.rs`
    covers prompt-resolution + accept/reject
    semantics + the snapshot annotation.

### Concordance: jump-to-occurrence on Enter

Carryover from 1.2.10.  The `Ctrl+B Shift+L`
concordance modal was read-only — users could see
that the word `walk` occurs 47 times across the
project but had no way to land in the editor on
any of them.  Now: pressing **Enter** on the
selected row closes the modal and navigates to the
first sample's source paragraph at the
corresponding editor line.

Implementation notes:

  * `concordance_jump_to_selection` in
    `src/tui/app.rs`.  Pulls `(slug_path, line_no,
    headword)` out of the first sample of the
    selected entry, resolves the slug path to a
    paragraph node via
    `Hierarchy::find_by_path` (exact inverse of
    `slug_path` used at index-build time), opens
    via the existing `open_paragraph_by_uuid`,
    then jumps the textarea cursor via
    `tui_textarea::CursorMove::Jump`.
  * **Heading-offset translation.**  The
    concordance index is built over
    *heading-stripped* paragraph bodies (so
    `line_no` is 1-based within the prose).  The
    editor displays the *raw* paragraph
    (including the `= title` heading line and
    any blank lines after it).  Naively jumping
    to row `line_no - 1` would land on the
    wrong line.  The jump function replays the
    same heading-strip logic on the live
    textarea, computes the prefix offset, and
    adds it to `line_no - 1` so the cursor
    lands on the right row regardless of the
    paragraph's heading shape.
  * **Graceful failure modes.**  Source
    paragraph no longer exists (rename / delete
    after index was built) — status reports
    `concordance: source paragraph
    <slug_path> no longer exists`, modal stays
    open.  Selected entry has zero samples
    (shouldn't happen — index dedup keeps at
    least one — but defensive) — status
    reports the empty case, modal stays open.
    Open-paragraph failure — status surfaces
    the underlying error, modal already
    closed.
  * Plain `Enter` only; `Ctrl+Enter` and
    `Alt+Enter` fall through to the filter
    input (which today no-ops them, the
    desired behaviour).
  * **System books excluded from the corpus.**
    `open_concordance` now skips any paragraph
    whose ancestor chain contains a node with
    `system_tag.is_some()` — Prompts,
    Characters, Places, Lore, Help, Notes,
    Artefacts, Typst, Scripts.  Two reasons:
    (1) those books are metadata /
    scaffolding, not prose, so they dilute
    the lexical signal (the keybind
    description has always promised "the
    words actually carrying the prose's
    weight").  (2) System-book paragraphs
    are stored only in bdslib (the prompts
    editor never writes `.typ` files), so an
    Enter-jump to one would fail with
    `read …: No such file or directory`
    inside `load_paragraph`.  Filtering them
    out at index time eliminates the broken
    nav case entirely and tightens the
    concordance to actual manuscript prose.

### Show-don't-tell: per-language built-ins + `bootstrap` CLI

Carryover from 1.2.9.  Until now the show-don't-tell
overlay (`editor.style_warnings.show_dont_tell`)
only had built-in word lists for English; Russian,
French, German, and Spanish projects saw nothing
until the user filled in HJSON by hand.  Two
changes land together:

**Built-in lists for RU/FR/DE/ES.**
`built_in_linking_verbs` / `built_in_emotion_adjectives`
/ `built_in_manner_adverbs` / `built_in_cognition_verbs`
in `src/config.rs` now return curated lemmas for
all four languages.  Conservative + dictionary-
shape — the major emotion families (anger /
sadness / fear / joy / fatigue / surprise / shame)
plus the obvious copula / cognition verbs.  Per-
genre or per-corpus tuning is the LLM bootstrap's
job (below), not the built-ins'.  Snowball
stemming at match time means a handful of canonical
forms cover the inflection space.

**`inkhaven show-dont-tell bootstrap <lang>`.**
New CLI subcommand that uses the configured LLM as
a one-shot vocabulary curator.  Reads
`inkhaven.hjson` for the LLM provider, asks for the
four lists in the requested language, parses the
JSON response, and emits a copy-paste-ready HJSON
snippet on stdout.  **Never writes to
`inkhaven.hjson` automatically** — mirrors
`doctor --filter-words-snippet`: the author
reviews the snippet and pastes what they like.

Implementation notes:

  * `src/cli/show_dont_tell.rs`.  Streams the
    response (dots to stderr for progress so the
    HJSON on stdout stays pipe-clean), strips
    optional markdown code fences, brace-balanced
    JSON extraction so stray model chatter
    doesn't kill the parse.  Four unit tests lock
    the parser against the obvious failure modes
    (chatter wrapping, nested braces in string
    values, fenced output, plain garbage).
  * **Strict JSON contract.**  The system prompt
    instructs the model to emit ONE JSON object
    with four arrays and no other text;
    `extract_json_object` walks a brace-balanced
    range so an example object embedded in
    explanatory prose still parses correctly.  On
    parse failure the raw response is dumped on
    stderr and the command exits non-zero —
    debug + retry is one keypress.
  * **Optional `--genre` hint.**  Folded into the
    user prompt so the model picks vocabulary at
    the right register ("literary fiction",
    "thriller", "YA fantasy", …).  Empty by
    default — the genre-neutral built-ins ship
    so nobody *has* to run this.
  * **Why not always-on AI detection?**  Considered
    and rejected.  The overlay updates as the
    user types; running an LLM per keystroke is
    fatal on latency (~1-3s per call), expensive
    on tokens, and breaks offline use — a core
    value of a terminal editor.  Using the LLM
    as a one-shot curator gives the multilingual
    coverage without paying the always-on cost,
    and the existing `Ctrl+B Shift+T` AI
    show-don't-tell scan remains the deep-
    analysis layer for context-sensitive cases.
  * Test `sdt_unsupported_language_falls_back_quiet`
    updated to use `"klingon"` (or any other
    language inkhaven doesn't ship lists for)
    since German now has built-ins — the
    semantic the test locks (empty config +
    no built-ins → `is_empty()` detector) is
    preserved.

**`--update` flag for in-place HJSON merge.**  By
default `bootstrap` prints to stdout and touches
nothing.  With `--update`, the LLM-discovered lists
are merged into `<project>/inkhaven.hjson` directly:
union with the existing per-language entries
(case-insensitive dedup, existing order preserved at
the head, new arrivals appended), then spliced into
the file via the shared
`config_tui::apply_in_place_edits` helper.  Comments
and formatting around untouched leaves survive;
parent stanzas missing from the HJSON are filled in
on append.  A versioned backup of the **pre-patch**
file lands under `<project>/.config-backups/` first,
so rolling back is one `cp` away.  The atomic write
(.tmp + rename) means an interrupted run cannot
truncate `inkhaven.hjson`.

Implementation notes:

  * `config_tui::apply_in_place_edits` is the new
    public surface — a small wrapper around the
    existing config-TUI splice machinery
    (`hjson_index::parse` → `save::apply_edits` →
    `save::write_atomic` + `save::write_backup`).
    Hides the four private submodules so the CLI
    doesn't need to know about `Edit` / `EditKind`
    / `HjsonIndex` internals.  Returns
    `InPlacePatchOutcome { config_path, backup }`
    so callers can report both paths on success.
  * `merge` in `src/cli/show_dont_tell.rs` is the
    actual union: trims whitespace, drops empties,
    lowercases for the dedup key, preserves the
    pushing order.  Locked by two unit tests
    (`merge_preserves_existing_order_and_dedups_case_insensitively`,
    `merge_handles_empty_existing`).
  * **Both modes print the snippet to stdout.**
    `--update` also dumps the merged snippet so
    the user can see what landed — convenient for
    `tee`-style audit logs and for confirming
    visually that the merge produced what was
    expected.
  * **Multi-line pretty-print for string arrays.**
    The splice renderer in `src/config_tui/save.rs`
    used to dump arrays as compact one-line JSON
    (`["a","b","c",…]`).  A 50-entry SDT list on
    one line is unreadable.  Now: arrays of
    strings break to multi-line when they're
    longer than 3 elements or wider than 60
    chars — one element per line, indented to
    `line_indent + 2`, closing `]` flush at
    `line_indent` so it sits under the leaf's
    key.  The line-indent comes from a new
    `line_indent_at_offset` helper that walks
    back to the previous newline and counts
    leading whitespace, so the multi-line render
    matches whatever indentation the user's
    HJSON uses.  Short arrays + mixed-type
    arrays + non-string arrays stay compact.
    Five new unit tests lock the behaviour
    (`render_array_short_stays_compact`,
    `render_array_long_breaks_multiline`,
    `render_array_mixed_types_falls_back_compact`,
    `line_indent_at_offset_walks_back_to_previous_newline`,
    `splice_long_string_array_lands_multiline`).

### Multilingual prompts — Phase A foundation

Carries over the design from
`Documentation/PROPOSALS/MULTILINGUAL_PROMPTS.md`.  Phase A
lands the resolver plumbing with **no UI-visible change**:
existing 1.2.11 projects keep working exactly as before
(every prompt is untagged, so Pass 2 of the new resolver
picks them up with the same semantics as the old code),
and projects that begin adding `lang:<code>` tags to
Prompts-book paragraphs or `language: <code>` to
`prompts.hjson` entries get correct-language matches
without any further config.

Changes in this commit:

  * **`Prompt::language: Option<String>`** field on the
    `prompts.hjson` struct (skip-serialised when None for
    clean diffs on legacy files).
    `PromptLibrary::find_lang(name, lang_filter)` is the
    new language-aware primitive; the existing `find`
    stays for back-compat callers.
  * **`lang:<code>` tag convention** in Prompts-book
    paragraphs.  `node_lang_tag` / `node_lang_matches`
    helpers in `src/tui/app.rs` filter the subtree
    walker (`lookup_book_prompt_template_with_lang`)
    by the tag.  Reuses the existing `Node.tags` field
    — no bdslib schema migration.
  * **`Config::editor::prompt_language_mode`** (default
    `"book_defined"`) and
    **`prompt_language_detection_min_chars`** (default
    `50`) added to HJSON.  Config-TUI enum registration
    in `src/config_tui/schema.rs` surfaces the picker
    automatically.
  * **`iso_from_long` / `iso_to_long` / `iso_from_alpha3`**
    helpers in `src/ai/prompts.rs`.  The long-form
    helper is `#[allow(dead_code)]` in Phase A —
    Phase C consumes it for the AI pane decoration.
  * **`OpenedDoc::detected_language`** field cached on
    paragraph load.  `App::detect_paragraph_language()`
    runs whatlang against the live body when
    `prompt_language_mode = "paragraph_detected"` and
    the body is at least
    `prompt_language_detection_min_chars` of non-
    whitespace text; below that, detection is skipped
    silently and the resolver uses the book language.
    No-op in `book_defined` mode.
  * **Three-pass resolver**
    (`App::resolve_prompt(name, want_lang, fallback)`
    and `App::resolve_prompt_optional(name, want_lang)`)
    in `src/tui/app.rs`:

      1. Pass 1 — strict same-language match (Prompts
         book paragraph tagged `lang:<want>`, or
         `prompts.hjson` entry with `language: <want>`).
      2. Pass 2 — untagged match (back-compat: every
         pre-1.2.12 prompt lands here).
      3. Pass 3 — any-language match; embedded fallback
         (still English-only in Phase A) is the floor.

    Both the slug form (`grammar-check`) and the title
    form (`grammar check`) are tried inside each pass.
    The legacy `resolve_prompt_template` thin-wraps the
    new resolver with `active_prompt_language()` as the
    target so every existing caller keeps working
    unchanged.  The two inline 4-step patterns (start
    show-don't-tell scan, start sentence-rhythm
    rewrite) and the `/name` AI-prompt form are
    migrated to the new resolver.
  * **whatlang dep** added — 0.16, character n-gram
    detection, no network, no CLDR bloat.  ISO 639-3
    → 639-1 mapping in `iso_from_alpha3` filters
    languages outside inkhaven's supported set
    (en/ru/es/de/fr) so unsupported detections fall
    back to the book language silently.
  * **Tests** — 13 new unit tests:
    - `find_lang_strict_match_returns_in_language`,
      `find_lang_strict_match_is_case_insensitive`,
      `find_lang_strict_skips_untagged`,
      `find_lang_none_filter_only_matches_untagged`,
      `iso_from_long_maps_supported_languages`,
      `iso_from_alpha3_filters_unsupported`
      (`src/ai/prompts.rs`).
    - `node_lang_tag_extracts_code`,
      `node_lang_tag_case_insensitive`,
      `node_lang_tag_absent_returns_none`,
      `node_lang_tag_ignores_empty_value`,
      `node_lang_matches_strict_same_language`,
      `node_lang_matches_untagged_pass_skips_tagged_nodes`,
      `node_lang_matches_untagged_pass_matches_untagged_nodes`
      (`src/tui/app.rs::tests_node_lang`).

484 tests pass (was 471, +13).  Zero warnings.

What Phase A does **not** ship — see the proposal:

  * No `Ctrl+B Shift+N` runtime toggle yet (Phase C).
  * No per-language embedded prompt tables yet
    (Phase B — will add the `inkhaven prompts bootstrap
    <lang>` CLI mirroring the SDT bootstrap).
  * No UI surfacing of `found_lang` / `source` —
    AI pane decoration is Phase C.
  * Cache invalidation on edits is Phase D polish;
    today detection runs once at paragraph load.

### Multilingual prompts — Phase B (embedded content + bootstrap CLI)

Builds on Phase A.  Phase B has two pieces:

**Piece 1 — Promote the seven embedded prompts to
5-language matches.**  Every `*_default_prompt`
function in `src/tui/app.rs` (`grammar_check`,
`explain_diagnostic`, `critique_edit`,
`critique_changes`, `show_dont_tell`,
`sentence_rhythm_rewrite`, `timeline_health`) now
takes an ISO 639-1 code and dispatches to a 5-arm
match: hand-written English / Russian / Spanish /
German / French variants, each preserving the
original prompt's directive tone, technical
references (Typst markup, `{{selection}}` /
`{{context}}` placeholders), and output-discipline
clauses (e.g. "Return ONLY the rewritten
paragraph").  Unknown codes fall back to English so
the resolver always has a floor.

Callers (`start_grammar_check`, `start_critique`,
`start_show_dont_tell_scan`,
`start_sentence_rhythm_rewrite`,
`explain_diagnostic`, `timeline_health`) now pass
`active_prompt_language()` to the embedded
fallback.  `inkhaven init` seeds the Prompts book's
`.example` paragraphs in the project's working
language by mapping `cfg.language` through
`iso_from_long` once.

The Phase A resolver was already wired for this —
Pass 3 (any-language with embedded floor) now
delivers a same-language embedded prompt because
the floor is itself language-aware.  Projects with
zero custom prompts get correct-language behaviour
out of the box for all seven embedded flows.

**Piece 2 — `inkhaven prompts bootstrap <lang> [--update]`.**
New CLI subcommand mirroring
`inkhaven show-dont-tell bootstrap`.  Asks the
configured LLM to produce per-language variants of
the same seven embedded prompts and emits either
an HJSON snippet on stdout (default) or merges
into `<project>/prompts.hjson` in place
(`--update`).

  * **Snippet form**: one HJSON block per prompt,
    each with `name: …`, `language: <iso>`,
    `description: …`, and a triple-quoted
    `template` body.  Ready to paste under the
    `prompts: [ … ]` array.
  * **`--update` form**: loads existing
    `prompts.hjson` (or the embedded
    `assets/default_prompts.hjson` when absent),
    merges by `(name, language)` tuple
    (overwrite on exact match, append
    otherwise — same-name-different-language
    entries are left untouched, since they're how
    the resolver's Pass 1 cascade works), backs
    up the pre-patch file under
    `<project>/.config-backups/prompts_YYYYMMDD_HHMMSS.hjson`,
    writes back atomically via `.tmp` + rename.
    Same destructive caveat as the prompts-editor
    TUI save: comments in `prompts.hjson` don't
    round-trip; entries do.
  * **Optional `--genre` hint** folds into the
    user prompt for register-aware vocabulary
    ("literary fiction", "thriller", "YA
    fantasy", …).
  * **Strict JSON contract** with the model; same
    brace-balanced extractor as SDT bootstrap so
    chatter / fenced output don't break parsing.
    Three parser tests + three merge tests +
    one forcing-function test (asserting
    `EMBEDDED_PROMPTS` covers every named
    resolver path — if a future commit adds a
    new embedded prompt without adding it to
    the bootstrap table, the test fails).

Implementation notes:

  * **No new dependencies.**  Reuses `whatlang`
    (Phase A), `serde_json` + `serde_hjson` +
    `chrono` (already pulled in by other paths).
    The full pipeline — stream + parse + merge +
    backup + atomic write — lives in
    `src/cli/prompts.rs`, 6 functions, no
    external state.
  * **Backup naming** uses `prompts_<ts>.hjson`
    (not `inkhaven_<ts>.hjson`) so the
    `.config-backups/` directory remains
    self-describing when both bootstraps + the
    config TUI have written to it.
  * **The `Help` system book** got a one-line
    update so the `inkhaven prompts bootstrap`
    chord is discoverable via the help library
    refresh.

491 tests pass (was 484 — +7 in this batch: 3
parser tests, 3 merge tests, 1 EMBEDDED_PROMPTS
forcing-function).  Zero warnings.

What Phase B does **not** ship — Phases C / D:

  * No UI surfacing of `found_lang` / `source` —
    AI pane decoration remains Phase C.
  * No prompts-editor TUI language picker — Phase C.
  * No `Ctrl+B Shift+N` runtime toggle — Phase C.
  * No edit-time cache invalidation for
    `OpenedDoc.detected_language` — Phase D.
  * No surgical-splice array-append primitive for
    `prompts.hjson` — Phase D polish.  The current
    `--update` round-trip via serde_hjson loses
    comments (matches the prompts-editor TUI's
    save behaviour; consistent within inkhaven).

### Multilingual prompts — Phase C (UI surfacing)

Builds on Phases A + B.  Phase C surfaces the
resolver state in the UI so the author can see what
language the next AI call will use and steer it
without leaving the editor.

**`Ctrl+B Shift+N` runtime toggle.**  Cycles the
prompt-language mode through three states:

  * `None` — defer to `editor.prompt_language_mode`
    in HJSON (the persistent default).
  * `Some("book_defined")` — session override:
    always use the book's `language` field.
  * `Some("paragraph_detected")` — session
    override: run whatlang on the live paragraph.

The cycle ends back at `None` so the user can return
to the HJSON setting without restarting.  Status bar
echoes the new mode AND the resolved language code on
toggle:

  ```
  prompt language mode: paragraph_detected · resolving as `ru` · session override
  ```

`App.prompt_lang_mode_runtime: Option<String>` is the
session-local field; `Config` is untouched.
`active_prompt_language()` consults the override
first, then the HJSON; `toggle_prompt_language_mode()`
triggers a fresh `detect_paragraph_language()` so the
whatlang cache stays accurate against the new mode.

**AI pane title-bar `lang=` chip.**  The pane title
bar now carries `· lang=ru (paragraph)` (or `(book)`)
right after the existing `infer=` chip.  Same style
as the other chips (`ai_infer_fg` colour, BOLD).
`ai_pane_language_label()` produces the
`<iso> (<mode>)` string; updates immediately on every
toggle.

**`/` prompt picker sectioning.**  The picker now
groups prompts into three buckets:

  ```
  ── In active language (ru) ──
    [system] [ru] /grammar-check
            Грамматическая проверка
    [book]   [ru] /tighten-russian
            Сжать русскую прозу

  ── Untagged ──
    [system] [—]  /tighten
            Tighten prose; remove flab without …
    [book]   [—]  /continue
            Continue the scene in the same voice …

  ── Other languages ──
    [system] [fr] /grammar-check
            Vérification grammaticale
  ```

Inline `[ru]`/`[—]` chips show each prompt's
language at a glance.  Within each bucket the
existing prefix-then-substring scoring is preserved.
Section headers are dim+bold so they fade behind
the entries.

**Prompts editor TUI language picker.**  The list
pane gains an inline `[ru]`/`[—]` chip per row, and
the list-pane keymap adds `l` to cycle the focused
prompt's language tag through `None → en → ru → es →
de → fr → None`.  Cycle direction matches the
inkhaven default-language list order.  Marks the
prompt dirty so `Ctrl+S` persists.  Editor-pane
focus is unaffected; the chord only fires when the
list pane has focus.

Implementation summary:

  * `keybind.rs` — new `Action::TogglePromptLanguageMode`
    + chord registration (`Ctrl+B Shift+N`,
    `Scope::Any`) + help description text.
  * `src/tui/app.rs` — `App.prompt_lang_mode_runtime`
    field, `toggle_prompt_language_mode()`,
    `effective_prompt_language_mode()`,
    `ai_pane_language_label()`.  `active_prompt_language()`
    refactored to read the runtime override first.
  * `src/tui/modal.rs` — `PromptCandidate.language`
    field threaded through to the picker render.
  * `src/tui/app/ai_impl.rs` — `prompt_picker_matches`
    populates `language` from
    `prompts.hjson.language` for system entries and
    from the `lang:<code>` tag for book entries; the
    final sort uses a three-tier
    bucket-then-score comparator.
  * `src/tui/app/render/panes.rs` — `lang=` chip on
    the AI pane title bar; `/` picker emits section
    headers on bucket transitions + inline language
    chips per row.
  * `src/prompts_tui/app.rs` — list-pane row gains
    a yellow-dim `[lang]` chip; `l` keystroke
    cycles the focused prompt's language and marks
    it dirty; help body picks up the new chord.
  * `Documentation/KEYBINDING.md` — row for
    `Ctrl+B Shift+N`.

491 tests still pass.  Zero new dependencies; zero
warnings.

What Phase C does **not** ship:

  * No live cache invalidation on edits for
    `OpenedDoc.detected_language` — still Phase D
    polish; today the cache populates at paragraph
    load and stays stable across the session.

### Multilingual prompts — Phase D (polish)

Closes the design from the proposal.  Two pieces:

**Phase C correctness fix.**
`detect_paragraph_language` previously consulted
`cfg.editor.prompt_language_mode` directly, which
meant the Phase C session override (`Ctrl+B Shift+N`)
into `paragraph_detected` was ignored when HJSON
said `book_defined`.  Function now uses
`effective_prompt_language_mode()` — runtime
override wins, otherwise HJSON.  The Phase C
status-bar echo on toggle was already correct; this
just makes the underlying detector actually fire
when the user asks for it.

**Cache-invalidation hooks for the whatlang cache.**
`OpenedDoc.detected_language` is now refreshed at the
moments where the body changes enough to plausibly
flip the dominant-language signal:

  * **`save_current`** — calls
    `maybe_redetect_paragraph_language()` after the
    on-disk write + bookkeeping.  The "maybe"
    variant guards on a length-delta against the
    cached `detected_language_length`: only fires
    `detect_paragraph_language()` when the
    non-whitespace char delta meets or exceeds
    `editor.prompt_language_detection_min_chars`
    (default 50).  Saves cost on small edits while
    catching the multi-paragraph rewrites that
    actually matter.
  * **`apply_ai_diff_accepted`** — the AI diff
    modal's accept handler replaces the buffer
    wholesale; calls `detect_paragraph_language()`
    unconditionally (no delta-guard needed when
    every char might be new).
  * **`apply_inference`** — AI critique / replace
    paths that insert text into the buffer.
    Routes through `maybe_redetect_paragraph_language`
    (length-delta guard) — a `Top`/`Bottom`
    paste of a short AI suggestion usually
    doesn't move the needle, but a `Replace`
    can.
  * **External file-change reload** —
    `pump_external_reload` (file mtime drift)
    swaps the buffer with the on-disk content;
    calls `detect_paragraph_language()`
    unconditionally.

`maybe_redetect_paragraph_language` lives next to
`detect_paragraph_language` in `src/tui/app.rs`;
both short-circuit when the effective mode is
`book_defined`, so the new hooks are free for
projects that haven't opted into paragraph
detection.

**Surfacing in config TUI** — already lands as part
of Phase A: the `editor.prompt_language_mode` enum
was registered in `src/config_tui/schema.rs` with
the variants `book_defined` / `paragraph_detected`,
so the config-TUI picker surfaces it automatically
without further wiring.

**Documentation in the Help system book.**  Skipped
deliberately — the Help book is for user-imported
documentation (Typst reference, project notes); it
doesn't have an inkhaven-feature catalogue section
that would naturally absorb a Ctrl+B Shift+N entry.
The chord is discoverable via `KEYBINDING.md`,
`Ctrl+B Shift+/` (the runtime help modal — auto-
populated from the keybind action descriptions),
and the AI pane's `lang=` chip, which the user
sees on every render.

491 tests still pass.  Zero new dependencies; zero
warnings.

**Phase D wraps the multilingual prompts cycle.**
All four phases shipped in 1.2.11:

  * Phase A — resolver foundation + whatlang +
    config field + tag convention + `Prompt.language`.
  * Phase B — 5-language embedded prompts +
    `inkhaven prompts bootstrap <lang> [--update]`
    CLI with hand-written EN/RU/ES/DE/FR variants
    for all seven embedded flows.
  * Phase C — `Ctrl+B Shift+N` runtime toggle +
    AI pane `lang=` chip + `/` picker sectioning
    + prompts-editor TUI `l` chord.
  * Phase D — Phase C correctness fix + cache
    invalidation on edit / diff-apply / paste /
    external reload.

What's intentionally out of scope (per the
proposal §8): mixed-language paragraphs (whatlang
picks the dominant), per-prompt fallback chains,
runtime translation, languages beyond the
supported five (en/ru/es/de/fr).

### Config-TUI polish: F3 file-picker + more enums + HSL slider

Three small carryovers from 1.2.10 land together; all
three live inside the standalone config TUI launched
via `inkhaven config`.

**F3 file-picker in the path widget.**
`PathWidget` used to be text-input + existence-check
only — sensible enough but missing the "browse for
me" affordance every other path field in the main TUI
has.  The widget now embeds an optional
`tui::file_picker::FilePicker` (the same tree-style
browser the main TUI's F3 chord opens) keyed on F3.
Root selection prefers the buffer's parent directory
when the buffer points somewhere existing; otherwise
the project root.  Inside the picker: ↑↓ / PgUp /
PgDn / Home / End navigate, → expands a directory,
← collapses or steps to parent, Enter selects (the
selected path overwrites the buffer; user presses
Enter again in text mode to commit the config), Esc
or F3 cancels.  `tui::file_picker` is now
`pub(crate)` so config-TUI can import it without
duplicating the picker's data model.

**Three new enum entries** registered in
`src/config_tui/schema.rs::enum_variants_for`,
surfacing as pickers automatically:

  * `language` (top-level Config) — `english`,
    `russian`, `french`, `german`, `spanish`.  Matches
    inkhaven's actually-supported set across the
    stemmer / stop-word / SDT / prompt-resolver
    plumbing.
  * `typst_page.language` — the matching ISO 639-1
    short codes (`en`, `ru`, `fr`, `de`, `es`).
    Skips the broader typst-supported set because the
    picker only makes sense as a quick switch among
    the languages inkhaven plumbs end-to-end; users
    wanting an exotic code (it/pt/ja/zh/…) still have
    the `Ctrl+B 0` raw HJSON edit path.
  * `typst_page.page_numbering` — typst's standard
    numbering patterns: empty (no numbers, the typst
    default), `"1"`, `"i"`, `"I"`, `"a"`, `"A"`, and
    `"1 of 1"`.  Custom prefixes ("Chapter 1.1") fall
    outside this fixed set and need the raw edit.

  Skipped audit candidate: `typst_page.paper` — too
  many valid values (a0..a10, b0..b8, us-letter,
  us-legal, presentation-16-9, …) for a fixed picker;
  arrow-key cycling through 20+ entries is worse UX
  than typing the name.

**HSL slider modal for the colour widget.**  Hex +
RGB-swatch + theme-preview already shipped; the HSL
mode is the third input shape.  `h` toggles between
the two modes.  Inside HSL:

  * Three sliders — Hue (0-360°, wraps), Saturation
    (0-100%), Lightness (0-100%).  Tab cycles which
    slider has focus (Up/Down also work); the active
    slider gets a ▶ marker + BOLD label, inactive
    sliders dim down.
  * ← → adjust the active slider by 1; Shift+ ← →
    adjust by 10; Home / End jump to min / max.
  * `h` toggles back to hex mode at any point; the
    hex buffer is kept in lock-step with every
    slider move (`sync_buffer_from_hsl`) so the
    on-disk commit value is always current.
  * Enter commits the same normalised
    `#rrggbb` form the hex mode produces; Esc
    cancels.
  * The shared theme-preview block (fg / bg / border,
    chosen by the field-path suffix) renders below
    the sliders so the candidate colour's effect on
    pane text / background / borders is visible
    immediately.

Standard sRGB ↔ HSL math, no premultiplication, no
gamma adjustment — round-trip preserves the source
colour to within 2 LSB per channel for the
catppuccin palette (locked by
`round_trip_preserves_colour_within_rounding`).
Five HSL unit tests + the parser helper test land
in a new `tests_hsl` module.

496 tests pass (was 491 — +5 HSL math tests).
Zero new dependencies; zero warnings.


