aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-blush-design.md74
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-design.md13
-rw-r--r--docs/superpowers/specs/2026-10-23-companion-music-design.md125
3 files changed, 206 insertions, 6 deletions
diff --git a/docs/superpowers/specs/2026-10-23-companion-blush-design.md b/docs/superpowers/specs/2026-10-23-companion-blush-design.md
new file mode 100644
index 0000000..3ef8c9d
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-23-companion-blush-design.md
@@ -0,0 +1,74 @@
+# Companion: blushing at a wallpaper
+
+She turns to look at the wallpaper behind her and blushes, for the
+wallpapers on a list, when the workspace in front of her is empty enough
+for her to see it.
+
+## Trigger
+
+Checked at two moments, in `Companion.qml`:
+
+- the focused workspace changes or loses a window (`workspacev2`,
+ `closewindow`), so switching to an empty workspace or closing its last
+ window counts;
+- she stops being away: shown (SUPER+\ or the settings switch), back
+ from a remap after another layer opened, moved to another monitor with
+ the focus, back from a dodge, or out of fullscreen.
+
+It fires when all three hold:
+
+- the focused workspace has no windows (`toplevels.values.length === 0`,
+ after `Hyprland.refreshToplevels()`);
+- the wallpaper on her monitor is on the list;
+- at least 3 minutes since the last blush (`T.admireCooldown`).
+
+Her monitor's wallpaper: wallp's `wallp.conf` maps `OUTPUT_H` and
+`OUTPUT_V` to connectors, and `Wallpapers.currentH`/`currentV` say what is
+on each. The connector names are read from `wallp.conf`, not hardcoded. A
+monitor matching neither has no wallpaper and never fires.
+
+## Reaction
+
+A new state in `Mood.js`, `admire`, stamped by `admireAt`:
+
+| since `admireAt` | frame |
+| --- | --- |
+| 0 to 1.5 s (`T.admireLook`) | `watch`, the back view: she turns to the wallpaper |
+| 1.5 to 4.5 s (`T.admireBlush`) | `blush3` |
+
+`watch` and `blush3` exist in both outfits, so no new art is generated.
+
+Priority: below destruct, scorched, dodge, shoved, listen, think, wave and
+clicks; above mock, watch (typing), stretch, muse and idle. A click during
+it wins, as it does over everything below it now.
+
+A line from a `wallpaper` set in `Lines.js` as the blush starts, under the
+usual 30 s line cooldown.
+
+## The list
+
+A plain text file: one absolute path per line, blank lines and lines
+starting with `#` skipped. Paths compare exactly with what wallp writes to
+`wall_h`/`wall_v`.
+
+- Default path `~/.config/keystone/companion-blush.txt`; a missing file is
+ an empty list.
+- The path is `Prefs.companionBlushFile`, with a text field on Settings >
+ Desktop > Companion (`desktop/modules/companion/`).
+- Watched with a `FileView`, so a hand edit applies live.
+- The wallpaper picker (`WallpaperTab.qml`) draws a heart toggle on each
+ thumbnail, filled when the path is listed. Toggling rewrites the file
+ through a `Process` running `cat > <file>`, because
+ `FileView.setText("")` on an emptied list writes no file (AGENTS.md).
+ Comments in the file are kept on rewrite; only the toggled path's line is
+ added or removed.
+
+The parsed list lives in one place both the companion and the picker read,
+a small singleton in `desktop/companion/`.
+
+## Check
+
+`test_mood.js` gains: the `watch` then `blush3` sequence and its end, the
+3 minute cooldown, a click winning over it, and it winning over typing.
+The trigger is checked by hand: an empty workspace with a listed
+wallpaper, a non-empty one, an unlisted wallpaper, each monitor.
diff --git a/docs/superpowers/specs/2026-10-23-companion-design.md b/docs/superpowers/specs/2026-10-23-companion-design.md
index 03feaff..8a8c426 100644
--- a/docs/superpowers/specs/2026-10-23-companion-design.md
+++ b/docs/superpowers/specs/2026-10-23-companion-design.md
@@ -111,7 +111,7 @@ base, so every frame descends from one reference.
| --- | --- |
| klein edit of `base-sitting.png` | `base`: "make her stand upright, facing the viewer, arms relaxed at her sides" |
| img2img from `base`, strength 0.75 | `smile`, `mad`, `smirk`, `blush1`, `blush2`, `blush3`, `sleepy` |
-| klein edit of `base` | `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`, `shoved` |
+| klein edit of `base` | `wave`, `watch`, `point`, `yawn`, `lying`, `listen`, `think`, `muse`, `stretch`, `shoved`, `sing` |
| klein edit of the dress frame | every `leotard/<name>`: "her skirt is gone, revealing a plain white leotard; change nothing else" |
| klein edit of `leotard/base` | `scorched`, leotard only: the moment after the explosion |
@@ -137,11 +137,12 @@ timers.
| 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 | `shoved`, 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` | |
+| 5 | Singing | Sony headphones connected | `sing` | 4 s |
+| 6 | 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 |
+| 7 | Mock | BackSpace, at most once in 20 s | `smirk`, `point` | 2.5 s |
+| 8 | Watching | typing pulse in the last 2 s | `watch` | 2 s after typing stops |
+| 9 | Idle | no input for 1 / 3 / 5 min | `sleepy` / `yawn` / `lying` | any input: `stretch`, then base |
+| 10 | 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
diff --git a/docs/superpowers/specs/2026-10-23-companion-music-design.md b/docs/superpowers/specs/2026-10-23-companion-music-design.md
new file mode 100644
index 0000000..dc30257
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-23-companion-music-design.md
@@ -0,0 +1,125 @@
+# Companion music: design
+
+Date: 2026-10-23. Status: designed, not implemented.
+
+## Goal
+
+The companion reacts to what is playing, not only to headphones
+connecting. When a track starts or changes she DJs behind a console for a
+few seconds, and says a line if she knows the artist. While music plays
+and she is bored, she grooves instead of looking sleepy: whistling,
+headbanging or playing air guitar, with an occasional line. Her artist
+lines are edited in Settings > Companion artists, like the app lines.
+
+## Verified before designing
+
+- **`modules/sound/Player.qml` already picks the real player.** It is a
+ singleton the island reads, filters out the playerctld and
+ plasma-browser-integration duplicates, and exposes `playing`, `artist`
+ and `title`. The companion reads it; it opens no MPRIS of its own.
+- **The app lines page is one parameter away from serving artists.**
+ `PhrasesPage.qml` edits a list of `{cls, lines}` owned by
+ `PhrasesModule.qml`, which saves through `Prefs.setCompanionApps` and
+ falls back to `Lines.apps`. Only the key's label is app specific.
+
+## Behaviour
+
+### DJ burst
+
+A track key is `artist + "\n" + title`. While `Player.playing` is true, a
+key different from the last one seen sets `djAt` to now and becomes the
+last one seen. Resuming the same track after a pause is not a change, so
+it does not DJ. A shell start with music already playing does DJ once:
+unlike Bluetooth, MPRIS has no empty startup window to mistake for a
+connection, and a DJ on launch is harmless.
+
+`Mood.T.djHold` is 6000. In `Mood.pick` the DJ ranks directly below
+`sing`, so headphones connecting together with music starting sings
+first. Its frames are `dj` and `dj-scratch`, cycled by Companion.qml the
+way the wave frames are.
+
+On the burst she says an artist line if one matches, under the 30 s line
+cooldown. No match says nothing.
+
+### Artist matching
+
+`Lines.artistLine(map, artist)`: the first key of the map that is a
+case-insensitive substring of the reported artist wins, and one of its
+lines is picked at random. "daft punk" matches "Daft Punk, Pharrell
+Williams". Short keys can over-match ("air" in "Airbourne"); accepted.
+
+### Groove
+
+`Mood.pick` gains `s.music` (`Player.playing`). At `idle === 1` with
+music, the state is `groove` instead of `bored`; yawning and lying down
+are unchanged, so music does not stop her dozing off and the screensaver
+handoff stays the same.
+
+Reaching idle stage 1, or music starting while already there, picks one
+groove at random: `whistle`, `headbang` or `airguitar`; an unset pick
+reads as `whistle`. The pick is passed to `pick` as `s.groove`, so
+`Mood.js` stays pure. Headbang cycles `headbang-up` and `headbang-down`
+fast; the other two are single frames.
+
+The bored line becomes a `groove` line when music is playing, same
+chance, same cooldown. The opening monologue keeps its bored trigger
+only when there is no music.
+
+## Lines
+
+New reaction group in `Lines.lines`:
+
+ groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."],
+
+Default artists, `Lines.artists`:
+
+ "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."],
+ "evanescence": ["Wake me up inside. ...Operator, don't laugh."],
+ "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."],
+ "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."],
+
+`appList` and `toMap` take the default set as an argument, so the same
+functions serve apps and artists; `appMap` becomes `pickMap(saved,
+defaults)`.
+
+## Settings
+
+- `Prefs.companionArtists`, `setCompanionArtists`, `resetCompanionArtists`,
+ parsed and defaulted like `companionApps`.
+- `PhrasesPage.qml` gains `keyLabel` (default "Class") used for the key
+ field's placeholder and label.
+- `modules/companion/ArtistsModule.qml`: "Companion artists", group
+ Companion, a copy of `PhrasesModule` bound to the artist prefs and
+ defaults, `keyLabel: "Artist"`. Registered in `shell.qml`, which means a
+ restart (see AGENTS.md, Reloading).
+
+## Frames
+
+In `frames.txt`, each with its leotard line (`${LEOTARD}`), 12 in all:
+
+ DECK = A small black DJ console with two turntables and a mixer stands in front of her at waist height, hiding her legs.
+ dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile; she wears black over-ear headphones. ${EYES} ${KEEP}
+ dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP}
+ dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. ${POSE} ${EYES} ${KEEP}
+ dress/headbang-up <- dress/base : She headbangs: head tipped back, hair flying up, eyes shut, fists clenched at her sides. ${BARE} ${KEEP_BARE}
+ dress/headbang-down <- dress/headbang-up : Change only her head: tipped sharply forward and down, hair thrown forward over her face. Everything else exactly the same. ${KEEP_BARE}
+ dress/airguitar <- dress/base : She plays an invisible air guitar: one hand strumming at her hip, the other fretting in the air to the side, knees bent, eyes shut, mouth open in a grin. Nothing in her hands. ${BARE} ${KEEP_BARE}
+
+Prompts are a first draft: klein results get rerolled and reworded as
+before, and what failed goes into `recipe.txt`. The user runs
+`gen-frames`; a missing frame falls back to `base`, so the code can land
+before the art.
+
+## Tests
+
+- `test_mood.js`: dj wins over clicks and loses to sing; groove replaces
+ bored only with music; yawn and lying still win with music; groove
+ frame follows `s.groove`.
+- `test_lines.js`: substring match is case-insensitive and picks the
+ first matching key; no match returns nothing; `appList`/`toMap` round
+ trip with the artist defaults.
+
+## Not doing
+
+- Window title or URL reactions (separate idea, not this spec).
+- Per-artist frames, genre detection, beat sync.