aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-10-23-companion-design.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs/2026-10-23-companion-design.md')
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-design.md215
1 files changed, 215 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-10-23-companion-design.md b/docs/superpowers/specs/2026-10-23-companion-design.md
new file mode 100644
index 0000000..b8910d8
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-23-companion-design.md
@@ -0,0 +1,215 @@
+# Desktop companion: design
+
+Date: 2026-10-23. Status: designed, not implemented.
+
+## Goal
+
+A small chibi 2B (NieR: Automata) who lives in the bottom-right corner of
+the focused monitor, follows focus between DP-1 and DP-3, and reacts to
+what happens on the desktop: notifications shove her, typing makes her
+watch, deleting makes her mock, the assistant gets a face, idling bores
+her, clicks make her blush and, at seven, self-destruct. She talks in short
+fixed English lines in a speech bubble. SUPER+\ shows and hides her.
+
+The idea comes from Modesty's window companion, read for ideas only
+(GPLv3; see AGENTS.md). Nothing here is taken from its code. The
+difference that shapes everything: this desktop runs mostly maximised
+windows, so she lives in a screen corner, not on a window edge.
+
+## Verified before designing
+
+- **The art can be made locally.** Pony Diffusion V6 XL with two LoRAs
+ makes the base frame, img2img makes expressions, FLUX.2 klein makes
+ poses. Recipes, prompts and what failed are in
+ `~/.local/share/keystone/companion/2b/recipe.txt`. Measured on the way:
+ img2img at strength 0.75 changes a face and not an arm, and at 0.85
+ moves a hand halfway and starts to drift; klein moves limbs and keeps
+ the character when every detail to keep is named.
+- **sd.cpp 929 cannot load an SDXL diffusers directory**: it detects it
+ as SD 1.x (leejet/stable-diffusion.cpp#1938). sd-webui's Pony entry now
+ uses the single-file checkpoint, which loads as SDXL.
+- **A resident llama.cpp model starves image generation.** Gemma left
+ 1.1 GB of 12 GB free and the first unet segment failed; an img2img
+ encode needed 4.5 GB. sd-webui unloads llama-server's models at start.
+- **The white background comes off cleanly.** ImageMagick, fuzz 6%,
+ flood fill from the four corners, trim, scale to 256 px tall: the line
+ art stops the fill, so the white hair and the white leotard survive.
+- **The look was checked on screen** with a throwaway shell: 128 px tall,
+ 24 px from the right edge and 16 px from the bottom of DP-1, a dark
+ translucent bubble up and to the left of her head. Approved by eye.
+- **Balloons are bottom-right of DP-1** (`~/.config/notifyd/config.json`,
+ `"corner": "bottom-right"`), her corner. `shared/Notify.qml` already
+ exposes the queue, DND, snooze and the drawer state; the check of
+ which queued balloons are on screen lives in `notifications/`.
+- **Hyprland 0.56.2 has what the inputs need**, per
+ `/usr/share/hypr/stubs/hl.meta.lua`: an `input.keyboard.key` event, a
+ `non_consuming` bind flag, and an `above_fullscreen` layer rule. None
+ has been tried on this machine yet; see Open checks.
+- **SUPER+\ is free**, and the keyboard carries two layouts, `it,us`.
+ The `backslash` keysym sits on a different physical key in each, so the
+ bind uses the physical key, `code:49`, left of 1.
+
+## Where she lives
+
+A component of `desktop/`, a sibling of `island/` and `assistant/`, not a
+drawer module: she has no tile and no page. `desktop/` already hosts the
+assistant, so she reads its state directly. The cost, accepted: restarting
+`desktop/` restarts her.
+
+```
+desktop/companion/
+ Companion.qml the window, inputs, timers, IPC
+ Sprite.qml draws a frame; crossfade, bob, squash, explosion
+ Mood.js which state wins, as a pure function (.pragma library)
+ test_mood.js node check of Mood.js, like assistant/test_md.js
+ lines.js her English lines, grouped by reaction
+ prep-frames bash + ImageMagick: generated images to frames/
+ README.md the component's notes
+```
+
+`desktop/shell.qml` creates one `Companion { asst: assistant }`, the way
+`Island` gets `asst: assistant`.
+
+### The window
+
+A `PanelWindow` anchored bottom and right, on the focused monitor
+(`Hyprland.focusedMonitor`), layer `Top`, `ExclusionMode.Ignore`,
+`WlrKeyboardFocus.None`, namespace `keystone-companion`. Its `mask` is her
+body only, so clicks anywhere else reach the window below; the bubble is
+not in the mask either.
+
+It is the second window that exists for the whole session, alongside the
+keepalive. Hidden, it keeps existing with an empty mask and nothing drawn,
+so a toggle never creates or destroys a surface.
+
+Moving between monitors plays her exit on the old one and her entrance
+on the new: the window's screen changes only once the exit has finished.
+
+### Art
+
+Never in the repo: 2B is Square Enix's character and the repo is public
+under GPLv2. Generated images and their recipe live in
+`~/.local/share/keystone/companion/2b/`; `prep-frames` writes the
+prepared PNGs, 256 px tall with a transparent background, to `frames/`
+inside it. The component loads `frames/<name>.png` and falls back to
+`frames/base.png` when a frame is missing, so it works from the first
+commit with the three frames that exist.
+
+| Method | Frames |
+| --- | --- |
+| Made | `base`, `smile`, `wave` |
+| img2img from `base`, strength 0.75 | `mad`, `smirk`, `blush1`, `blush2`, `blush3`, `sleepy` |
+| klein edit of `base` | `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`, `leotard`, `leotard-scorched` |
+
+Dodging, shoving, the drop-in and the explosion are QML motion, not
+frames. She is drawn 128 px tall; both monitors are at scale 1. No size
+preference: one would need a control in the settings window, and nothing
+asks for it yet.
+
+## Behaviour
+
+One state at a time, the highest that applies. `Mood.js` takes the inputs
+and the time and returns the state; `Companion.qml` owns the inputs and
+timers.
+
+| Priority | State | Trigger | Frame | Ends |
+| --- | --- | --- | --- | --- |
+| 1 | Self-destruct | 7th click of a burst | glow, flash, explosion, then `leotard-scorched` | see below |
+| 2 | Dodge | pointer rests on her 0.6 s with no click | slides out of the corner | returns after 5 s |
+| 3 | Shoved | a balloon is on screen on her monitor | `mad`, slid left by the stack's width | balloons gone |
+| 4 | Assistant | recording / working / reply arrived | `listen` / `think` / `wave` | state ends; wave lasts 3 s |
+| 5 | Clicks | clicks 1-6 of a burst; a burst ends after 1.5 s without one | `smile`, `blush1` to `blush3` | 2 s after the last click |
+| 6 | Mock | BackSpace, at most once in 20 s | `smirk`, `point` | 2.5 s |
+| 7 | Watching | typing pulse in the last 2 s | `watch` | 2 s after typing stops |
+| 8 | Idle | no input for 1 / 3 / 5 min | `sleepy` / `yawn` / `lying` | any input: `stretch`, then base |
+| 9 | Base | nothing else | `base` | |
+
+- **Self-destruct, as in the game.** She glows, says "Self-destruct
+ sequence initiated.", a white flash covers the sprite for about a
+ second with particles and a shockwave ring, and she is back in place
+ without her skirt (`leotard-scorched`, then `leotard`), saying
+ "...That was unpleasant." The skirt returns after 60 s, or at the next
+ hide and show, whichever is first. Every other frame has the skirt, so
+ for those 60 s she stays on `leotard` whatever the state: reactions
+ still happen, as lines and motion, but the frame does not change.
+ ponytail: one skirtless frame; skirtless variants of the others only if
+ the 60 s feel frozen.
+- **Dodge.** She cannot feel the pointer until it is over her: Quickshell
+ has no global pointer and Hyprland sends no motion events. A click
+ before the 0.6 s still reaches her, so clicking her keeps working. If
+ the pointer is still there when she returns, she dodges again.
+- **Shoved** only on DP-1, where the balloons are. A balloon counts when
+ the balloon shell would show it: not withheld by DND, snooze or the
+ open drawer, not expired. That check moves into `shared/Notify.qml`
+ as one function both shells call, so they cannot disagree.
+- **Assistant.** Recording is `asst.recording`; working is `asst.busy`
+ (`thinking`, `tool`, `transcribing`); "reply arrived" is the reply
+ ending, the same moment the canvas's done notification fires.
+- **Idle** is Quickshell's `IdleMonitor`, respecting inhibitors, so
+ presentation mode keeps her awake.
+- **Hidden** while the focused window is fullscreen, while the screen is
+ locked, and while toggled off.
+
+### Speech
+
+Fixed English lines from `lines.js`, picked at random per reaction, for
+example "...Operator, I'm bored.", "Typo again?", "Hey.", "Answer's
+ready.", "Glory to mankind.". A bubble shows 3 s. At most one line per
+30 s, except clicks and the self-destruct.
+
+The game's opening monologue, verbatim:
+
+> Everything that lives is designed to end. We are perpetually trapped in
+> a never-ending spiral of life and death. Is this a curse? Or some kind
+> of punishment? I often think about the god who blessed us with this
+> cryptic puzzle...and wonder if we'll ever get the chance to kill him.
+
+It plays with a 1-in-4 chance on entering bored and 1-in-3 on being shown
+with SUPER+\, never more than once an hour. It types out at about 15
+characters a second, one sentence per bubble, the last held 4 s, over the
+`muse` frame. Any state above idle cuts it short.
+
+## Inputs from Hyprland
+
+In the live config, `~/.config/hypr/sections/`, not this repo:
+
+- `hl.bind("SUPER + code:49", ...)` runs
+ `qs -p <desktop> ipc call companion toggle`.
+- A hook on `input.keyboard.key` dispatches a custom `keystone-typing`
+ event at most once a second. It reads no key data.
+- A `non_consuming` bind on BackSpace dispatches `keystone-delete`, so
+ the key still reaches the focused window. BackSpace is the only key
+ the companion learns about.
+
+`Companion.qml` reads both through `Hyprland.rawEvent`, the stream the
+island already uses.
+
+## Testing
+
+- `node desktop/companion/test_mood.js`: the priority table, the click
+ burst (1 smiles, 2-6 blush harder, 7 self-destructs, a 1.5 s gap
+ resets), the 20 s mock cooldown, the 30 s line cooldown, the hourly
+ monologue cap, the idle thresholds.
+- `prep-frames` checked on `base.png`: alpha present, 256 px tall, the
+ hair not eaten (corner pixel transparent, a hair pixel opaque).
+- By hand, since it is visual: each reaction once, monitor hand-over,
+ fullscreen and lock hiding, the dodge, and that BackSpace still deletes
+ in a terminal and a browser with the bind active.
+
+## Open checks
+
+Each is tested before the code that depends on it:
+
+1. A `non_consuming` BackSpace bind still deletes in the focused window.
+2. The `input.keyboard.key` hook fires and its dispatch reaches
+ `Hyprland.rawEvent`.
+3. A `Top` layer surface is covered by a fullscreen window, so the
+ fullscreen rule may be the compositor's and not code. If not, hide on
+ the focused toplevel's fullscreen flag.
+
+## Out of scope
+
+- Generated lines (Gemma): slow, and it would hold VRAM against sd-webui.
+- A size preference or any other setting.
+- Following the focused window's edge, Modesty-style.
+- Italian lines.