diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 78564d9..5a746f4 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,21 +1,38 @@
name: Bug report
-description: Something in Brow does not work the way it should.
+description: Something in Brow or the brow command-line tool does not work the way it should.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
- Thanks for reporting a bug. With a notch app, the Mac, the display and the accounts matter as much as the steps, so the form asks for each.
+ Thanks for reporting a bug. With a notch and menu-bar app, the Mac, the display and the accounts matter as much as the steps, so the form asks for each.
- Questions and setup help: [Discussions → Q&A](https://github.com/efim0v/brow/discussions/categories/q-a).
- Security problems: never here. See [SECURITY.md](https://github.com/efim0v/brow/blob/main/SECURITY.md).
- - Never paste a token, a Keychain item or a `.credentials.json` file. The panel shows account names or emails; blur them in screenshots if you prefer.
+ - **"cmux unavailable" while cmux is running** has a known cause and fix in [troubleshooting](https://github.com/efim0v/brow/blob/main/docs/troubleshooting.md).
+ - Never paste a token, a Keychain item or a `.credentials.json` file. Logs and command output can contain account emails, project paths and repository names: look through what you paste, and blur screenshots if you prefer.
+
+ - type: dropdown
+ id: component
+ attributes:
+ label: Which part of Brow
+ multiple: true
+ options:
+ - "Notch and limits: the readouts, the panel, limit warnings, copy my week"
+ - "Accounts and sign-in: adding an account, Sign in again, browser profiles, Keychain access"
+ - "Workspaces and sessions: projects, workspaces, Resume as…, Migrate to…, Share, Graph, Stats"
+ - Transcript safety net
+ - Settings
+ - brow command-line tool
+ - Something else
+ validations:
+ required: true
- type: textarea
id: what-happened
attributes:
label: What happened
- description: What you did, what you expected, and what Brow did instead. A screenshot or screen recording of the notch, the panel or Settings helps a lot.
+ description: What you did, what you expected, and what Brow did instead. A screenshot or screen recording of the notch, the panel, Workspaces or Settings helps a lot.
validations:
required: true
@@ -31,8 +48,8 @@ body:
id: brow-version
attributes:
label: Brow version
- description: "Run `plutil -extract CFBundleShortVersionString raw /Applications/Brow.app/Contents/Info.plist`. For a build of your own, give the commit."
- placeholder: "0.2.0"
+ description: "Run `plutil -extract CFBundleShortVersionString raw /Applications/Brow.app/Contents/Info.plist`, or read it at the bottom of the Workspaces panel. For a build of your own, give the commit."
+ placeholder: "0.4.0"
validations:
required: true
@@ -96,6 +113,20 @@ body:
validations:
required: true
+ - type: input
+ id: cmux-version
+ attributes:
+ label: cmux version
+ description: "For Workspaces problems: run `cmux --version` in a cmux terminal. Leave it empty if you don't use cmux."
+ placeholder: cmux 0.x
+
+ - type: textarea
+ id: doctor
+ attributes:
+ label: Output of brow doctor --json
+ description: "For Workspaces problems, if you have the command-line tool (`brow-cli.zip` from the release, or `brew install efim0v/tap/brow-cli`): paste what `brow doctor --json` prints (`grove doctor --json` with the older tool). It shows the git, Claude Code and cmux that Brow finds."
+ render: json
+
- type: markdown
attributes:
value: |
@@ -113,7 +144,9 @@ body:
log show --last 1h --info --style compact --predicate 'subsystem == "dev.artemefimov.brow"'
```
- The log names account folders and organization IDs. Look it over and remove anything you would rather not post.
+ The log names account folders, project folders and organization IDs. Look it over and remove anything you would rather not post.
+
+ For cmux problems a probe report helps most. Run `open -nW /Applications/Brow.app --args --cmux-probe /tmp/cmux-probe.txt` and attach `/tmp/cmux-probe.txt` ([more on the probe](https://github.com/efim0v/brow/blob/main/docs/troubleshooting.md#cmux-diagnostic-probe)). The probe opens and closes a test workspace in cmux, and the report lists your environment variables, so remove secrets before you attach it.
- type: textarea
id: log
@@ -126,4 +159,14 @@ body:
id: context
attributes:
label: Anything else
- description: "Did it start after an update of Brow, Claude Code or macOS? Do you also use Grove? Which browser opens the account profiles? Is the `claude -p` fallback on (Settings → Accounts)?"
+ description: "Did it start after an update of Brow, Claude Code or macOS? Is Grove.app still installed or running? Which browser opens the account profiles? Is the `claude -p` fallback on (Settings → Accounts)? For Workspaces: how your repositories are laid out (how many, side by side or nested)."
+
+ - type: checkboxes
+ id: checks
+ attributes:
+ label: Before you submit
+ options:
+ - label: I searched the existing issues for this problem.
+ required: true
+ - label: I removed tokens, emails and private paths from what I pasted.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 177fa67..495d897 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -3,9 +3,9 @@ contact_links:
- name: Question or setup help
url: https://github.com/efim0v/brow/discussions/categories/q-a
about: Ask how something works, or get help getting Brow running.
- - name: Grove (workspaces and sessions)
- url: https://github.com/efim0v/grove/issues
- about: Brow's companion app has its own issue tracker.
+ - name: Report a security vulnerability
+ url: https://github.com/efim0v/brow/security/advisories/new
+ about: Report it privately here, never in a public issue.
- name: Claude Code
url: https://github.com/anthropics/claude-code/issues
about: Problems with Claude Code itself go to Anthropic's repository.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 65617b0..3411ab4 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -5,7 +5,20 @@ body:
- type: markdown
attributes:
value: |
- Brow shows the limits of every Claude Code account in the notch and signs each account in with one click. Ideas about workspaces, worktrees or moving sessions between accounts belong to [Grove](https://github.com/efim0v/grove). To talk an idea through before filing it, use [Discussions → Ideas](https://github.com/efim0v/brow/discussions/categories/ideas).
+ Brow shows the limits of every Claude Code account in the notch, signs each account in with one click, and runs parallel Claude Code sessions in workspaces of git worktrees (Workspaces, in the menu bar). To talk an idea through before filing it, use [Discussions → Ideas](https://github.com/efim0v/brow/discussions/categories/ideas).
+
+ - type: dropdown
+ id: component
+ attributes:
+ label: Which part of Brow
+ options:
+ - Notch and limits
+ - Accounts and sign-in
+ - Workspaces and sessions
+ - brow command-line tool
+ - Something else
+ validations:
+ required: true
- type: textarea
id: problem
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 9b8dad2..5b48fcc 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -9,7 +9,13 @@
-- [ ] `swift build` and `swift test` pass
+- [ ] `Scripts/test-in-empty-home.sh` passes (the whole suite, with the home folder pointed at an empty one)
+- [ ] `Scripts/test-in-empty-home.sh -c release` passes
- [ ] Tests added or updated for changed behaviour
+- [ ] UI change: screenshots attached (`swift run BrowApp --snapshot
` renders the Workspaces screens)
- [ ] README or `docs/` updated where behaviour changed
+
+## Worth flagging
+
- [ ] No token is logged, written to disk or sent anywhere but Anthropic's usage and profile endpoints
+- [ ] Changes what Brow writes to Claude Code's files, and the README's "What Brow does on your machine" is updated to match
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 78a3459..9c6e222 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,7 +21,7 @@ jobs:
# Package.swift targets macOS 26 with swift-tools-version 6.2, but Swift 6.2.4
# cannot compile BrowKit, so this relies on the image's newer default Xcode.
runs-on: macos-26
- timeout-minutes: 20
+ timeout-minutes: 45
steps:
- uses: actions/checkout@v7
with:
@@ -30,18 +30,33 @@ jobs:
- name: Show versions
run: |
sw_vers
+ xcode-select --print-path
xcodebuild -version
swift --version
- # Package.resolved is git-ignored, so every run resolves grove afresh;
- # the tree shows which version it got.
- - name: Resolve dependencies
- run: |
- swift package resolve
- swift package show-dependencies
+ # The package has no dependencies: GroveCore, GroveAppKit, BrowKit, the app
+ # and the command-line tool all build from this repository. A clean build of
+ # every target that fails on any warning the script does not list.
+ - name: Build with no new warnings
+ run: Scripts/check-warnings.sh
+
+ # HOME and CFFIXED_USER_HOME point at an empty folder; the script fails if the
+ # suite leaves Brow's or Grove's data, or Claude Code's folders, in it.
+ - name: Test in an empty home folder
+ run: Scripts/test-in-empty-home.sh
- - name: Build
- run: swift build
+ # Release builds are optimized (-O). ScanWorktreeCollectionTests covers a
+ # release-only miscompile (see WorkspaceService.collectWorktrees).
+ - name: Test (release)
+ run: Scripts/test-in-empty-home.sh -c release
- - name: Test
- run: swift test
+ - name: Build the app and the tool
+ run: |
+ Scripts/build-brow.sh
+ codesign --verify --deep --strict --verbose=2 dist/Brow.app
+ dist/brow --version
+
+ # Every .swift, .sh and .py file starts with the SPDX licence line and the
+ # copyright line.
+ - name: Licence headers
+ run: Scripts/check-headers.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b612446..0d4ddf8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.4.0] - 2026-10-18
+
+Grove is now part of Brow. Its Workspaces engine — workspaces of git worktrees across several repositories, the Claude Code sessions in them and moving sessions between accounts — runs inside Brow, merged into this repository with its history. Grove's own changelog is in [`docs/history/grove-changelog.md`](docs/history/grove-changelog.md). The notch also gets a pet that eats what your accounts use.
+
+### Added
+
+- **Workspaces**, in the menu bar: Grove's panel — projects, workspaces, sessions, Resume as…, Migrate to…, Share, the commit graph and the code statistics — on Liquid Glass, behind Brow's mark, the notch with two dots. It is off until you use it: for someone who never used Grove nothing of it runs, and nothing is read or written for it. It turns on by itself for someone who did, with their projects, accounts and settings ([what changes](README.md#coming-from-grove)).
+- While Grove.app is running, Workspaces waits and offers Quit Grove, so the two never write the same files. It also runs in one copy of Brow at a time: a second install or a development build waits while another copy runs it.
+- The notch panel gets a Workspaces button, which also opens Workspaces when macOS hides the menu-bar item behind the notch. Opening Brow again from Finder or Spotlight shows Workspaces while it is on, and Settings otherwise. Brow's menu gets Show Workspaces.
+- Every account picker in Workspaces — the launch sheet, Resume as…, Migrate to…, the Accounts screen — shows that account's 5-hour and weekly use as the notch reads it. Workspaces names each account as the notch does, by the name given in Settings → Accounts, and writes its plan the same way (Max 20x).
+- One Settings window with four tabs. General gains Show Workspaces in the menu bar. Each card in Accounts gains a Workspaces section: Share sessions, Capture the status line, and the account's default model and effort; a Workspaces account with no card of its own, such as a second folder of the same login, gets a section after the cards, and one whose folder is gone can be removed from Workspaces there. Workspaces holds the projects, the workspace folder, the transcript safety net and the panel material. About has the version, the licence and the credits.
+- The command-line tool is `brow`, with `grove` as a second name, so scripts written for Grove's tool keep working unchanged. `brow --version` prints Brow's version. It ships as `brow-cli.zip`.
+- The model pickers offer Claude Code's aliases (`opus`, `sonnet`, `haiku`), pinned versions, and Other… for any model id.
+- Settings → General → Show Brow in the notch. Off, Brow draws nothing at the top of the screen and no panel opens there, for someone who uses Brow for Workspaces only. Limit warnings still arrive, and opening Brow again shows Workspaces or Settings.
+- **Glim, a pet that eats your limits.** A small creature lives in the black beside the notch, below it, or in the pill, and eats what your accounts use: when the 5-hour number goes up, it takes a bite, drawn as a bead of light that leaves the number untouched. Hours at the Mac without Claude Code make it hungry; it never gets hungry while you are away, and its light is its hunger meter. When the 5-hour limit is used up it falls asleep full until the window resets; when the weekly limit is used up it hibernates in its den behind the camera until the weekly reset. It perks up when a prompt is written to an account's `history.jsonl`, which Brow asks macOS to watch and never reads. Hover the notch and it hops into a new first row of the panel, with its state, its last meal and a hunger meter; a click on it boops it. It draws only while someone can see it, follows Reduce Motion, Reduce Transparency and Increase Contrast, and keeps its state in `~/Library/Application Support/Brow/pet.json`. On by default; Settings → General → **Show the pet** turns it off, and with Show Brow in the notch off there is no pet at all.
+
+### Changed
+
+- Capturing an account's status line is a choice per account. Grove installed its wrapper into every account's `settings.json` at launch; Brow installs it only where you turn capture on, and keeps it where Grove already had it. Claude Code keeps running the wrapper while Workspaces is off, so Settings → Accounts keeps the switch that gives the status line back, whether Workspaces is on or off.
+- The transcript safety net is off for a setup Brow creates; a Grove setup keeps its setting.
+- The transcript safety net no longer puts a transcript back by itself. The transcripts that vanish are listed in Settings → Workspaces, newest first, with Restore: a period passed to `claude --settings` cannot be read from outside, so only you can tell a transcript deleted by accident from one cleaned up on purpose.
+- Sessions Workspaces starts use Brow's own browser router and Settings → General → Path to claude.
+- Brow's settings decode field by field: a value that cannot be read costs that one setting instead of all of them, and the file as it was is kept as `config.json.bak` ([#7](https://github.com/efim0v/brow/issues/7)).
+- One log subsystem, `dev.artemefimov.brow`, for the whole app.
+
+### Fixed
+
+- The transcript safety net no longer brings back, or keeps, transcripts Claude Code's own retention deleted. It restored every mirrored transcript under 90 days old; it now reads `cleanupPeriodDays` from your organization's managed settings, server-managed settings, the account's settings and the project settings of every folder the account's sessions ran in, takes the shortest — for a workspace shared between accounts, the shortest of all of theirs — and keeps nothing past it. Migrate to… follows the same rule when it falls back on the safety net's copy ([efim0v/grove#6](https://github.com/efim0v/grove/issues/6)).
+- Opening Accounts no longer shows a red "unknown session store" banner for the folders Claude Code itself writes ([efim0v/grove#7](https://github.com/efim0v/grove/issues/7)).
+- Launch no longer re-creates an account folder you deleted ([efim0v/grove#12](https://github.com/efim0v/grove/issues/12)).
+- Reading a token through the `security` tool gives up after ten seconds, as it was meant to ([efim0v/grove#9](https://github.com/efim0v/grove/issues/9)).
+- The model pickers no longer miss new models ([efim0v/grove#10](https://github.com/efim0v/grove/issues/10)).
+- The launch-at-login toggle can no longer show on while it is off, and says when macOS is waiting for your approval ([#8](https://github.com/efim0v/brow/issues/8)).
+- The build has no compiler warnings again ([efim0v/grove#11](https://github.com/efim0v/grove/issues/11)).
+- With Show readouts next to the notch off, a display without a notch no longer shows an empty black pill: nothing is drawn until the pointer reaches the top centre, as the setting says.
+
## [0.3.1] - 2026-10-15
### Fixed
@@ -77,7 +113,8 @@ First public release: the usage and rate limits of every Claude Code account, in
- Background token refresh for idle accounts. If it stops working, Brow falls back to a tiny `claude -p` request, which spends a little limit and starts that account's 5-hour window. The fallback is on by default; turn it off in Settings → Accounts.
- A prebuilt `Brow.zip` for macOS 26 or later on Apple silicon, signed ad hoc and not notarized, with `SHA256SUMS.txt` to verify it.
-[Unreleased]: https://github.com/efim0v/brow/compare/0.3.1...HEAD
+[Unreleased]: https://github.com/efim0v/brow/compare/0.4.0...HEAD
+[0.4.0]: https://github.com/efim0v/brow/compare/0.3.1...0.4.0
[0.3.1]: https://github.com/efim0v/brow/compare/0.3.0...0.3.1
[0.3.0]: https://github.com/efim0v/brow/compare/0.2.3...0.3.0
[0.2.3]: https://github.com/efim0v/brow/compare/0.2.2...0.2.3
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e4b8913..441c3ca 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,9 +1,9 @@
# Contributing to Brow
-Thanks for helping. Brow is a small app with one maintainer, so this guide is short: where to ask, what fits, how to build and test, and how to send a change.
+Thanks for helping. Brow has one maintainer, so this guide stays short: where to ask, what fits, how to build and test, and how to send a change.
- **Questions and setup help:** [Discussions → Q&A](https://github.com/efim0v/brow/discussions/categories/q-a).
-- **Bugs:** [open an issue](https://github.com/efim0v/brow/issues/new/choose) with the bug report form. It asks for the versions, the Mac and the log needed to track a problem down.
+- **Bugs:** [open an issue](https://github.com/efim0v/brow/issues/new/choose) with the bug report form. It asks which part of Brow the bug is in, and for the versions, the Mac and the log needed to track it down.
- **Ideas:** the feature request form, or [Discussions → Ideas](https://github.com/efim0v/brow/discussions/categories/ideas) to talk one through first.
- **Security problems:** never in public. See [SECURITY.md](SECURITY.md).
@@ -11,68 +11,87 @@ Everyone taking part follows the [code of conduct](CODE_OF_CONDUCT.md).
## What fits in Brow
-Brow has one job: show the 5-hour and weekly limits of every Claude Code account on the Mac, in the notch, and sign each account in with one click ([What it does](README.md#what-it-does)). Fixes and improvements to that job are welcome, and so are fixes for a Mac or display that Brow draws wrongly on.
+Brow has two surfaces ([What it does](README.md#what-it-does)):
-Some things live elsewhere:
+- **the notch**: the 5-hour and weekly limits of every Claude Code account on the Mac, one-click sign-in for each, and the pet that eats what they use;
+- **Workspaces**, in the menu bar: workspaces of git worktrees across several repositories, the Claude Code sessions in them, and moving sessions between accounts — with the commit graph, the code statistics and the `brow` command-line tool.
-- **Workspaces, worktrees and moving sessions between accounts** are [Grove](https://github.com/efim0v/grove)'s job.
-- **Account discovery, credentials, usage readings and token refresh** come from `GroveCore`, a library in the grove repository ([how the two fit together](docs/grove-and-brow.md)). A change there lands in grove first and gets a release; Brow then raises the version it depends on.
-- **Problems with Claude Code itself** belong in [anthropics/claude-code](https://github.com/anthropics/claude-code/issues).
+Fixes and improvements to either are welcome, and so are fixes for a Mac or display that Brow draws wrongly on. Problems with Claude Code itself belong in [anthropics/claude-code](https://github.com/anthropics/claude-code/issues), and problems with cmux in [manaflow-ai/cmux](https://github.com/manaflow-ai/cmux/issues). If Brow reports cmux as unavailable while cmux is running, read [`docs/troubleshooting.md`](docs/troubleshooting.md) first.
-The promises in [What Brow does on your machine](README.md#what-brow-does-on-your-machine) have to stay true: a change must not write a token to disk, log it, or make Brow send anything anywhere but Anthropic's usage and profile endpoints.
+Three things to keep in mind:
+
+- **[What Brow does on your machine](README.md#what-brow-does-on-your-machine) has to stay true.** A change must not write a token to disk, log it, or send anything anywhere but Anthropic's usage and profile endpoints. A change to what Brow writes into Claude Code's own files — each account's `settings.json` and `.claude.json`, its session folders — updates that section in the same pull request.
+- **Claude Code's JSON files are written through `HotJSONFile` only**, which takes Claude Code's own lock, writes nothing when nothing changes and never overwrites a file that does not parse. And nothing of Workspaces runs, or writes, until the user has turned it on (`WorkspacesActivation`).
+- **No third-party dependencies.** The package has none, on purpose (see [COVERAGE.md](COVERAGE.md)).
For anything bigger than a small fix, open an issue first, so the approach is agreed before you spend time on code.
## Build, run and test
-You need macOS 26 or later and Xcode with Swift 6.2 or newer. The only dependency, `GroveCore`, is fetched from the grove repository on the first build.
+You need macOS 26 or later, Xcode with Swift 6.4 or newer, and git.
```sh
git clone https://github.com/efim0v/brow.git
cd brow
-swift build # debug build
-swift test # the whole suite
-swift test --filter LimitsAggregateTests # one test class
-Scripts/build-brow.sh # release build -> dist/Brow.app
-open dist/Brow.app # Scripts/build-brow.sh --launch does both
+swift build # debug build: libraries, app and CLI
+Scripts/test-in-empty-home.sh # the whole suite, about three minutes
+Scripts/test-in-empty-home.sh -c release # the same suite, optimized
+swift test --filter LimitsAggregateTests # one test class
+Scripts/check-warnings.sh # a clean build that fails on new warnings
+Scripts/build-brow.sh # release build -> dist/Brow.app and dist/brow
```
-- The tests need no network, no Keychain access and no Claude account. One test is skipped on purpose: it renders the README screenshots and runs only when `DEMO_SCREENSHOTS_DIR` is set.
-- `Scripts/build-brow.sh` signs the app ad hoc unless `GROVE_SIGN_IDENTITY` names a code-signing identity of yours, such as `Apple Development: Name (TEAMID)`. With an ad hoc signature macOS forgets Brow's Keychain and Automation permissions on every rebuild. The script expects Xcode at `/Applications/Xcode.app` (see [`Scripts/xcode-env.sh`](Scripts/xcode-env.sh)).
-- Brow does not check for a second copy of itself, so quit the installed one before you open your build: open its Settings from the ⚙ in the panel and press ⌘Q while that window is in front.
+- `Scripts/test-in-empty-home.sh` runs `swift test` with the home folder pointed at an empty one and fails if the suite leaves Brow's or Grove's data, or Claude Code's folders, in it. Plain `swift test` works too, but only the script proves a test kept out of your own `~/.claude`.
+- Both the debug and the release run should pass before a pull request. The optimized run is not a formality: release builds have miscompiled the engine before (see `WorktreeScanResult` in `Sources/GroveCore/Services/WorkspaceService.swift`).
+- The suite runs offline and needs no Claude account and no Keychain access. A few tests are skipped unless you ask for them: `CmuxLiveTests` talks to a running cmux (`GROVE_LIVE_CMUX=1`), the two `DemoScreenshotTests` render the README screenshots (`DEMO_SCREENSHOTS_DIR`), the review renders write to `BROW_REVIEW_DIR`, and the pet's films and stills, its cost measurements and its longest sweeps wait for `PET_SHOWREEL_DIR`, `PET_FINAL_DIR`, `PET_PERF` and `PET_SWEEP`.
+- Some tests are timed. `ISODateParsingTests` gives 100,000 date parses one second, so it can fail on a heavily loaded Mac; run it again on its own before you dig in.
+- New tests follow the existing pattern: fixtures, temporary directories and injected runners rather than the real `~/.claude`, the network or a running cmux. An `AppState` built on a temporary config file keeps every folder it writes beside that file (`WorkspacesFolders`). [COVERAGE.md](COVERAGE.md) describes how each layer is tested.
+
+To run what you built:
+
+- **A build you run is Brow for real.** It reads and writes the same `~/Library/Application Support/Brow` and, with Workspaces on, `~/Library/Application Support/Grove` as an installed copy. Workspaces runs in one copy at a time (the second waits for the first, see [troubleshooting](docs/troubleshooting.md#workspaces-says-it-is-open-in-another-brow)), but the notch runs in both, so quit the installed one first (⌘Q with its Settings window in front, or **Quit Brow** in the Workspaces panel).
+- `Scripts/dev-run.sh` builds and runs the debug binary under its own identifier, `dev.artemefimov.brow.dev`, so rebuilds cannot disturb the installed app's menu-bar slot or permissions.
+- Both build scripts sign ad hoc unless `BROW_SIGN_IDENTITY` names a code-signing identity of yours, such as `Apple Development: Name (TEAMID)`. With an ad hoc signature macOS forgets Brow's Keychain and Automation permissions on every rebuild.
+- `swift run BrowApp --snapshot /tmp/brow-snap` renders the Workspaces screens to PNG from made-up data and exits without starting the app; see [`docs/snapshot-testing.md`](docs/snapshot-testing.md). `swift run brow --help` and `swift run brow doctor` run the command-line tool.
- To watch the logs while you work:
```sh
log stream --level info --predicate 'subsystem == "dev.artemefimov.brow"'
```
- Brow's own categories are `limits`, `tokens` and `panel`; GroveCore's `TokenKeeper`, `ProcessRunner` and `keychain` log under the same subsystem.
-- The README screenshots are rendered from made-up accounts:
+ The notch logs under the categories `limits`, `tokens`, `panel` and `notify`, its pet under `pet`, Workspaces under `workspaces` and `perf`; `TokenKeeper`, `ProcessRunner`, `ClaudeService` and `keychain` log under the same subsystem.
+- The README screenshots are rendered from made-up accounts and projects, one set of accounts (`Tests/DemoFixture`) for the notch, Settings and Workspaces, so the pictures agree where they meet:
```sh
DEMO_SCREENSHOTS_DIR="$PWD/docs/screenshots" swift test --filter DemoScreenshotTests
```
- The panel shots carry the current date and time, so they can change between runs. Commit them only with a change that alters what they show.
+ The notch's shots are drawn at 09:41 today, and Workspaces' ages count back from the moment of the render, so the pictures change from one day to the next. Commit them only with a change that alters what they show.
-[Layout](README.md#layout) in the README says where the code lives. [`docs/brow.md`](docs/brow.md) describes the behaviour in detail, and [`docs/grove-and-brow.md`](docs/grove-and-brow.md) lists the contracts Brow and Grove share on disk.
+[Layout](README.md#layout) in the README says where the code lives. [`docs/brow.md`](docs/brow.md) and [`docs/workspaces.md`](docs/workspaces.md) describe the behaviour in detail, and [`docs/architecture.md`](docs/architecture.md) lists the modules and the contracts between them.
+
+## Style
+
+- Match the code around your change: four-space indentation, and names and structure like the neighbouring code. There is no formatter or linter to run.
+- Every target builds in the Swift 5 language mode (`Package.swift`). `Scripts/check-warnings.sh` must find no new warnings.
+- Every `.swift`, `.sh` and `.py` file starts with the licence header (`Scripts/check-headers.sh` checks it).
+- Comments say why. Many lines carry a note about the bug they fixed; keep yours in that spirit, and keep a comment until what it explains is gone.
+- Log through `BrowLog` or `GroveLog`. Never log a token or anything read from a Keychain item.
+- `GroveAppKit` never imports `BrowKit`: what the Workspaces panel needs from the app comes in through closures and protocols such as `AccountLimitsProviding`.
+- UI changes: re-render the affected screens and look at them. If a change alters a screen the README shows, regenerate its screenshots.
+- Commit subjects follow the history: a type, an optional scope, and a lower-case summary of what changes for the user, such as `fix(brow): reset times in the bars carry the date, not the weekday` or `fix(core): the transcript mirror keeps only what Claude Code keeps`.
## Pull requests
-- One change per pull request, branched from `main`. Small is easier to review.
-- `swift build` and `swift test` pass. New behaviour comes with a test in `Tests/BrowKitTests`; a fixed bug comes with a test that fails without the fix.
-- For a visible change, attach a screenshot or a short recording and name the Mac you checked it on. Notch geometry is tuned by eye against the physical bezel (the constants are in [`NotchGeometry.swift`](Sources/BrowKit/Panel/NotchGeometry.swift)), so the model and screen size matter.
-- Update the README or `docs/` when behaviour changes.
-- Keep the [runtime contracts](docs/grove-and-brow.md#runtime-contracts) with Grove intact. `accountKey` in particular exists twice, in GroveCore and in `Resources/brow-browser.sh`, and both must compute the same value.
-- The maintainer reviews pull requests as time allows and may ask for changes.
+1. Fork the repository and branch off `main`.
+2. Keep one change per pull request; a small one is easier to review.
+3. Run both test scripts. New behaviour comes with a test; a fixed bug comes with a test that fails without the fix.
+4. Open the pull request and fill in the template: what changed and why, how you tested it, screenshots for UI changes (name the Mac you checked a notch change on — the outline is tuned by eye, see [`NotchGeometry.swift`](Sources/BrowKit/Panel/NotchGeometry.swift)), and whether the change touches Claude Code's files.
+5. Keep the [contracts](docs/architecture.md#contracts) intact. `accountKey` in particular exists twice, in `GroveCore` and in `Resources/brow-browser.sh`; `BrowserRouterContractTests` holds the two together.
-Brow is licensed under the [GNU GPL v3](LICENSE); what you contribute is licensed under the same terms.
+With one maintainer, a review can take a few days.
-## Style
+## License
-- Match the code around your change: four-space indentation, and names and structure like the neighbouring code. The repository has no formatter or linter configuration.
-- Comments say why. Many lines carry a note about the bug they fixed; keep yours in that spirit, and keep a comment until what it explains is gone.
-- Log through `BrowLog` ([`Sources/BrowKit/BrowLog.swift`](Sources/BrowKit/BrowLog.swift)). Never log a token or anything read from a Keychain item.
-- Keep the build free of compiler warnings.
-- Commit subjects follow the history: a type, an optional scope, and a lower-case summary of what changes for the user, such as `fix(brow): reset times in the bars carry the date, not the weekday` or `docs: say what Brow is in the first line`.
+Brow is licensed under the [GNU GPL v3](LICENSE). By sending a pull request you agree that your contribution is released under the same licence.
diff --git a/COVERAGE.md b/COVERAGE.md
new file mode 100644
index 0000000..456aa03
--- /dev/null
+++ b/COVERAGE.md
@@ -0,0 +1,54 @@
+# Test coverage policy
+
+Run `Scripts/coverage.sh` to measure coverage and enforce the gate.
+
+## The gate: logic layer ≥ 95%
+
+Brow is a SwiftUI app with two surfaces. Per standard practice for UI apps, the
+unit-coverage **gate** is the **pure / mockable logic layer** of the engine it took
+over from Grove — it must stay at **≥ 95%** line coverage:
+
+- `GroveCore` services (git, claude, statusline capture reader, workspace, shared
+ store, the transcript safety net and Claude Code's retention, statusline, config
+ store, process runner — and the rate-limit model, OAuth clients, token keeper and
+ account directory the notch builds on),
+- `GroveCore` models (config, errors, paths, folders),
+- `GroveAppKit/Presentation/*` (all the pure view-models),
+- `GroveAppKit/State/*` (`AppState`, routing, account limits).
+
+These are tested directly with fixtures, temp dirs, and injected runners/fetchers
+(no real network, git, cmux, or `~/.claude` access).
+
+`BrowKit` is reported on a line of its own, not gated. It measures `Model/*`,
+`State/*` and all of `Pet/*`: the store, settings, activation and limit-warning logic
+and the pet's brain, rig and words, unit-tested the same way, and also the pet's views
+(`PetCanvas`, `PetLayerView`), its senses (`PetSenses`) and its perf log (`PetPerfLog`),
+which render tests and `PetSensesTests` cover only in part. BrowKit's other views, its
+panel and its app delegate are left out, as in the table below.
+
+## Excluded from the gate (validated by other means)
+
+These are **not** unit-testable in isolation; covering them to 95% would require
+real external infrastructure or UI automation, which would be brittle and pull in
+dependencies the app deliberately avoids (it has none):
+
+| Excluded | Why | How it's validated instead |
+|----------|-----|----------------------------|
+| `GroveAppKit/Views/*`, `DesignSystem`, `GroveLog` | SwiftUI rendering + interactive `@State`/button closures | **Snapshot/render tests** (`SnapshotModeTests`, `DashboardRenderTests`, `ViewStatesRenderTests`) render every scene — both snapshot and live branches — and assert non-empty output, catching layout/render crashes. |
+| `WorkspacesMenuBarController` | `NSStatusItem`/`NSPanel` bootstrap | Its anchor logic is unit-tested (`PanelAnchorTests`); the rest by running the app. |
+| `BrowKit/Views/*`, `BrowKit/Panel/*`, `BrowKit/App/*` | The notch window, SwiftUI views, the app delegate | Render and pixel tests (`PanelRefreshGateTests`, `EarsViewFillTests`, `PanelViewShapeTests`, `BrowMarkTests`), the host's own tests (`WorkspacesHostTests`), and the app. |
+| `Services/CmuxService`, `Services/CmuxAppleScript` | Drive the real cmux CLI / control socket / `osascript` + a live shell handshake | Their pure/parse logic IS unit-tested with a scripted runner (`CmuxServiceTests`, `CmuxAppleScriptBackendTests`); the real-socket/osascript paths are validated manually and via `Brow --cmux-probe`. |
+| `Diagnostics/CmuxProbe.swift` | A manual diagnostic that benchmarks the real cmux socket | Run by hand: `open -nW /Applications/Brow.app --args --cmux-probe `. |
+| `Snapshot/SnapshotMode.swift` | Is itself the test/render harness | n/a |
+| `BrowApp`, `brow-cli` | Entry points | `brow`'s name handling and help text are in `GroveCore` and unit-tested (`ToolNameTests`). |
+
+The overall (all-files) number is also printed for transparency; it is lower
+because it includes the excluded view/integration code above.
+
+## Where it stands
+
+At 0.4.0 the gate measures about 93 %, under the policy, and `coverage.sh` exits 1.
+Nearly all of the gap came in with Grove's code: `TerminalFocus` has no tests, and
+`GitStatsService`, `ClaudeService`, `ClaudeCredentials`, `ProcessRunner` and
+`SessionMigration` hold most of the other untested lines. CI does not run
+`coverage.sh`, so no build fails on it; bringing the gate back to 95 % is open work.
diff --git a/Package.swift b/Package.swift
index bec6c9f..90d1392 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,4 +1,7 @@
// swift-tools-version:6.2
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import PackageDescription
let package = Package(
@@ -6,25 +9,61 @@ let package = Package(
platforms: [.macOS(.v26)],
products: [
.executable(name: "BrowApp", targets: ["BrowApp"]),
- ],
- dependencies: [
- // GroveCore: accounts, credentials, usage and token refresh, shared with Grove.
- .package(url: "https://github.com/efim0v/grove.git", from: "0.2.2"),
+ // The command-line tool. `grove`, its second name, is a symlink made by packaging.
+ .executable(name: "brow", targets: ["brow-cli"]),
],
targets: [
+ // Accounts, credentials, usage and token refresh (used by the notch), and
+ // git worktrees, cmux, Claude Code sessions and the shared session store
+ // (used by Workspaces). It came from the grove repository with its history.
+ .target(
+ name: "GroveCore",
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ // The Workspaces panel and its engine state. Never imports BrowKit: what it
+ // needs from the app hosting it comes in through protocols and closures.
+ .target(
+ name: "GroveAppKit",
+ dependencies: ["GroveCore"],
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ // The notch, the limits, Settings, and Brow's side of Workspaces.
.target(
name: "BrowKit",
- dependencies: [.product(name: "GroveCore", package: "grove")],
+ dependencies: ["GroveCore", "GroveAppKit"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.executableTarget(
name: "BrowApp",
- dependencies: ["BrowKit"],
+ dependencies: ["BrowKit", "GroveAppKit", "GroveCore"],
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ .executableTarget(
+ name: "brow-cli",
+ dependencies: ["GroveCore"],
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ // The made-up accounts of the README screenshots, shared by both test bundles that
+ // render them so the pictures agree. For tests only: no product links it.
+ .target(
+ name: "DemoFixture",
+ dependencies: ["GroveCore"],
+ path: "Tests/DemoFixture",
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ .testTarget(
+ name: "GroveCoreTests",
+ dependencies: ["GroveCore"],
+ swiftSettings: [.swiftLanguageMode(.v5)]
+ ),
+ .testTarget(
+ name: "GroveAppKitTests",
+ dependencies: ["GroveAppKit", "DemoFixture"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "BrowKitTests",
- dependencies: ["BrowKit", .product(name: "GroveCore", package: "grove")],
+ dependencies: ["BrowKit", "GroveAppKit", "GroveCore", "DemoFixture"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
]
diff --git a/README.md b/README.md
index a6a63af..091ca00 100644
--- a/README.md
+++ b/README.md
@@ -2,27 +2,42 @@ English · [Русский](README.ru.md) · [简体中文](README.zh-CN.md)
Website: [efimov.page/brow](https://efimov.page/brow/)
-# Brow — Claude Code usage limits in the MacBook notch
+# Brow — Claude Code usage limits in the MacBook notch, and workspaces for parallel sessions
[](https://github.com/efim0v/brow/actions/workflows/ci.yml)
[](https://github.com/efim0v/brow/releases/latest)
[](LICENSE)
[](#requirements)
-A macOS app that shows the **usage and rate limits of every [Claude Code](https://claude.com/claude-code) account** you have, right in the MacBook notch: the 5-hour limit, the weekly limit, and when each resets. One click signs you in to an account in a browser profile that belongs to that account alone.
+A macOS app for people who run [Claude Code](https://claude.com/claude-code) on several accounts at once. It does two things:
-It is for people who keep several Claude subscriptions and run a separate stream of AI-assisted work on each. Brow shows at a glance which account is close to its limit, which has room, and when each one frees up. It works with a single account too: Brow picks up `~/.claude` by itself and shows that account's limits.
+- **The notch shows the usage and rate limits of every account**: the 5-hour limit, the weekly limit, and when each resets. One click signs you in to an account in a browser profile that belongs to that account alone.
+- **Workspaces, in the menu bar, runs parallel Claude Code sessions on a product split across several repositories**: one folder of git worktrees per feature, all on one branch, and a session moves to the account that still has room.
+
+The notch is for looking, Workspaces is for working. Brow works with a single account too: it picks up `~/.claude` by itself and shows that account's limits, and Workspaces stays off until you use it.
**[Download Brow for macOS](https://github.com/efim0v/brow/releases/latest/download/Brow.zip)** or `brew install --cask efim0v/tap/brow` · macOS 26 or later, Apple silicon · [install notes](#install)
+> **Coming from Grove?** Grove is now part of Brow. Brow finds your Grove projects and accounts and runs them in Workspaces; [here is what changes](#coming-from-grove).
+
### Which account still has room
Hover the notch: every account's 5-hour and weekly limits open out of it. Copy an account's launch command and Claude Code starts as that account.
-
-
-
+
+
+
+
+
+### Glim, a pet that eats your limits
+
+A small creature lives beside the 5-hour readout. Each time the number goes up it takes a bite, and its glow is its hunger meter. At the 5-hour limit it falls asleep full, at the weekly limit it hides in its den behind the camera, and it comes back out at the reset. Settings → General → **Show the pet** turns it off.
+
+
+
+
+
### When each limit resets
@@ -30,9 +45,9 @@ Hover the notch: every account's 5-hour and weekly limits open out of it. Copy a
The calendar marks every account's weekly resets and subscription renewals. Point at a day to see what frees up.
-
-
-
+
+
+
### Sign in again in one click
@@ -40,27 +55,44 @@ The calendar marks every account's weekly resets and subscription renewals. Poin
When an account's sign-in expires, the key next to it starts `claude auth login` for that account and opens the browser profile that belongs to it, already signed in to that account. Other accounts stay signed in.
-
-
-
+
+
+
-All demos show made-up demo data.
+### One branch across every repository
+
+Workspaces gives each feature of a multi-repo product its own folder, with a git worktree of each repository on the same branch. Claude Code starts there and sees the whole product; ten features in flight are ten folders, and no session can change another's branch.
-> **Companion app: [Grove](https://github.com/efim0v/grove).** Grove gives each feature of a multi-repo project its own workspace for Claude Code and moves a session from one account to another. Brow tells you which account has room; Grove moves the work there. Each runs on its own, and they are built to be used together.
+
+
+
+
+
+All demos and screenshots show made-up demo data.
## What it does
+In the notch:
+
- **Every account at a glance.** For each account: the 5-hour limit, the weekly limit and any model-specific weekly limit, as percent used, with the time until each resets.
- **A reset calendar.** Weekly resets and estimated subscription renewals on a month grid, so you can see which account frees up when.
- **Limit warnings.** Off by default. Turn them on and Brow posts a macOS notification when an account passes 80 % and 95 % of a limit, once per level until it resets.
+- **Glim, a pet that eats your limits.** A small creature lives beside the notch and eats what your accounts use: when the 5-hour number goes up, it takes a bite. Hours at the Mac without Claude Code make it hungry; it never gets hungry while you are away. When the 5-hour limit is used up it falls asleep full until the window resets, and when the weekly limit is used up it hibernates until the weekly reset. On by default; Settings → General → **Show the pet** turns it off.
- **Copy my week.** One button copies a picture of your limits, ready to paste into a chat or a post. Accounts are numbered unless you choose to show their names, and emails never appear.
- **Copy the launch command.** One button copies the command that starts Claude Code as that account.
- **Sign in again, in that account's own browser.** Each account gets a separate browser profile — its own cookies, history and saved logins — so signing in to one never disturbs another. A second button opens that profile directly.
- **Add an account in one click.** Brow creates the account folder and opens Anthropic's own sign-in flow.
- **Tokens kept fresh.** Idle accounts are refreshed in the background, so the numbers do not go stale. An account a Claude Code session is using is left to that session, which refreshes its own token.
-Separate browser profiles need Google Chrome, Chromium, Brave or Edge. Without one of them links open in your default browser, with no isolation. More detail on pacing, refresh and layout options is in [`docs/brow.md`](docs/brow.md).
+In Workspaces:
+
+- **A workspace per feature.** One folder holding a git worktree of each repository you pick, all on one branch from your template. Shared files such as `CLAUDE.md` are seeded, each repository can run a post-create hook, and a workspace can fork from another one.
+- **Sessions across accounts.** **Resume as…** continues a session under another account without copying, **Migrate to…** copies it in full, even into an account that is in use, and **Share** makes it visible to every linked account. Every account picker shows that account's limits, under the name you gave it in Settings → Accounts.
+- **Graph and statistics.** The commit graph of each repository, and lines of code across the project over time.
+- **The `brow` command-line tool**, with `scan`, `workspaces`, `sessions`, `create`, `graph` and `doctor`, each with `--json`.
+
+Separate browser profiles need Google Chrome, Chromium, Brave or Edge. Without one of them links open in your default browser, with no isolation. More on the notch is in [`docs/brow.md`](docs/brow.md), on Workspaces in [`docs/workspaces.md`](docs/workspaces.md).
@@ -74,30 +106,45 @@ Collapsed, Brow shows two numbers beside the notch: how much of the 5-hour limit
## What Brow does on your machine
-Worth knowing before you run it:
+Worth knowing before you run it. The notch:
- **Brow reads Claude Code's sign-in token from the macOS Keychain** and uses it only to call Anthropic's usage and profile endpoints. Those endpoints are not a documented public API and may change. No token is written to disk or logged, and nothing is sent anywhere else. If macOS withholds an account's token, that account shows a key; pressing it asks macOS for access.
- **Brow looks at your running Claude Code processes** when an account's token is due for a refresh or has been rejected, so that it never refreshes a token a session is using. It reads the command line of `claude`, node, bun and deno processes to tell which ones are Claude Code, takes nothing from their environment but `CLAUDE_CONFIG_DIR`, and checks when each account last saved a prompt (`history.jsonl`). The [FAQ](#does-brow-refresh-a-token-claude-code-is-using) says why.
- **The fallback token refresh is on by default.** If the normal refresh stops working Brow runs a tiny `claude -p` request, which spends a little limit and starts that account's 5-hour window. Turn it off in Settings → Accounts; the [FAQ](#does-brow-use-up-my-limits) says exactly when it runs.
- **Each account's browser profile is a separate directory** under `~/Library/Application Support/Brow/browser-profiles/`. Brow sets the profile's name and allows third-party cookies in it so that sign-in works.
- **Signing in opens Terminal** with `claude auth login`; macOS asks once for permission to control Terminal.
+- **Brow marks Claude Code's first-run setup as done** in account folders other than `~/.claude` before it starts Claude Code in one: it sets `hasCompletedOnboarding` in that folder's `.claude.json` (and copies your theme if the folder has none), so the session doesn't open the onboarding wizard.
+- **With the pet on, Brow asks macOS to tell it when each account's `history.jsonl` is written**, so the pet can perk up when you start Claude Code. It never reads the file. The pet's own state (its hunger, the last readings it has seen, the days it was fed) is in `~/Library/Application Support/Brow/pet.json`.
+
+Workspaces, only once you turn it on (or Brow finds Grove's settings):
+
+- **It edits an account's `settings.json` only if you turn on Capture the status line** for that account in Settings → Accounts (accounts Grove already captured stay captured). It points `statusLine.command` at Brow's wrapper, which records each session's model, context use and limits, then passes the same input on to the command you had, so your own status line keeps working. Turning it off puts your command back. Claude Code keeps running the wrapper while Workspaces is off, so the switch stays in Settings → Accounts until you do.
+- **Sharing an account moves its session files into `~/.claude`.** Anything that would be overwritten is backed up under `/grove-backup/` first.
+- **The transcript safety net is off unless you turn it on** (a Grove setup keeps its setting). When it is on, Brow keeps a hard link to every transcript under `~/.claude/grove/transcripts/`. A transcript that vanishes is listed in Settings → Workspaces and comes back only when you press **Restore**. Nothing is kept longer than Claude Code keeps it: [`cleanupPeriodDays`](https://code.claude.com/docs/en/settings-reference#cleanupperioddays), the shortest that your organization's managed settings, the account's settings or the project settings of any folder its sessions ran in set — for a workspace shared between accounts, the shortest of all of theirs — and nothing within a day of that is offered back. The one period Brow cannot see is one passed to `claude` with `--settings`.
+- **It runs git in your repositories**: `git worktree add` and a branch per repository when you create a workspace, and the post-create hooks you configured. It never deletes a worktree or a branch on its own.
+- **It drives cmux**: through cmux's socket, with the password from `~/.config/cmux/cmux.json` when cmux asks for one, and through AppleScript, which macOS asks you to allow once.
+- **`--dangerously-skip-permissions` only when you ask**, per project or per launch.
+
+[Uninstall](#uninstall) shows how to reverse all of it.
This is an independent tool, not affiliated with or endorsed by Anthropic.
## Requirements
- macOS 26 or later; the prebuilt app is Apple silicon only. The notch layout is for MacBooks with a notch; on other displays Brow shows a pill.
-- Xcode with Swift 6.2, to build.
+- Xcode with Swift 6.4 or later, to build (Swift 6.2 cannot compile BrowKit).
- [Claude Code](https://claude.com/claude-code).
- For separate browser profiles: Chrome, Chromium, Brave or Edge.
+- For Workspaces: git, and a terminal for the sessions — [cmux](https://cmux.dev), recommended, or Terminal.app ([the difference](docs/workspaces.md#cmux-or-terminalapp)). Cross-account resume and the transcript safety net need an account signed in at the default `~/.claude`.
## Install
```sh
-brew install --cask efim0v/tap/brow
+brew install --cask efim0v/tap/brow # the app
+brew install efim0v/tap/brow-cli # the brow command-line tool, if you want it
```
-Or download **[Brow.zip](https://github.com/efim0v/brow/releases/latest/download/Brow.zip)** from the [latest release](https://github.com/efim0v/brow/releases/latest), unzip it and move `Brow.app` to `/Applications`.
+Or download **[Brow.zip](https://github.com/efim0v/brow/releases/latest/download/Brow.zip)** from the [latest release](https://github.com/efim0v/brow/releases/latest), unzip it and move `Brow.app` to `/Applications`. The release also has `brow-cli.zip`, the command-line tool (with `grove` as a second name for it), and `SHA256SUMS.txt`: with the downloads in one folder, run `shasum -a 256 -c --ignore-missing SHA256SUMS.txt`.
The build is signed ad hoc and is not notarized by Apple, so macOS blocks the first launch whichever way you installed it. Open **System Settings → Privacy & Security**, find the message about Brow and press **Open Anyway** — or clear the quarantine flag yourself:
@@ -105,11 +152,22 @@ The build is signed ad hoc and is not notarized by Apple, so macOS blocks the fi
xattr -dr com.apple.quarantine /Applications/Brow.app
```
+The `brow` tool from `brow-cli.zip` carries the same flag when you download it in a browser: `xattr -d com.apple.quarantine brow`.
+
If macOS withholds an account's Keychain item from Brow, that account shows an orange key and Settings → Accounts lists its token as *Keychain access needed*: press the key, or **Grant Keychain access** there, and choose **Always Allow**. Because the build is signed ad hoc, the key can come back after you update to a newer build.
-`SHA256SUMS.txt` in the release is there to check the download against: with both files in one folder, run `shasum -a 256 -c SHA256SUMS.txt`.
+Brow has no Dock icon: it lives in the notch, and its settings open from the ⚙ in the panel. Turn on **Launch at login** in Settings → General to start it when you log in. To start using Workspaces, open **Settings → Workspaces**, or press the stacked-squares button in the notch panel and choose **Add project…**.
+
+## Coming from Grove
-Brow has no Dock icon: it lives in the notch, and its settings open from the ⚙ in the panel. Turn on **Launch at login** in Settings → General to start it when you log in.
+Grove's Workspaces engine is part of Brow from 0.4.0, merged with its history; the [grove repository](https://github.com/efim0v/grove) stays up, read-only, for older builds.
+
+- **Your setup comes along untouched.** Brow finds `~/Library/Application Support/Grove/config.json` and turns Workspaces on with your projects, accounts, captured status lines and safety-net settings. Nothing moves: the status-line wrappers stay in `Grove/bin`, where every captured account's `settings.json` points.
+- **The notch reads every account, Workspaces' too.** Grove never read the Keychain or called Anthropic; the notch does, for every account it finds, the ones Workspaces knows included. It reads each account's sign-in from the Keychain (an account whose item macOS withholds shows an orange key: press it and choose **Always Allow**), asks Anthropic's usage endpoint for its limits, and keeps idle accounts' tokens fresh with `claude doctor`. If that fails, it falls back to a one-turn `claude -p` request, which spends a little of that account's limit and starts its 5-hour window; Settings → Accounts → **Allow `claude -p` fallback** turns that off. [What Brow does on your machine](#what-brow-does-on-your-machine) has the rest.
+- **Quit Grove.app first.** While it runs, Workspaces waits and offers **Quit Grove**, so the two never write the same files. Then delete `Grove.app` (or `brew uninstall --cask grove`), and if you had added it to Login Items, remove it there and turn on **Launch at login** in Brow instead.
+- **Where things went.** The tree in the menu bar is now Brow's mark, the notch with two dots. The panel's settings screen moved to **Settings → Workspaces**; adding, sharing and capturing accounts moved to **Settings → Accounts**, where adding an account is Brow's one-click **Sign in…**. Per-project settings stay in the panel.
+- **The command-line tool is `brow`.** `brow-cli.zip` installs `brow` with `grove` as a second name, so your scripts keep working unchanged. If you installed Grove's tool with Homebrew, run `brew uninstall grove-cli` before `brew install efim0v/tap/brow-cli`: both install a `grove`.
+- **The transcript safety net no longer puts transcripts back by itself.** It used to restore any transcript under 90 days old on its own, including ones Claude Code's own clean-up had removed. Now it keeps nothing past Claude Code's retention, and Settings → Workspaces lists the transcripts that vanished, with **Restore**. So the transcripts older than `cleanupPeriodDays` (30 days unless you set it) that Grove kept putting back go at Claude Code's next clean-up, and the safety net drops its copies of them. To keep older sessions, raise `cleanupPeriodDays` in each account's `settings.json` before you switch.
## FAQ
@@ -127,41 +185,49 @@ No. Claude Code renews its token with a refresh token that changes on every rene
From the Keychain item Claude Code keeps for each account: `Claude Code-credentials` for `~/.claude`, `Claude Code-credentials-` for any other account folder. Brow reads it with macOS's `security` tool and falls back to the Keychain API; with older Claude Code installs it reads `.credentials.json` in the account folder instead. It uses the access token only and leaves renewing it to Claude Code.
-Brow's own network requests go to two Anthropic endpoints and nowhere else: `api.anthropic.com/api/oauth/usage` for the numbers and, about once a day per account, `api.anthropic.com/api/oauth/profile` for the renewal estimate in the calendar. Brow has no analytics and no update check. The `claude` commands it runs make Claude Code's usual connections, and the per-account browser profiles open claude.ai.
+Brow's own network requests go to two Anthropic endpoints and nowhere else: `api.anthropic.com/api/oauth/usage` for the numbers and, about once a day per account, `api.anthropic.com/api/oauth/profile` for the renewal estimate in the calendar. Brow has no analytics and no update check. Workspaces makes no network requests of its own; the `claude` and `git` commands it runs make their usual connections, and the per-account browser profiles open claude.ai.
### Which Macs does it run on, and where does it sit?
-macOS 26 or later; the prebuilt app is Apple silicon only. Brow sits at the top of the main display. If that display has a notch, the two readouts sit beside it (the default) or in a strip below it: Settings → General → **Ears**. Turn off **Show readouts next to the notch** in the same tab and nothing is drawn until the pointer reaches the notch. When the main display has no notch, an external display for instance, Brow shows a pill at the top centre instead.
+macOS 26 or later; the prebuilt app is Apple silicon only. Brow sits at the top of the main display. If that display has a notch, the two readouts sit beside it (the default) or in a strip below it: Settings → General → **Ears**. Turn off **Show readouts next to the notch** in the same tab and nothing is drawn until the pointer reaches the notch. When the main display has no notch, an external display for instance, Brow shows a pill at the top centre instead, and with the readouts off, nothing until the pointer reaches the top centre. If you use Brow for Workspaces only, turn off **Show Brow in the notch** and it draws nothing at the top of the screen at all; limit warnings still arrive.
+
+### Does the pet cost battery?
+
+It draws only while the screen is on and you are at the Mac: 30 frames a second at rest, 60 to 120 for a second or two while it eats or moves, and nothing when the screen is locked or asleep, when you have been away for ten minutes, or when the pet is off. It follows Reduce Motion.
+
+### I don't see Brow's item in the menu bar.
+
+On a MacBook with a notch, macOS hides the menu-bar items that don't fit beside it. Workspaces still opens from the stacked-squares button in the notch panel, from **Show Workspaces** in Brow's menu, and when you open Brow again from Finder or Spotlight. Settings → General → **Show Workspaces in the menu bar** turns the item off altogether.
+
+### Why not Claude Code's built-in worktrees?
+
+`claude --worktree` gives one session a worktree of the repository you run it in, which may be all you need for a single repository. Workspaces are for a product split across repositories: one branch across all of them under one folder, your own branch names and start points, files and hooks set up on creation, and sessions that move between accounts. [The longer answer](docs/workspaces.md#why-not-claude-codes-built-in-worktrees).
## Uninstall
-1. If you turned on **Launch at login**, turn it off in Settings → General. Then quit Brow with ⌘Q while its Settings window is in front.
-2. Delete the app: `brew uninstall --cask efim0v/tap/brow`, or move `Brow.app` from `/Applications` to the Trash.
-3. Close any browser window Brow opened for an account, then delete Brow's files: its settings, cached readings and per-account browser profiles (cookies and saved passwords included), the request-pacing file it keeps in Grove's folder, and what macOS stored for its network requests:
+1. **If you used Workspaces, give each account its status line back first** — the wrappers live in Brow's Grove folder, which step 4 deletes. In Settings → Accounts, turn off **Capture the status line** on every account that shows it; the switch is there whether Workspaces is on or off, and if Grove is still running, quit it first. Then check that no `settings.json` still points at the wrapper:
```sh
- rm -rf ~/Library/Application\ Support/Brow
- rm -f ~/Library/Application\ Support/Grove/oauth-usage-ledger.json
- rm -rf ~/Library/Caches/dev.artemefimov.brow ~/Library/HTTPStorages/dev.artemefimov.brow
+ find ~/.claude ~/.claude-accounts -maxdepth 2 -name settings.json -exec grep -l grove-statusline {} +
```
-4. If you added Brow's `export BROWSER=…` line to `~/.zshrc`, remove it.
-
-If you deleted the app first and Brow is still listed in System Settings → General → Login Items & Extensions, remove it there. Account folders under `~/.claude-accounts` belong to Claude Code and keep working without Brow.
+ For each file it lists, open the script that file's `statusLine.command` names: its `GROVE_ORIG` line holds the command you had before. Put that command back, or delete the `statusLine` key if `GROVE_ORIG` is empty.
+2. If you turned on **Launch at login**, turn it off in Settings → General. Then quit Brow with ⌘Q while its Settings window is in front, or with **Quit Brow** in the Workspaces panel.
+3. Delete the app: `brew uninstall --cask efim0v/tap/brow` and `brew uninstall efim0v/tap/brow-cli`, or move `Brow.app` and the `brow` tool to the Trash.
+4. Close any browser window Brow opened for an account, then delete Brow's files: its settings, cached readings, the pet's state and per-account browser profiles (cookies and saved passwords included), Workspaces' folder (its config, the wrapper scripts, code statistics and the request-pacing file), and what macOS stored for Brow's network requests:
-## How Brow and Grove fit together
+ ```sh
+ rm -rf ~/Library/Application\ Support/Brow
+ rm -rf ~/Library/Application\ Support/Grove
+ rm -rf ~/Library/Caches/dev.artemefimov.brow ~/Library/HTTPStorages/dev.artemefimov.brow
+ ```
-Claude Code keeps each account in its own directory: `~/.claude` for the default account and `~/.claude-accounts/` for the others. Both apps read those directories; neither talks to the other directly.
+ If you used Grove, `defaults delete dev.artemefimov.grove3` removes its preferences too.
-- **Accounts.** Brow creates account folders when you sign in; Grove picks them up and can launch any session under any of them.
-- **The browser router.** Brow ships a small script that opens links in the right account's browser profile. When Brow is installed, Grove passes that script to every Claude Code session it starts, so a sign-in prompt from a session lands in the right profile.
-- **Shared code.** The account, credential and usage logic lives in Grove's `GroveCore` library, which this package depends on.
+5. If you used Workspaces, delete what it kept in your account folders: `~/.claude/grove/` (the transcript safety net and the default account's status-line captures; the transcripts in each account's `projects/` stay) and `grove/` in every other account folder. Look through any `grove-backup/` folder — what sharing set aside — before you delete it. A shared account's `file-history`, `tasks` and `session-env`, and `projects/` for each shared workspace, are symbolic links into `~/.claude` and keep working; [`docs/workspaces.md`](docs/workspaces.md#how-accounts-share-sessions) explains them.
+6. If you added Brow's `export BROWSER=…` line to `~/.zshrc`, remove it.
-The full picture, including how sessions are shared between accounts, is in [Grove's README](https://github.com/efim0v/grove#how-grove-and-brow-fit-together) and in [`docs/grove-and-brow.md`](docs/grove-and-brow.md).
-
-
-
-
+If you deleted the app first and Brow is still listed in System Settings → General → Login Items & Extensions, remove it there. Account folders under `~/.claude-accounts` belong to Claude Code and keep working without Brow. Workspaces are ordinary git worktrees and branches in your repositories, so they stay: remove one with `git worktree remove` on each of its worktrees, then delete its folder.
## Build
@@ -169,31 +235,43 @@ The full picture, including how sessions are shared between accounts, is in [Gro
git clone https://github.com/efim0v/brow.git
cd brow
-swift test # fetches GroveCore from the Grove repository
-Scripts/build-brow.sh # -> dist/Brow.app
+Scripts/test-in-empty-home.sh # the whole suite, with the home folder pointed at an empty one
+Scripts/build-brow.sh # -> dist/Brow.app and dist/brow
cp -R dist/Brow.app /Applications/
```
-The app is signed ad hoc by default. macOS then forgets the Keychain and Automation permissions on every rebuild; to keep them, set `GROVE_SIGN_IDENTITY` to your own `Apple Development: Name (TEAMID)` identity before building.
+The app is signed ad hoc by default. macOS then forgets the Keychain and Automation permissions on every rebuild; to keep them, set `BROW_SIGN_IDENTITY` to your own `Apple Development: Name (TEAMID)` identity before building.
## Layout
| Path | What it is |
|---|---|
-| `Sources/BrowKit` | The app: notch panel and geometry, limits store, views, settings |
+| `Sources/GroveCore` | Accounts, credentials, usage and token refresh; git, worktrees, cmux, Claude Code sessions, the shared store, the transcript safety net, migration, code statistics |
+| `Sources/GroveAppKit` | The Workspaces panel and its state |
+| `Sources/BrowKit` | The notch, the limits, the pet, Settings, and Brow's side of Workspaces |
| `Sources/BrowApp` | Entry point |
-| `Resources/brow-browser.sh` | The per-account browser router |
-| `Tests/BrowKitTests` | About 150 tests |
-| `docs` | [Brow in detail](docs/brow.md), [how Brow and Grove fit together](docs/grove-and-brow.md) |
+| `Sources/brow-cli` | The `brow` command-line tool |
+| `Resources` | The app icon, the per-account browser router, the Info.plist template |
+| `Tests` | About 1,550 tests |
+| `docs` | [The notch in detail](docs/brow.md), [Workspaces](docs/workspaces.md), [architecture](docs/architecture.md), [troubleshooting](docs/troubleshooting.md) |
-The only dependency is [`GroveCore`](https://github.com/efim0v/grove) from the Grove repository.
+There are no third-party dependencies. The module names `GroveCore` and `GroveAppKit` come from Grove, whose history this repository includes.
## Development
+- `Scripts/test-in-empty-home.sh` and `Scripts/test-in-empty-home.sh -c release` should both stay green; `Scripts/check-warnings.sh` should find no new warnings.
+- `swift run BrowApp --snapshot /tmp/brow-snap` renders the Workspaces screens to PNG without starting the app; see [`docs/snapshot-testing.md`](docs/snapshot-testing.md).
- The screenshots in this README are regenerated with
`DEMO_SCREENSHOTS_DIR="$PWD/docs/screenshots" swift test --filter DemoScreenshotTests`.
- The notch outline's tuning constants are in `Sources/BrowKit/Panel/NotchGeometry.swift`; they are meant to be tuned by eye against the physical bezel.
+## Credits
+
+- [boring.notch](https://github.com/TheBoredTeam/boring.notch) (GPL-3.0): the notch outline's constants, and the idea of drawing in a SkyLight space of its own so the panel stays put through Spaces changes.
+- [NotchNook](https://lo.cafe/notchnook): the same way of keeping a notch panel still across Spaces.
+- [cmux](https://cmux.dev): the terminal Workspaces opens sessions in.
+- [Grove](https://github.com/efim0v/grove): Brow's companion app until 0.4, whose Workspaces engine is now part of Brow.
+
## License
-[GNU GPL v3](LICENSE). Version 0.2.0 and earlier were released under the MIT License.
+[GNU GPL v3](LICENSE). Brow and Grove 0.2.0 and earlier were released under the MIT License.
diff --git a/README.ru.md b/README.ru.md
index 4b10e0b..34fab3b 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -4,27 +4,42 @@
> Это перевод [английского README](README.md). Английская версия основная и может быть новее перевода; если тексты расходятся, ориентируйтесь на неё.
-# Brow — лимиты Claude Code в чёлке MacBook
+# Brow — лимиты Claude Code в чёлке MacBook и рабочие пространства для параллельных сессий
[](https://github.com/efim0v/brow/actions/workflows/ci.yml)
[](https://github.com/efim0v/brow/releases/latest)
[](LICENSE)
[](#требования)
-Приложение для macOS, которое показывает **расход и лимиты всех ваших аккаунтов [Claude Code](https://claude.com/claude-code)** прямо в чёлке MacBook: 5-часовой лимит, недельный лимит и время сброса каждого из них. Войти в аккаунт можно в один клик — в отдельном профиле браузера, который принадлежит только этому аккаунту.
+Приложение для macOS для тех, кто работает в [Claude Code](https://claude.com/claude-code) сразу с нескольких аккаунтов. Оно делает две вещи:
-Brow для тех, у кого несколько подписок Claude и на каждой идёт своя работа с ИИ. С одного взгляда видно, какой аккаунт почти упёрся в лимит, где ещё есть запас и когда какой освободится. С одним аккаунтом Brow тоже работает: он сам находит `~/.claude` и показывает лимиты этого аккаунта.
+- **Чёлка показывает расход и лимиты всех аккаунтов**: 5-часовой лимит, недельный лимит и время сброса каждого из них. Войти в аккаунт можно в один клик — в отдельном профиле браузера, который принадлежит только этому аккаунту.
+- **Workspaces в строке меню запускает параллельные сессии Claude Code над продуктом, разбитым на несколько репозиториев**: на каждую фичу — своя папка с git worktree всех репозиториев на одной ветке, а сессия переезжает на тот аккаунт, у которого ещё есть запас.
+
+На чёлку смотрят, в Workspaces работают. С одним аккаунтом Brow тоже работает: он сам находит `~/.claude` и показывает лимиты этого аккаунта, а Workspaces остаётся выключенным, пока вы его не включите.
**[Скачать Brow для macOS](https://github.com/efim0v/brow/releases/latest/download/Brow.zip)** или `brew install --cask efim0v/tap/brow` · macOS 26 или новее, Apple silicon · [подробнее об установке](#установка)
+> **Пользовались Grove?** Grove теперь часть Brow. Brow находит ваши проекты и аккаунты Grove и запускает их в Workspaces; [вот что меняется](#если-вы-пользовались-grove).
+
### У какого аккаунта ещё есть запас
Наведите курсор на чёлку — из неё раскроются 5-часовые и недельные лимиты всех аккаунтов. Скопируйте команду запуска аккаунта, и Claude Code стартует под этим аккаунтом.
-
-
-
+
+
+
+
+
+### Глим, питомец, который ест ваши лимиты
+
+Рядом с 5-часовым лимитом живёт маленький зверёк. Каждый раз, когда число растёт, он откусывает кусочек, а его свечение — это шкала голода. Когда 5-часовой лимит исчерпан, он засыпает сытым, когда исчерпан недельный, прячется в нору за камерой и выходит после сброса. Выключается в Настройках → General → **Show the pet**.
+
+
+
+
+
### Когда сбросится каждый лимит
@@ -32,9 +47,9 @@ Brow для тех, у кого несколько подписок Claude и н
В календаре отмечены недельные сбросы и продления подписок всех аккаунтов. Наведите курсор на день, чтобы увидеть, что в этот день освободится.
-
-
-
+
+
+
### Повторный вход в один клик
@@ -42,128 +57,179 @@ Brow для тех, у кого несколько подписок Claude и н
Если у аккаунта истекла авторизация, нажмите на ключ рядом с ним: Brow запустит для этого аккаунта `claude auth login` и откроет его собственный профиль браузера, где вход в аккаунт уже выполнен. Остальные аккаунты остаются авторизованными.
-
-
-
+
+
+
-Во всех демо — выдуманные данные.
+### Одна ветка во всех репозиториях
+
+Workspaces выделяет каждой фиче продукта из нескольких репозиториев свою папку, в которой лежит git worktree каждого репозитория на одной и той же ветке. Claude Code запускается в этой папке и видит весь продукт целиком; десять фич в работе — это десять папок, и ни одна сессия не может переключить ветку под другой.
-> **Приложение-компаньон: [Grove](https://github.com/efim0v/grove).** Grove выделяет каждой фиче проекта из нескольких репозиториев своё рабочее пространство для Claude Code и переносит сессию с одного аккаунта на другой. Brow подсказывает, у какого аккаунта есть запас, а Grove перебрасывает туда работу. Каждое приложение работает и само по себе, но задуманы они как пара.
+
+
+
+
+
+Во всех демо и на всех скриншотах — выдуманные данные.
## Возможности
+В чёлке:
+
- **Все аккаунты перед глазами.** Для каждого аккаунта: 5-часовой лимит, недельный лимит и недельный лимит отдельной модели, если он есть, — сколько процентов израсходовано и сколько времени осталось до сброса каждого.
- **Календарь сбросов.** Недельные сбросы и примерные даты продления подписок на сетке месяца: видно, какой аккаунт когда освободится.
- **Предупреждения о лимитах.** По умолчанию выключены. Если их включить, Brow присылает уведомление macOS, когда аккаунт израсходует 80 % и 95 % лимита, по одному на каждый порог до сброса.
-- **Картинка с лимитами.** Кнопка внизу панели копирует картинку с лимитами ваших аккаунтов, чтобы вставить её в чат или пост. Аккаунты на ней пронумерованы, если не включить показ имён, а адресов почты там нет.
+- **Глим, питомец, который ест лимиты.** Рядом с чёлкой живёт маленький зверёк и ест то, что тратят ваши аккаунты: когда растёт число 5-часового лимита, он откусывает кусочек. Если вы несколько часов за Mac, а Claude Code молчит, он проголодается; пока вас нет, голод не растёт. Когда 5-часовой лимит исчерпан, он засыпает сытым до сброса окна, а когда исчерпан недельный, впадает в спячку до недельного сброса. Включён по умолчанию; выключается в Настройках → General → **Show the pet**.
+- **Картинка с лимитами.** Одна кнопка копирует картинку с лимитами ваших аккаунтов, чтобы вставить её в чат или пост. Аккаунты на ней пронумерованы, если не включить показ имён, а адресов почты там нет.
- **Команда запуска в буфер обмена.** Одна кнопка копирует команду, которая запускает Claude Code под этим аккаунтом.
-- **Повторный вход в собственном браузере аккаунта.** У каждого аккаунта отдельный профиль браузера со своими cookie, историей и сохранёнными логинами, поэтому вход в один аккаунт никак не мешает другому. Вторая кнопка открывает этот профиль напрямую.
-- **Новый аккаунт в один клик.** Brow создаёт папку аккаунта и запускает штатный вход Anthropic.
-- **Свежие токены.** Токены простаивающих аккаунтов обновляются в фоне, так что цифры не устаревают. Аккаунт, в котором работает сессия Claude Code, Brow оставляет ей: такая сессия обновляет свой токен сама.
+- **Повторный вход — в собственном браузере аккаунта.** У каждого аккаунта отдельный профиль браузера со своими cookie, историей и сохранёнными входами, поэтому вход в один аккаунт никогда не мешает другому. Вторая кнопка открывает этот профиль напрямую.
+- **Новый аккаунт в один клик.** Brow создаёт папку аккаунта и открывает собственный вход Anthropic.
+- **Токены всегда свежие.** Токены простаивающих аккаунтов обновляются в фоне, поэтому цифры не устаревают. Аккаунт, которым пользуется сессия Claude Code, Brow оставляет этой сессии: она сама обновляет свой токен.
+
+В Workspaces:
-Для отдельных профилей браузера нужен Google Chrome, Chromium, Brave или Edge. Если их нет, ссылки открываются в браузере по умолчанию, без изоляции. Подробнее о частоте запросов, обновлении и вариантах расположения — в [`docs/brow.md`](docs/brow.md) (на английском).
+- **Рабочее пространство на каждую фичу.** Одна папка, в которой лежит git worktree каждого выбранного репозитория, все на одной ветке по вашему шаблону. Общие файлы вроде `CLAUDE.md` подкладываются сами, у каждого репозитория может быть свой хук после создания, а одно пространство можно ответвить от другого.
+- **Сессии между аккаунтами.** **Resume as…** продолжает сессию под другим аккаунтом без копирования, **Migrate to…** копирует её целиком, даже в аккаунт, которым сейчас пользуются, а **Share** делает её видимой всем связанным аккаунтам. В каждом выборе аккаунта видны его лимиты, под тем именем, которое вы дали ему в Settings → Accounts.
+- **Граф и статистика.** Граф коммитов каждого репозитория и число строк кода по всему проекту во времени.
+- **Утилита командной строки `brow`** с командами `scan`, `workspaces`, `sessions`, `create`, `graph` и `doctor`, у каждой есть `--json`.
+
+Для отдельных профилей браузера нужен Google Chrome, Chromium, Brave или Edge. Без них ссылки открываются в браузере по умолчанию, без разделения. Подробнее о чёлке — в [`docs/brow.md`](docs/brow.md), о Workspaces — в [`docs/workspaces.md`](docs/workspaces.md).
-В свёрнутом виде Brow показывает по бокам от чёлки два числа: сколько 5-часового и недельного лимита израсходовано в целом по вашим аккаунтам. Наведите курсор, и Brow развернётся в панель, как на демо выше. Показатели можно вынести и в полосу под чёлкой:
+В свёрнутом виде Brow показывает рядом с чёлкой два числа: какая доля 5-часового и недельного лимита израсходована по всем вашим аккаунтам. Наведите курсор — и раскроется панель, как выше. Числа можно перенести в полосу под чёлкой:
-
+
## Что Brow делает на вашем Mac
-Что стоит знать до запуска:
+Стоит знать до запуска. Чёлка:
+
+- **Brow читает токен входа Claude Code из Связки ключей macOS** и использует его только для запросов к эндпоинтам Anthropic с расходом и профилем. Эти эндпоинты — не документированный публичный API и могут измениться. Токен не пишется на диск и в логи, и больше никуда не отправляется. Если macOS не отдаёт токен аккаунта, у этого аккаунта появляется ключ; нажатие на него просит у macOS доступ.
+- **Brow смотрит на запущенные процессы Claude Code**, когда токен аккаунта пора обновлять или его отклонили, чтобы никогда не обновлять токен, которым пользуется сессия. Он читает командную строку процессов `claude`, node, bun и deno, чтобы понять, какие из них — Claude Code, не берёт из их окружения ничего, кроме `CLAUDE_CONFIG_DIR`, и смотрит, когда каждый аккаунт последний раз сохранил промпт (`history.jsonl`). Зачем — в [FAQ](#обновляет-ли-brow-токен-которым-пользуется-claude-code).
+- **Резервное обновление токена включено по умолчанию.** Если обычное обновление перестало работать, Brow отправляет крошечный запрос `claude -p`: он тратит немного лимита и запускает 5-часовое окно этого аккаунта. Выключается в Настройках → Accounts; когда именно он срабатывает, написано в [FAQ](#расходует-ли-brow-мои-лимиты).
+- **Профиль браузера каждого аккаунта — отдельная папка** в `~/Library/Application Support/Brow/browser-profiles/`. Brow задаёт имя профиля и разрешает в нём сторонние cookie, чтобы вход работал.
+- **Вход открывает Терминал** с `claude auth login`; macOS один раз спрашивает разрешение управлять Терминалом.
+- **Brow отмечает первичную настройку Claude Code как пройденную** в папках аккаунтов, кроме `~/.claude`, перед тем как запустить там Claude Code: ставит `hasCompletedOnboarding` в `.claude.json` этой папки (и копирует вашу тему, если её там нет), чтобы сессия не открывала мастер первого запуска.
+- **Когда питомец включён, Brow просит macOS сообщать ему о каждой записи в `history.jsonl` каждого аккаунта**, чтобы питомец оживлялся, когда вы запускаете Claude Code. Сам файл Brow не читает. Состояние питомца (его голод, последние показания, которые он видел, дни, когда он был сыт) хранится в `~/Library/Application Support/Brow/pet.json`.
-- **Brow читает токен авторизации Claude Code из связки ключей macOS (Keychain)** и использует его только для запросов к эндпоинтам Anthropic, которые отдают расход лимитов и данные профиля. Эти эндпоинты не входят в документированный публичный API и могут измениться. Токен не пишется на диск и не попадает в логи, а больше никуда ничего не отправляется. Если macOS не отдаёт токен аккаунта, у этого аккаунта появляется значок ключа; нажмите на него, и Brow запросит доступ у macOS.
-- **Brow смотрит на запущенные процессы Claude Code**, когда токену аккаунта пора обновиться или Anthropic его отклонила, чтобы никогда не обновлять токен, которым пользуется сессия. Он читает командную строку процессов `claude`, node, bun и deno, чтобы понять, какие из них Claude Code, берёт из их окружения только `CLAUDE_CONFIG_DIR` и смотрит, когда аккаунт в последний раз сохранил запрос (`history.jsonl`). Зачем это нужно, написано в [частых вопросах](#обновляет-ли-brow-токен-которым-пользуется-claude-code).
-- **Запасное обновление токена включено по умолчанию.** Если обычное обновление перестаёт работать, Brow выполняет крошечный запрос `claude -p`: он тратит немного лимита и запускает 5-часовое окно этого аккаунта. Отключается в Settings → Accounts; когда именно оно срабатывает, написано в [частых вопросах](#тратит-ли-brow-мои-лимиты).
-- **Профиль браузера каждого аккаунта — отдельная папка** в `~/Library/Application Support/Brow/browser-profiles/`. Brow задаёт имя профиля и разрешает в нём сторонние cookie, чтобы работал вход.
-- **Для входа открывается Терминал** с `claude auth login`; macOS один раз спросит, можно ли Brow управлять Терминалом.
+Workspaces — только после того, как вы его включите (или Brow найдёт настройки Grove):
-Это независимый инструмент. Anthropic не имеет к нему отношения и не одобряла его.
+- **Он правит `settings.json` аккаунта, только если включить «Capture the status line»** для этого аккаунта в Настройках → Accounts (аккаунты, у которых Grove это уже делал, так и остаются). Он направляет `statusLine.command` на обёртку Brow, которая записывает модель, заполненность контекста и лимиты каждой сессии, а затем передаёт тот же ввод вашей прежней команде, поэтому ваша строка состояния продолжает работать. Выключение возвращает вашу команду. Claude Code запускает обёртку и при выключенном Workspaces, поэтому переключатель остаётся в Настройках → Accounts, пока вы его не выключите.
+- **Общий доступ для аккаунта переносит его файлы сессий в `~/.claude`.** Всё, что было бы перезаписано, сначала сохраняется в `<аккаунт>/grove-backup/`.
+- **Страховка транскриптов выключена, пока вы её не включите** (настройка Grove сохраняется). Когда она включена, Brow держит жёсткую ссылку на каждый транскрипт в `~/.claude/grove/transcripts/`. Пропавший транскрипт появляется в списке в Настройках → Workspaces и возвращается, только когда вы нажмёте **Restore**. Дольше, чем его хранит сам Claude Code, не хранится ничего: [`cleanupPeriodDays`](https://code.claude.com/docs/en/settings-reference#cleanupperioddays) — самый короткий срок из управляемых настроек вашей организации, настроек аккаунта и настроек проектов, в папках которых работали его сессии (для рабочего пространства, общего для нескольких аккаунтов, — самый короткий из сроков всех этих аккаунтов), — а транскрипт, до конца срока которого остаётся меньше дня, вернуть уже не предлагается. Не видит Brow только срок, переданный `claude` через `--settings`.
+- **Он запускает git в ваших репозиториях**: `git worktree add` и ветку в каждом репозитории при создании пространства, а также хуки, которые вы настроили. Сам он никогда не удаляет worktree и ветки.
+- **Он управляет cmux**: через сокет cmux — с паролем из `~/.config/cmux/cmux.json`, если cmux его требует, — и через AppleScript, на что macOS один раз спросит разрешение.
+- **`--dangerously-skip-permissions` — только по вашей просьбе**, для проекта или для одного запуска.
+
+Как всё это отменить, описано в разделе [Удаление](#удаление).
+
+Это независимый инструмент, не связанный с Anthropic и не одобренный ею.
## Требования
-- macOS 26 или новее; готовая сборка — только для Apple silicon. Интерфейс вокруг чёлки рассчитан на MacBook, у которых она есть; на других дисплеях Brow показывает капсулу.
-- Для сборки — Xcode со Swift 6.2.
+- macOS 26 или новее; готовая сборка — только для Apple silicon. Раскладка рассчитана на MacBook с чёлкой; на других экранах Brow показывает «пилюлю».
+- Xcode со Swift 6.4 или новее — для сборки (Swift 6.2 не собирает BrowKit).
- [Claude Code](https://claude.com/claude-code).
-- Для отдельных профилей браузера — Chrome, Chromium, Brave или Edge.
+- Для отдельных профилей браузера: Chrome, Chromium, Brave или Edge.
+- Для Workspaces: git и терминал для сессий — [cmux](https://cmux.dev) (рекомендуется) или Terminal.app ([в чём разница](docs/workspaces.md#cmux-or-terminalapp)). Возобновлению под другим аккаунтом и страховке транскриптов нужен аккаунт, вошедший в стандартной папке `~/.claude`.
## Установка
```sh
-brew install --cask efim0v/tap/brow
+brew install --cask efim0v/tap/brow # приложение
+brew install efim0v/tap/brow-cli # утилита командной строки brow, если нужна
```
-Или скачайте **[Brow.zip](https://github.com/efim0v/brow/releases/latest/download/Brow.zip)** из [последнего релиза](https://github.com/efim0v/brow/releases/latest), распакуйте и перенесите `Brow.app` в `/Applications`.
+Или скачайте **[Brow.zip](https://github.com/efim0v/brow/releases/latest/download/Brow.zip)** из [последнего релиза](https://github.com/efim0v/brow/releases/latest), распакуйте и перенесите `Brow.app` в `/Applications`. В релизе есть ещё `brow-cli.zip` — утилита командной строки (с `grove` в качестве второго имени) — и `SHA256SUMS.txt`: положите скачанное в одну папку и выполните `shasum -a 256 -c --ignore-missing SHA256SUMS.txt`.
-Сборка подписана ad hoc и не нотаризована Apple, поэтому macOS заблокирует первый запуск, каким бы способом вы ни установили Brow. Откройте **Системные настройки → Конфиденциальность и безопасность**, найдите сообщение о Brow и нажмите **Все равно открыть** — или снимите атрибут карантина сами:
+Сборка подписана ad hoc и не нотаризована Apple, поэтому при первом запуске macOS её заблокирует, как бы вы её ни ставили. Откройте **Системные настройки → Конфиденциальность и безопасность**, найдите сообщение о Brow и нажмите **Всё равно открыть** — или снимите флаг карантина сами:
```sh
xattr -dr com.apple.quarantine /Applications/Brow.app
```
-Если macOS не отдаёт Brow запись аккаунта из связки ключей, у аккаунта появляется оранжевый ключ, а в Settings → Accounts его токен помечен как *Keychain access needed*: нажмите на ключ или на **Grant Keychain access** там же и выберите **Разрешать всегда**. Сборка подписана ad hoc, поэтому после обновления на новую версию ключ может появиться снова.
+Утилита `brow` из `brow-cli.zip` получает тот же флаг, если скачать её в браузере: `xattr -d com.apple.quarantine brow`.
+
+Если macOS не отдаёт Brow элемент Связки ключей какого-то аккаунта, у этого аккаунта появляется оранжевый ключ, а в Настройках → Accounts его токен помечен как *Keychain access needed*: нажмите на ключ или **Grant Keychain access** и выберите **Всегда разрешать**. Поскольку сборка подписана ad hoc, ключ может вернуться после обновления на новую сборку.
+
+У Brow нет значка в Dock: он живёт в чёлке, а его настройки открываются через ⚙ на панели. Чтобы Brow запускался при входе в систему, включите **Launch at login** в Настройках → General. Чтобы начать пользоваться Workspaces, откройте **Настройки → Workspaces** или нажмите кнопку со стопкой квадратов на панели чёлки и выберите **Add project…**.
-Проверить загрузку можно по `SHA256SUMS.txt` из релиза: положите оба файла в одну папку и выполните `shasum -a 256 -c SHA256SUMS.txt`.
+## Если вы пользовались Grove
-Значка в Dock у Brow нет: он живёт в чёлке, а настройки открываются кнопкой ⚙ на панели. Чтобы Brow запускался при входе в систему, включите **Launch at login** в Settings → General.
+С версии 0.4.0 движок Workspaces из Grove — часть Brow, вместе со своей историей; [репозиторий grove](https://github.com/efim0v/grove) остаётся, только для чтения, для старых сборок.
-## Частые вопросы
+- **Ваша настройка переезжает нетронутой.** Brow находит `~/Library/Application Support/Grove/config.json` и включает Workspaces с вашими проектами, аккаунтами, перехваченными строками состояния и настройками страховки. Ничего не переносится: обёртки строки состояния остаются в `Grove/bin`, куда указывает `settings.json` каждого такого аккаунта.
+- **Чёлка читает каждый аккаунт, в том числе аккаунты Workspaces.** Grove не обращался ни к Связке ключей, ни к Anthropic; чёлка обращается — для каждого аккаунта, который находит, включая те, что знает Workspaces. Она читает вход аккаунта из Связки ключей (если macOS не отдаёт элемент, у аккаунта появляется оранжевый ключ: нажмите на него и выберите **Всегда разрешать**), запрашивает его лимиты у эндпоинта расхода Anthropic и обновляет токены простаивающих аккаунтов через `claude doctor`. Если это не помогло, она отправляет однократный запрос `claude -p`: он тратит немного лимита этого аккаунта и запускает его 5-часовое окно. Выключается в Настройках → Accounts → **Allow `claude -p` fallback**. Остальное — в разделе [Что Brow делает на вашем Mac](#что-brow-делает-на-вашем-mac).
+- **Сначала закройте Grove.app.** Пока он работает, Workspaces ждёт и предлагает **Quit Grove**, чтобы два приложения не писали в одни и те же файлы. Затем удалите `Grove.app` (или `brew uninstall --cask grove`), а если вы добавляли его в объекты входа, уберите его оттуда и включите **Launch at login** в Brow.
+- **Что куда переехало.** Дерево в строке меню теперь знак Brow — чёлка с двумя точками. Экран настроек панели переехал в **Настройки → Workspaces**; добавление аккаунтов, общий доступ и перехват строки состояния — в **Настройки → Accounts**, где новый аккаунт добавляется кнопкой Brow **Sign in…**. Настройки отдельных проектов остались в панели.
+- **Утилита командной строки теперь `brow`.** `brow-cli.zip` ставит `brow` и `grove` как второе имя, так что ваши скрипты работают без изменений. Если утилита Grove стоит у вас через Homebrew, выполните `brew uninstall grove-cli` перед `brew install efim0v/tap/brow-cli`: обе ставят `grove`.
+- **Страховка транскриптов больше ничего не возвращает сама.** Раньше она сама возвращала любой транскрипт младше 90 дней, в том числе удалённые собственной очисткой Claude Code. Теперь она ничего не хранит дольше срока хранения Claude Code, а пропавшие транскрипты показывает в Настройках → Workspaces с кнопкой **Restore**. Поэтому транскрипты старше `cleanupPeriodDays` (30 дней, если вы не задали другой срок), которые Grove раз за разом возвращал, уйдут при следующей очистке Claude Code, а страховка удалит свои копии. Чтобы сохранить старые сессии, увеличьте `cleanupPeriodDays` в `settings.json` каждого аккаунта до перехода.
-### Тратит ли Brow мои лимиты?
+## FAQ
-Только через запасное обновление токена, и его можно отключить. Чтение лимитов — это запрос к эндпоинту Anthropic с данными о расходе, а не к модели. Токен простаивающего аккаунта Brow обновляет через `claude doctor`, который к модели тоже не обращается.
+### Расходует ли Brow мои лимиты?
-Запасное обновление включено по умолчанию. Когда токену аккаунта осталось жить 30 минут или меньше, а `claude doctor` его не продлил, Brow выполняет в этом аккаунте запрос `claude -p` в один ход на Haiku. Это тратит крошечную долю лимита аккаунта и запускает его 5-часовое окно. Brow не делает такой запрос, если Anthropic отклонила токен или `claude doctor` завершился по тайм-ауту либо не смог запуститься. Запрос уходит не чаще раза в 30 минут на аккаунт, а после трёх безрезультатных попыток подряд Brow перестаёт использовать его для этого аккаунта. Выключить запасное обновление можно в Settings → Accounts → **Allow `claude -p` fallback**.
+Только через резервное обновление токена, и его можно выключить. Чтение лимитов — это запрос к эндпоинту расхода Anthropic, а не к модели, а токен простаивающего аккаунта поддерживается свежим через `claude doctor`, который не обращается к модели.
+
+Резервное обновление включено по умолчанию. Когда у токена аккаунта остаётся 30 минут или меньше, а `claude doctor` его не продлил, Brow отправляет в этом аккаунте однократный запрос `claude -p` к Haiku. Он тратит крошечную долю лимита аккаунта и запускает его 5-часовое окно. Brow пропускает его, если Anthropic отклонил токен или `claude doctor` не уложился во время или не смог запуститься, запускает его не чаще раза в 30 минут на аккаунт и перестаёт использовать для аккаунта после трёх безуспешных попыток подряд. Выключается в Настройках → Accounts → **Allow `claude -p` fallback**.
### Обновляет ли Brow токен, которым пользуется Claude Code?
-Нет. Claude Code продлевает токен с помощью refresh-токена, а тот меняется при каждом продлении: два продления почти одновременно могут оставить сессию с мёртвым токеном и просьбой выполнить `/login`. Поэтому Brow не трогает аккаунт, для папки которого запущен процесс `claude` (без `CLAUDE_CONFIG_DIR` это `~/.claude`), сколько бы эта сессия ни простаивала, и аккаунт, в котором за последние 15 минут вводили запрос. Сессия продлит токен сама. Пока этого не случилось, в Settings → Accounts у токена написано *in use by Claude Code, waiting for it to refresh*. Если токен уже истёк, панель показывает *Token in use by Claude Code*, а не предлагает войти заново; цифры вернутся, как только вы снова воспользуетесь этой сессией или закроете её.
+Нет. Claude Code продлевает токен с помощью refresh-токена, который меняется при каждом продлении, поэтому два продления примерно в одно время могут оставить сессию с мёртвым токеном и просьбой выполнить `/login`. Brow не трогает аккаунт, если для его папки запущен процесс `claude` (без `CLAUDE_CONFIG_DIR` — это `~/.claude`), сколько бы эта сессия ни простаивала, и аккаунт, в котором за последние 15 минут вводили промпт. Сессия сама продлит токен. Пока она этого не сделала, Настройки → Accounts показывают токен как *in use by Claude Code, waiting for it to refresh*. Если токен уже истёк, панель показывает *Token in use by Claude Code* вместо просьбы войти, а цифры вернутся, как только вы снова воспользуетесь этой сессией или закроете её.
+
+### Откуда Brow читает мой токен и уходит ли что-нибудь с моего Mac?
+
+Из элемента Связки ключей, который Claude Code хранит для каждого аккаунта: `Claude Code-credentials` для `~/.claude`, `Claude Code-credentials-<хеш>` для любой другой папки аккаунта. Brow читает его системной утилитой `security`, а если не вышло — через API Связки ключей; со старыми версиями Claude Code он читает `.credentials.json` в папке аккаунта. Он использует только токен доступа и оставляет его продление Claude Code.
+
+Собственные сетевые запросы Brow идут к двум эндпоинтам Anthropic и больше никуда: `api.anthropic.com/api/oauth/usage` за цифрами и примерно раз в день на аккаунт `api.anthropic.com/api/oauth/profile` за оценкой продления для календаря. У Brow нет аналитики и проверки обновлений. Workspaces сам ничего не запрашивает по сети; запускаемые им команды `claude` и `git` устанавливают свои обычные соединения, а профили браузера аккаунтов открывают claude.ai.
+
+### На каких Mac он работает и где располагается?
-### Откуда Brow берёт мой токен и уходит ли что-нибудь с моего Mac?
+macOS 26 или новее; готовая сборка — только для Apple silicon. Brow располагается вверху основного экрана. Если у этого экрана есть чёлка, два числа стоят по бокам от неё (по умолчанию) или в полосе под ней: Настройки → General → **Ears**. Если выключить **Show readouts next to the notch** на той же вкладке, ничего не рисуется, пока курсор не дойдёт до самой чёлки. Если у основного экрана чёлки нет, например у внешнего монитора, Brow показывает вверху по центру «пилюлю», а с выключенными числами — ничего, пока курсор не дойдёт до верха экрана по центру. Если Brow нужен вам только ради Workspaces, выключите **Show Brow in the notch**, и вверху экрана он не будет рисовать ничего; предупреждения о лимитах при этом приходят.
-Из записи в связке ключей, которую Claude Code хранит для каждого аккаунта: `Claude Code-credentials` для `~/.claude` и `Claude Code-credentials-` для любой другой папки аккаунта. Brow читает её утилитой macOS `security`, а если не выходит — через Keychain API; со старыми установками Claude Code он вместо этого читает `.credentials.json` из папки аккаунта. Brow берёт только access-токен, а продлевать его оставляет самому Claude Code.
+### Тратит ли питомец батарею?
-Собственные сетевые запросы Brow идут к двум эндпоинтам Anthropic и больше никуда: `api.anthropic.com/api/oauth/usage` — за цифрами и, примерно раз в день на аккаунт, `api.anthropic.com/api/oauth/profile` — за оценкой даты продления подписки для календаря. Ни аналитики, ни проверки обновлений в Brow нет. Команды `claude`, которые он запускает, ходят в сеть так же, как обычно ходит Claude Code, а профили браузера аккаунтов открывают claude.ai.
+Он рисуется, только пока экран включён и вы за Mac: 30 кадров в секунду в покое, от 60 до 120 на секунду-другую, пока он ест или двигается, и ни одного, когда экран заблокирован или спит, когда вас нет уже десять минут или когда питомец выключен. Он учитывает настройку «Уменьшение движения» (Reduce Motion).
-### На каких Mac работает Brow и где он находится на экране?
+### Я не вижу значка Brow в строке меню.
-macOS 26 или новее; готовая сборка — только для Apple silicon. Brow находится вверху основного дисплея. Если на нём есть чёлка, оба показателя стоят по бокам от неё (по умолчанию) или в полосе под ней: Settings → General → **Ears**. А если на той же вкладке выключить **Show readouts next to the notch**, Brow ничего не рисует, пока курсор не дойдёт до чёлки. Когда у основного дисплея чёлки нет (например, это внешний монитор), Brow вместо этого показывает капсулу вверху по центру.
+На MacBook с чёлкой macOS прячет значки строки меню, которые не помещаются рядом с ней. Workspaces всё равно открывается кнопкой со стопкой квадратов на панели чёлки, пунктом **Show Workspaces** в меню Brow и повторным открытием Brow из Finder или Spotlight. Настройки → General → **Show Workspaces in the menu bar** убирает значок совсем.
+
+### Почему не встроенные worktree Claude Code?
+
+`claude --worktree` даёт одной сессии worktree репозитория, в котором вы его запустили, — для одного репозитория этого может хватить. Workspaces — для продукта из нескольких репозиториев: одна ветка во всех них в одной папке, свои имена веток и точки старта, файлы и хуки при создании и сессии, которые переезжают между аккаунтами. [Подробный ответ](docs/workspaces.md#why-not-claude-codes-built-in-worktrees).
## Удаление
-1. Если вы включали **Launch at login**, выключите его в Settings → General. Затем завершите Brow: нажмите ⌘Q, когда его окно настроек на переднем плане.
-2. Удалите приложение: `brew uninstall --cask efim0v/tap/brow` или перенесите `Brow.app` из `/Applications` в корзину.
-3. Закройте все окна браузера, которые Brow открыл для аккаунтов, и удалите файлы Brow: настройки, кеш с данными о лимитах и профили браузера аккаунтов (вместе с cookie и сохранёнными паролями), файл учёта частоты запросов, который Brow хранит в папке Grove, и то, что macOS сохранила для его сетевых запросов:
+1. **Если вы пользовались Workspaces, сначала верните каждому аккаунту его строку состояния** — обёртки лежат в папке Grove, которую шаг 4 удаляет. В Настройках → Accounts выключите **Capture the status line** у каждого аккаунта, где он есть; переключатель там и при включённом, и при выключенном Workspaces, а если Grove ещё запущен, сначала закройте его. Затем проверьте, что ни один `settings.json` больше не указывает на обёртку:
```sh
- rm -rf ~/Library/Application\ Support/Brow
- rm -f ~/Library/Application\ Support/Grove/oauth-usage-ledger.json
- rm -rf ~/Library/Caches/dev.artemefimov.brow ~/Library/HTTPStorages/dev.artemefimov.brow
+ find ~/.claude ~/.claude-accounts -maxdepth 2 -name settings.json -exec grep -l grove-statusline {} +
```
-4. Если вы добавляли в `~/.zshrc` строку Brow `export BROWSER=…`, удалите её.
-
-Если вы сначала удалили приложение, а Brow по-прежнему виден в разделе Системные настройки → Основные → Объекты входа и расширения, удалите его там. Папки аккаунтов в `~/.claude-accounts` принадлежат Claude Code и без Brow работают как прежде.
+ Для каждого найденного файла откройте скрипт, который назван в его `statusLine.command`: строка `GROVE_ORIG` в нём хранит команду, которая была у вас раньше. Верните эту команду или удалите ключ `statusLine`, если `GROVE_ORIG` пуст.
+2. Если вы включали **Launch at login**, выключите его в Настройках → General. Затем закройте Brow сочетанием ⌘Q, пока впереди его окно настроек, или кнопкой **Quit Brow** на панели Workspaces.
+3. Удалите приложение: `brew uninstall --cask efim0v/tap/brow` и `brew uninstall efim0v/tap/brow-cli` либо перенесите `Brow.app` и утилиту `brow` в Корзину.
+4. Закройте окна браузера, которые Brow открывал для аккаунтов, затем удалите файлы Brow: его настройки, сохранённые показания, состояние питомца и профили браузера аккаунтов (вместе с cookie и сохранёнными паролями), папку Workspaces (его настройки, скрипты-обёртки, статистику кода и файл с темпом запросов) и то, что macOS сохранила для сетевых запросов Brow:
-## Как Brow и Grove работают вместе
+ ```sh
+ rm -rf ~/Library/Application\ Support/Brow
+ rm -rf ~/Library/Application\ Support/Grove
+ rm -rf ~/Library/Caches/dev.artemefimov.brow ~/Library/HTTPStorages/dev.artemefimov.brow
+ ```
-Claude Code хранит каждый аккаунт в своей папке: `~/.claude` — аккаунт по умолчанию, `~/.claude-accounts/` — остальные. Оба приложения читают эти папки, но напрямую друг с другом не общаются.
+ Если вы пользовались Grove, `defaults delete dev.artemefimov.grove3` удалит и его настройки.
-- **Аккаунты.** Brow создаёт папки аккаунтов, когда вы входите; Grove их подхватывает и может запустить любую сессию под любым из них.
-- **Браузерный роутер.** Вместе с Brow идёт небольшой скрипт, который открывает ссылки в профиле браузера нужного аккаунта. Если Brow установлен, Grove передаёт этот скрипт каждой сессии Claude Code, которую запускает, поэтому запрос на вход из сессии попадает в нужный профиль.
-- **Общий код.** Логика аккаунтов, учётных данных и расхода живёт в библиотеке `GroveCore` из Grove, от которой зависит этот пакет.
+5. Если вы пользовались Workspaces, удалите то, что он хранил в папках аккаунтов: `~/.claude/grove/` (страховка транскриптов и перехваты строки состояния основного аккаунта; сами транскрипты в `projects/` каждого аккаунта остаются) и `grove/` в каждой другой папке аккаунта. Прежде чем удалять папку `grove-backup/` — то, что отложил общий доступ, — просмотрите её. У аккаунта с общим доступом `file-history`, `tasks` и `session-env`, а также `projects/<пространство>` для каждого общего пространства — символические ссылки на `~/.claude`, и они продолжают работать; подробнее в [`docs/workspaces.md`](docs/workspaces.md#how-accounts-share-sessions).
+6. Если вы добавляли строку Brow `export BROWSER=…` в `~/.zshrc`, удалите её.
-Полная картина, в том числе то, как аккаунты пользуются общими сессиями, — в [README Grove](https://github.com/efim0v/grove#how-grove-and-brow-fit-together) и в [`docs/grove-and-brow.md`](docs/grove-and-brow.md) (оба на английском).
-
-
-
-
+Если вы сначала удалили приложение, а Brow всё ещё есть в Системных настройках → Основные → Объекты входа и расширения, уберите его там. Папки аккаунтов в `~/.claude-accounts` принадлежат Claude Code и работают без Brow. Рабочие пространства — это обычные git worktree и ветки в ваших репозиториях, поэтому они остаются: чтобы убрать пространство, выполните `git worktree remove` для каждого его worktree, затем удалите его папку.
## Сборка
@@ -171,31 +237,43 @@ Claude Code хранит каждый аккаунт в своей папке: `
git clone https://github.com/efim0v/brow.git
cd brow
-swift test # fetches GroveCore from the Grove repository
-Scripts/build-brow.sh # -> dist/Brow.app
+Scripts/test-in-empty-home.sh # все тесты, с домашней папкой, подменённой пустой
+Scripts/build-brow.sh # -> dist/Brow.app и dist/brow
cp -R dist/Brow.app /Applications/
```
-По умолчанию приложение подписывается ad hoc, и тогда macOS после каждой пересборки забывает разрешения на доступ к связке ключей и на автоматизацию. Чтобы они сохранялись, перед сборкой укажите в `GROVE_SIGN_IDENTITY` свой сертификат подписи `Apple Development: Name (TEAMID)`.
+По умолчанию приложение подписывается ad hoc. Тогда macOS забывает разрешения Связки ключей и автоматизации после каждой пересборки; чтобы они сохранялись, перед сборкой задайте в `BROW_SIGN_IDENTITY` свой сертификат `Apple Development: Name (TEAMID)`.
-## Структура репозитория
+## Структура
-| Путь | Что там |
+| Путь | Что это |
|---|---|
-| `Sources/BrowKit` | Само приложение: панель и геометрия чёлки, хранилище лимитов, представления, настройки |
+| `Sources/GroveCore` | Аккаунты, учётные данные, расход и обновление токенов; git, worktree, cmux, сессии Claude Code, общее хранилище, страховка транскриптов, перенос сессий, статистика кода |
+| `Sources/GroveAppKit` | Панель Workspaces и её состояние |
+| `Sources/BrowKit` | Чёлка, лимиты, питомец, Настройки и сторона Brow в Workspaces |
| `Sources/BrowApp` | Точка входа |
-| `Resources/brow-browser.sh` | Браузерный роутер для аккаунтов |
-| `Tests/BrowKitTests` | Около 150 тестов |
-| `docs` | [Brow в подробностях](docs/brow.md), [как Brow и Grove работают вместе](docs/grove-and-brow.md) (на английском) |
+| `Sources/brow-cli` | Утилита командной строки `brow` |
+| `Resources` | Значок приложения, маршрутизатор браузера по аккаунтам, шаблон Info.plist |
+| `Tests` | Около 1550 тестов |
+| `docs` | [Чёлка подробно](docs/brow.md), [Workspaces](docs/workspaces.md), [архитектура](docs/architecture.md), [решение проблем](docs/troubleshooting.md) |
-Единственная зависимость — [`GroveCore`](https://github.com/efim0v/grove) из репозитория Grove.
+Сторонних зависимостей нет. Имена модулей `GroveCore` и `GroveAppKit` пришли из Grove, история которого есть в этом репозитории.
## Разработка
-- Скриншоты в этом README заново генерируются командой
+- `Scripts/test-in-empty-home.sh` и `Scripts/test-in-empty-home.sh -c release` должны оставаться зелёными; `Scripts/check-warnings.sh` не должен находить новых предупреждений.
+- `swift run BrowApp --snapshot /tmp/brow-snap` рисует экраны Workspaces в PNG, не запуская приложение; см. [`docs/snapshot-testing.md`](docs/snapshot-testing.md).
+- Скриншоты в этом README пересоздаются командой
`DEMO_SCREENSHOTS_DIR="$PWD/docs/screenshots" swift test --filter DemoScreenshotTests`.
-- Константы, которыми настраивается контур чёлки, лежат в `Sources/BrowKit/Panel/NotchGeometry.swift`; их подгоняют на глаз под реальную рамку экрана.
+- Константы контура чёлки лежат в `Sources/BrowKit/Panel/NotchGeometry.swift`; их подгоняют на глаз по реальной рамке экрана.
+
+## Благодарности
+
+- [boring.notch](https://github.com/TheBoredTeam/boring.notch) (GPL-3.0): константы контура чёлки и идея рисовать в собственном пространстве SkyLight, чтобы панель не двигалась при переключении Spaces.
+- [NotchNook](https://lo.cafe/notchnook): тот же способ держать панель у чёлки на месте при переключении Spaces.
+- [cmux](https://cmux.dev): терминал, в котором Workspaces открывает сессии.
+- [Grove](https://github.com/efim0v/grove): приложение-компаньон Brow до версии 0.4, чей движок Workspaces теперь часть Brow.
## Лицензия
-[GNU GPL v3](LICENSE). Версии 0.2.0 и более ранние выходили под лицензией MIT.
+[GNU GPL v3](LICENSE). Версии Brow и Grove до 0.2.0 включительно выходили под лицензией MIT.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 5490517..afc83be 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -4,37 +4,52 @@
> 本文是[英文 README](README.md) 的译文。英文版可能比译文更新,两者不一致时以英文版为准。
-# Brow:MacBook 刘海里的 Claude Code 用量限额
+# Brow:MacBook 刘海里的 Claude Code 用量限额,以及用于并行会话的工作区
[](https://github.com/efim0v/brow/actions/workflows/ci.yml)
[](https://github.com/efim0v/brow/releases/latest)
[](LICENSE)
[](#系统要求)
-一款 macOS 应用,直接在 MacBook 刘海里显示**你所有 [Claude Code](https://claude.com/claude-code) 账号的用量和限额**:5 小时限额、每周限额,以及各自的重置时间。只需点一下,就能在专属于某个账号的浏览器配置文件中登录这个账号。
+一款 macOS 应用,面向同时用多个账号运行 [Claude Code](https://claude.com/claude-code) 的人。它做两件事:
-Brow 适合同时持有多个 Claude 订阅、在每个订阅上分别进行 AI 辅助工作的人。它让你一眼看出哪个账号快到限额、哪个还有余量、各自何时恢复。只有一个账号也能用:Brow 会自动找到 `~/.claude` 并显示该账号的限额。
+- **刘海显示每个账号的用量和限额**:5 小时限额、每周限额,以及各自的重置时间。只需点一下,就能在专属于某个账号的浏览器配置文件中登录这个账号。
+- **菜单栏里的 Workspaces 在拆分为多个仓库的产品上运行并行的 Claude Code 会话**:每个功能一个文件夹,里面是所有仓库在同一分支上的 git worktree;会话可以转移到仍有余量的账号上。
+
+刘海用来看,Workspaces 用来干活。只有一个账号也能用:Brow 会自动找到 `~/.claude` 并显示该账号的限额,而在你使用之前,Workspaces 一直处于关闭状态。
**[下载 macOS 版 Brow](https://github.com/efim0v/brow/releases/latest/download/Brow.zip)** 或 `brew install --cask efim0v/tap/brow` · macOS 26 或更高版本,Apple 芯片 · [安装说明](#安装)
+> **用过 Grove?** Grove 现在是 Brow 的一部分。Brow 会找到你在 Grove 中的项目和账号,并在 Workspaces 中运行它们;[有哪些变化](#从-grove-迁移)。
+
### 哪个账号还有余量
将指针悬停在刘海上,所有账号的 5 小时限额和每周限额就会从刘海中展开。复制某个账号的启动命令,Claude Code 就会以该账号的身份启动。
-
-
-
+
+
+
+
+
+### Glim:一只吃限额的宠物
+
+一个小家伙住在 5 小时限额的数字旁边。数字每上涨一次,它就咬一口;它身上的光就是它的饥饿程度。5 小时限额用完时,它吃饱睡着;每周限额用完时,它躲进摄像头后面的窝里,等重置后再出来。在 Settings → General → **Show the pet** 中可以关闭。
+
+
: warning: ".
+# Keep it short, and say why next to each one.
+allowed=(
+ # The process-wide Keychain dialog switch: one function, see
+ # KeychainCredentialsReader.keychainInteraction.
+ "Sources/GroveCore/Services/ClaudeCredentials\.swift:[0-9]+:[0-9]+: warning: 'SecKeychain(Get|Set)UserInteractionAllowed' was deprecated"
+)
+
+SCRATCH=.build/check-warnings
+rm -rf "$SCRATCH"
+log=$(swift build --build-tests --scratch-path "$SCRATCH" 2>&1)
+build_status=$?
+if (( build_status != 0 )); then
+ echo "$log" | tail -40
+ echo "check-warnings: the build failed" >&2
+ exit $build_status
+fi
+
+warnings=$(echo "$log" | sed 's/\x1b\[[0-9;]*m//g' | command grep -E '^/.*: warning: ' | sed "s#^$PWD/##" | sort -u)
+unexpected=$warnings
+for pattern in "${allowed[@]}"; do
+ unexpected=$(echo "$unexpected" | command grep -vE "$pattern")
+done
+unexpected=$(echo "$unexpected" | sed '/^$/d')
+
+allowed_count=$(( $(echo "$warnings" | sed '/^$/d' | wc -l) - $(echo "$unexpected" | sed '/^$/d' | wc -l) ))
+if [[ -n "$unexpected" ]]; then
+ echo "check-warnings: new compiler warnings:" >&2
+ echo "$unexpected" | cut -c1-300 >&2
+ exit 1
+fi
+echo "check-warnings: no new warnings ($allowed_count allowed)"
diff --git a/Scripts/check_seo.py b/Scripts/check_seo.py
index 3a48f6d..eeda166 100755
--- a/Scripts/check_seo.py
+++ b/Scripts/check_seo.py
@@ -1,4 +1,7 @@
#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-3.0-only
+# Copyright (C) 2026 Artem Efimov
+
"""Check the SEO head of every page in site/ (stdlib only).
Usage: python3 check_seo.py
diff --git a/Scripts/coverage.sh b/Scripts/coverage.sh
new file mode 100755
index 0000000..5cc3a12
--- /dev/null
+++ b/Scripts/coverage.sh
@@ -0,0 +1,56 @@
+#!/bin/zsh
+# SPDX-License-Identifier: GPL-3.0-only
+# Copyright (C) 2026 Artem Efimov
+
+# Measures test coverage and enforces the project's coverage policy (see COVERAGE.md).
+#
+# The GATE is the pure/mockable LOGIC layer of the Workspaces engine and the code the
+# notch shares with it (GroveCore services and models, GroveAppKit presentation and
+# state) and must be >= 95%. SwiftUI views are validated by the snapshot/render tests;
+# real cmux/osascript/socket adapters and the NSApplication entry points are validated
+# by manual/integration testing — both are excluded from the gate. BrowKit is reported
+# on a line of its own: its models, its state and the whole pet, the pet's views and
+# senses included, but not its other views, the notch window or the app delegate.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+# With the home folder pointed at an empty one, like Scripts/test-in-empty-home.sh.
+EMPTY_HOME=$(mktemp -d "${TMPDIR:-/tmp}/brow-coverage-home.XXXXXX")
+trap 'rm -rf "$EMPTY_HOME"' EXIT
+HOME="$EMPTY_HOME" CFFIXED_USER_HOME="$EMPTY_HOME" swift test --enable-code-coverage
+# With either SwiftPM build system .build/debug links to the debug products. The classic
+# one makes a single test bundle (plus a .dSYM folder next to its binary, hence plain
+# files only); the newer one, the default in Swift 6.4, makes one bundle per test target.
+# llvm-cov reads them all and counts code linked into several bundles once.
+PROF=.build/debug/codecov/default.profdata
+OBJECTS=()
+for bin in .build/debug/*.xctest/Contents/MacOS/*(.N); do
+ OBJECTS+=(-object "$bin")
+done
+if [[ ! -f "$PROF" || ${#OBJECTS[@]} -eq 0 ]]; then
+ echo "no coverage profile or test bundle under .build/debug" >&2
+ exit 1
+fi
+
+# Excluded from the logic gate (validated by other means, not unit coverage).
+GATE_IGNORE='Tests|\.build|GroveAppKit/Views/|WorkspacesMenuBarController|DesignSystem|GroveLog|Snapshot/SnapshotMode|Services/Cmux|Diagnostics/CmuxProbe|BrowKit/|BrowApp/|brow-cli/'
+# BrowKit's line: Model/, State/ and all of Pet/, the pet's views (PetCanvas, PetLayerView),
+# its senses (PetSenses) and its perf log (PetPerfLog) included. Left out: BrowKit's other
+# views, the notch window, the app delegate and its log (render tests and the app cover those).
+BROWKIT_IGNORE='Tests|\.build|GroveCore/|GroveAppKit/|BrowKit/Views/|BrowKit/Panel/|BrowKit/App/|BrowKit/BrowLog|BrowApp/|brow-cli/'
+
+cover() {
+ xcrun llvm-cov report "${OBJECTS[@]}" -instr-profile="$PROF" \
+ -ignore-filename-regex="$1" --show-region-summary=false 2>/dev/null \
+ | grep TOTAL | awk '{n=NF; v=$(n-3); gsub("%","",v); print v}'
+}
+
+ALL=$(cover 'Tests|\.build')
+GATE=$(cover "$GATE_IGNORE")
+BROWKIT=$(cover "$BROWKIT_IGNORE")
+echo "Overall line coverage: ${ALL}%"
+echo "Logic-gate coverage: ${GATE}% (policy: >= 95%)"
+echo "BrowKit coverage: ${BROWKIT}% (models, state and the pet; reported, not gated)"
+
+awk -v g="$GATE" 'BEGIN { exit (g+0 >= 95) ? 0 : 1 }' \
+ && echo "GATE PASS" || { echo "GATE FAIL"; exit 1; }
diff --git a/Scripts/dev-run.sh b/Scripts/dev-run.sh
new file mode 100755
index 0000000..ebbafff
--- /dev/null
+++ b/Scripts/dev-run.sh
@@ -0,0 +1,46 @@
+#!/bin/zsh
+# SPDX-License-Identifier: GPL-3.0-only
+# Copyright (C) 2026 Artem Efimov
+
+# Builds and runs the development binary of Brow under an identifier of its own.
+# Usage: Scripts/dev-run.sh [--release] [-- ]
+#
+# Why its own identifier: macOS keys per-app state on how a binary is signed and
+# named. The menu-bar item's slot is one of them, and on macOS 26.4 rebuild churn
+# under the shipping id corrupted that slot (Grove had to move to a new bundle id).
+# A dev build signed as dev.artemefimov.brow.dev cannot do that to dev.artemefimov.brow,
+# and consents (Automation for Terminal and cmux) given to it stay with it:
+#
+# ad hoc (what SwiftPM produces) -> keyed by cdhash -> DIES on every rebuild
+# stable identity -> keyed by identifier + leaf certificate
+#
+# Set BROW_SIGN_IDENTITY to your own "Apple Development: Name (TEAMID)" identity to
+# keep consents across rebuilds. The dev binary reads and writes the same settings
+# folders as the app (~/Library/Application Support/Brow and …/Grove).
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+source Scripts/xcode-env.sh
+
+CONFIGURATION="debug"
+if [[ "${1:-}" == "--release" ]]; then
+ CONFIGURATION="release"
+ shift
+fi
+[[ "${1:-}" == "--" ]] && shift
+
+IDENTITY="${BROW_SIGN_IDENTITY:-${GROVE_SIGN_IDENTITY:-}}"
+IDENTIFIER="dev.artemefimov.brow.dev"
+
+swift build -c "$CONFIGURATION" --product BrowApp
+BINARY="$(swift build -c "$CONFIGURATION" --show-bin-path)/BrowApp"
+
+if [[ -n "$IDENTITY" ]] && security find-identity -v -p codesigning 2>/dev/null | grep -qF "$IDENTITY"; then
+ codesign --force --sign "$IDENTITY" --identifier "$IDENTIFIER" "$BINARY"
+ echo "signed: $IDENTITY ($IDENTIFIER)"
+else
+ codesign --force --sign - --identifier "$IDENTIFIER" "$BINARY"
+ echo "signed: ad hoc ($IDENTIFIER); consents reset on every rebuild" >&2
+fi
+
+exec "$BINARY" "$@"
diff --git a/Scripts/release.sh b/Scripts/release.sh
new file mode 100755
index 0000000..64c68f6
--- /dev/null
+++ b/Scripts/release.sh
@@ -0,0 +1,104 @@
+#!/bin/zsh
+# SPDX-License-Identifier: GPL-3.0-only
+# Copyright (C) 2026 Artem Efimov
+
+# Packages a release from what Scripts/build-brow.sh built, and checks it the way a
+# user will get it. Uploads nothing: the tag (bare semver, `0.4.0`) and the GitHub
+# release are made by hand.
+#
+# Usage: Scripts/release.sh [--build]
+#
+# dist/Brow.zip Brow.app
+# dist/brow-cli.zip brow, grove (a symlink to brow), LICENSE
+# dist/SHA256SUMS.txt for both
+set -euo pipefail
+cd "$(dirname "$0")/.."
+# nm, below, is one of the /usr/bin shims that stop working until the Xcode license is accepted.
+source Scripts/xcode-env.sh
+
+[[ "${1:-}" == "--build" ]] && Scripts/build-brow.sh
+if [[ ! -d dist/Brow.app || ! -x dist/brow ]]; then
+ echo "release: no dist/Brow.app or dist/brow — run Scripts/build-brow.sh first" >&2
+ exit 1
+fi
+VERSION=$(sed -n 's/.*static let current = "\(.*\)".*/\1/p' Sources/GroveCore/BrowVersion.swift)
+PLIST_VERSION=$(plutil -extract CFBundleShortVersionString raw dist/Brow.app/Contents/Info.plist)
+if [[ "$VERSION" != "$PLIST_VERSION" ]]; then
+ echo "release: dist/Brow.app is $PLIST_VERSION but BrowVersion is $VERSION — rebuild" >&2
+ exit 1
+fi
+codesign --verify --deep --strict dist/Brow.app
+# No path of this machine in what ships: build-brow.sh strips the debug map, and no string may name
+# the source tree either (a #filePath, say). grep -a reads every byte, the symbol table's included.
+for binary in dist/Brow.app/Contents/MacOS/Brow dist/brow; do
+ if LC_ALL=C grep -a -q -F -e "$PWD/" -e "$(pwd -P)/" "$binary" || nm -ap "$binary" | grep -q ' OSO '; then
+ echo "release: $binary names paths on this machine — rebuild with Scripts/build-brow.sh" >&2
+ exit 1
+ fi
+done
+
+rm -f dist/Brow.zip dist/brow-cli.zip dist/SHA256SUMS.txt
+# --norsrc --noextattr --noqtn --noacl: no resource forks, extended attributes, quarantine
+# or ACLs in the archive. With them, an unzip other than ditto writes AppleDouble ._ files
+# into the bundle and its signature no longer verifies.
+ZIP=(ditto -c -k --norsrc --noextattr --noqtn --noacl)
+"${ZIP[@]}" --keepParent dist/Brow.app dist/Brow.zip
+
+STAGE=$(mktemp -d "${TMPDIR:-/tmp}/brow-release.XXXXXX")
+CHECK=$(mktemp -d "${TMPDIR:-/tmp}/brow-release-check.XXXXXX")
+trap 'rm -rf "$STAGE" "$CHECK"' EXIT
+cp dist/brow "$STAGE/brow"
+# The tool's second name, for scripts written for Grove's: one binary, a symlink.
+ln -s brow "$STAGE/grove"
+cp LICENSE "$STAGE/LICENSE"
+"${ZIP[@]}" "$STAGE" dist/brow-cli.zip
+
+(cd dist && shasum -a 256 Brow.zip brow-cli.zip > SHA256SUMS.txt)
+
+# What a user gets: unzipped by /usr/bin/unzip, not ditto.
+/usr/bin/unzip -q dist/Brow.zip -d "$CHECK/app"
+codesign --verify --deep --strict --verbose=1 "$CHECK/app/Brow.app"
+if [[ -n "$(find "$CHECK/app" -name '._*' -print -quit)" ]]; then
+ echo "release: Brow.zip carries AppleDouble files" >&2
+ exit 1
+fi
+/usr/bin/unzip -q dist/brow-cli.zip -d "$CHECK/cli"
+if [[ ! -L "$CHECK/cli/grove" || "$(readlink "$CHECK/cli/grove")" != brow ]]; then
+ echo "release: brow-cli.zip has no grove -> brow symlink" >&2
+ exit 1
+fi
+codesign --verify --strict "$CHECK/cli/brow"
+[[ "$("$CHECK/cli/brow" --version)" == "$VERSION" ]] || { echo "release: brow --version is not $VERSION" >&2; exit 1; }
+[[ "$("$CHECK/cli/grove" --help | head -1)" == "grove — "* ]] || { echo "release: grove does not answer as grove" >&2; exit 1; }
+(cd dist && shasum -a 256 -c SHA256SUMS.txt)
+
+echo "release $VERSION:"
+ls -l dist/Brow.zip dist/brow-cli.zip dist/SHA256SUMS.txt
+
+# The changelog's heading for this version carries the day it is tagged.
+TODAY=$(date +%F)
+DATED=$(sed -n "s/^## \[$VERSION\] - \([0-9-]*\).*/\1/p" CHANGELOG.md | head -1)
+if [[ "$DATED" != "$TODAY" ]]; then
+ echo "note: CHANGELOG.md dates $VERSION ${DATED:-nowhere}; set it to the day you tag ($TODAY)." >&2
+fi
+
+# The README, the site and the tap point at each other: publish them in one sitting,
+# in this order, so nothing points at something that is not there yet.
+SUM=$(awk '$2 == "Brow.zip" { print $1 }' dist/SHA256SUMS.txt)
+CLISUM=$(awk '$2 == "brow-cli.zip" { print $1 }' dist/SHA256SUMS.txt)
+cat <&2
+ for entry in "${leaks[@]}"; do
+ echo " ~/$entry" >&2
+ (cd "$EMPTY_HOME" && find "$entry" -maxdepth 3 | head -10 | sed 's/^/ /') >&2
+ done
+ exit 1
+fi
+exit $test_status
diff --git a/Scripts/test.sh b/Scripts/test.sh
new file mode 100755
index 0000000..7763b9d
--- /dev/null
+++ b/Scripts/test.sh
@@ -0,0 +1,68 @@
+#!/bin/zsh
+# SPDX-License-Identifier: GPL-3.0-only
+# Copyright (C) 2026 Artem Efimov
+
+# Build the test bundles and run them with Xcode's `xctest` directly.
+#
+# Usage:
+# Scripts/test.sh # every bundle
+# Scripts/test.sh GroveCoreTests # one bundle
+# Scripts/test.sh GroveCoreTests ConfigStoreTests # one XCTestCase class
+# Scripts/test.sh GroveCoreTests ConfigStoreTests/testFoo
+#
+# See Scripts/xcode-env.sh for why `swift test` is not used.
+set -uo pipefail
+cd "$(dirname "$0")/.."
+source Scripts/xcode-env.sh
+
+BUNDLE="${1:-}"
+FILTER="${2:-}"
+
+# Check swift's own exit code straight after the pipeline. Do not append `|| true`:
+# pipefail makes it run when the build fails, and it resets pipestatus to its own 0.
+swift build --build-tests 2>&1 | grep -E "error:|warning:|Build complete" | awk '!seen[$0]++'
+if [[ ${pipestatus[1]} -ne 0 ]]; then
+ echo "BUILD FAILED" >&2
+ exit 1
+fi
+
+# SwiftPM's newer build system puts products under .build/out; the classic one under .build/debug.
+if [[ -d .build/out/Products/Debug ]]; then
+ PRODUCTS=.build/out/Products/Debug
+else
+ PRODUCTS=.build/debug
+fi
+
+if [[ -n "$BUNDLE" ]]; then
+ bundles=("$PRODUCTS/$BUNDLE.xctest")
+else
+ bundles=("$PRODUCTS"/*.xctest(N))
+fi
+if [[ ${#bundles[@]} -eq 0 ]]; then
+ echo "no test bundles under $PRODUCTS" >&2
+ exit 1
+fi
+
+failed=0
+for b in "${bundles[@]}"; do
+ if [[ ! -d "$b" ]]; then
+ echo "missing bundle: $b" >&2
+ failed=1
+ continue
+ fi
+ name="${b:t:r}"
+ args=()
+ [[ -n "$FILTER" ]] && args=(-XCTest "$FILTER")
+ out=$("$DEVELOPER_DIR/usr/bin/xctest" "${args[@]}" "$b" 2>&1)
+ rc=$?
+ # Failures, crashes, and the outermost "Executed N tests" summary.
+ echo "$out" | grep -E "error:|Fatal error|failed \(|XCTAssert" | head -40
+ summary=$(echo "$out" | grep -E "Executed [0-9]+ tests?" | tail -1)
+ echo "$name (xctest exit $rc): ${summary:-no summary}"
+ # xctest's exit code is not a reliable pass/fail signal (a skipped test can make it
+ # non-zero); the outermost summary line is.
+ if [[ -z "$summary" ]] || ! echo "$summary" | grep -qE "(with|and) 0 failures"; then
+ failed=1
+ fi
+done
+exit $failed
diff --git a/Scripts/xcode-env.sh b/Scripts/xcode-env.sh
index b05183b..52bcf37 100644
--- a/Scripts/xcode-env.sh
+++ b/Scripts/xcode-env.sh
@@ -1,3 +1,6 @@
+# SPDX-License-Identifier: GPL-3.0-only
+# Copyright (C) 2026 Artem Efimov
+
# Source me: `source Scripts/xcode-env.sh`
#
# Why this exists: after a macOS/Xcode update the Xcode license must be
@@ -14,7 +17,13 @@
# directly instead:
# swift build --build-tests
# "$DEVELOPER_DIR/usr/bin/xctest" .build/debug/*.xctest
-export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
+# The Xcode xcode-select points at (a CI image may keep several side by side), else
+# the usual place. xcode-select itself is not one of the shims, so it answers.
+if [[ -z "${DEVELOPER_DIR:-}" ]]; then
+ DEVELOPER_DIR=$(xcode-select --print-path 2>/dev/null)
+ [[ "$DEVELOPER_DIR" == */Xcode*.app/Contents/Developer ]] || DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
+fi
+export DEVELOPER_DIR
export SDKROOT="$DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"
export PATH="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/bin:$DEVELOPER_DIR/usr/bin:$PATH"
export DYLD_FRAMEWORK_PATH="$DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/Library/Frameworks"
diff --git a/Sources/BrowApp/main.swift b/Sources/BrowApp/main.swift
index 6285bf3..fbc9e91 100644
--- a/Sources/BrowApp/main.swift
+++ b/Sources/BrowApp/main.swift
@@ -1,5 +1,20 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import BrowKit
+import GroveAppKit
+import GroveCore
-// main.swift (not @main) to match GroveApp: top-level code runs on the main
-// thread, and assumeIsolated bridges into the @MainActor entry point.
-MainActor.assumeIsolated { BrowApp.run() }
+// main.swift (not @main) so the two hidden modes can run and exit BEFORE any
+// NSApplication machinery starts:
+// --snapshot renders the Workspaces screens offscreen and exits
+// (SnapshotMode, docs/snapshot-testing.md);
+// --cmux-probe writes a cmux diagnostic report (CmuxProbe,
+// docs/troubleshooting.md); it parks the main thread in
+// dispatchMain() and never returns when the flag is present.
+// Top-level code runs on the main thread; assumeIsolated bridges into the @MainActor
+// entry points.
+let handledSnapshot = MainActor.assumeIsolated { SnapshotMode.runIfRequested() }
+if !handledSnapshot && !CmuxProbe.runIfRequested() {
+ MainActor.assumeIsolated { BrowApp.run() }
+}
diff --git a/Sources/BrowKit/App/AddAccountFlow.swift b/Sources/BrowKit/App/AddAccountFlow.swift
index 893d37a..7d139af 100644
--- a/Sources/BrowKit/App/AddAccountFlow.swift
+++ b/Sources/BrowKit/App/AddAccountFlow.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import AppKit
import Foundation
import GroveCore
@@ -12,7 +15,9 @@ public final class AddAccountFlow: ObservableObject {
@Published public private(set) var status: String?
private let store: LimitsStore
- private let home: String
+ /// The home folder whose `.claude-accounts` new accounts go in, and whose `.claude`
+ /// is the default account.
+ public let home: String
private var pollTask: Task?
public init(store: LimitsStore, home: String = NSHomeDirectory()) {
@@ -56,8 +61,8 @@ public final class AddAccountFlow: ObservableObject {
/// `dir` starts with. The default `~/.claude` is the exception: it is selected by
/// the variable being ABSENT (`isDefaultClaudeDir`), so its prefix removes it —
/// the terminal the line is pasted into may well have another account exported.
- public static func environmentPrefix(dir: String) -> String {
- var prefix = isDefaultClaudeDir(dir) ? "env -u CLAUDE_CONFIG_DIR" : "CLAUDE_CONFIG_DIR=\(shellQuote(dir))"
+ public static func environmentPrefix(dir: String, home: String = NSHomeDirectory()) -> String {
+ var prefix = isDefaultClaudeDir(dir, home: home) ? "env -u CLAUDE_CONFIG_DIR" : "CLAUDE_CONFIG_DIR=\(shellQuote(dir))"
if let helper = browserHelperPath { prefix += " BROWSER=\(shellQuote(helper))" }
return prefix
}
@@ -65,8 +70,8 @@ public final class AddAccountFlow: ObservableObject {
/// The one line a user pastes into any terminal to run Claude Code as this
/// account. Plain `claude`, not the resolved binary path: this is what people
/// type, and `claude` in a fresh config dir walks them through sign-in itself.
- public static func launchCommand(dir: String) -> String {
- "\(environmentPrefix(dir: dir)) claude"
+ public static func launchCommand(dir: String, home: String = NSHomeDirectory()) -> String {
+ "\(environmentPrefix(dir: dir, home: home)) claude"
}
/// The line for a shell profile (`~/.zshrc`): with it, a sign-in started from
diff --git a/Sources/BrowKit/App/BrowAppController.swift b/Sources/BrowKit/App/BrowAppController.swift
index 65daabe..e67b361 100644
--- a/Sources/BrowKit/App/BrowAppController.swift
+++ b/Sources/BrowKit/App/BrowAppController.swift
@@ -1,6 +1,10 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import AppKit
import Combine
import SwiftUI
+import GroveAppKit
import GroveCore
/// `TokenKeeper` is an actor: it calls `allowPromptFallback` on its OWN executor,
@@ -29,9 +33,8 @@ private final class SendableText: @unchecked Sendable {
func set(_ newValue: String) { lock.lock(); value = newValue; lock.unlock() }
}
-/// Process entry: wires the stores, the notch panel, the triggers and the
-/// settings window. Mirrors GroveMenuBarApp: plain AppKit, `.accessory`
-/// activation policy, no MenuBarExtra.
+/// Process entry: wires the stores, the notch panel, the triggers, Workspaces and the
+/// settings window. Plain AppKit, `.accessory` activation policy, no MenuBarExtra.
public enum BrowApp {
@MainActor private static var controller: BrowAppController?
@@ -54,11 +57,17 @@ final class BrowAppController: NSObject, NSApplicationDelegate, NSWindowDelegate
private var panel: NotchPanelController!
private var addFlow: AddAccountFlow!
private var notifier: LimitNotifier!
+ /// Workspaces: Grove's panel and engine, hosted. Decides at launch whether anything
+ /// of it runs (`WorkspacesActivation`); for a Brow-only user nothing does.
+ private var workspaces: WorkspacesHost?
+ private var pet: PetController!
private let clock = PanelClock()
private var settingsWindow: NSWindow?
/// Names typed into Settings but not committed yet. Owned here, not by the
/// window's SwiftUI tree, because that tree is destroyed when the window closes.
private let drafts = SettingsDrafts()
+ /// Which Settings tab is in front; the Workspaces panel's links turn it.
+ private let navigation = SettingsNavigation()
private var screenObserver: (any NSObjectProtocol)?
private let promptFallback = SendableFlag(true)
/// Reachability, mirrored out of `RefreshTriggers`' path monitor. Same reason as
@@ -125,11 +134,35 @@ final class BrowAppController: NSObject, NSApplicationDelegate, NSWindowDelegate
triggers.$isOnline
.sink { [online] reachable in online.set(reachable) }
.store(in: &cancellables)
- self.panel = NotchPanelController(store: store, clock: clock,
+ // The pet reads only what the store already has; it adds no request and no Keychain read.
+ let pet = PetController(store: store)
+ self.pet = pet
+ self.panel = NotchPanelController(store: store, clock: clock, pet: pet,
onExpandedChange: { [weak self] in self?.triggers.setExpanded($0) },
- onSettings: { [weak self] in self?.showSettings() })
- self.panel.show()
- BrowLog.panel.info("panel shown")
+ onSettings: { [weak self] in self?.showSettings() },
+ onWorkspaces: { [weak self] in self?.workspaces?.togglePanel() })
+ // Unless Settings › General has it off: then nothing at the top of the screen.
+ if settings.showNotch { self.panel.show() }
+ BrowLog.panel.info("panel \(settings.showNotch ? "shown" : "off", privacy: .public)")
+ store.$settings
+ .map(\.showNotch)
+ .removeDuplicates()
+ .dropFirst()
+ .sink { [weak self] show in
+ Task { @MainActor in
+ guard let panel = self?.panel else { return }
+ if show { panel.show() } else { panel.hide() }
+ }
+ }
+ .store(in: &cancellables)
+ // After the panel, so its first frame has its zones; from the persisted accounts alone until the
+ // first account scan lands.
+ pet.start()
+ // After the notch is up, like everything that may touch the disk.
+ let workspaces = WorkspacesHost(store: store)
+ self.workspaces = workspaces
+ workspaces.openSettings = { [weak self] link in self?.showSettings(pane: SettingsPane(link)) }
+ workspaces.start()
// Off until the user turns it on; even then it reads only what the store has.
notifier = LimitNotifier(poster: SystemNotifications.isSupported ? SystemNotifications() : UnavailableNotifications())
notifier.start(store: store)
@@ -188,7 +221,11 @@ final class BrowAppController: NSObject, NSApplicationDelegate, NSWindowDelegate
if let store { drafts.flush(into: store) }
triggers?.stop()
notifier?.stop()
+ workspaces?.stop()
panel?.hide()
+ // After the panel, whose collapse and leaving the screen are the last the pet hears: `pet.json`
+ // is written whatever its schedule says, so nothing since the last save is lost.
+ pet?.flush()
if let screenObserver { NotificationCenter.default.removeObserver(screenObserver); self.screenObserver = nil }
cancellables.removeAll()
}
@@ -206,12 +243,12 @@ final class BrowAppController: NSObject, NSApplicationDelegate, NSWindowDelegate
}
/// A non-nib app gets NO main menu of its own, and Brow has no Dock icon
- /// (`LSUIElement` + `.accessory`), no status item and no window at launch —
- /// so without this menu `Cmd-Q` is inert and the only way out of a running
- /// Brow is `pkill`. Grove reaches `NSApp.terminate` from its panel footer
- /// (`ProjectsFooter.swift`); Brow's panel is a hover strip with no room for
- /// a Quit button, so the menu carries it — and hands the settings window
- /// back its standard `Cmd-,` and `Cmd-W`.
+ /// (`LSUIElement` + `.accessory`) and no window at launch — and, for a Brow-only
+ /// user, no status item — so without this menu `Cmd-Q` is inert and the only way
+ /// out of a running Brow is `pkill`. The Workspaces panel has its own Quit Brow
+ /// (`ProjectsFooter.swift`); the notch panel is a hover strip with no room for one,
+ /// so the menu carries it — and hands the settings window back its standard
+ /// `Cmd-,` and `Cmd-W`.
func makeMainMenu() -> NSMenu {
let main = NSMenu()
@@ -221,6 +258,9 @@ final class BrowAppController: NSObject, NSApplicationDelegate, NSWindowDelegate
let settings = NSMenuItem(title: "Settings…", action: #selector(openSettingsMenuItem(_:)), keyEquivalent: ",")
settings.target = self
appMenu.addItem(settings)
+ let showWorkspaces = NSMenuItem(title: "Show Workspaces", action: #selector(showWorkspacesMenuItem(_:)), keyEquivalent: "")
+ showWorkspaces.target = self
+ appMenu.addItem(showWorkspaces)
appMenu.addItem(.separator())
// nil target: the action walks the responder chain up to NSApp, which
// implements `terminate:` — the same exit Grove's footer button takes.
@@ -255,8 +295,26 @@ final class BrowAppController: NSObject, NSApplicationDelegate, NSWindowDelegate
}
@objc private func openSettingsMenuItem(_ sender: Any?) { showSettings() }
+ @objc private func showWorkspacesMenuItem(_ sender: Any?) { workspaces?.showPanel() }
+
+ /// What opening Brow again (Finder, Spotlight, `open -a Brow`) shows: the Workspaces
+ /// panel while Workspaces is wanted — the way in when macOS hides the menu-bar item
+ /// behind the notch — and Settings otherwise. It used to do nothing.
+ enum ReopenTarget: Equatable { case workspaces, settings }
+
+ static func reopenTarget(for activation: WorkspacesActivation) -> ReopenTarget {
+ activation.isWanted ? .workspaces : .settings
+ }
+
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
+ switch Self.reopenTarget(for: workspaces?.activation ?? .off) {
+ case .workspaces: workspaces?.showPanel()
+ case .settings: showSettings()
+ }
+ return true
+ }
- func showSettings() {
+ func showSettings(pane: SettingsPane? = nil) {
// `Cmd-,` is live from the moment the menu is installed, which is before
// the launch Task has built the store — and both are implicitly
// unwrapped, so an early hit would trap instead of doing nothing.
@@ -264,12 +322,14 @@ final class BrowAppController: NSObject, NSApplicationDelegate, NSWindowDelegate
BrowLog.panel.error("settings opened before the accounts finished loading; ignoring")
return
}
+ if let pane { navigation.pane = pane }
if settingsWindow == nil {
// The detected path is NOT passed in: the window is built once and cached,
// and detection can still be running. `SettingsView` reads it live off the
// store instead (`store.claudeDetection`).
- let view = SettingsView(store: store, addFlow: addFlow, drafts: drafts, notifier: notifier)
- let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 460),
+ let view = SettingsView(store: store, addFlow: addFlow, drafts: drafts, notifier: notifier,
+ workspaces: workspaces, navigation: navigation)
+ let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 660, height: 540),
styleMask: [.titled, .closable], backing: .buffered, defer: false)
window.title = "Brow Settings"
window.contentView = NSHostingView(rootView: view)
diff --git a/Sources/BrowKit/App/ClaudePathResolver.swift b/Sources/BrowKit/App/ClaudePathResolver.swift
index 65b6e2e..702ec53 100644
--- a/Sources/BrowKit/App/ClaudePathResolver.swift
+++ b/Sources/BrowKit/App/ClaudePathResolver.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
diff --git a/Sources/BrowKit/App/LimitNotifier.swift b/Sources/BrowKit/App/LimitNotifier.swift
index 2924a36..395b344 100644
--- a/Sources/BrowKit/App/LimitNotifier.swift
+++ b/Sources/BrowKit/App/LimitNotifier.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import AppKit
import Combine
import UserNotifications
diff --git a/Sources/BrowKit/App/WeekCardExport.swift b/Sources/BrowKit/App/WeekCardExport.swift
index 31653e1..3e65826 100644
--- a/Sources/BrowKit/App/WeekCardExport.swift
+++ b/Sources/BrowKit/App/WeekCardExport.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import AppKit
import SwiftUI
import UniformTypeIdentifiers
diff --git a/Sources/BrowKit/App/WorkspacesHost.swift b/Sources/BrowKit/App/WorkspacesHost.swift
new file mode 100644
index 0000000..5e28804
--- /dev/null
+++ b/Sources/BrowKit/App/WorkspacesHost.swift
@@ -0,0 +1,546 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import AppKit
+import Combine
+import GroveAppKit
+import GroveCore
+
+/// Brow's side of Workspaces: whether the engine runs (`WorkspacesActivation`), the
+/// Grove-running guard, the lock that keeps the engine to one copy of Brow
+/// (`WorkspacesEngineLock`), the menu-bar item and the panel. GroveAppKit never imports
+/// BrowKit; this is where the two meet.
+///
+/// Nothing of the engine runs until Workspaces is wanted: no account adoption, no
+/// status-line reconcile, no timer. While it is off, Grove's config is still read for
+/// a status line Workspaces captured, which Claude Code keeps running through the
+/// wrapper (`dormantAccounts`), and changed only to give one back or to take out such
+/// an account whose folder is gone (`withoutEngine`). The introduction the notch's
+/// Workspaces button shows meanwhile sits on a state whose engine never starts, until
+/// the user adds a project.
+@MainActor
+public final class WorkspacesHost: ObservableObject {
+ @Published public private(set) var activation: WorkspacesActivation = .off
+ /// What Workspaces waits for while it is paused; nil otherwise.
+ @Published public private(set) var pause: WorkspacesPause?
+ /// The engine's state while Workspaces is wanted; nil while it is off.
+ @Published public private(set) var state: AppState?
+ /// Grove's accounts as its config has them, read without the engine while Workspaces
+ /// is not on: an account whose status line it captured keeps running the wrapper
+ /// under Claude Code whatever Workspaces does, so Settings keeps the switch that gives
+ /// it back. Empty while Workspaces is on — the engine's state has them then.
+ @Published public private(set) var dormantAccounts: [AccountConfig] = []
+ /// What went wrong changing Grove's config while Workspaces is not on — giving a
+ /// status line back, taking out an account whose folder is gone — by the name in
+ /// Grove's config of the account it concerns: Settings shows it under that account.
+ @Published public private(set) var changeErrors: [String: String] = [:]
+ /// Whether Grove.app runs, as last looked. While Workspaces is wanted that pauses it;
+ /// while it is off it decides nothing, but Grove.app still writes the files a change
+ /// from Settings would (`dormantChangeBlock`).
+ @Published public private(set) var groveRunning = false
+ /// Folders picked as projects in the introduction while Workspaces could not take
+ /// them — paused, with Grove.app or another copy of Brow writing the same config —
+ /// added once it runs. Not kept past a quit of Brow.
+ @Published public private(set) var pendingProjects: [String] = []
+ /// Told when the panel is ordered in or out (the notch latches its hover meanwhile).
+ public var onPanelVisibilityChange: ((Bool) -> Void)?
+ /// Opens Brow's Settings window where the panel asks for it: the footer's gear
+ /// (Workspaces), the Accounts screen's link (Accounts).
+ public var openSettings: ((WorkspacesSettingsLink) -> Void)?
+
+ public let folders: WorkspacesFolders
+ private let store: LimitsStore
+ private let groveGuard: GroveAppGuard
+ private let groveConfigExists: () -> Bool
+ private let groveDefaults: () -> UserDefaults?
+ private let watchRunningApps: RunningAppsWatch
+ /// Held while this copy of Brow runs the engine; another copy that runs it first
+ /// keeps this one paused.
+ private let engineLock: WorkspacesEngineLock
+ private var controller: WorkspacesMenuBarController?
+ /// Keeps the running-apps watch alive while started.
+ private var runningAppsWatch: AnyObject?
+ /// Grove.app instances `quitGrove()` asked to quit, watched until they have.
+ private var quitting: [ObjectIdentifier: NSKeyValueObservation] = [:]
+ private var cancellables: Set = []
+ /// Keeps the notch's scan in step with the folders Workspaces knows.
+ private var accountsWatch: AnyCancellable?
+ private var started = false
+
+ /// Starts telling `onChange` about every app that launches or quits, for as long as
+ /// the returned object is kept.
+ public typealias RunningAppsWatch = @MainActor (_ onChange: @escaping @MainActor @Sendable () -> Void) -> AnyObject
+
+ /// NSWorkspace's list of running apps, observed. Not its launch and quit
+ /// notifications: those leave out apps that run as agents (`LSUIElement`), which
+ /// Grove.app is, as Brow is. The list is complete, and documented as observable.
+ public static let watchWorkspaceApps: RunningAppsWatch = { onChange in
+ NSWorkspace.shared.observe(\.runningApplications, options: [.new, .old]) { _, _ in
+ Task { @MainActor in onChange() }
+ }
+ }
+
+ public init(store: LimitsStore,
+ folders: WorkspacesFolders = .live(),
+ groveGuard: GroveAppGuard = GroveAppGuard(runningBundleIdentifiers: {
+ NSWorkspace.shared.runningApplications.compactMap(\.bundleIdentifier)
+ }),
+ groveConfigExists: (() -> Bool)? = nil,
+ groveDefaults: @escaping () -> UserDefaults? = { UserDefaults(suiteName: GroveAppGuard.bundleIdentifiers[0]) },
+ watchRunningApps: RunningAppsWatch? = nil) {
+ self.store = store
+ self.folders = folders
+ self.groveGuard = groveGuard
+ let configPath = folders.configFile.path
+ self.groveConfigExists = groveConfigExists ?? { FileManager.default.fileExists(atPath: configPath) }
+ self.groveDefaults = groveDefaults
+ self.watchRunningApps = watchRunningApps ?? Self.watchWorkspaceApps
+ self.engineLock = WorkspacesEngineLock(folders: folders)
+ }
+
+ /// Decides once, then again whenever Grove.app launches or quits, or the settings
+ /// that decide change — and at every background tick of the engine, in case a
+ /// launch or a quit went unreported.
+ public func start() {
+ guard !started else { return }
+ started = true
+ runningAppsWatch = watchRunningApps { [weak self] in self?.refreshIfChanged() }
+ // `@Published` fires in willSet: the new value is the argument, not the store's.
+ store.$settings
+ .map(\.workspaces)
+ .removeDuplicates()
+ .sink { [weak self] workspaces in
+ Task { @MainActor in self?.apply(workspaces: workspaces) }
+ }
+ .store(in: &cancellables)
+ refresh()
+ }
+
+ public func stop() {
+ runningAppsWatch = nil
+ quitting.removeAll()
+ cancellables.removeAll()
+ controller?.stop()
+ controller = nil
+ engineLock.release()
+ started = false
+ }
+
+ /// Re-decides from the current settings, the Grove config and Grove.app.
+ public func refresh() {
+ apply(workspaces: store.settings.workspaces)
+ }
+
+ /// An app launched or quit: re-decides only if that changes the answer, since most
+ /// of them are not Grove.
+ func refreshIfChanged() {
+ let decision = WorkspacesActivation.decide(setting: store.settings.workspaces.enabled,
+ groveConfigExists: groveConfigExists(),
+ groveRunning: lookForGrove())
+ if decision != activation { refresh() }
+ }
+
+ /// Whether Grove.app runs now, kept in `groveRunning` for Settings.
+ private func lookForGrove() -> Bool {
+ let running = groveGuard.isGroveRunning
+ if running != groveRunning { groveRunning = running }
+ return running
+ }
+
+ private func apply(workspaces settings: WorkspacesSettings) {
+ var next = WorkspacesActivation.decide(setting: settings.enabled,
+ groveConfigExists: groveConfigExists(),
+ groveRunning: lookForGrove())
+ var waitsFor: WorkspacesPause? = next == .paused ? .groveRunning : nil
+ // On in one copy of Brow at a time: the one holding the engine's lock.
+ if next == .on, !engineLock.acquire() {
+ next = .paused
+ waitsFor = .anotherBrow
+ }
+ if next != .on { engineLock.release() }
+ let previous = activation
+ if next != previous || waitsFor != pause {
+ BrowLog.workspaces.info("workspaces: \(String(describing: previous), privacy: .public) → \(String(describing: next), privacy: .public)\(waitsFor.map { " (\($0))" } ?? "", privacy: .public)")
+ }
+ activation = next
+ pause = waitsFor
+ switch next {
+ case .off:
+ controller?.stop()
+ controller = nil
+ state = nil
+ accountsWatch = nil
+ store.runtimeExtraDirs = []
+ pendingProjects = []
+ case .paused:
+ let controller = ensureController()
+ controller.stopEngine()
+ controller.notice = waitsFor == .anotherBrow ? anotherBrowNotice : groveRunningNotice
+ controller.showsStatusItem = settings.showInMenuBar
+ controller.start()
+ case .on:
+ recordFirstRunOnce()
+ // Coming from paused or off, read Grove's config afresh: Grove.app may have
+ // written it while Workspaces waited, and a state loaded before that would
+ // save its old copy over it.
+ if previous != .on, let stale = controller {
+ stale.stop()
+ controller = nil
+ state = nil
+ accountsWatch = nil
+ }
+ let controller = ensureController()
+ controller.notice = nil
+ controller.showsStatusItem = settings.showInMenuBar
+ controller.start()
+ controller.startEngine()
+ // The projects picked while it waited, now that nothing else writes the config.
+ if let state, !pendingProjects.isEmpty {
+ let paths = pendingProjects
+ pendingProjects = []
+ for path in paths { state.addProject(at: path) }
+ }
+ // Accounts Workspaces knows outside ~/.claude and ~/.claude-accounts join the
+ // notch's scan, so their limits show in the pickers too.
+ if accountsWatch == nil, let state {
+ accountsWatch = state.$config
+ .map { WorkspacesAccountJoin.extraDirs(for: $0.accounts) }
+ .removeDuplicates()
+ .sink { [weak store = self.store] dirs in store?.runtimeExtraDirs = dirs }
+ }
+ }
+ // The store's, not the argument's: importing Grove's material just changed it.
+ WindowSubstrateStyle.current = WindowSubstrateStyle(stored: store.settings.workspaces.panelMaterial)
+ reloadDormantAccounts()
+ }
+
+ // MARK: - Status lines captured while Workspaces is not on
+
+ /// The accounts whose status line runs Workspaces' wrapper while Workspaces is not on.
+ public var dormantCaptures: [AccountConfig] { dormantAccounts.filter(\.monitoring) }
+
+ /// What a change Settings makes to Grove's config without the engine — giving a status
+ /// line back, taking out an account whose folder is gone — waits for: what a paused
+ /// Workspaces waits for, or, while Workspaces is off, Grove.app, which writes the same
+ /// files whether Workspaces is on or not. nil when nothing is in the way.
+ public var dormantChangeBlock: WorkspacesPause? {
+ switch activation {
+ case .on: return nil
+ case .paused: return pause ?? .groveRunning
+ case .off: return groveRunning ? .groveRunning : nil
+ }
+ }
+
+ /// What `account` is called where the notch shows it, with or without the engine: the
+ /// name from Settings › Accounts, else Grove's own — as the engine names it.
+ public func displayName(for account: AccountConfig) -> String {
+ store.displayName(for: account, among: dormantAccounts)
+ }
+
+ /// Re-reads `dormantAccounts` from Grove's config. Reads only: no engine starts, and
+ /// nothing is written.
+ public func reloadDormantAccounts() {
+ guard !isPreview else { return }
+ let accounts = activation == .on || !FileManager.default.fileExists(atPath: folders.configFile.path)
+ ? [] : ConfigStore(url: folders.configFile).load().config.accounts
+ if accounts != dormantAccounts { dormantAccounts = accounts }
+ // An account no longer listed takes what went wrong with it along.
+ let kept = changeErrors.filter { name, _ in accounts.contains { $0.name == name } }
+ if kept != changeErrors { changeErrors = kept }
+ }
+
+ /// What went wrong with the last change Settings made to `account` without the engine.
+ public func changeError(for account: AccountConfig) -> String? {
+ changeErrors[account.name]
+ }
+
+ /// What went wrong for any of `accounts`, in their order, each failure once: Give them
+ /// back can fail the same way for all of them.
+ public func changeErrors(for accounts: [AccountConfig]) -> [String] {
+ var seen = Set()
+ return accounts.compactMap { changeErrors[$0.name] }.filter { seen.insert($0).inserted }
+ }
+
+ /// Gives `account`'s status line back while Workspaces is off: the command it had in
+ /// place of the wrapper, in every folder of the login, and capture marked off in
+ /// Grove's config so nothing turns it on again. Grove's config is read for just this,
+ /// with no engine. Not while Grove.app runs, whether that pauses Workspaces or it is
+ /// off: Grove.app writes the same files. While Workspaces is on, the engine's own
+ /// switch does this.
+ public func releaseStatusLine(_ account: AccountConfig) {
+ let failure: String?
+ switch activation {
+ case .on:
+ failure = state.flatMap { state in
+ state.config.accounts.first { $0.name == account.name }.flatMap { current in
+ state.attempt { state.disableMonitoring(current) }
+ }
+ }
+ case .paused:
+ failure = Self.pausedChangeNote(pause)
+ case .off:
+ failure = withoutEngine { engine in
+ if let current = engine.config.accounts.first(where: { $0.name == account.name }), current.monitoring {
+ engine.disableMonitoring(current)
+ }
+ }
+ }
+ changeErrors[account.name] = failure
+ }
+
+ /// Takes `account` out of Workspaces' config, folder untouched: what Settings offers
+ /// for an account whose folder is gone, which nothing else can change any more.
+ public func removeAccount(_ account: AccountConfig) {
+ let failure: String?
+ switch activation {
+ case .on:
+ failure = state.flatMap { state in state.attempt { state.removeAccount(name: account.name) } }
+ case .paused:
+ failure = Self.pausedChangeNote(pause)
+ case .off:
+ failure = withoutEngine { $0.removeAccount(name: account.name) }
+ }
+ changeErrors[account.name] = failure
+ }
+
+ /// Makes one change to Grove's config while Workspaces is off: a state read for just
+ /// this, no engine started, and the dormant list read again after. Not while Grove.app
+ /// runs, which writes the same config and settings.json whether Workspaces is on or
+ /// off, nor under the feet of another copy of Brow whose engine runs on this config.
+ /// Returns what went wrong.
+ private func withoutEngine(_ change: (AppState) -> Void) -> String? {
+ guard !lookForGrove() else { return Self.pausedChangeNote(.groveRunning) }
+ guard engineLock.acquire() else { return Self.pausedChangeNote(.anotherBrow) }
+ defer { engineLock.release() }
+ let engine = AppState(folders: folders)
+ engine.capturesByDefault = false
+ engine.mayWrite = { [groveGuard] in !groveGuard.isGroveRunning }
+ // What goes wrong names the account as Settings does.
+ engine.limitsProvider = store
+ let failure = engine.attempt { change(engine) }
+ reloadDormantAccounts()
+ return failure
+ }
+
+ /// Why Settings can't change Grove's config now: `pause` writes the same files.
+ static func pausedChangeNote(_ pause: WorkspacesPause?) -> String {
+ pause == .anotherBrow
+ ? "Another copy of Brow runs Workspaces: make the change there, or quit it first."
+ : "Quit Grove first: it writes the same settings."
+ }
+
+ /// Every captured status line back, as `releaseStatusLine` does one.
+ public func releaseAllStatusLines() {
+ for account in dormantCaptures { releaseStatusLine(account) }
+ }
+
+ private func ensureController() -> WorkspacesMenuBarController {
+ if let controller { return controller }
+ let state = AppState(folders: folders)
+ // Brow asks before it edits an account's settings.json: capture is turned on
+ // per account, in Settings › Accounts, or kept where Grove had it on.
+ state.capturesByDefault = false
+ // Never two engines writing one config.json: a Grove.app that started since the
+ // last decision stops every write until Workspaces has paused.
+ state.mayWrite = { [groveGuard] in !groveGuard.isGroveRunning }
+ // The pickers show each account's limits, from the notch's own readings.
+ state.limitsProvider = store
+ state.onPanelMaterialChange = { [weak self] style in self?.setPanelMaterial(style) }
+ // Sessions launch with Brow's own router and Brow's Path to claude, like every
+ // sign-in Brow starts.
+ state.browserRouter = AddAccountFlow.browserHelperPath ?? state.browserRouter
+ state.claudePath = { [weak store = self.store] in store.flatMap { $0.settings.claudePath ?? $0.claudeDetected } }
+ state.openSettings = { [weak self] link in
+ self?.hidePanel()
+ self?.openSettings?(link)
+ }
+ self.state = state
+ let controller = WorkspacesMenuBarController(
+ state: state, icon: BrowMark.templateImage(), fallbackTitle: "Brow",
+ accessibilityLabel: "Brow Workspaces", showsStatusItem: store.settings.workspaces.showInMenuBar)
+ controller.onVisibilityChange = { [weak self] visible in self?.onPanelVisibilityChange?(visible) }
+ // Every background tick re-checks for Grove.app first: a tick that finds it pauses
+ // Workspaces instead of running.
+ controller.tickGate = { [weak self] in
+ guard let self else { return false }
+ self.refreshIfChanged()
+ return self.activation == .on
+ }
+ self.controller = controller
+ return controller
+ }
+
+ // MARK: - What the user can do
+
+ /// Turns Workspaces on or off in Brow's own settings.
+ public func setEnabled(_ on: Bool) {
+ store.settings.workspaces.enabled = on
+ refresh()
+ }
+
+ /// Asks Grove.app to quit; Workspaces starts when it has — told by the app itself,
+ /// as well as by the running-apps watch.
+ public func quitGrove() {
+ for app in NSWorkspace.shared.runningApplications where GroveAppGuard.isGrove(app.bundleIdentifier) {
+ let id = ObjectIdentifier(app)
+ if quitting[id] == nil {
+ quitting[id] = app.observe(\.isTerminated, options: [.new]) { [weak self] app, _ in
+ guard app.isTerminated else { return }
+ Task { @MainActor in
+ self?.quitting[id] = nil
+ self?.refresh()
+ }
+ }
+ }
+ app.terminate()
+ }
+ }
+
+ /// One background tick of the engine, as its timer runs it; for tests.
+ func backgroundTick() async {
+ await controller?.runBackgroundTick()
+ }
+
+ /// Offscreen renders and tests: this state and activation, nothing started.
+ func preview(state: AppState?, activation: WorkspacesActivation, dormant: [AccountConfig] = []) {
+ self.state = state
+ self.activation = activation
+ self.dormantAccounts = dormant
+ isPreview = true
+ }
+
+ /// A preview's accounts are its own: nothing is read from disk for it.
+ private var isPreview = false
+
+ public var isPanelVisible: Bool { controller?.isPanelVisible == true }
+
+ /// Whether the engine's launch work and background cadence are running.
+ var isEngineRunning: Bool { controller?.isEngineRunning == true }
+
+ /// The panel: Workspaces when it runs, the reason it is paused, or — while it is
+ /// off — the introduction, from which a project can be added.
+ public func showPanel() {
+ // What it waits for may have gone without a word: another copy of Brow turning
+ // Workspaces off quits no app.
+ refreshIfChanged()
+ switch activation {
+ case .on, .paused:
+ ensureController().show()
+ case .off:
+ let controller = ensureController()
+ controller.showsStatusItem = false
+ controller.notice = introductionNotice
+ controller.start()
+ controller.show()
+ }
+ }
+
+ public func togglePanel() {
+ if isPanelVisible { controller?.hide() } else { showPanel() }
+ }
+
+ public func hidePanel() { controller?.hide() }
+
+ // MARK: - Notices
+ // Internal, not private: the README's screenshots render them.
+
+ var groveRunningNotice: WorkspacesNotice {
+ WorkspacesNotice(
+ symbol: "pause.circle",
+ title: "Grove is still running",
+ message: "Grove is now part of Brow, and Workspaces runs your Grove projects. It stays paused while Grove.app is open, so the two never edit the same files at once." + pendingNote,
+ primary: .init("Quit Grove") { [weak self] in self?.quitGrove() })
+ }
+
+ var anotherBrowNotice: WorkspacesNotice {
+ WorkspacesNotice(
+ symbol: "pause.circle",
+ title: "Workspaces is open in another Brow",
+ message: "Another copy of Brow, a second install or a development build, is running Workspaces on the same projects. Workspaces runs in one copy at a time, so the two never edit the same files at once; it starts here when that copy quits." + pendingNote,
+ primary: .init("Try Again") { [weak self] in self?.refresh() })
+ }
+
+ var introductionNotice: WorkspacesNotice {
+ WorkspacesNotice(
+ symbol: "square.stack.3d.up",
+ title: "Workspaces",
+ message: "Give each feature of a multi-repo project its own folder of git worktrees, and run parallel Claude Code sessions in them. Move a session to the account that still has room.",
+ primary: .init("Add project…") { [weak self] in self?.addProjectFromIntroduction() },
+ secondary: .init("Learn more") {
+ if let url = URL(string: "https://github.com/efim0v/brow/blob/main/docs/workspaces.md") {
+ NSWorkspace.shared.open(url)
+ }
+ })
+ }
+
+ private func addProjectFromIntroduction() {
+ hidePanel()
+ let panel = NSOpenPanel()
+ panel.canChooseFiles = false
+ panel.canChooseDirectories = true
+ panel.allowsMultipleSelection = false
+ panel.prompt = "Add Project"
+ panel.message = "Pick the folder that contains your repositories."
+ NSApp.activate(ignoringOtherApps: true)
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ addProject(fromIntroduction: url.path)
+ showPanel()
+ }
+
+ /// Turns Workspaces on with `path` as its project. Added at once only while the
+ /// engine runs: paused, the config is another app's to write, so the folder waits in
+ /// `pendingProjects` — the paused notice names it — and is added when Workspaces starts.
+ func addProject(fromIntroduction path: String) {
+ setEnabled(true)
+ if activation == .on, let state {
+ state.addProject(at: path)
+ } else if !pendingProjects.contains(path) {
+ pendingProjects.append(path)
+ refresh() // the notice now names it
+ }
+ }
+
+ /// " “shop” is added when Workspaces starts.", after a paused notice's message.
+ private var pendingNote: String {
+ guard !pendingProjects.isEmpty else { return "" }
+ let names = pendingProjects.map { "“\(($0 as NSString).lastPathComponent)”" }
+ // English, like the sentence it ends: not the Mac's list format.
+ let list = names.count == 1 ? names[0] : names.dropLast().joined(separator: ", ") + " and " + names[names.count - 1]
+ return " \(list) \(names.count == 1 ? "is" : "are") added when Workspaces starts."
+ }
+
+ // MARK: - The first run
+
+ /// Whether Workspaces runs, or will run, a setup Grove made — for the words Settings
+ /// uses. Recorded the first time it runs; before that, a Grove config can only be
+ /// Grove's, since Brow writes one only once Workspaces runs.
+ public var isGroveSetup: Bool {
+ store.settings.workspaces.groveSetup ?? groveConfigExists()
+ }
+
+ /// The first time Workspaces runs in Brow, before the engine writes anything: whether
+ /// the setup is Grove's, and Grove.app's panel material, which it kept in its own
+ /// defaults. Once each: the material's flag is set whether or not Grove had one.
+ private func recordFirstRunOnce() {
+ var settings = store.settings
+ if settings.workspaces.groveSetup == nil {
+ settings.workspaces.groveSetup = groveConfigExists()
+ }
+ if !settings.workspaces.importedGroveMaterial {
+ if settings.workspaces.panelMaterial == nil,
+ let raw = groveDefaults()?.string(forKey: WindowSubstrateStyle.groveDefaultsKey),
+ WindowSubstrateStyle(rawValue: raw) != nil {
+ settings.workspaces.panelMaterial = raw
+ }
+ settings.workspaces.importedGroveMaterial = true
+ }
+ if settings != store.settings { store.settings = settings }
+ }
+
+ // MARK: - Panel material
+
+ /// Settings › Workspaces writes the material here.
+ public func setPanelMaterial(_ style: WindowSubstrateStyle) {
+ store.settings.workspaces.panelMaterial = style.rawValue
+ }
+}
diff --git a/Sources/BrowKit/BrowLog.swift b/Sources/BrowKit/BrowLog.swift
index 5774477..53c5377 100644
--- a/Sources/BrowKit/BrowLog.swift
+++ b/Sources/BrowKit/BrowLog.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import os
@@ -8,6 +11,12 @@ public enum BrowLog {
public static let tokens = Logger(subsystem: "dev.artemefimov.brow", category: "tokens")
public static let panel = Logger(subsystem: "dev.artemefimov.brow", category: "panel")
public static let notify = Logger(subsystem: "dev.artemefimov.brow", category: "notify")
+ /// Hosting Workspaces: activation, the Grove-running guard. The engine itself logs
+ /// under the same subsystem (GroveLog).
+ public static let workspaces = Logger(subsystem: "dev.artemefimov.brow", category: "workspaces")
+ /// The pet: an unreadable `pet.json`, a clock that jumped, and the `-petPerfLog` lines once a
+ /// minute.
+ public static let pet = Logger(subsystem: "dev.artemefimov.brow", category: "pet")
}
/// "Say this once per process." For conditions that are real failures but are
diff --git a/Sources/BrowKit/Model/BrowSettings.swift b/Sources/BrowKit/Model/BrowSettings.swift
index e4799c3..4b1ec03 100644
--- a/Sources/BrowKit/Model/BrowSettings.swift
+++ b/Sources/BrowKit/Model/BrowSettings.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
@@ -16,10 +19,36 @@ public struct AccountOverride: Codable, Sendable, Equatable {
public var name: String?
public var hidden: Bool
public init(name: String?, hidden: Bool) { self.name = name; self.hidden = hidden }
+
+ enum CodingKeys: String, CodingKey { case name, hidden }
+
+ /// Lenient like the file around it: a hand edit that breaks `hidden` keeps the name.
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ let issues = decoder.userInfo[.browSettingsIssues] as? SettingsIssues
+ do { name = try c.decodeIfPresent(String.self, forKey: .name) } catch { name = nil; issues?.record("accounts.name") }
+ do { hidden = try c.decodeIfPresent(Bool.self, forKey: .hidden) ?? false } catch { hidden = false; issues?.record("accounts.hidden") }
+ }
+}
+
+/// The fields of a settings file that did not decode, collected while it is read so the
+/// store can keep a copy of the file before the next save replaces them with defaults.
+public final class SettingsIssues: @unchecked Sendable {
+ private let lock = NSLock()
+ private var fields: [String] = []
+ public init() {}
+ public func record(_ field: String) { lock.withLock { fields.append(field) } }
+ public var all: [String] { lock.withLock { fields } }
+}
+
+public extension CodingUserInfoKey {
+ /// A `SettingsIssues` the settings decoders record their failures in.
+ static let browSettingsIssues = CodingUserInfoKey(rawValue: "browSettingsIssues")!
}
/// `~/Library/Application Support/Brow/config.json`. Every field has a default so
-/// a file written by an older build still loads.
+/// a file written by an older build still loads, and every field decodes ON ITS OWN:
+/// a value that does not fit costs that field, never the rest of the file (brow#7).
public struct BrowSettings: Codable, Sendable, Equatable {
public var accounts: [String: AccountOverride] = [:]
public var extraDirs: [String] = []
@@ -32,11 +61,19 @@ public struct BrowSettings: Codable, Sendable, Equatable {
/// name set here is used, never the email or the folder `displayName` falls back to.
public var cardShowNames: Bool = false
public var cardShape: CardShape = .wide
+ /// False: Brow draws nothing at the top of the screen and no panel opens there — for
+ /// someone who uses Brow for Workspaces only. Limit warnings still post, and opening
+ /// Brow again shows Workspaces or Settings.
+ public var showNotch: Bool = true
/// False: nothing is drawn while collapsed — the notch is just the notch — and the
/// panel appears only when the pointer reaches the notch itself.
public var showEars: Bool = true
/// The month of weekly resets and renewals under the accounts in the panel.
public var showCalendar: Bool = true
+ /// The pet beside the notch that eats what the accounts use. On by default, like the readouts and
+ /// the calendar: it draws only in Brow's own black, covers no number, interrupts nothing and needs
+ /// no permission. Off: no frames, no timers, no file watchers; `pet.json` is kept.
+ public var showPet: Bool = true
/// A macOS notification when a visible account passes one of `notifyThresholds`
/// on any of its windows — see `LimitWarnings`. Off until the user opts in.
public var notifyLimits: Bool = false
@@ -44,61 +81,61 @@ public struct BrowSettings: Codable, Sendable, Equatable {
public var notifyThresholds: [Int] = LimitWarnings.defaultLevels
/// A second notice when a window Brow warned about resets.
public var notifyResets: Bool = false
+ /// Whether Workspaces runs, its menu-bar item, its panel's material. Older builds
+ /// ignore the key and drop it on their next save, and nothing else changes.
+ public var workspaces: WorkspacesSettings = WorkspacesSettings()
public init() {}
- enum CodingKeys: String, CodingKey { case accounts, extraDirs, allowPromptFallback, claudePath, earsPlacement, showEars, showCalendar, cardShowNames, cardShape, notifyLimits, notifyThresholds, notifyResets }
+ enum CodingKeys: String, CodingKey { case accounts, extraDirs, allowPromptFallback, claudePath, earsPlacement, showNotch, showEars, showCalendar, showPet, cardShowNames, cardShape, notifyLimits, notifyThresholds, notifyResets, workspaces }
+
+ // `decodeIfPresent` answers nil only for a MISSING key: a key that is PRESENT with a
+ // value of the wrong type or an unmatched raw value (a hand edit, or a value a later
+ // build writes) throws, and one throwing field used to fail `init(from:)` — so
+ // `load()` fell back to a fresh `BrowSettings`, and one typo took the account names,
+ // the alias dirs, the `claude` path and `allowPromptFallback` with it; the next
+ // settings write made the loss permanent. Every field is caught on its own now, and
+ // recorded, so `BrowSettingsStore.load` keeps a copy of the file.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
- accounts = try c.decodeIfPresent([String: AccountOverride].self, forKey: .accounts) ?? [:]
- extraDirs = try c.decodeIfPresent([String].self, forKey: .extraDirs) ?? []
- allowPromptFallback = try c.decodeIfPresent(Bool.self, forKey: .allowPromptFallback) ?? true
- claudePath = try c.decodeIfPresent(String.self, forKey: .claudePath)
- cardShowNames = (try? c.decodeIfPresent(Bool.self, forKey: .cardShowNames)) ?? false
- // Through its raw value and never throwing, for the reasons `earsPlacement`
- // gives below: an unknown shape costs this one field, not the whole file.
- let rawShape: String?? = try? c.decodeIfPresent(String.self, forKey: .cardShape)
- cardShape = rawShape.flatMap { $0 }.flatMap(CardShape.init(rawValue:)) ?? .wide
- if case .some(.some(let raw)) = rawShape, CardShape(rawValue: raw) == nil {
- BrowLog.panel.error("config.json: unknown cardShape \"\(raw, privacy: .public)\"; using wide")
- } else if rawShape == nil {
- BrowLog.panel.error("config.json: cardShape is not a string; using wide")
- }
- showEars = (try? c.decodeIfPresent(Bool.self, forKey: .showEars)) ?? true
- showCalendar = (try? c.decodeIfPresent(Bool.self, forKey: .showCalendar)) ?? true
- notifyLimits = (try? c.decodeIfPresent(Bool.self, forKey: .notifyLimits)) ?? false
- // Never allowed to throw, for the reason spelled out under `earsPlacement`.
- do {
- notifyThresholds = LimitWarnings.sanitized(try c.decodeIfPresent([Int].self, forKey: .notifyThresholds) ?? [])
- } catch {
- BrowLog.notify.error("config.json: notifyThresholds is not a list of numbers; using 80 and 95")
- notifyThresholds = LimitWarnings.defaultLevels
- }
- notifyResets = (try? c.decodeIfPresent(Bool.self, forKey: .notifyResets)) ?? false
- // Decoded through its raw value, not as the enum, and never allowed to throw.
- // `decodeIfPresent` answers nil only for a MISSING key: a key that is PRESENT
- // with an unmatched value (a hand-edited config — the plan's own Task 10 asks
- // the owner to edit this very key with `plutil` — or a third case written by a
- // later build) throws `dataCorrupted`, which fails `init(from:)`, which makes
- // `load()` fall back to a fresh `BrowSettings`. One typo would take the account
- // names, the alias dirs, the `claude` path and `allowPromptFallback` with it —
- // against this file's own "every field has a default" contract — and the next
- // settings write would make the loss permanent.
- // `String??`: the outer nil is "the value is not even a string", the inner one
- // "the key is absent or null".
- let rawPlacement: String?? = try? c.decodeIfPresent(String.self, forKey: .earsPlacement)
- switch rawPlacement {
- case .some(.some(let raw)):
- earsPlacement = EarsPlacement(rawValue: raw) ?? .beside
- if EarsPlacement(rawValue: raw) == nil {
- BrowLog.panel.error("config.json: unknown earsPlacement \"\(raw, privacy: .public)\"; using beside")
+ let issues = decoder.userInfo[.browSettingsIssues] as? SettingsIssues
+ func field(_ key: CodingKeys, _ type: T.Type) -> T? {
+ do { return try c.decodeIfPresent(type, forKey: key) } catch {
+ issues?.record(key.stringValue)
+ BrowLog.panel.error("config.json: \(key.stringValue, privacy: .public) unreadable; using the default")
+ return nil
}
- case .some(.none):
- earsPlacement = .beside // absent (an older file) or null
- case .none:
- BrowLog.panel.error("config.json: earsPlacement is not a string; using beside")
- earsPlacement = .beside
}
+ accounts = field(.accounts, [String: AccountOverride].self) ?? [:]
+ extraDirs = field(.extraDirs, [String].self) ?? []
+ allowPromptFallback = field(.allowPromptFallback, Bool.self) ?? true
+ claudePath = field(.claudePath, String.self)
+ cardShowNames = field(.cardShowNames, Bool.self) ?? false
+ cardShape = Self.rawValue(field(.cardShape, String.self), CardShape.self, key: "cardShape", issues) ?? .wide
+ showNotch = field(.showNotch, Bool.self) ?? true
+ showEars = field(.showEars, Bool.self) ?? true
+ showCalendar = field(.showCalendar, Bool.self) ?? true
+ showPet = field(.showPet, Bool.self) ?? true
+ notifyLimits = field(.notifyLimits, Bool.self) ?? false
+ notifyThresholds = LimitWarnings.sanitized(field(.notifyThresholds, [Int].self) ?? LimitWarnings.defaultLevels)
+ notifyResets = field(.notifyResets, Bool.self) ?? false
+ // Through its raw value: an unknown placement (a later build's third case, a
+ // typo from the `plutil` edit the docs suggest) costs this field only.
+ earsPlacement = Self.rawValue(field(.earsPlacement, String.self), EarsPlacement.self,
+ key: "earsPlacement", issues) ?? .beside
+ workspaces = field(.workspaces, WorkspacesSettings.self) ?? WorkspacesSettings()
+ }
+
+ /// A string field as an enum; an unknown value is recorded and reads as nil.
+ private static func rawValue(_ raw: String?, _ type: E.Type, key: String,
+ _ issues: SettingsIssues?) -> E? where E.RawValue == String {
+ guard let raw else { return nil }
+ guard let value = E(rawValue: raw) else {
+ issues?.record(key)
+ BrowLog.panel.error("config.json: unknown \(key, privacy: .public) \"\(raw, privacy: .public)\"; using the default")
+ return nil
+ }
+ return value
}
/// Override → email → last path component of the config dir.
@@ -133,18 +170,27 @@ public struct BrowSettingsStore: Sendable {
public static let defaultDirectory = NSHomeDirectory() + "/Library/Application Support/Brow"
private let directory: String
private var file: String { directory + "/config.json" }
+ /// Where a copy of an unreadable `config.json` goes before defaults replace it.
+ public var backupFile: String { directory + "/config.json.bak" }
public init(directory: String = BrowSettingsStore.defaultDirectory) { self.directory = directory }
+ /// Defaults are the recovery, but an unreadable value is still a failure: it is
+ /// logged, and the file as it was is copied to `config.json.bak` first — the next
+ /// settings write replaces the unreadable values with defaults for good.
public func load() -> BrowSettings {
let path = file
guard let data = FileManager.default.contents(atPath: path) else { return BrowSettings() }
+ let issues = SettingsIssues()
+ let decoder = JSONDecoder()
+ decoder.userInfo[.browSettingsIssues] = issues
do {
- return try JSONDecoder().decode(BrowSettings.self, from: data)
+ let settings = try decoder.decode(BrowSettings.self, from: data)
+ if !issues.all.isEmpty { keepCopy(data, because: issues.all.joined(separator: ", ")) }
+ return settings
} catch {
- // Defaults are the recovery, but an unreadable settings file is still a
- // failure: log it rather than swallow it.
BrowLog.panel.error("settings unreadable at \(path, privacy: .public): \(String(describing: error), privacy: .public)")
+ keepCopy(data, because: "not a settings object")
return BrowSettings()
}
}
@@ -155,4 +201,13 @@ public struct BrowSettingsStore: Sendable {
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
try encoder.encode(settings).write(to: URL(fileURLWithPath: file), options: .atomic)
}
+
+ private func keepCopy(_ data: Data, because reason: String) {
+ do {
+ try data.write(to: URL(fileURLWithPath: backupFile), options: .atomic)
+ BrowLog.panel.error("config.json kept as config.json.bak (\(reason, privacy: .public))")
+ } catch {
+ BrowLog.panel.error("config.json.bak not written: \(error.localizedDescription, privacy: .public)")
+ }
+ }
}
diff --git a/Sources/BrowKit/Model/LimitSnapshot.swift b/Sources/BrowKit/Model/LimitSnapshot.swift
index 5e76470..18575c3 100644
--- a/Sources/BrowKit/Model/LimitSnapshot.swift
+++ b/Sources/BrowKit/Model/LimitSnapshot.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
diff --git a/Sources/BrowKit/Model/LimitSnapshotStore.swift b/Sources/BrowKit/Model/LimitSnapshotStore.swift
index 76dddd2..bae005b 100644
--- a/Sources/BrowKit/Model/LimitSnapshotStore.swift
+++ b/Sources/BrowKit/Model/LimitSnapshotStore.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
/// Everything `limits.json` holds: the per-organisation snapshots and the
diff --git a/Sources/BrowKit/Model/LimitWarnings.swift b/Sources/BrowKit/Model/LimitWarnings.swift
index 01b4504..254200f 100644
--- a/Sources/BrowKit/Model/LimitWarnings.swift
+++ b/Sources/BrowKit/Model/LimitWarnings.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
diff --git a/Sources/BrowKit/Model/LimitsAggregate.swift b/Sources/BrowKit/Model/LimitsAggregate.swift
index d2fb96a..aed20ec 100644
--- a/Sources/BrowKit/Model/LimitsAggregate.swift
+++ b/Sources/BrowKit/Model/LimitsAggregate.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
diff --git a/Sources/BrowKit/Model/PersistedAccount.swift b/Sources/BrowKit/Model/PersistedAccount.swift
index 58ee02e..7f25bcb 100644
--- a/Sources/BrowKit/Model/PersistedAccount.swift
+++ b/Sources/BrowKit/Model/PersistedAccount.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
diff --git a/Sources/BrowKit/Model/ResetCalendar.swift b/Sources/BrowKit/Model/ResetCalendar.swift
index c97b0e9..187468b 100644
--- a/Sources/BrowKit/Model/ResetCalendar.swift
+++ b/Sources/BrowKit/Model/ResetCalendar.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
diff --git a/Sources/BrowKit/Model/SubscriptionInfo.swift b/Sources/BrowKit/Model/SubscriptionInfo.swift
index 7d83ba7..ccff80e 100644
--- a/Sources/BrowKit/Model/SubscriptionInfo.swift
+++ b/Sources/BrowKit/Model/SubscriptionInfo.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
diff --git a/Sources/BrowKit/Model/WorkspacesAccountJoin.swift b/Sources/BrowKit/Model/WorkspacesAccountJoin.swift
new file mode 100644
index 0000000..a535743
--- /dev/null
+++ b/Sources/BrowKit/Model/WorkspacesAccountJoin.swift
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+/// One Claude account as both halves of Brow know it: the notch's `DiscoveredAccount`
+/// (an organisation, found by scanning folders) and Workspaces' `AccountConfig` (an
+/// entry in Grove's config). They are the same account when they share a config folder.
+public enum WorkspacesAccountJoin {
+ /// The Workspaces account that shares a config folder — its own or an alias — with
+ /// `account`, if any.
+ public static func match(_ account: DiscoveredAccount, in accounts: [AccountConfig]) -> AccountConfig? {
+ let dirs = Set(([account.configDir] + account.aliasDirs).map(normalized))
+ return accounts.first { !dirs.isDisjoint(with: $0.allConfigDirs.map(normalized)) }
+ }
+
+ /// The notch's account for a Workspaces account, the other way round.
+ public static func match(_ account: AccountConfig, in accounts: [DiscoveredAccount]) -> DiscoveredAccount? {
+ let dirs = Set(account.allConfigDirs.map(normalized))
+ return accounts.first { !dirs.isDisjoint(with: ([$0.configDir] + $0.aliasDirs).map(normalized)) }
+ }
+
+ /// The Workspaces accounts no card shows: one whose folder the notch's scan does not
+ /// list (no sign-in Brow can read there), and a second Workspaces account of an
+ /// organisation the notch shows once (`match` gives a card the first). Settings
+ /// lists them on their own, so their sharing, status-line capture and defaults can
+ /// still be changed — turning capture off before an uninstall included.
+ public static func unmatched(_ accounts: [AccountConfig], cards: [DiscoveredAccount]) -> [AccountConfig] {
+ let shown = Set(cards.compactMap { match($0, in: accounts)?.name })
+ return accounts.filter { !shown.contains($0.name) }
+ }
+
+ /// Whether every folder of `account` is gone — deleted, or on a volume no longer
+ /// there. Nothing re-creates it any more (grove#12), so Settings offers to take the
+ /// account out of Workspaces.
+ public static func isGone(_ account: AccountConfig,
+ exists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }) -> Bool {
+ !account.allConfigDirs.map(normalized).contains(where: exists)
+ }
+
+ /// Workspaces' folders the notch's scan would not find by itself — anything outside
+ /// `~/.claude` and `~/.claude-accounts` — so they are scanned too.
+ public static func extraDirs(for accounts: [AccountConfig], home: String = NSHomeDirectory()) -> [String] {
+ var seen = Set()
+ return accounts.flatMap(\.allConfigDirs).map(normalized).filter { dir in
+ guard dir != home + "/.claude", !dir.hasPrefix(home + "/.claude-accounts/") else { return false }
+ return seen.insert(dir).inserted
+ }
+ }
+
+ /// Tilde expanded, no trailing slash: the form both halves compare in.
+ static func normalized(_ path: String) -> String {
+ var dir = expandTilde(path)
+ if dir.count > 1 && dir.hasSuffix("/") { dir.removeLast() }
+ return dir
+ }
+}
diff --git a/Sources/BrowKit/Model/WorkspacesActivation.swift b/Sources/BrowKit/Model/WorkspacesActivation.swift
new file mode 100644
index 0000000..998d096
--- /dev/null
+++ b/Sources/BrowKit/Model/WorkspacesActivation.swift
@@ -0,0 +1,113 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// The Workspaces part of Brow's own `config.json`: whether it runs, whether its
+/// menu-bar item shows, and the panel's material. Grove's own `config.json` is not
+/// touched by any of these.
+public struct WorkspacesSettings: Codable, Sendable, Equatable {
+ /// The user's choice; nil until they make one, and Workspaces then follows whether
+ /// a Grove config exists (`WorkspacesActivation`).
+ public var enabled: Bool?
+ /// Settings › General › Show Workspaces in the menu bar.
+ public var showInMenuBar: Bool = true
+ /// `liquidGlass` or `visualEffect`; nil means Liquid Glass.
+ public var panelMaterial: String?
+ /// Grove's own material setting has been looked at, and copied if there was one.
+ /// Once only: a later change in Grove.app is not Brow's.
+ public var importedGroveMaterial: Bool = false
+ /// Whether the setup Workspaces first ran on was Grove's: its `config.json` was there
+ /// before Brow's engine ever ran. nil until Workspaces first runs, which is when it is
+ /// recorded — afterwards the file exists either way. Settings words itself by it.
+ public var groveSetup: Bool?
+
+ public init(enabled: Bool? = nil, showInMenuBar: Bool = true, panelMaterial: String? = nil,
+ importedGroveMaterial: Bool = false, groveSetup: Bool? = nil) {
+ self.enabled = enabled
+ self.showInMenuBar = showInMenuBar
+ self.panelMaterial = panelMaterial
+ self.importedGroveMaterial = importedGroveMaterial
+ self.groveSetup = groveSetup
+ }
+
+ enum CodingKeys: String, CodingKey { case enabled, showInMenuBar, panelMaterial, importedGroveMaterial, groveSetup }
+
+ /// Field by field: one bad value costs that field, never the others (brow#7).
+ public init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ let issues = decoder.userInfo[.browSettingsIssues] as? SettingsIssues
+ func field(_ key: CodingKeys, _ type: T.Type) -> T? {
+ do { return try c.decodeIfPresent(type, forKey: key) } catch {
+ issues?.record("workspaces.\(key.stringValue)")
+ return nil
+ }
+ }
+ enabled = field(.enabled, Bool.self)
+ showInMenuBar = field(.showInMenuBar, Bool.self) ?? true
+ panelMaterial = field(.panelMaterial, String.self)
+ importedGroveMaterial = field(.importedGroveMaterial, Bool.self) ?? false
+ groveSetup = field(.groveSetup, Bool.self)
+ }
+}
+
+/// Whether Workspaces runs. Pure: the three facts it depends on are passed in.
+///
+/// - Someone who used Grove has `~/Library/Application Support/Grove/config.json`, and
+/// Workspaces turns itself on for them, projects, accounts and all. The folder alone
+/// says nothing: a Brow-only Mac has a `Grove` folder too, holding Brow's own request
+/// pacing file.
+/// - Everyone else has it off until they turn it on in Settings, or add a project.
+/// - While Grove.app runs, nothing runs: two engines would race on Grove's
+/// `config.json` (an atomic replace, the last writer wins) and run two mirrors and two
+/// status-line reconcilers. The user is offered "Quit Grove".
+/// - Off means none of the engine's launch work at all: no account adoption, no
+/// status-line install, no 90 s capture and mirror timer, no pre-render.
+public enum WorkspacesActivation: Equatable, Sendable {
+ case off
+ /// Wanted, but Grove.app is running.
+ case paused
+ case on
+
+ public static func decide(setting: Bool?, groveConfigExists: Bool, groveRunning: Bool) -> WorkspacesActivation {
+ guard setting ?? groveConfigExists else { return .off }
+ return groveRunning ? .paused : .on
+ }
+
+ /// Wanted at all: on, or waiting for Grove.app to quit.
+ public var isWanted: Bool { self != .off }
+}
+
+/// What a paused Workspaces waits for.
+public enum WorkspacesPause: Equatable, Sendable {
+ /// Grove.app to quit (`WorkspacesActivation.decide`).
+ case groveRunning
+ /// Another copy of Brow — a second install, the development build — runs Workspaces
+ /// on the same config (`WorkspacesEngineLock`).
+ case anotherBrow
+}
+
+/// Grove.app, the app Brow 0.4 replaces, as far as Brow needs to know about it.
+public struct GroveAppGuard: Sendable {
+ /// `grove3` since the 26.4 menu-bar cache broke the slot of the older id.
+ public static let bundleIdentifiers = ["dev.artemefimov.grove3", "dev.artemefimov.grove"]
+
+ private let runningBundleIdentifiers: @Sendable () -> [String]
+
+ /// `runningBundleIdentifiers`: the bundle ids of the running apps; injectable so
+ /// tests never look at the real ones.
+ public init(runningBundleIdentifiers: @escaping @Sendable () -> [String]) {
+ self.runningBundleIdentifiers = runningBundleIdentifiers
+ }
+
+ public var isGroveRunning: Bool {
+ let running = Set(runningBundleIdentifiers())
+ return Self.bundleIdentifiers.contains(where: running.contains)
+ }
+
+ /// Whether an app with `bundleIdentifier` is a Grove: what a launch or quit
+ /// notification is checked against.
+ public static func isGrove(_ bundleIdentifier: String?) -> Bool {
+ bundleIdentifier.map(bundleIdentifiers.contains) ?? false
+ }
+}
diff --git a/Sources/BrowKit/Model/WorkspacesEngineLock.swift b/Sources/BrowKit/Model/WorkspacesEngineLock.swift
new file mode 100644
index 0000000..c7b440a
--- /dev/null
+++ b/Sources/BrowKit/Model/WorkspacesEngineLock.swift
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+/// One Workspaces engine at a time on one Grove config, however many copies of Brow the
+/// user runs: the installed app, a second copy somewhere else, the build
+/// `Scripts/dev-run.sh` starts. Two engines would race on Grove's `config.json` and run
+/// two safety nets and two status-line reconcilers, as Brow and Grove.app would.
+///
+/// An engine holds an advisory lock (`flock`) on a file beside Grove's config for as long
+/// as it runs; a copy of Brow that finds it taken waits, the way it waits for Grove.app.
+/// Bundle ids could not tell the copies apart — the dev build runs as a bare executable
+/// with no bundle id at all — and the lock needs no such knowledge. The kernel lets go of
+/// it when the process holding it exits, crashed or not, so it never outlives its holder.
+final class WorkspacesEngineLock {
+ let path: String
+ private var descriptor: Int32 = -1
+
+ init(path: String) { self.path = path }
+
+ /// The lock of the engine that works on `folders`: beside Grove's `config.json`.
+ convenience init(folders: WorkspacesFolders) {
+ self.init(path: folders.configFile.deletingLastPathComponent()
+ .appendingPathComponent("workspaces.lock").path)
+ }
+
+ deinit { release() }
+
+ /// Whether this copy of Brow holds the lock.
+ var isHeld: Bool { descriptor >= 0 }
+
+ /// Takes the lock unless another process holds it; true when no one else does. A file
+ /// that cannot be opened or locked at all (a folder that cannot be written, a volume
+ /// without locks) does not stop Workspaces: the lock keeps a second copy of Brow out,
+ /// it is not a condition for running.
+ @discardableResult
+ func acquire() -> Bool {
+ guard descriptor < 0 else { return true }
+ let path = self.path
+ try? FileManager.default.createDirectory(atPath: (path as NSString).deletingLastPathComponent,
+ withIntermediateDirectories: true)
+ let fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0o644)
+ guard fd >= 0 else {
+ BrowLog.workspaces.error("workspaces lock not taken, \(path, privacy: .public): \(String(cString: strerror(errno)), privacy: .public)")
+ return true
+ }
+ guard flock(fd, LOCK_EX | LOCK_NB) == 0 else {
+ let error = errno
+ close(fd)
+ if error == EWOULDBLOCK { return false }
+ BrowLog.workspaces.error("workspaces lock not taken, \(path, privacy: .public): \(String(cString: strerror(error)), privacy: .public)")
+ return true
+ }
+ descriptor = fd
+ return true
+ }
+
+ /// Lets go of the lock, if this copy holds it.
+ func release() {
+ guard descriptor >= 0 else { return }
+ flock(descriptor, LOCK_UN)
+ close(descriptor)
+ descriptor = -1
+ }
+}
diff --git a/Sources/BrowKit/Panel/CalendarHoverTracker.swift b/Sources/BrowKit/Panel/CalendarHoverTracker.swift
index 7ecbfd8..68b7233 100644
--- a/Sources/BrowKit/Panel/CalendarHoverTracker.swift
+++ b/Sources/BrowKit/Panel/CalendarHoverTracker.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import Combine
diff --git a/Sources/BrowKit/Panel/NotchGeometry.swift b/Sources/BrowKit/Panel/NotchGeometry.swift
index 870f671..87da7e8 100644
--- a/Sources/BrowKit/Panel/NotchGeometry.swift
+++ b/Sources/BrowKit/Panel/NotchGeometry.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
/// The subset of NSScreen the geometry needs, so the math is testable without a display.
diff --git a/Sources/BrowKit/Panel/NotchHoverLatch.swift b/Sources/BrowKit/Panel/NotchHoverLatch.swift
new file mode 100644
index 0000000..7d8dd6b
--- /dev/null
+++ b/Sources/BrowKit/Panel/NotchHoverLatch.swift
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// After the notch panel's Workspaces button: the notch stays shut until the pointer
+/// has left, once, the area the expanded panel covers.
+///
+/// The Workspaces panel opens under the menu bar, right where the expanded notch hangs,
+/// and the notch's window sits in a layer above it. Without the latch the pointer, still
+/// resting where the button was, would open the notch again on top of the panel it had
+/// just asked for.
+struct NotchHoverLatch: Equatable {
+ private(set) var isEngaged = false
+
+ mutating func engage() { isEngaged = true }
+
+ /// Whether the notch may react to the pointer now. Engaged, it may not while the
+ /// pointer is inside `expandedArea`; the first position outside releases it.
+ mutating func allowsHover(pointerInExpandedArea inside: Bool) -> Bool {
+ guard isEngaged else { return true }
+ if inside { return false }
+ isEngaged = false
+ return true
+ }
+}
diff --git a/Sources/BrowKit/Panel/NotchPanel.swift b/Sources/BrowKit/Panel/NotchPanel.swift
index 769e0a9..547b0f2 100644
--- a/Sources/BrowKit/Panel/NotchPanel.swift
+++ b/Sources/BrowKit/Panel/NotchPanel.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import AppKit
import SwiftUI
import Combine
@@ -30,8 +33,14 @@ final class NotchPanelModel: ObservableObject {
@Published var showEars = true
@Published var ears = AnyView(EmptyView())
@Published var panel = AnyView(EmptyView())
+ /// The pet's layer, the third one, above the strip and the panel. Set once, so its identity is stable:
+ /// `render()` replacing `ears` and `panel` on every store publish never resets it.
+ let pet: PetLayer?
- init(frames: NotchFrames) { self.frames = frames }
+ init(frames: NotchFrames, pet: PetLayer? = nil) {
+ self.frames = frames
+ self.pet = pet
+ }
}
/// What the hosting view shows, hung from the top edge of a window that never moves:
@@ -43,22 +52,45 @@ final class NotchPanelModel: ObservableObject {
/// what animating the window from the strip's rect to the panel's rect used to do.
struct NotchRootView: View {
@ObservedObject var model: NotchPanelModel
+ /// Offscreen renders only: the outline caught part-way through its spring, 0 the strip and 1 the
+ /// panel, drawn the way SwiftUI interpolates a change of `expanded` (the size and the corner radius
+ /// along the spring, the strip fading out as the panel fades in). `ImageRenderer` cannot run a
+ /// spring. The app always passes nil and animates `model.expanded`.
+ var outlineProgress: CGFloat?
private var frames: NotchFrames { model.frames }
private var shapeSize: CGSize {
- if model.expanded { return frames.expanded.size }
- if !model.showEars, let notch = frames.notch {
- return CGSize(width: notch.width + 2 * frames.flare, height: notch.height)
+ if let p = outlineProgress {
+ let collapsed = Self.outlineSize(frames: frames, expanded: false, showEars: model.showEars)
+ return CGSize(width: collapsed.width + (frames.expanded.width - collapsed.width) * p,
+ height: collapsed.height + (frames.expanded.height - collapsed.height) * p)
}
- return frames.collapsed.size
+ return Self.outlineSize(frames: frames, expanded: model.expanded, showEars: model.showEars)
+ }
+
+ /// The black outline's size. Expanded, the panel's. Collapsed with the readouts on,
+ /// the strip's. With them off, the physical notch, which the black then hides in — and
+ /// on a display without a notch, nothing at all: the pill's width at no height, so
+ /// nothing is drawn until the pointer reaches the top centre, and the panel unrolls
+ /// from the top edge.
+ static func outlineSize(frames: NotchFrames, expanded: Bool, showEars: Bool) -> CGSize {
+ if expanded { return frames.expanded.size }
+ guard !showEars else { return frames.collapsed.size }
+ guard let notch = frames.notch else { return CGSize(width: frames.collapsed.width, height: 0) }
+ return CGSize(width: notch.width + 2 * frames.flare, height: notch.height)
}
private var outline: AnyShape {
if frames.hasNotch {
- return AnyShape(NotchShape(topFlare: frames.flare,
- bottomRadius: model.expanded ? NotchGeometry.expandedBottomRadius
- : NotchGeometry.collapsedBottomRadius))
+ let radius: CGFloat
+ if let p = outlineProgress {
+ radius = NotchGeometry.collapsedBottomRadius
+ + (NotchGeometry.expandedBottomRadius - NotchGeometry.collapsedBottomRadius) * p
+ } else {
+ radius = model.expanded ? NotchGeometry.expandedBottomRadius : NotchGeometry.collapsedBottomRadius
+ }
+ return AnyShape(NotchShape(topFlare: frames.flare, bottomRadius: radius))
}
return AnyShape(RoundedRectangle(cornerRadius: NotchGeometry.collapsedBottomRadius, style: .continuous))
}
@@ -68,11 +100,18 @@ struct NotchRootView: View {
outline.fill(Color.black)
.frame(width: shapeSize.width, height: shapeSize.height)
ZStack(alignment: .top) {
- if model.expanded {
+ if let p = outlineProgress {
+ if model.showEars { model.ears.opacity(Double(1 - min(max(p, 0), 1))) }
+ model.panel.opacity(Double(min(max(p, 0), 1)))
+ } else if model.expanded {
model.panel.transition(.opacity)
} else if model.showEars {
model.ears.transition(.opacity)
}
+ // The pet, inside the clip: cut by the same shape as it grows, never over the menu bar.
+ if let pet = model.pet {
+ PetLayerView(layer: pet, size: frames.expanded.size, expanded: model.expanded)
+ }
}
.frame(width: shapeSize.width, height: shapeSize.height, alignment: .top)
.clipShape(outline)
@@ -94,14 +133,22 @@ public final class NotchPanelController {
public static let collapseDelay: TimeInterval = 0.5
public static let animation: TimeInterval = 0.18
/// Opening springs a little; closing is quicker and settles without a bounce —
- /// a strip that overshoots into the notch reads as a glitch.
- static let openAnimation: Animation = .spring(response: 0.32, dampingFraction: 0.86)
- static let closeAnimation: Animation = .spring(response: 0.26, dampingFraction: 1.0)
+ /// a strip that overshoots into the notch reads as a glitch. Built from the numbers the
+ /// pet's hop is timed against, so the two cannot drift apart.
+ static let openAnimation: Animation = .spring(response: PetTuning.Hop.openResponse,
+ dampingFraction: PetTuning.Hop.openDamping)
+ static let closeAnimation: Animation = .spring(response: PetTuning.Hop.closeResponse,
+ dampingFraction: PetTuning.Hop.closeDamping)
private let store: LimitsStore
private let clock: PanelClock
+ /// The pet; nil only where a panel is built without one.
+ private let pet: PetController?
private let onExpandedChange: (Bool) -> Void
private let onSettings: () -> Void
+ private let onWorkspaces: (() -> Void)?
+ /// Holds the notch shut after the Workspaces button (see `NotchHoverLatch`).
+ private var latch = NotchHoverLatch()
private let panel: BrowPanel
private let host: FirstMouseHostingView
private let model: NotchPanelModel
@@ -114,14 +161,19 @@ public final class NotchPanelController {
private var collapseWork: DispatchWorkItem?
private var mouseMonitor: Any?
private var localMonitor: Any?
+ private var clickMonitor: Any?
+ private var occlusionObserver: (any NSObjectProtocol)?
private var cancellables: Set = []
- public init(store: LimitsStore, clock: PanelClock, onExpandedChange: @escaping (Bool) -> Void,
- onSettings: @escaping () -> Void) {
+ public init(store: LimitsStore, clock: PanelClock, pet: PetController? = nil,
+ onExpandedChange: @escaping (Bool) -> Void, onSettings: @escaping () -> Void,
+ onWorkspaces: (() -> Void)? = nil) {
self.store = store
self.clock = clock
+ self.pet = pet
self.onExpandedChange = onExpandedChange
self.onSettings = onSettings
+ self.onWorkspaces = onWorkspaces
self.frames = NotchGeometry.frames(for: Self.metrics(), expandedHeight: 200,
placement: store.settings.earsPlacement)
panel = BrowPanel(contentRect: frames.expanded,
@@ -141,7 +193,7 @@ public final class NotchPanelController {
// The ears and the panel are drawn on black; `.secondary` text only reads
// as light grey in a dark appearance, so the host never inherits a light one.
panel.appearance = NSAppearance(named: .darkAqua)
- model = NotchPanelModel(frames: frames)
+ model = NotchPanelModel(frames: frames, pet: pet?.layer)
host = FirstMouseHostingView(rootView: NotchRootView(model: model))
panel.contentView = host
space = SkyLightSpace()
@@ -153,7 +205,12 @@ public final class NotchPanelController {
}.store(in: &cancellables)
}
+ /// Whether the panel is up, its pointer monitors with it.
+ public private(set) var isShown = false
+
public func show() {
+ guard !isShown else { return }
+ isShown = true
render()
panel.orderFrontRegardless()
// Only once the window is ordered in does it have a window number to add.
@@ -165,14 +222,39 @@ public final class NotchPanelController {
Task { @MainActor in self?.mouseMoved() }
return event
}
+ guard let pet else { return }
+ // A window macOS reports as occluded (covered, or on a display that is off) shows nobody the
+ // pet: it stops drawing.
+ occlusionObserver = NotificationCenter.default.addObserver(
+ forName: NSWindow.didChangeOcclusionStateNotification, object: panel, queue: .main) { [weak self] _ in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ self.pet?.windowVisibility(self.panel.occlusionState.contains(.visible))
+ }
+ }
+ pet.windowVisibility(panel.occlusionState.contains(.visible))
+ // A click on the pet in the open panel boops it. The click still goes on to the panel:
+ // under the pet there is only the header's empty stage.
+ clickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in
+ MainActor.assumeIsolated {
+ guard let self, self.expanded, event.window === self.panel else { return }
+ self.pet?.click(at: self.hostPoint(fromWindow: event.locationInWindow))
+ }
+ return event
+ }
}
/// Tears down the global monitors and any pending collapse. Without this the two
/// `NSEvent` monitors installed by `show()` outlive the controller.
public func hide() {
+ guard isShown else { return }
+ isShown = false
+ if expanded { setExpanded(false) }
collapseWork?.cancel(); collapseWork = nil
if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor); self.mouseMonitor = nil }
if let localMonitor { NSEvent.removeMonitor(localMonitor); self.localMonitor = nil }
+ if let clickMonitor { NSEvent.removeMonitor(clickMonitor); self.clickMonitor = nil }
+ if let occlusionObserver { NotificationCenter.default.removeObserver(occlusionObserver); self.occlusionObserver = nil }
space?.remove(panel)
panel.orderOut(nil)
}
@@ -185,6 +267,13 @@ public final class NotchPanelController {
private func mouseMoved() {
let point = NSEvent.mouseLocation
+ // Wherever it is on the screen: the pet notices it from 160 pt and watches it.
+ // Ahead of the latch, which holds the notch shut, not the pet's eyes: the Workspaces
+ // panel the latch makes room for opens right under the strip the pet sits in.
+ pet?.pointerMoved(to: hostPoint(fromScreen: point))
+ // After the Workspaces button the notch stays shut until the pointer has left
+ // the expanded panel's area once: the Workspaces panel opens right under it.
+ guard latch.allowsHover(pointerInExpandedArea: frames.expanded.contains(point)) else { return }
// With the readouts hidden there is nothing beside the notch to hover: the
// pointer has to reach the notch itself.
let collapsedHot = store.settings.showEars ? frames.collapsed : (frames.notch ?? frames.collapsed)
@@ -200,10 +289,23 @@ public final class NotchPanelController {
}
}
+ /// The notch panel's Workspaces button: the notch closes at once and stays closed
+ /// until the pointer has left it (the latch), and the Workspaces panel opens.
+ private func openWorkspaces() {
+ latch.engage()
+ collapseWork?.cancel(); collapseWork = nil
+ setExpanded(false)
+ onWorkspaces?()
+ }
+
/// A screen point in the hosting view's space — the `.global` space SwiftUI's
/// `GeometryReader` reports in: origin top-left, whatever the view's flip.
private func hostPoint(fromScreen point: NSPoint) -> CGPoint {
- let inHost = host.convert(panel.convertPoint(fromScreen: point), from: nil)
+ hostPoint(fromWindow: panel.convertPoint(fromScreen: point))
+ }
+
+ private func hostPoint(fromWindow point: NSPoint) -> CGPoint {
+ let inHost = host.convert(point, from: nil)
return CGPoint(x: inHost.x, y: host.isFlipped ? inHost.y : host.bounds.height - inHost.y)
}
@@ -218,6 +320,8 @@ public final class NotchPanelController {
if value { clock.start() } else { clock.stop() }
onExpandedChange(value)
render()
+ // The same instant as the outline's spring: the hop is timed against it.
+ pet?.dockChanged(expanded: value)
withAnimation(value ? Self.openAnimation : Self.closeAnimation) { model.expanded = value }
BrowLog.panel.debug("expanded=\(value)")
}
@@ -240,8 +344,9 @@ public final class NotchPanelController {
// The frame's OWN flare, not the constant: an
// external display's frame carries none.
flare: frames.flare, drawsBackground: false, hover: hover,
- onSettings: onSettings,
- onRefresh: { [store] in Task { await store.refresh(force: true) } })
+ pet: pet?.panel, onSettings: onSettings,
+ onRefresh: { [store] in Task { await store.refresh(force: true) } },
+ onWorkspaces: onWorkspaces == nil ? nil : { [weak self] in self?.openWorkspaces() })
// The flare-widened frame, not `expandedWidth`: `NotchShape` draws its
// concave corners in those 6 pt, and the visible black still starts at
// the notch edge.
@@ -252,6 +357,8 @@ public final class NotchPanelController {
model.showEars = store.settings.showEars
model.ears = AnyView(ears)
model.panel = AnyView(panelView)
+ // The pet's zones come from the very frames and readouts just drawn, so they match the screen.
+ pet?.layout(frames: frames, aggregate: store.aggregate, showEars: store.settings.showEars)
applyFrame()
}
@@ -268,7 +375,8 @@ public final class NotchPanelController {
private func expandedHeight() -> CGFloat {
let modelled = Self.estimatedExpandedHeight(barCounts: store.rows.map(Self.barCount),
topInset: frames.contentTopInset,
- calendar: store.settings.showCalendar && !store.rows.isEmpty)
+ calendar: store.settings.showCalendar && !store.rows.isEmpty,
+ pet: pet != nil && store.settings.showPet)
guard expanded else { return max(120, modelled, measuredHeight) }
host.layoutSubtreeIfNeeded()
measuredHeight = max(modelled, host.fittingSize.height)
@@ -277,20 +385,24 @@ public final class NotchPanelController {
/// A layout-independent floor for the expanded panel, from the model PanelView
/// draws: the top inset that keeps the content clear of the notch, 14 pt of bottom
- /// padding, the Overall line, one block per account (header + bars) each followed
- /// by a divider, and the footer, with 10 pt stack spacing. The inset is PanelView's
- /// whole top padding, so it is added, not stacked on a second 14.
+ /// padding, the pet's header row when the pet is on, the Overall line, one block per
+ /// account (header + bars) each followed by a divider, and the footer, with 10 pt
+ /// stack spacing. The inset is PanelView's whole top padding, so it is added, not
+ /// stacked on a second 14.
///
/// Deliberately WITHOUT `PanelView`'s `max(topInset, 14)` floor: this is a floor,
/// and `expandedHeight()` takes `max(modelled, fittingSize)`, so the laid-out tree —
/// which does carry the floor — wins on the only path where the two differ (a screen
/// with no notch, inset 8).
- static func estimatedExpandedHeight(barCounts: [Int], topInset: CGFloat, calendar: Bool = false) -> CGFloat {
+ static func estimatedExpandedHeight(barCounts: [Int], topInset: CGFloat, calendar: Bool = false,
+ pet: Bool = false) -> CGFloat {
let bottomPadding: CGFloat = 14, overall: CGFloat = 20, footer: CGFloat = 20
let spacing: CGFloat = 10, divider: CGFloat = 1
let blocks = barCounts.reduce(CGFloat(0)) { $0 + 18 + CGFloat($1) * 19 + spacing + divider + spacing }
let month = calendar ? CalendarView.estimatedHeight + spacing + divider + spacing : 0
- return topInset + bottomPadding + overall + spacing + divider + spacing + blocks + month + footer
+ // The pet's header row and the stack spacing under it.
+ let header = pet ? CGFloat(PetTuning.Panel.addedHeight) : 0
+ return topInset + bottomPadding + header + overall + spacing + divider + spacing + blocks + month + footer
}
/// 5h and Weekly always; the model-scoped weekly only when the snapshot has one.
diff --git a/Sources/BrowKit/Panel/NotchShape.swift b/Sources/BrowKit/Panel/NotchShape.swift
index ee4094c..ec3e0af 100644
--- a/Sources/BrowKit/Panel/NotchShape.swift
+++ b/Sources/BrowKit/Panel/NotchShape.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import SwiftUI
/// The notch outline: concave where the black meets the screen edge (the physical
diff --git a/Sources/BrowKit/Panel/SkyLightSpace.swift b/Sources/BrowKit/Panel/SkyLightSpace.swift
index d98d3b1..5882963 100644
--- a/Sources/BrowKit/Panel/SkyLightSpace.swift
+++ b/Sources/BrowKit/Panel/SkyLightSpace.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import AppKit
import Foundation
diff --git a/Sources/BrowKit/Pet/PetBodyGeometry.swift b/Sources/BrowKit/Pet/PetBodyGeometry.swift
new file mode 100644
index 0000000..6f3cf9c
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetBodyGeometry.swift
@@ -0,0 +1,247 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import SwiftUI
+
+/// Glim's body in one frame: its size after squash, girth, curl and breath, and the one mapping every
+/// part is placed with, so the face, the arms and the light deform with the body. `PetCanvas` draws
+/// from it and `PetRig` measures with it, so the box the zone clamp keeps inside the zone is the box
+/// that is drawn.
+struct PetBodyGeometry {
+ let body: PetBody
+ /// The scale.
+ let s: Double
+ /// The body's height and half width, in points.
+ let height: Double
+ let halfWidth: Double
+ /// The parts of the height above and below the body's centre line.
+ let top: Double
+ let bottom: Double
+ /// The body's bottom above the contact point: the feet's height.
+ let feet: Double
+ /// Superellipse exponents of the dome and of the bottom.
+ let nTop: Double
+ let nBottom: Double
+ /// The contact point in window coordinates.
+ let base: CGPoint
+ /// The speed stretch about the body's centre: the matrix and the centre it turns about.
+ private let smear: (a: Double, b: Double, c: Double, d: Double)?
+ private let smearCentre: CGPoint
+
+ /// Glim at scale 1 at rest: a 23 × 20 pt gumdrop on 1.5 pt feet, inside 24 × 22 pt: the room a resting
+ /// body has beside the notch.
+ static let restHeight = 20.0
+ static let restHalfWidth = 11.5
+ static let restFeet = 1.5
+
+ init(_ p: PetBody) {
+ body = p
+ s = p.size
+ let sq = max(0.45, p.squash)
+ let curl = PetEase.clamp01(p.curl)
+ // Curled up asleep the dome flattens and spreads into a low loaf; squash keeps the area on its
+ // own, so the loaf's extra width is the posture, not the squash.
+ height = Self.restHeight * s * sq * pow(p.girth, 0.35) * (1 - 0.40 * curl)
+ halfWidth = Self.restHalfWidth * s * pow(sq, -0.5) * p.girth * (1 + 0.20 * curl)
+ bottom = height * 0.42
+ top = height * 0.58
+ // Lying down, the feet tuck under the body.
+ feet = Self.restFeet * s * (1 - 0.8 * curl)
+ nTop = 2.15 + 0.5 * curl
+ // Squashed against the floor the bottom flattens; in the air it rounds off.
+ let airborne = PetCurve.clamp(p.lift / (3 * s), 0, 1)
+ nBottom = 2.9 + max(0, 1 - sq) * 7 - 0.5 * airborne + 1.2 * curl
+ base = CGPoint(x: p.x, y: p.floorY - p.lift)
+ if abs(p.smear - 1) > 1e-9 {
+ // R(θ) · diag(k, 1/k) · R(−θ): longer along the travel, thinner across it, area kept.
+ let k = p.smear, c = cos(p.smearAngle), sn = sin(p.smearAngle)
+ smear = (a: c * c * k + sn * sn / k, b: c * sn * (k - 1 / k),
+ c: c * sn * (k - 1 / k), d: sn * sn * k + c * c / k)
+ } else {
+ smear = nil
+ }
+ // The stretch turns about the body's middle, placed without the stretch.
+ smearCentre = Self.leanAndTilt(p, base: base, feet: feet, 0, feet + bottom)
+ }
+
+ /// Local coordinates (x right, y up from the contact point) to the window, with the lean (a shear
+ /// above the feet) and the tilt (about the contact point), before the speed stretch.
+ @inline(__always)
+ private static func leanAndTilt(_ p: PetBody, base: CGPoint, feet: Double, _ x: Double, _ y: Double) -> CGPoint {
+ let xs = x + p.lean * (y - feet)
+ let c = cos(p.tilt), sn = sin(p.tilt)
+ return CGPoint(x: base.x + xs * c + y * sn, y: base.y - (-xs * sn + y * c))
+ }
+
+ /// Local coordinates to the window: the lean, the tilt, then the speed stretch.
+ @inline(__always)
+ func scene(_ x: Double, _ y: Double) -> CGPoint {
+ let p = Self.leanAndTilt(body, base: base, feet: feet, x, y)
+ guard let m = smear else { return p }
+ let o = smearCentre
+ let dx = p.x - o.x, dy = p.y - o.y
+ return CGPoint(x: o.x + m.a * dx + m.b * dy, y: o.y + m.c * dx + m.d * dy)
+ }
+
+ /// Body-normalised coordinates (u, v in -1 ... 1, v up from the centre line) to local.
+ @inline(__always)
+ func local(u: Double, v: Double) -> (x: Double, y: Double) {
+ (halfWidth * u, feet + bottom + (v >= 0 ? top : bottom) * v)
+ }
+
+ @inline(__always)
+ func point(u: Double, v: Double) -> CGPoint {
+ let l = local(u: u, v: v)
+ return scene(l.x, l.y)
+ }
+
+ /// The face's height on the body: it slides down a little as the body curls up.
+ var faceV: Double { 0.10 + body.nod * 0.16 - 0.10 * PetEase.clamp01(body.curl) }
+
+ /// Features squash with the body, but less, so the face stays readable.
+ var featureScaleX: Double { pow(halfWidth / (Self.restHalfWidth * s), 0.55) }
+ var featureScaleY: Double { pow(height / (Self.restHeight * s), 0.55) }
+
+ /// A point on the face, painted on the dome: `dTheta` is the angle about the vertical axis from the
+ /// face's centre, `dv` the height from it. Returns the point and the horizontal foreshortening there.
+ func facePoint(dTheta: Double, dv: Double) -> (p: CGPoint, fx: Double) {
+ let theta = body.turn * 0.62 + body.spin + dTheta
+ let v = faceV + dv
+ let u = sin(theta) * max(0, 1 - 0.55 * v * v).squareRoot()
+ return (point(u: u, v: v), cos(theta))
+ }
+
+ /// How much of the face is on the visible side: a twirl takes it round the back.
+ var faceVisibility: Double { PetCurve.ramp(cos(body.turn * 0.62 + body.spin), -0.4, -0.1) }
+
+ // MARK: - The outline
+
+ /// The outline's sample points, before smoothing. `inflate` grows it.
+ func outlinePoints(inflate: Double = 0, samples: Int = 44) -> [CGPoint] {
+ var points: [CGPoint] = []
+ points.reserveCapacity(samples)
+ let cheekY = local(u: 0, v: faceV - 0.26).y
+ let mouthY = local(u: 0, v: faceV - 0.36).y
+ let bellyY = feet + height * 0.22
+ let p = body
+ for i in 0..= 0 ? nTop : nBottom
+ let u = copysign(pow(abs(c), 2 / n), c)
+ let v = copysign(pow(abs(sn), 2 / n), sn)
+ var (x, y) = local(u: u, v: v)
+ let side = x >= 0 ? 1.0 : -1.0
+ let edge = u * u
+ if p.cheekPuff > 0.001 {
+ let amount = p.cheekPuff * (1.7 + 0.9 * p.cheekBias * side) * s
+ let g = exp(-pow((y - cheekY) / (0.21 * height), 2))
+ x += side * amount * g * edge
+ }
+ if p.lump >= 0 && p.lump <= 1 {
+ let yl = PetCurve.mix(mouthY, bellyY, PetEase.inQuad(p.lump))
+ let g = exp(-pow((y - yl) / (0.12 * height), 2))
+ x += side * 1.2 * s * g * edge * sin(.pi * p.lump)
+ }
+ if abs(p.tremor) > 0.0001 {
+ let r = p.tremor * 0.32 * s * cos(3 * phi + 0.6)
+ x += r * c
+ y += r * sn
+ }
+ if inflate != 0 {
+ x += inflate * c
+ y += inflate * sn
+ }
+ points.append(scene(x, y))
+ }
+ return points
+ }
+
+ /// The outline, smoothed with a closed Catmull-Rom through the samples.
+ func outline(inflate: Double = 0, samples: Int = 44) -> Path {
+ Self.smoothClosed(outlinePoints(inflate: inflate, samples: samples))
+ }
+
+ static func smoothClosed(_ p: [CGPoint]) -> Path {
+ var path = Path()
+ let n = p.count
+ guard n > 3 else { return path }
+ path.move(to: p[0])
+ for i in 0.. Arm {
+ let p = body
+ let az = side * 1.42 + p.spin
+ let u = sin(az)
+ let screenSide: Double = u >= 0 ? 1 : -1
+ let raise = side < 0 ? p.armL : p.armR
+ // Curled up asleep, the arms fold in along the loaf's sides.
+ let curl = PetEase.clamp01(p.curl)
+ let attach = point(u: u * PetCurve.mix(0.91, 0.78, curl), v: PetCurve.mix(-0.2, -0.35, curl))
+ let rest = 0.95 + (1 - abs(u)) * 0.5 + 0.35 * curl
+ let theta = screenSide > 0 ? (rest - raise + p.tilt) : (.pi - rest + raise + p.tilt)
+ // Raised arms reach: a stretch reads as arms up even at 20 pt.
+ let reach = 1 + 0.32 * PetCurve.clamp((raise - 0.4) / 1.3, 0, 1)
+ let length = 4.6 * s * reach * (1 - 0.25 * curl)
+ let thickness = 2.7 * s
+ let centre = CGPoint(x: attach.x + cos(theta) * length * 0.34, y: attach.y + sin(theta) * length * 0.34)
+ return Arm(centre: centre, angle: theta, length: length, thickness: thickness, front: cos(az) >= 0)
+ }
+
+ /// One foot: an ellipse `width` × `height` (axis-aligned) centred at `centre`.
+ func foot(_ side: Double) -> (centre: CGPoint, width: Double, height: Double) {
+ let p = body
+ let lift = side < 0 ? p.footL : p.footR
+ let cx = 6.8 * s * p.girth * sin(side * 0.75 + p.spin) * (1 - 0.15 * PetEase.clamp01(p.curl))
+ let cy = 1.45 * s + lift
+ let c = scene(cx, cy)
+ return (c, 5.6 * s, 3.0 * s * (1 - 0.6 * PetEase.clamp01(p.curl)))
+ }
+
+ // MARK: - The box
+
+ /// The box of everything the body draws (outline, arms, feet), without the glow or particles. The
+ /// zone clamp keeps it inside the zone; the hit test and the zone tests read it. It is a little larger
+ /// than the sample points: the smoothed curve bows past them, the arms' edge is stroked, and the edge of
+ /// any shape is anti-aliased.
+ func box() -> CGRect {
+ var minX = Double.infinity, minY = Double.infinity, maxX = -Double.infinity, maxY = -Double.infinity
+ @inline(__always) func add(_ x: Double, _ y: Double) {
+ minX = min(minX, x); maxX = max(maxX, x); minY = min(minY, y); maxY = max(maxY, y)
+ }
+ for q in outlinePoints(samples: 32) { add(q.x, q.y) }
+ for side in [-1.0, 1.0] {
+ let a = arm(side)
+ let c = cos(a.angle), sn = sin(a.angle)
+ let ex = abs(c) * a.length / 2 + abs(sn) * a.thickness / 2
+ let ey = abs(sn) * a.length / 2 + abs(c) * a.thickness / 2
+ add(a.centre.x - ex, a.centre.y - ey)
+ add(a.centre.x + ex, a.centre.y + ey)
+ let f = foot(side)
+ add(f.centre.x - f.width / 2, f.centre.y - f.height / 2)
+ add(f.centre.x + f.width / 2, f.centre.y + f.height / 2)
+ }
+ let margin = 0.45 * s
+ return CGRect(x: minX - margin, y: minY - margin, width: maxX - minX + 2 * margin, height: maxY - minY + 2 * margin)
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetBrain.swift b/Sources/BrowKit/Pet/PetBrain.swift
new file mode 100644
index 0000000..7beb259
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetBrain.swift
@@ -0,0 +1,1086 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+extension PetTuning {
+ /// The brain's own bookkeeping: numbers its rules imply without stating, each with the reason it is
+ /// what it is.
+ public enum Brain {
+ /// A fresh window that could not play at once (the pet busy, or not seen) waits this long,
+ /// then is let go: a hop at the readout minutes after it changed would be about nothing.
+ public static let freshWindowWait: TimeInterval = 30
+ /// After a return from away, a welcome back stays due this long while the morning's other
+ /// beats play: about 10 s at most in all.
+ public static let returnWait: TimeInterval = PetTuning.Clock.morningAtMost
+ /// A deadline worked out by arithmetic (a mood threshold, a full meter) lands at least one
+ /// rig step after the instant it was worked out at, so an instant floating point puts a hair
+ /// early never asks for the same tick twice.
+ public static let step: TimeInterval = PetTuning.Secondary.step
+ /// Pace damping is held to this grid, so the meter gets a new key when the week's pace has
+ /// moved, not at every presence check.
+ public static let paceGrid: Double = 0.01
+ /// A guard on planning: real chains of actions are two to four long.
+ static let chainLimit = 24
+ }
+}
+
+extension PetNoise.Channel {
+ /// The gaps between peeks out of the den.
+ static let denPeek = PetNoise.Channel(0x100)
+}
+
+/// The pet's mind: conditions from the data, behaviours from the conditions and the events, and the
+/// timeline the rig plays. Pure and mutating, like `LimitWarnings`: the same events at the same times
+/// always give the same timeline. Nothing inside reads a clock or a random number; the time comes with
+/// each event, and every choice comes from `PetNoise` and `seed`.
+///
+/// How it works. The brain has deadlines of its own (a meal's swallow, the end of `eager`, a reset
+/// instant, the next peek), and `advance(to:)` plays them in time order before each event, so a tick
+/// that comes late, or not at all, changes nothing but when the work is done. After each event it
+/// plans ahead: a copy of itself plays on to the end of the action in progress and of everything
+/// that follows it, and what the copy commits is the timeline's future. The plan is thrown away and
+/// made again at the next event, so it is never more than a forecast; what the brain commits when
+/// the time really comes is the same, unless an event changed it.
+public struct PetBrain: Sendable {
+ /// What the rig plays: the segment on now, what is planned after it, and `PetTimeline.memory` of
+ /// the past.
+ public private(set) var timeline = PetTimeline()
+ /// The pet's own seed from `pet.json`: it shapes the pet's quirks and never changes.
+ public let seed: UInt64
+
+ // MARK: What it has been told
+
+ var showPet = true
+ var motion = PetMotion()
+ /// Fed days are dated in this zone; the controller sends the system's at start and on each change.
+ var timeZone = TimeZone(identifier: "UTC")!
+ var presence = PetPresence()
+ /// `PetTime.mono` of the last input, from the last presence check.
+ var lastInput: TimeInterval?
+ var visibleFlag = false
+ var systemAsleep = false
+ /// nil until the first `readings`: the brain does not know yet whether there are accounts.
+ var accounts: [PetAccount]?
+ var forced: PetForcedState?
+
+ // MARK: The clocks
+
+ /// The `PetTime.mono` the state has been brought to.
+ var clock: TimeInterval?
+ /// `wall − mono` in seconds at the latest event, for turning wall-clock deadlines into ticks.
+ var offset: TimeInterval = 0
+
+ // MARK: Hunger and food
+
+ var hunger = PetHunger()
+ /// H between its jumps: a straight line from the last time food, a debug value or a change of
+ /// rate moved it. Read from the line, not added up step by step, H at an instant is the same
+ /// bits however many updates came in between, and so is every meter key.
+ var hungerLine: HungerLine?
+ /// Whether the hunger clock runs, and at which pace: set at each update, held until the next.
+ var running = false
+ var pace: Double = 1
+ var food = PetFood()
+ var plate = Plate()
+ /// Meals started whose drop has not reached the meter yet.
+ var swallows: [Swallow] = []
+ var mood: PetMood = .content
+ var lastMealMono: TimeInterval?
+ var lastMealWall: Date?
+ var lastMealPoints: Double?
+ /// Meals eaten since launch, for the save schedule.
+ public internal(set) var meals = 0
+
+ // MARK: Rest
+
+ var nap = PetRest(afterWake: PetTuning.Limits.sleepAfterWake)
+ var den = PetRest(afterWake: 0)
+ /// What the data says, re-evaluated at every update.
+ public private(set) var condition: PetCondition = .unsure
+ /// Bumped at every change of `condition`, for the save schedule.
+ public private(set) var conditionChanges = 0
+ /// Unsure keeps the mood it had when the numbers went stale.
+ var unsureMood: PetMood = .content
+
+ // MARK: Moments
+
+ var moments = PetMoments()
+ var introDone = false
+ var bornAt: Date?
+ var pendingCelebrations: [PendingCelebration] = []
+ /// `PetTime.mono` at which a fresh window became due.
+ var pendingFreshWindow: TimeInterval?
+ var lastFreshWindow: TimeInterval?
+ /// `PetTime.mono` at which someone came back after 600 s or more away.
+ var returnPending: TimeInterval?
+
+ // MARK: Claude Code
+
+ /// The first perk of the eager stretch in progress, and when it ends.
+ var eagerSince: TimeInterval?
+ var eagerUntil: TimeInterval?
+ var lastPrompt: TimeInterval?
+ var lastPerk: TimeInterval?
+ /// Eager ran its 600 s; no perk until a minute passes with no prompt.
+ var capped = false
+ /// A prompt waiting for an action or `satisfied` to end, by its time.
+ var queuedPrompt: TimeInterval?
+
+ // MARK: Away, the pointer, boops, the den
+
+ var away = false
+ /// When the last input before going away was.
+ var awaySince: Date?
+ var noticed = false
+ /// The last twitch in its sleep, of either kind, and the last one a prompt caused: a prompt twitches
+ /// it at most once a minute, the pointer at most once per 20 s, and never two within 20 s.
+ var lastTwitch: TimeInterval?
+ var lastPromptTwitch: TimeInterval?
+ /// Boops of the last six seconds, for the giggle.
+ var boops: [TimeInterval] = []
+ var lastBoop: TimeInterval?
+ var quietUntil: TimeInterval?
+ /// A boop waiting for a short action to end; true when it is the giggle.
+ var queuedBoop: Bool?
+ /// Peeks so far, the index of the next peek's gap.
+ var peeks = 0
+
+ public init(seed: UInt64) {
+ self.seed = seed
+ }
+
+ /// Takes in one event. Returns the segments it planned, in start order (a plan it replaced is
+ /// replaced by these), or nothing when the plan stayed as it was; `timeline` holds the result.
+ public mutating func handle(_ event: PetEvent, at time: PetTime) -> [PetSegment] {
+ let before = timeline.segments
+ // Time never runs backwards inside the brain, whatever a script or a clock does.
+ let now = clock.map { PetTime(wall: time.wall, mono: max(time.mono, $0)) } ?? time
+ // The pointer moves up to 120 times a second. With nothing of the brain's own due before it,
+ // all it can do is add a cue at its instant: the plan and every deadline stay as they are.
+ if case .pointer(let distance) = event, clock != nil, nextWakeup().map({ $0 > now.mono }) ?? true {
+ pointer(at: distance, time: now)
+ // Nothing was due, so nothing planned lies between the last update and now: the clock can
+ // move up without playing anything, and the cue counts as committed. H is read from its
+ // line, so it needs no step either.
+ clock = now.mono
+ offset = now.wall.timeIntervalSince1970 - now.mono
+ return []
+ }
+ if let clock { timeline.dropPlans(after: clock) }
+ advance(to: now)
+ // The event lands on the state as it is at its own instant: food is taken off H as it stands
+ // then, not as it stood at the last update.
+ integrate(to: now)
+ clock = max(clock ?? now.mono, now.mono)
+ apply(event, at: now)
+ step(at: now)
+ plan(from: now)
+ prune(at: now)
+ let old = Set(before)
+ guard let first = timeline.segments.firstIndex(where: { !old.contains($0) }) else { return [] }
+ return Array(timeline.segments[first...])
+ }
+
+ /// When the brain next needs a `.tick` if nothing else happens: a meal flush, the end of `eager`,
+ /// the drowsy limit, a wake instant, a minimum stay, the next peek. nil: no such moment.
+ public func nextDeadline(after time: PetTime) -> PetTime? {
+ guard showPet, clock != nil else { return nil }
+ guard let due = brought(to: time).nextWakeup() else { return nil }
+ return time.adding(due - time.mono)
+ }
+
+ /// A copy of the brain as it will be at `time` if no event comes first: the plan is let go and
+ /// what really happens up to `time` is played, so the copy's segment, hunger and state are those
+ /// of that instant. For questions about it (`nextDeadline`, `status`, `save`).
+ func brought(to time: PetTime) -> PetBrain {
+ var copy = self
+ if let clock { copy.timeline.dropPlans(after: clock) }
+ copy.advance(to: time)
+ copy.integrate(to: time)
+ copy.clock = max(copy.clock ?? time.mono, time.mono)
+ return copy
+ }
+}
+
+// MARK: - The parts of the state
+
+extension PetBrain {
+ /// Food the pet has not been seen eating yet: the hunger model already has it.
+ struct Plate: Sendable, Equatable {
+ var points: Double = 0
+ /// The part of H's drop that belongs to this food: the meter shows it until the swallow.
+ var drop: Double = 0
+ /// `PetTime.mono` of the last food, for the flush.
+ var lastFood: TimeInterval = 0
+ }
+
+ /// H rising at `rate` from `value` at `t`, up to 100, and standing still from `stoppingAt` on:
+ /// 600 s after the last input, the hunger clock stops.
+ struct HungerLine: Sendable, Equatable {
+ var t: TimeInterval
+ var value: Double
+ var rate: Double
+
+ func value(at time: TimeInterval, stoppingAt stop: TimeInterval?) -> Double {
+ let end = max(t, min(time, stop ?? time))
+ return min(PetTuning.Hunger.maximum, value + rate * (end - t))
+ }
+ }
+
+ /// A meal's drop on its way to the meter, landing at the swallow.
+ struct Swallow: Sendable, Equatable {
+ var at: TimeInterval
+ var drop: Double
+ }
+
+ struct PendingCelebration: Sendable, Equatable {
+ var celebration: PetBehaviour.Celebration
+ /// A milestone is skipped when its day ends unseen; a good week after its line's 24 hours.
+ var until: Date
+ }
+
+ /// Where a behaviour leaves the pet, which decides what a change of condition plays.
+ enum Place: Sendable, Equatable {
+ /// Out in its zone, awake.
+ case open
+ /// Asleep in the open at the 5-hour limit.
+ case nap
+ /// Hibernating in the den at the weekly limit.
+ case den
+ /// In the den with no account to eat from, or before its intro.
+ case burrow
+ case doze
+ case off
+ }
+
+ var pendingDrop: Double { plate.drop + swallows.reduce(0) { $0 + $1.drop } }
+
+ /// H + pendingDrop: what the meter shows before the clamp, and what moods are judged on.
+ var meterValue: Double { hunger.value + pendingDrop }
+
+ var isVisible: Bool { showPet && visibleFlag && !systemAsleep }
+
+ /// Someone can see the pet and is there to see it: meals, moments and transitions wait for this.
+ var audience: Bool { isVisible && !away }
+
+ var isAwake: Bool {
+ if case .awake = condition { return true }
+ return false
+ }
+
+ var current: PetSegment? { timeline.segments.last }
+
+ var currentPlace: Place { current.map { place(after: $0.behaviour) } ?? .open }
+
+ func place(after behaviour: PetBehaviour) -> Place {
+ switch behaviour {
+ case .fallAsleep, .asleep, .drowsy, .rollOver: return .nap
+ case .enterDen(let retreat): return retreat ? .burrow : .den
+ case .hibernating, .stirring: return .den
+ case .denWait, .peek, .intro(noAccounts: true): return .burrow
+ case .dozeIn, .doze: return .doze
+ case .off: return .off
+ default: return .open
+ }
+ }
+
+ /// The place the condition calls for.
+ var targetPlace: Place {
+ switch condition {
+ case .off: return .off
+ case .noAccounts: return .burrow
+ case .hibernating: return .den
+ case .asleep: return .nap
+ case .unsure, .awake: return !introDone ? .burrow : away ? .doze : .open
+ }
+ }
+
+ /// The open-ended behaviour the state calls for when nothing else is going on.
+ func restingBehaviour(at t: PetTime) -> PetBehaviour {
+ switch condition {
+ case .off: return .off
+ case .noAccounts: return .denWait
+ case .hibernating: return den.isWaiting ? .stirring : .hibernating
+ case .asleep: return nap.isWaiting ? .drowsy : .asleep
+ case .unsure: return !introDone ? .denWait : away ? .doze : .unsureIdle(unsureMood)
+ case .awake(let mood):
+ if !introDone { return .denWait }
+ if away { return .doze }
+ if eagerUntil != nil { return .eager }
+ if let lastInput, t.mono - lastInput >= PetTuning.Presence.settled { return .settled(mood) }
+ return .idle(mood)
+ }
+ }
+
+ /// The hunger clock: the pet on, someone at the Mac, awake with fresh numbers.
+ func hungerClockRuns(at t: PetTime) -> Bool {
+ guard showPet, isAwake, !away, !systemAsleep, !presence.displayAsleep, !presence.screenLocked,
+ !presence.sessionInactive else { return false }
+ return lastInput.map { t.mono - $0 < PetTuning.Presence.away } ?? true
+ }
+
+ func peekGap(_ index: Int) -> TimeInterval {
+ PetTuning.Den.peekMinimum
+ + (PetTuning.Den.peekMaximum - PetTuning.Den.peekMinimum) * PetNoise.hash(seed, .denPeek, index)
+ }
+
+ func sinceLastMeal(at t: PetTime) -> TimeInterval? {
+ if let lastMealMono { return t.mono - lastMealMono }
+ return lastMealWall.map { max(0, t.wall.timeIntervalSince($0)) }
+ }
+
+ func mono(_ wall: Date) -> TimeInterval { wall.timeIntervalSince1970 - offset }
+}
+
+// MARK: - Time
+
+extension PetBrain {
+ /// Plays the brain's own deadlines up to `now`, each at its own instant.
+ mutating func advance(to now: PetTime) {
+ offset = now.wall.timeIntervalSince1970 - now.mono
+ guard clock != nil else {
+ clock = now.mono
+ return
+ }
+ while let due = nextWakeup(), due <= now.mono {
+ step(at: PetTime(wall: now.wall.addingTimeInterval(due - now.mono), mono: due))
+ }
+ }
+
+ /// One update at `t`: the clocks, the rules, then whatever the state says should start.
+ mutating func step(at t: PetTime) {
+ integrate(to: t)
+ clock = max(clock ?? t.mono, t.mono)
+ updateRules(at: t)
+ for _ in 0..<8 {
+ guard let next = decide(at: t) else { break }
+ commit(next, at: t)
+ }
+ refreshMeter(at: t)
+ }
+
+ /// Hunger since `clock` at the rate held since then, stopping where the person went away by
+ /// input (the clock stops 600 s after the last input), and the swallows that came due.
+ mutating func integrate(to t: PetTime) {
+ swallows.removeAll { $0.at <= t.mono }
+ guard let clock, t.mono > clock, let line = hungerLine else { return }
+ hunger = PetHunger(value: line.value(at: t.mono, stoppingAt: lastInput.map { $0 + PetTuning.Presence.away }))
+ }
+
+ /// The earliest instant after `clock` at which a rule could change something.
+ func nextWakeup() -> TimeInterval? {
+ guard showPet, let clock else { return nil }
+ typealias P = PetTuning.Plate
+ var times: [TimeInterval] = []
+ // Between events the timeline also holds the plan: the segment playing is the last one that
+ // has started, and the next planned start is a deadline of its own, so that what starts then
+ // is committed then, with what starting it means.
+ if let next = timeline.segments.first(where: { $0.start > clock }) { times.append(next.start) }
+ if let playing = timeline.segments.last(where: { $0.start <= clock }) {
+ if let duration = playing.behaviour.duration { times.append(playing.start + duration) }
+ switch playing.behaviour {
+ case .satisfied:
+ times.append(playing.start + PetTuning.Meal.satisfiedMinimum)
+ case .eager:
+ times.append(playing.start + PetTuning.Eager.minimum)
+ case .denWait where introDone && audience:
+ times.append(playing.start + peekGap(peeks))
+ default:
+ break
+ }
+ }
+ times += swallows.map(\.at)
+ if plate.points > 0 {
+ times.append(plate.lastFood + P.flushAfter)
+ if let lastMealMono { times.append(lastMealMono + P.mealGap) }
+ }
+ if let eagerUntil { times.append(eagerUntil) }
+ if let queuedPrompt { times.append(queuedPrompt + PetTuning.Eager.afterPrompt) }
+ let wall = Date(timeIntervalSince1970: clock + offset)
+ if let close = food.nextClose(after: wall) { times.append(mono(close)) }
+ if let accounts, !accounts.isEmpty {
+ let e5 = PetLimits.aggregate(accounts, .fiveHour, now: wall, food: food)
+ let e7 = PetLimits.aggregate(accounts, .sevenDay, now: wall, food: food)
+ for (rest, used) in [(nap, e5), (den, e7)] {
+ if let wake = rest.wakesAt { times.append(mono(wake)) }
+ if let deadline = rest.deadline(effective: used) { times.append(deadline) }
+ }
+ if isAwake, let newest = accounts.compactMap({ $0.reading?.fetchedAt }).max() {
+ times.append(mono(newest.addingTimeInterval(PetTuning.Stale.unsure)))
+ }
+ }
+ if let lastInput {
+ times.append(lastInput + PetTuning.Presence.settled)
+ times.append(lastInput + PetTuning.Presence.away)
+ }
+ // Worked out from the line, not from `clock`, so they are the same instants whenever asked.
+ if let line = hungerLine, line.rate > 0, hunger.value < PetTuning.Hunger.maximum {
+ times.append(line.t + (PetTuning.Hunger.maximum - line.value) / line.rate)
+ if let threshold = PetHunger.nextThreshold(above: meterValue, mood: mood) {
+ times.append(max(clock + PetTuning.Brain.step, line.t + (threshold - pendingDrop - line.value) / line.rate))
+ }
+ }
+ return times.filter { $0 > clock }.min()
+ }
+}
+
+// MARK: - The rules
+
+extension PetBrain {
+ /// Everything that follows from the state and the time alone, in the order it depends on itself.
+ mutating func updateRules(at t: PetTime) {
+ guard showPet else {
+ setCondition(.off)
+ return
+ }
+ for close in food.closeElapsed(now: t.wall) { periodClosed(close, at: t) }
+
+ let accounts = self.accounts ?? []
+ if accounts.isEmpty {
+ nap.forget()
+ den.forget()
+ } else {
+ let newest = accounts.compactMap { $0.reading?.fetchedAt }.max()
+ let e7 = PetLimits.aggregate(accounts, .sevenDay, now: t.wall, food: food)
+ _ = den.update(effective: e7, wakeInstant: PetLimits.wakeInstant(accounts, .sevenDay, now: t.wall, food: food),
+ newestFetch: newest, seen: audience, at: t)
+ // While it hibernates, the 5-hour wait is nobody's to watch: a reset just wakes that rest.
+ let e5 = PetLimits.aggregate(accounts, .fiveHour, now: t.wall, food: food)
+ _ = nap.update(effective: e5, wakeInstant: PetLimits.wakeInstant(accounts, .fiveHour, now: t.wall, food: food),
+ newestFetch: newest, seen: audience && !den.isResting, at: t)
+ }
+
+ mood = PetHunger.mood(meterValue, from: mood)
+ setCondition(evaluateCondition(at: t))
+
+ if let until = eagerUntil, t.mono >= until || !isAwake || away {
+ if let since = eagerSince, t.mono >= since + PetTuning.Eager.maximum { capped = true }
+ eagerSince = nil
+ eagerUntil = nil
+ }
+ if let queued = queuedPrompt, t.mono - queued > PetTuning.Eager.afterPrompt || !isAwake { queuedPrompt = nil }
+ // A boop waits only for the short beat it came during; at rest it has nobody to reach.
+ if queuedBoop != nil, let current, current.behaviour.duration == nil, currentPlace != .open { queuedBoop = nil }
+
+ // Crumbs: less than 0.1 point that no more food followed is dropped, unseen.
+ if plate.points > 0, plate.points < PetTuning.Plate.flushMinimum,
+ t.mono - plate.lastFood >= PetTuning.Plate.flushAfter {
+ plate = Plate()
+ }
+ // Asleep, hibernating, in the den: the hunger model has the food already, and there is no
+ // meal. The meal that fills the window is the exception, eaten before it falls asleep.
+ switch condition {
+ case .hibernating, .noAccounts: plate = Plate()
+ case .asleep where currentPlace != .open: plate = Plate()
+ default: break
+ }
+
+ // A new pet first seen asleep or hibernating has no intro: it is simply found that way.
+ if !introDone, audience, self.accounts != nil {
+ switch condition {
+ case .asleep, .hibernating: introDone = true
+ default: break
+ }
+ }
+
+ pendingCelebrations.removeAll { $0.until <= t.wall }
+ pendingCelebrations.removeAll {
+ $0.celebration == .goodWeek && !moments.mayCelebrateGoodWeek(at: t.wall)
+ }
+ if let due = pendingFreshWindow, t.mono - due > PetTuning.Brain.freshWindowWait { pendingFreshWindow = nil }
+ if let due = returnPending, t.mono - due > PetTuning.Brain.returnWait { returnPending = nil }
+ }
+
+ func evaluateCondition(at t: PetTime) -> PetCondition {
+ guard showPet else { return .off }
+ switch forced {
+ case .idle?: return .awake(mood)
+ case .asleep?: return .asleep
+ case .hibernating?: return .hibernating
+ case nil: break
+ }
+ guard let accounts else { return .unsure }
+ if accounts.isEmpty { return .noAccounts }
+ if den.isResting { return .hibernating }
+ if nap.isResting { return .asleep }
+ let readings = accounts.compactMap(\.reading)
+ if readings.isEmpty
+ || readings.allSatisfy({ t.wall.timeIntervalSince($0.fetchedAt) >= PetTuning.Stale.unsure }) {
+ return .unsure
+ }
+ return .awake(mood)
+ }
+
+ mutating func setCondition(_ new: PetCondition) {
+ guard new != condition else { return }
+ if case .unsure = new {
+ // The words freeze on the mood it had while the numbers were fresh.
+ if case .awake(let last) = condition { unsureMood = last } else { unsureMood = mood }
+ }
+ condition = new
+ conditionChanges += 1
+ }
+
+ /// A period ended: a weekly close is where a good week is judged, a 5-hour close where a fresh
+ /// window is.
+ mutating func periodClosed(_ close: PetPeriodClose, at t: PetTime) {
+ switch close.window {
+ case .sevenDay:
+ guard moments.weekClosed(account: close.account, resetAt: close.resetAt, used: close.high) else { return }
+ pendingCelebrations.append(PendingCelebration(
+ celebration: .goodWeek, until: close.resetAt.addingTimeInterval(PetTuning.Moments.goodWeekLine)))
+ case .fiveHour:
+ guard isAwake, t.wall.timeIntervalSince(close.resetAt) <= PetTuning.Brain.freshWindowWait,
+ lastFreshWindow.map({ t.mono - $0 >= PetTuning.Moments.freshWindowEvery }) ?? true
+ else { return }
+ let share = PetFood.shares(accounts ?? [])[close.account] ?? 0
+ if share * close.high >= PetTuning.Moments.freshWindowFreed { pendingFreshWindow = t.mono }
+ }
+ }
+
+ /// The meter's track: a new key wherever the value jumped or the rate changed.
+ mutating func refreshMeter(at t: PetTime) {
+ running = hungerClockRuns(at: t)
+ if running, let accounts {
+ let raw = PetLimits.pace(accounts, now: t.wall, food: food)
+ pace = (raw / PetTuning.Brain.paceGrid).rounded() * PetTuning.Brain.paceGrid
+ }
+ let rate = running && hunger.value < PetTuning.Hunger.maximum ? PetHunger.rate(pace: pace) : 0
+ let stop = lastInput.map { $0 + PetTuning.Presence.away }
+ if hungerLine.map({ $0.rate != rate || $0.value(at: t.mono, stoppingAt: stop) != hunger.value }) ?? true {
+ hungerLine = HungerLine(t: t.mono, value: hunger.value, rate: rate)
+ }
+ let value = meterValue
+ if let last = timeline.meter.last {
+ let predicted = last.value + last.rate * (t.mono - last.t)
+ guard abs(predicted - value) > 1e-9 || last.rate != rate else { return }
+ if last.t == t.mono { timeline.meter.removeLast() }
+ } else if value == PetTuning.Hunger.newPet && rate == 0 {
+ return // no key reads as a new pet's 30
+ }
+ timeline.meter.append(PetMeterKey(t: t.mono, value: value, rate: rate))
+ }
+}
+
+// MARK: - Events
+
+extension PetBrain {
+ mutating func apply(_ event: PetEvent, at t: PetTime) {
+ if bornAt == nil { bornAt = t.wall }
+ switch event {
+ case .readings(let accounts):
+ self.accounts = accounts
+ if showPet { take(accounts, at: t) }
+ case .prompt:
+ prompt(at: t)
+ case .presence(let presence):
+ sense(presence, at: t)
+ case .systemSleep:
+ systemAsleep = true
+ goAway(at: t)
+ case .systemWake:
+ // Away until the next presence check says otherwise; the gap's closes and resets are
+ // played at the next update, in their own order.
+ systemAsleep = false
+ case .clockChanged:
+ break // every rule reads the wall clock afresh
+ case .timeZone(let zone):
+ timeZone = zone
+ case .visible(let visible):
+ visibleFlag = visible
+ case .showPet(let on):
+ turn(on: on, at: t)
+ case .motion(let motion):
+ self.motion = motion
+ case .pointer(let distance):
+ pointer(at: distance, time: t)
+ case .boop:
+ boop(at: t)
+ case .tick:
+ break
+ case .debugHunger(let value):
+ hunger = PetHunger(value: value)
+ case .forceState(let state):
+ forced = state
+ }
+ }
+
+ /// Food from a set of readings, and the periods they closed.
+ mutating func take(_ accounts: [PetAccount], at t: PetTime) {
+ let intake = food.take(accounts)
+ for close in intake.closes { periodClosed(close, at: t) }
+ guard intake.points > 0 else { return }
+ let drop = hunger.eat(intake.points)
+ if let milestone = moments.credit(intake.points, at: t.wall, timeZone: timeZone) {
+ pendingCelebrations.append(PendingCelebration(celebration: .streak(days: milestone),
+ until: PetMoments.endOfDay(t.wall, in: timeZone)))
+ }
+ // At rest H has it and there is no meal; still out in the open, it is the meal that fills
+ // the window, eaten before the yawn.
+ if isResting && currentPlace != .open { return }
+ plate.points += intake.points
+ plate.drop += drop
+ plate.lastFood = t.mono
+ }
+
+ var isResting: Bool {
+ switch condition {
+ case .asleep, .hibernating, .noAccounts: return true
+ default: return false
+ }
+ }
+
+ mutating func prompt(at t: PetTime) {
+ guard showPet, let current else { return }
+ let previous = lastPrompt
+ lastPrompt = t.mono
+ switch place(after: current.behaviour) {
+ case .nap:
+ // Asleep, a prompt is one brow twitch at most once a minute; it does not wake.
+ guard current.behaviour != .fallAsleep,
+ lastPromptTwitch.map({ t.mono - $0 >= PetTuning.Eager.sleepTwitchEvery }) ?? true,
+ lastTwitch.map({ t.mono - $0 >= PetTuning.Pointer.sleepTwitchEvery }) ?? true else { return }
+ lastPromptTwitch = t.mono
+ lastTwitch = t.mono
+ cue(.twitch, at: t)
+ case .open:
+ guard isAwake, !away, introDone else { return }
+ if capped {
+ // After the 600 s of eager, idle until a minute passes with no prompt.
+ guard let previous, t.mono - previous >= PetTuning.Eager.cooldown else { return }
+ capped = false
+ }
+ if eagerUntil != nil, let since = eagerSince {
+ eagerUntil = min(t.mono + PetTuning.Eager.afterPrompt, since + PetTuning.Eager.maximum)
+ if lastPerk.map({ t.mono - $0 >= PetTuning.Eager.reperk }) ?? true { queuedPrompt = t.mono }
+ } else {
+ queuedPrompt = t.mono
+ }
+ default:
+ return // hibernating or in the den: no reaction
+ }
+ }
+
+ mutating func sense(_ new: PetPresence, at t: PetTime) {
+ presence = new
+ lastInput = t.mono - max(0, new.inputIdle)
+ let gone = new.displayAsleep || new.screenLocked || new.sessionInactive || systemAsleep
+ || new.inputIdle >= PetTuning.Presence.away
+ if gone && !away {
+ goAway(at: t)
+ } else if !gone && away {
+ away = false
+ if let since = awaySince, t.wall.timeIntervalSince(since) >= PetTuning.Presence.away {
+ returnPending = t.mono
+ }
+ awaySince = nil
+ }
+ }
+
+ /// Away at once: eager ends, and the absence is counted from the last input.
+ mutating func goAway(at t: PetTime) {
+ guard !away else { return }
+ away = true
+ awaySince = lastInput.map { t.wall.addingTimeInterval($0 - t.mono) } ?? t.wall
+ eagerSince = nil
+ eagerUntil = nil
+ queuedPrompt = nil
+ }
+
+ mutating func turn(on: Bool, at t: PetTime) {
+ guard on != showPet else { return }
+ showPet = on
+ if on {
+ // The time it was off is away time: the trackers stood still, so what grew meanwhile
+ // is a catch-up meal now.
+ if let accounts { take(accounts, at: t) }
+ } else {
+ // Off: no hunger, no plans, no moments waiting. Turned on again, it is judged afresh.
+ nap.forget()
+ den.forget()
+ plate = Plate()
+ swallows = []
+ eagerSince = nil
+ eagerUntil = nil
+ queuedPrompt = nil
+ queuedBoop = nil
+ pendingFreshWindow = nil
+ returnPending = nil
+ noticed = false
+ }
+ }
+
+ mutating func pointer(at distance: Double, time t: PetTime) {
+ // The segment playing at `t`: on the fast path the plan after it is still on the timeline.
+ guard showPet, let playing = timeline.segments.last(where: { $0.start <= t.mono }) else { return }
+ typealias P = PetTuning.Pointer
+ switch place(after: playing.behaviour) {
+ case .nap:
+ guard distance <= P.sleepTwitch,
+ lastTwitch.map({ t.mono - $0 >= P.sleepTwitchEvery }) ?? true else { return }
+ lastTwitch = t.mono
+ cue(.twitch, at: t)
+ case .open:
+ guard introDone else { return }
+ if !noticed && distance <= P.notice {
+ noticed = true
+ cue(.notice, at: t)
+ } else if noticed && distance > P.release {
+ noticed = false
+ cue(.release, at: t)
+ }
+ default:
+ return
+ }
+ }
+
+ /// A click on the pet in the panel.
+ mutating func boop(at t: PetTime) {
+ guard showPet, let current,
+ lastBoop.map({ t.mono - $0 >= PetTuning.Boop.spacing }) ?? true,
+ quietUntil.map({ t.mono >= $0 }) ?? true else { return }
+ switch place(after: current.behaviour) {
+ case .nap where current.behaviour != .fallAsleep:
+ // A reaction, not a wake: one eye opens and closes.
+ lastBoop = t.mono
+ cue(.sleepyEye, at: t)
+ case .den where !isEnteringOrLeaving(current.behaviour), .burrow where !isEnteringOrLeaving(current.behaviour):
+ // In the den, or curled up where there is none: the glow pulses once.
+ lastBoop = t.mono
+ cue(.glowPulse, at: t)
+ case .open where !ignoresBoops(current.behaviour):
+ lastBoop = t.mono
+ boops = boops.filter { t.mono - $0 < PetTuning.Boop.giggleWindow } + [t.mono]
+ let giggle = boops.count >= PetTuning.Boop.giggleCount
+ if giggle {
+ boops = []
+ quietUntil = t.mono + PetTuning.Boop.giggleQuiet
+ }
+ queuedBoop = giggle
+ default:
+ return // dozing, off, or in a beat a boop never cuts
+ }
+ }
+
+ func isEnteringOrLeaving(_ behaviour: PetBehaviour) -> Bool {
+ switch behaviour {
+ case .enterDen, .leaveDen: return true
+ default: return false
+ }
+ }
+
+ /// A boop during these is ignored, not queued; `fallAsleep` too, which is not in the open.
+ func ignoresBoops(_ behaviour: PetBehaviour) -> Bool {
+ switch behaviour {
+ case .eat, .celebrate, .wake, .leaveDen: return true
+ default: return false
+ }
+ }
+
+ /// Cues stay in time order, also when the plan already holds later ones (a pointer between
+ /// updates, ahead of a release planned at a yawn).
+ mutating func cue(_ kind: PetCue.Kind, at t: PetTime) {
+ let index = timeline.cues.firstIndex { $0.t > t.mono } ?? timeline.cues.endIndex
+ timeline.cues.insert(PetCue(t: t.mono, kind: kind), at: index)
+ }
+}
+
+// MARK: - Deciding
+
+extension PetBrain {
+ /// What should start at `t`, or nil to let the current segment go on. Priorities, highest first:
+ /// `off` · the intro · changes of condition · `eat` · `celebrate` · `boop` · `perk` and `eager` ·
+ /// `freshWindow` · a welcome back · the open behaviour.
+ func decide(at t: PetTime) -> PetBehaviour? {
+ let target = restingBehaviour(at: t)
+ // Until it has heard the accounts it cannot know whether the pet is out, asleep or in the
+ // den, so it plays nothing; the controller sends them in the same turn as the rest.
+ guard let current else { return accounts == nil && condition != .off ? nil : target }
+ let behaviour = current.behaviour
+ let elapsed = t.mono - current.start
+ let done = behaviour.duration.map { elapsed >= $0 - 1e-9 } ?? false
+
+ if condition == .off { return behaviour == .off ? nil : .off }
+ if behaviour == .off { return target } // fade in at the data's condition
+ // An action finishes; whatever comes meanwhile waits. `satisfied` is the one action that
+ // may be cut.
+ if behaviour.duration != nil, !done, !isSatisfied(behaviour) { return nil }
+
+ if !introDone, introDue, behaviour == .denWait { return .intro(noAccounts: accounts?.isEmpty ?? true) }
+
+ let from = place(after: behaviour)
+ if let change = transition(from: from, behaviour: behaviour, done: done, target: target, at: t) {
+ return change
+ }
+ if case .eat(let meal) = behaviour, done {
+ return .satisfied(meal.feast ? .feast : meal.size == .full ? .meal : .lick)
+ }
+ guard from == .open || (from == .doze && targetPlace == .open) else {
+ return done ? held(from, target: target) : nil
+ }
+
+ func mayCut(_ kind: QueueKind) -> Bool {
+ if done { return true }
+ switch behaviour {
+ case .idle, .settled, .unsureIdle, .doze: return true
+ case .eager: return kind != .freshWindow && kind != .welcomeBack
+ case .satisfied:
+ if kind == .eat || kind == .celebrate { return elapsed >= PetTuning.Meal.satisfiedMinimum - 1e-9 }
+ return kind == .boop
+ default: return false
+ }
+ }
+ if mealDue(at: t), mayCut(.eat) { return .eat(planMeal(at: t)) }
+ if audience, isAwake, let pending = pendingCelebrations.first, mayCut(.celebrate) {
+ return .celebrate(pending.celebration)
+ }
+ if let giggle = queuedBoop, mayCut(.boop) { return .boop(giggle: giggle) }
+ if queuedPrompt != nil, isAwake, audience, mayCut(.perk) {
+ // A perk at most once a minute; a prompt inside the minute goes straight to eager.
+ if lastPerk.map({ t.mono - $0 >= PetTuning.Eager.reperk }) ?? true { return .perk(.prompt) }
+ if behaviour != .eager { return .eager }
+ }
+ if pendingFreshWindow != nil, audience, isAwake, mayCut(.freshWindow) { return .freshWindow }
+ if returnPending != nil, audience, mayCut(.welcomeBack) {
+ switch condition {
+ case .awake, .unsure: return .welcomeBack
+ default: break
+ }
+ }
+ guard done || isOpenEnded(behaviour) else { return nil }
+ guard behaviour != target else { return nil }
+ if behaviour == .eager, elapsed < PetTuning.Eager.minimum - 1e-9, !done { return nil }
+ if case .unsureIdle = behaviour, isAwake, audience { return .perk(.freshData) }
+ return target
+ }
+
+ enum QueueKind { case eat, celebrate, boop, perk, freshWindow, welcomeBack }
+
+ /// What a change of place plays. Nobody watching, a change into rest happens at once; a change out of
+ /// rest waits to be seen, so the stretch the person missed still plays.
+ func transition(from: Place, behaviour: PetBehaviour, done: Bool, target: PetBehaviour,
+ at t: PetTime) -> PetBehaviour? {
+ let seen = audience
+ let to = targetPlace
+ switch (from, to) {
+ case (_, .off):
+ return .off
+ case (.off, _):
+ return target
+ case (.open, .open), (.doze, .open), (.doze, .doze):
+ return nil
+ case (.open, .nap):
+ if case .leaveDen = behaviour { return .asleep } // out of the den still full: no yawn
+ // The meal that fills the window, then straight into the yawn.
+ if seen, plate.points >= PetTuning.Plate.flushMinimum, !isEat(behaviour) { return .eat(planMeal(at: t)) }
+ return seen ? .fallAsleep : .asleep
+ case (.open, .den), (.nap, .den):
+ return seen ? .enterDen(retreat: false) : .hibernating
+ case (.open, .burrow), (.nap, .burrow):
+ return seen ? .enterDen(retreat: true) : .denWait
+ case (.open, .doze):
+ return isVisible ? .dozeIn : .doze
+ case (.nap, .open):
+ return seen ? .wake : done ? .asleep : nil
+ case (.nap, .nap):
+ switch (behaviour, target) {
+ case (.drowsy, .asleep): return .rollOver
+ case (.asleep, .drowsy): return .drowsy
+ case (.fallAsleep, _), (.rollOver, _): return done ? target : nil
+ default: return nil
+ }
+ case (.nap, .doze), (.den, .doze), (.burrow, .doze):
+ return done ? held(from, target: target) : nil
+ case (.den, .open):
+ return seen ? .leaveDen(stretch: true) : done ? .hibernating : nil
+ case (.den, .nap):
+ return seen ? .leaveDen(stretch: false) : .asleep
+ case (.den, .den):
+ if behaviour == target { return nil }
+ if case .enterDen = behaviour, !done { return nil }
+ return target
+ case (.den, .burrow):
+ return .denWait
+ case (.burrow, .open):
+ guard introDone else { return done ? .denWait : nil }
+ return seen ? .leaveDen(stretch: false) : done ? .denWait : nil
+ case (.burrow, .nap):
+ return seen && introDone ? .leaveDen(stretch: false) : .asleep
+ case (.burrow, .den):
+ return .hibernating
+ case (.burrow, .burrow):
+ if behaviour == .denWait {
+ guard introDone, seen, t.mono - (current?.start ?? t.mono) >= peekGap(peeks) - 1e-9 else { return nil }
+ return .peek
+ }
+ return done ? .denWait : nil
+ case (.doze, .nap):
+ return .asleep
+ case (.doze, .den):
+ return .hibernating
+ case (.doze, .burrow):
+ return .denWait
+ }
+ }
+
+ /// Where an action that played out unseen leaves the pet.
+ func held(_ place: Place, target: PetBehaviour) -> PetBehaviour {
+ switch place {
+ case .nap: return .asleep
+ case .den: return .hibernating
+ case .burrow: return .denWait
+ case .doze: return .doze
+ case .open, .off: return target
+ }
+ }
+
+ var introDue: Bool {
+ guard !introDone, audience, let accounts else { return false }
+ if accounts.isEmpty { return true }
+ guard accounts.contains(where: { $0.reading != nil }) else { return false }
+ switch condition {
+ case .awake, .unsure: return true
+ default: return false
+ }
+ }
+
+ /// A meal starts when the plate holds half a point (or a flush is due), the pet is seen and awake,
+ /// and the last meal started 40 s ago or more.
+ func mealDue(at t: PetTime) -> Bool {
+ typealias P = PetTuning.Plate
+ guard audience, isAwake, plate.points >= P.flushMinimum else { return false }
+ guard plate.points >= P.mealMinimum || t.mono - plate.lastFood >= P.flushAfter - 1e-9 else { return false }
+ return sinceLastMeal(at: t).map { $0 >= P.mealGap - 1e-9 } ?? true
+ }
+
+ func planMeal(at t: PetTime) -> PetMeal {
+ typealias P = PetTuning.Plate
+ let points = plate.points
+ let feast = points >= P.feastFrom
+ let flush = points < P.mealMinimum
+ let quiet = sinceLastMeal(at: t).map { $0 >= P.fullMealAfter } ?? true
+ let chews = points < P.twoChewsFrom ? 1 : points < P.threeChewsFrom ? 2 : P.maxChews
+ return PetMeal(size: feast || (quiet && !flush) ? .full : .nibble, chews: chews, feast: feast,
+ points: points, reduceMotion: motion.reduceMotion)
+ }
+
+ func isSatisfied(_ behaviour: PetBehaviour) -> Bool {
+ if case .satisfied = behaviour { return true }
+ return false
+ }
+
+ func isEat(_ behaviour: PetBehaviour) -> Bool {
+ if case .eat = behaviour { return true }
+ return false
+ }
+
+ func isOpenEnded(_ behaviour: PetBehaviour) -> Bool { behaviour.duration == nil }
+}
+
+// MARK: - Committing
+
+extension PetBrain {
+ /// Starts `behaviour` at `t`, and does what starting it means.
+ mutating func commit(_ behaviour: PetBehaviour, at t: PetTime) {
+ // A segment planned for this very instant is replaced: the last decision wins, and the rig
+ // never sees a segment of no length.
+ while let last = timeline.segments.last, last.start >= t.mono { timeline.segments.removeLast() }
+ timeline.segments.append(PetSegment(start: t.mono, behaviour: behaviour))
+
+ switch behaviour {
+ case .eat(let meal):
+ swallows.append(Swallow(at: t.mono + meal.swallowStart, drop: plate.drop))
+ plate = Plate()
+ lastMealMono = t.mono
+ lastMealWall = t.wall
+ lastMealPoints = meal.points
+ meals += 1
+ // Food arriving ends eager, and the morning's catch-up meal stands in for the welcome back.
+ eagerSince = nil
+ eagerUntil = nil
+ returnPending = nil
+ case .intro:
+ introDone = true
+ case .celebrate(let celebration):
+ pendingCelebrations.removeAll { $0.celebration == celebration }
+ if celebration == .goodWeek { moments.lastGoodWeekCelebratedAt = t.wall }
+ returnPending = nil
+ case .boop:
+ queuedBoop = nil
+ case .perk(.prompt), .eager:
+ if let prompt = queuedPrompt {
+ if behaviour == .perk(.prompt) { lastPerk = t.mono }
+ let since = eagerSince ?? t.mono
+ eagerSince = since
+ eagerUntil = min(prompt + PetTuning.Eager.afterPrompt, since + PetTuning.Eager.maximum)
+ queuedPrompt = nil
+ }
+ case .freshWindow:
+ pendingFreshWindow = nil
+ lastFreshWindow = t.mono
+ case .welcomeBack, .wake, .leaveDen(stretch: true):
+ returnPending = nil
+ case .peek:
+ peeks += 1
+ default:
+ break
+ }
+
+ let place = self.place(after: behaviour)
+ // At rest the hunger model has had the food all along, and nobody eats it.
+ if place == .nap || place == .den || place == .burrow {
+ switch condition {
+ case .asleep, .hibernating, .noAccounts: plate = Plate()
+ default: break
+ }
+ }
+ // Out of the open, nothing watches the pointer: close the notice so the rig's gaze is free.
+ if noticed && place != .open {
+ noticed = false
+ cue(.release, at: t)
+ }
+ }
+}
+
+// MARK: - Planning and memory
+
+extension PetBrain {
+ /// Plays a copy forward to the end of the action in progress and of every action after it, and
+ /// takes its timeline: the plan.
+ mutating func plan(from now: PetTime) {
+ guard showPet else { return }
+ var forecast = self
+ for _ in 0.. 0 {
+ timeline.segments.removeFirst(keep)
+ }
+ timeline.cues.removeAll { $0.t < horizon }
+ if let keep = timeline.meter.lastIndex(where: { $0.t <= horizon }), keep > 0 {
+ timeline.meter.removeFirst(keep)
+ }
+ }
+}
+
+extension PetTimeline {
+ /// Forgets everything planned after `time`: the plan is made again from what really happened.
+ mutating func dropPlans(after time: TimeInterval) {
+ segments.removeAll { $0.start > time }
+ cues.removeAll { $0.t > time }
+ meter.removeAll { $0.t > time }
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetCanvas.swift b/Sources/BrowKit/Pet/PetCanvas.swift
new file mode 100644
index 0000000..84bd011
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetCanvas.swift
@@ -0,0 +1,795 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import SwiftUI
+
+extension PetPalette {
+ /// The look the pet ships with: cream, with its arms and feet as they are. `original` stays a case, so
+ /// going back to the honey light is this one line.
+ public static let standard: PetPalette = .cream
+}
+
+/// Draws one `PetFrame`. It holds no state and reads no clock: the same frame and palette always give the
+/// same picture, and the palette changes colours only.
+///
+/// The view is the pet's own small canvas, `frame.canvas` in window coordinates: the layer places it
+/// there (`.offset(x: canvas.minX, y: canvas.minY)` in a top-leading stack) and it draws everything
+/// else in window coordinates too. Soft light is gradients only: no blur, no shadow, no offscreen pass
+/// per frame.
+public struct PetCanvas: View {
+ public let frame: PetFrame
+ public let palette: PetPalette
+ /// `-petPerfLog`: told how long each drawing took, on the main thread where the canvas draws.
+ var measure: ((Duration) -> Void)?
+
+ public init(frame: PetFrame, palette: PetPalette = .standard) {
+ self.frame = frame
+ self.palette = palette
+ }
+
+ init(frame: PetFrame, palette: PetPalette, measure: ((Duration) -> Void)?) {
+ self.frame = frame
+ self.palette = palette
+ self.measure = measure
+ }
+
+ public var body: some View {
+ Canvas { context, _ in
+ let start = measure == nil ? nil : ContinuousClock.now
+ context.translateBy(x: -frame.canvas.minX, y: -frame.canvas.minY)
+ Self.draw(frame, palette: palette.colors, in: &context)
+ if let start, let measure { measure(ContinuousClock.now - start) }
+ }
+ .frame(width: max(frame.canvas.width, 1), height: max(frame.canvas.height, 1))
+ .allowsHitTesting(false)
+ }
+
+ /// Draws `frame` into `context`, whose coordinates are the window's.
+ public static func draw(_ frame: PetFrame, palette: PetPalette.Colors, in context: inout GraphicsContext) {
+ guard !frame.isEmpty else { return }
+ var ctx = context
+ // The camera has no pixels: whatever passes the notch's edge is simply not there.
+ if let notch = frame.notch {
+ ctx.clip(to: Path(notch), options: .inverse)
+ }
+ let look = Look(palette: palette, motion: frame.motion, guardMinX: frame.guardMinX,
+ guardMaxX: frame.guardMaxX, foodSource: frame.foodSource, foodDot: frame.foodDot)
+ if let door = frame.doorLight { drawDoorLight(door, look, in: &ctx) }
+ if let echo = frame.echo, echo.opacity > 0.001 { drawPet(echo, frame: frame, look, in: &ctx) }
+ if frame.pet.opacity > 0.001 { drawPet(frame.pet, frame: frame, look, in: &ctx) }
+ drawParticles(frame.particles, opacity: frame.pet.opacity, look, in: &ctx)
+ if let bead = frame.bead { drawBead(bead, look, in: &ctx) }
+ if let glint = frame.glint { drawGlint(glint, look, in: &ctx) }
+ }
+
+ /// What every part of the drawing reads besides the pose.
+ struct Look {
+ let palette: PetPalette.Colors
+ let motion: PetMotion
+ let guardMinX: CGFloat?
+ let guardMaxX: CGFloat?
+ let foodSource: CGPoint?
+ let foodDot: Severity?
+ }
+
+ // MARK: - The pet
+
+ static func drawPet(_ pose: PetBody, frame: PetFrame, _ look: Look, in context: inout GraphicsContext) {
+ let pal = look.palette
+ let g = PetBodyGeometry(pose)
+ let body = g.outline()
+ var ctx = context
+ ctx.opacity *= PetEase.clamp01(pose.opacity)
+ let light = PetCurve.clamp(pose.fullness, 0, 1.25)
+ let emissive = pose.glow * (0.22 + 0.78 * light)
+
+ if !look.motion.reduceTransparency {
+ drawGlow(g, emissive: emissive, look, in: &ctx)
+ }
+ drawFeet(g, pal, in: &ctx)
+ drawArms(g, front: false, pal, in: &ctx)
+ drawBodyFill(g, body: body, look, in: &ctx)
+ let visibility = g.faceVisibility
+ if visibility > 0.001 {
+ var face = ctx
+ face.opacity *= visibility
+ face.clip(to: body)
+ drawCheeks(g, pal, in: &face)
+ drawEyes(g, pal, in: &face)
+ drawBrows(g, pal, in: &face)
+ drawMouth(g, pal, in: &face)
+ }
+ drawArms(g, front: true, pal, in: &ctx)
+ if pose.flash > 0.001 {
+ var flare = ctx
+ flare.blendMode = .plusLighter
+ flare.clip(to: body)
+ flare.fill(body, with: .color(pal.flare.color(opacity: 0.42 * pose.flash)))
+ }
+ if look.motion.increaseContrast {
+ ctx.stroke(body, with: .color(pal.rim.color(opacity: PetTuning.Light.contrastOutlineOpacity)),
+ lineWidth: PetTuning.Light.contrastOutlineWidth * g.s)
+ }
+ if let edge = frame.denEdge {
+ // Going behind the camera: the side of the pet next to the notch falls into its shadow.
+ var shade = ctx
+ shade.clip(to: g.outline(inflate: 1.5 * g.s, samples: 28))
+ let x0 = Double(edge) - 6 * g.s
+ shade.fill(Path(CGRect(x: x0, y: g.base.y - 40 * g.s, width: 6 * g.s, height: 48 * g.s)),
+ with: .linearGradient(Gradient(colors: [.black.opacity(0), .black.opacity(0.6)]),
+ startPoint: CGPoint(x: x0, y: 0), endPoint: CGPoint(x: Double(edge), y: 0)))
+ }
+ }
+
+ // MARK: Light around the pet
+
+ /// The halo and the light on the floor. Both fade past the readouts' edges: whatever reaches a digit
+ /// stays under `PetTuning.Light.glowAtReadout`, so the pet never washes out a number.
+ static func drawGlow(_ g: PetBodyGeometry, emissive: Double, _ look: Look, in ctx: inout GraphicsContext) {
+ let pal = look.palette
+ let e = ellipse(g)
+ let spread = 15.0 * g.s
+ var halo = emissive * pal.glowGain
+ // The floor light: a flat ellipse under the feet, fading as the feet leave the floor.
+ let contact = exp(-g.body.lift / (5 * g.s))
+ let floorWidth = g.halfWidth * 2.9, floorHeight = 7.0 * g.s
+ let floorCentre = CGPoint(x: g.base.x + g.body.lean * g.height * 0.2, y: g.body.floorY + 0.6 * g.s)
+ var floor = 0.30 * emissive * contact * pal.glowGain
+
+ // What each would put on the nearest readout pixel, as a share of its own peak.
+ var reach = 0.0
+ for guardX in [look.guardMinX, look.guardMaxX].compactMap({ $0 }) {
+ let gx = Double(guardX)
+ reach = max(reach, halo * haloFalloff(abs(gx - e.c.x) / (e.rx + spread), k: e.rx / (e.rx + spread))
+ + floor * floorFalloff(abs(gx - floorCentre.x) / (floorWidth / 2)))
+ }
+ let limit = PetTuning.Light.glowAtReadout * 0.85
+ if reach > limit {
+ let cut = limit / reach
+ halo *= cut
+ floor *= cut
+ }
+
+ if floor > 0.004 {
+ var l = ctx
+ l.translateBy(x: floorCentre.x, y: floorCentre.y)
+ l.scaleBy(x: 1, y: floorHeight / floorWidth)
+ l.fill(Path(ellipseIn: CGRect(x: -floorWidth / 2, y: -floorWidth / 2, width: floorWidth, height: floorWidth)),
+ with: .radialGradient(Gradient(stops: [.init(color: pal.glow.color(opacity: floor), location: 0),
+ .init(color: pal.glow.color(opacity: floor * 0.35), location: 0.5),
+ .init(color: pal.glow.color(opacity: 0), location: 1)]),
+ center: .zero, startRadius: 0, endRadius: floorWidth / 2))
+ }
+ if halo > 0.004 {
+ let k = e.rx / (e.rx + spread)
+ softEllipse(&ctx, centre: e.c, rx: e.rx + spread, ry: e.ry + spread * 0.85, angle: g.body.tilt,
+ color: pal.glow, stops: haloStops(k).map { ($0.0, $0.1 * halo) })
+ }
+ }
+
+ /// Everything the body's light can touch: the halo's ellipse (turned with the body) and the floor light.
+ static func lightBox(_ g: PetBodyGeometry) -> CGRect {
+ let e = ellipse(g)
+ let spread = 15.0 * g.s
+ let rx = e.rx + spread, ry = e.ry + spread * 0.85
+ let c = cos(g.body.tilt), sn = sin(g.body.tilt)
+ let hx = (rx * rx * c * c + ry * ry * sn * sn).squareRoot(), hy = (rx * rx * sn * sn + ry * ry * c * c).squareRoot()
+ let halo = CGRect(x: e.c.x - hx, y: e.c.y - hy, width: 2 * hx, height: 2 * hy)
+ let floorWidth = g.halfWidth * 2.9, floorHeight = 7.0 * g.s
+ let floor = CGRect(x: g.base.x + g.body.lean * g.height * 0.2 - floorWidth / 2,
+ y: g.body.floorY + 0.6 * g.s - floorHeight / 2, width: floorWidth, height: floorHeight)
+ return halo.union(floor).insetBy(dx: -1, dy: -1)
+ }
+
+ /// The halo's opacity stops over its normalised radius, from the body's centre out. `k` is where the
+ /// body's own edge falls.
+ static func haloStops(_ k: Double) -> [(Double, Double)] {
+ let r = 1 - k
+ return [(0, 0.6), (k, 0.42), (k + r * 0.12, 0.24), (k + r * 0.3, 0.1), (k + r * 0.6, 0.035), (1, 0)]
+ }
+
+ /// The halo's opacity at normalised radius `u`, as a share of its peak: the stops, interpolated as
+ /// the gradient interpolates them.
+ static func haloFalloff(_ u: Double, k: Double) -> Double {
+ piecewise(u, haloStops(k))
+ }
+
+ static func floorFalloff(_ u: Double) -> Double {
+ piecewise(u, [(0, 1), (0.5, 0.35), (1, 0)])
+ }
+
+ static func piecewise(_ u: Double, _ stops: [(Double, Double)]) -> Double {
+ guard u < 1 else { return 0 }
+ guard u > 0 else { return stops[0].1 }
+ for i in 1.. 0.01, ry > 0.01 else { return }
+ var l = ctx
+ l.translateBy(x: c.x, y: c.y)
+ if angle != 0 { l.rotate(by: .radians(angle)) }
+ l.scaleBy(x: 1, y: ry / rx)
+ let shape: Path
+ if let region {
+ let local = CGAffineTransform(translationX: c.x, y: c.y).rotated(by: angle).scaledBy(x: 1, y: ry / rx)
+ shape = region.applying(local.inverted())
+ } else {
+ shape = Path(ellipseIn: CGRect(x: -rx, y: -rx, width: 2 * rx, height: 2 * rx))
+ }
+ l.fill(shape, with: .radialGradient(Gradient(stops: stops.map { .init(color: color.color(opacity: $0.1), location: $0.0) }),
+ center: .zero, startRadius: 0, endRadius: rx))
+ }
+
+ /// The body's centre and radii, for the gradient stand-ins.
+ static func ellipse(_ g: PetBodyGeometry) -> (c: CGPoint, rx: Double, ry: Double) {
+ let lo = g.point(u: 0, v: -1), hi = g.point(u: 0, v: 1)
+ return (CGPoint(x: (lo.x + hi.x) / 2, y: (lo.y + hi.y) / 2), g.halfWidth, g.height / 2)
+ }
+
+ // MARK: Body
+
+ static func drawFeet(_ g: PetBodyGeometry, _ pal: PetPalette.Colors, in ctx: inout GraphicsContext) {
+ for side in [-1.0, 1.0] {
+ let f = g.foot(side)
+ guard f.height > 0.2 else { continue }
+ let rect = CGRect(x: f.centre.x - f.width / 2, y: f.centre.y - f.height / 2, width: f.width, height: f.height)
+ ctx.fill(Path(ellipseIn: rect),
+ with: .linearGradient(Gradient(colors: [pal.limb.color(), pal.limbDark.color()]),
+ startPoint: CGPoint(x: f.centre.x, y: rect.minY),
+ endPoint: CGPoint(x: f.centre.x, y: rect.maxY)))
+ }
+ }
+
+ static func drawBodyFill(_ g: PetBodyGeometry, body: Path, _ look: Look, in ctx: inout GraphicsContext) {
+ let pal = look.palette
+ let s = g.s, p = g.body
+ let centre = g.point(u: -0.12, v: 0.05)
+ // The empty shell.
+ ctx.fill(body, with: .radialGradient(Gradient(colors: [pal.dimCore.color(), pal.dimEdge.color()]),
+ center: centre, startRadius: 0, endRadius: g.halfWidth * 1.25))
+ var inside = ctx
+ inside.clip(to: body)
+
+ // The light: a liquid level that stays level with the world and sloshes. 0 puts its surface at
+ // the bottom, 1 just under the crown, above 1 overfull.
+ let bottom = g.scene(0, g.feet).y
+ let top = g.scene(0, g.feet + g.height).y
+ let level = PetCurve.mix(bottom + 1.5 * s, top - 3.5 * s, PetCurve.clamp(p.fullness, 0, 1.25))
+ let x0 = g.base.x - 3 * g.halfWidth, x1 = g.base.x + 3 * g.halfWidth
+ var surface: [CGPoint] = []
+ surface.reserveCapacity(25)
+ for i in 0...24 {
+ let x = PetCurve.mix(x0, x1, Double(i) / 24)
+ let dx = x - g.base.x
+ surface.append(CGPoint(x: x, y: level + tan(p.slosh) * dx + p.wave * s * sin(dx / g.halfWidth * 2.4 + p.wavePhase)))
+ }
+ let belly = g.point(u: -0.1, v: -0.35)
+ inside.fill(body, with: .radialGradient(Gradient(colors: [pal.lightCore.color(), pal.lightMid.color(), pal.lightEdge.color()]),
+ center: belly, startRadius: 0, endRadius: g.halfWidth * 1.35))
+ // The empty shell laid back over the light above its surface, fading in over a few points, so
+ // the edge is soft without a blur.
+ var shell = Path()
+ shell.move(to: CGPoint(x: x0, y: top - 30 * s))
+ for q in surface { shell.addLine(to: q) }
+ shell.addLine(to: CGPoint(x: x1, y: top - 30 * s))
+ shell.closeSubpath()
+ let n = CGPoint(x: sin(p.slosh), y: -cos(p.slosh))
+ let from = CGPoint(x: g.base.x - n.x * 0.6 * s, y: level - n.y * 0.6 * s - 0.6 * s)
+ let span = 4.2 * s * 2.6
+ inside.fill(shell, with: .linearGradient(
+ Gradient(stops: [.init(color: pal.dimCore.color(opacity: 0), location: 0),
+ .init(color: pal.dimCore.color(opacity: 0.8), location: 0.5),
+ .init(color: pal.dimCore.color(opacity: 1), location: 0.75),
+ .init(color: pal.dimEdge.color(opacity: 1), location: 1)]),
+ startPoint: from, endPoint: CGPoint(x: from.x + n.x * span, y: from.y + n.y * span)))
+
+ // The swallowed bite: the food's light, in its dot's colour, dropping through the body into the light.
+ // It shows from the throat down, below the mouth, so the mouth never glows green or red; it leaves
+ // at once and falls faster as it goes, a drop with a push behind it, and the tint stays faint: the
+ // white core is what the eye follows.
+ if p.lump >= 0 && p.lump <= 1 {
+ let throat = g.facePoint(dTheta: 0, dv: -0.62).p
+ let to = CGPoint(x: g.base.x, y: max(level + 1.5 * s, g.point(u: 0, v: -0.6).y))
+ let k = 0.4 * p.lump + 0.6 * p.lump * p.lump
+ let c = CGPoint(x: PetCurve.mix(throat.x, to.x, k), y: PetCurve.mix(throat.y, to.y, k))
+ let r = 3.0 * s
+ let o = PetCurve.ramp(p.lump, 0, 0.12) * (1 - PetCurve.ramp(p.lump, 0.85, 1))
+ let tint = dotColor(look.foodDot)
+ inside.fill(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r * 0.85, width: 2 * r, height: 1.7 * r)),
+ with: .radialGradient(Gradient(stops: [.init(color: tint.opacity(0.38 * o), location: 0),
+ .init(color: tint.opacity(0.2 * o), location: 0.45),
+ .init(color: tint.opacity(0), location: 1)]),
+ center: c, startRadius: 0, endRadius: r))
+ var hot = inside
+ hot.blendMode = .plusLighter
+ let core = 1.1 * s
+ hot.fill(Path(ellipseIn: CGRect(x: c.x - core, y: c.y - core, width: 2 * core, height: 2 * core)),
+ with: .radialGradient(Gradient(colors: [.white.opacity(0.9 * o), .white.opacity(0)]),
+ center: c, startRadius: 0, endRadius: core))
+ }
+ // Volume: the edge of a self-lit jelly is thinner, so darker.
+ let e = ellipse(g)
+ softEllipse(&inside, centre: e.c, rx: e.rx, ry: e.ry * 1.02, angle: p.tilt, color: pal.shade,
+ stops: [(0, 0), (0.64, 0), (0.83, 0.18), (0.94, 0.45), (1, 0.6)], region: body)
+ // Rim light along the top, a soft specular on the dome and its hot spot.
+ let rimTop = g.point(u: -0.2, v: 1.0), rimMid = g.point(u: 0, v: 0.1)
+ let rimOpacity = look.motion.increaseContrast ? PetTuning.Light.contrastRimOpacity / 0.35 * 0.9 : 0.9
+ inside.stroke(body, with: .linearGradient(Gradient(colors: [pal.rim.color(opacity: rimOpacity), pal.rim.color(opacity: 0)]),
+ startPoint: rimTop, endPoint: rimMid), lineWidth: 1.5 * s)
+ if look.motion.increaseContrast {
+ // Increase Contrast: the rim goes all the way round.
+ inside.stroke(body, with: .color(pal.rim.color(opacity: PetTuning.Light.contrastRimOpacity)), lineWidth: 1.0 * s)
+ }
+ if let source = look.foodSource {
+ // A trace of the food source's dot on the rim nearest it, as if the number lit the pet.
+ let near = g.point(u: Double(source.x) < g.base.x ? -1 : 1, v: 0.1)
+ let tint = dotColor(look.foodDot)
+ inside.stroke(body, with: .radialGradient(Gradient(colors: [tint.opacity(PetTuning.Light.dotTint * 4), tint.opacity(0)]),
+ center: near, startRadius: 0, endRadius: g.halfWidth * 1.2),
+ lineWidth: 1.4 * s)
+ }
+ let sheen = g.point(u: -0.46, v: 0.62)
+ softEllipse(&inside, centre: sheen, rx: 2.5 * s, ry: 1.45 * s, angle: -0.55 + p.tilt, color: PetRGB(red: 1, green: 1, blue: 1),
+ stops: [(0, 0.55), (0.5, 0.45), (0.8, 0.15), (1, 0)])
+ let hotSpot = g.point(u: -0.54, v: 0.67)
+ inside.fill(Path(ellipseIn: CGRect(x: hotSpot.x - 0.5 * s, y: hotSpot.y - 0.5 * s, width: s, height: s)),
+ with: .color(.white.opacity(0.75)))
+ }
+
+ /// `EarsView.color(for:)`'s colours: the status dot the food came from.
+ static func dotColor(_ severity: Severity?) -> Color {
+ switch severity {
+ case .ok: return .green
+ case .warning: return .orange
+ case .critical: return .red
+ case nil: return .gray
+ }
+ }
+
+ static func drawCheeks(_ g: PetBodyGeometry, _ pal: PetPalette.Colors, in ctx: inout GraphicsContext) {
+ let s = g.s, p = g.body
+ let o = 0.5 * PetCurve.clamp(p.blush, 0, 1.2)
+ guard o > 0.01 else { return }
+ for side in [-1.0, 1.0] {
+ let f = g.facePoint(dTheta: side * 0.76, dv: -0.21)
+ let puff = 1 + 0.3 * p.cheekPuff * (1 + 0.5 * p.cheekBias * side)
+ let w = 3.2 * s * f.fx * puff, h = 1.9 * s * puff
+ let oo = o * max(0, f.fx) * PetCurve.ramp(f.fx, 0.04, 0.32)
+ guard oo > 0.003 else { continue }
+ softEllipse(&ctx, centre: f.p, rx: w * 0.62 + 0.6 * s, ry: h * 0.62 + 0.6 * s, color: pal.blush,
+ stops: [(0, oo), (0.45, 0.85 * oo), (0.75, 0.35 * oo), (1, 0)])
+ }
+ }
+
+ // MARK: Face
+
+ static func drawEyes(_ g: PetBodyGeometry, _ pal: PetPalette.Colors, in ctx: inout GraphicsContext) {
+ let s = g.s, p = g.body
+ for side in [-1.0, 1.0] {
+ let f = g.facePoint(dTheta: side * 0.37 + p.lookX * 0.05, dv: p.lookY * 0.04)
+ let c = CGPoint(x: f.p.x + p.lookX * 0.5 * s, y: f.p.y - p.lookY * 0.45 * s)
+ let w = 3.3 * s * p.eyeSize * g.featureScaleX * max(0.25, f.fx)
+ let h = 4.5 * s * p.eyeSize * g.featureScaleY
+ let lid = PetEase.clamp01(side < 0 ? p.lidL : p.lidR)
+ // Each feature fades as it turns away round the dome.
+ var e = ctx
+ e.opacity *= PetCurve.ramp(f.fx, 0.04, 0.32)
+ guard e.opacity > 0.001 else { continue }
+ drawEye(centre: c, w: w, h: h, lid: lid, slant: p.lidSlant, happy: PetEase.clamp01(p.happy),
+ squeeze: PetEase.clamp01(p.squeeze), side: side, tilt: p.tilt, s: s, pal, in: &e)
+ }
+ }
+
+ static func drawEye(centre c: CGPoint, w: Double, h: Double, lid: Double, slant: Double, happy: Double, squeeze: Double,
+ side: Double, tilt: Double, s: Double, _ pal: PetPalette.Colors, in ctx: inout GraphicsContext) {
+ let ink = pal.ink.color()
+ let lineWidth = 1.15 * s
+ // Four drawings, cross-faded so nothing pops. The lid's edge reaches 0.3 h, where the shut line
+ // is drawn, at lid 0.2: the cross-fade happens where the two drawings coincide.
+ let wSqueeze = PetCurve.ramp(squeeze, 0.35, 0.6)
+ let wClosed = (1 - wSqueeze) * (1 - PetCurve.ramp(lid, 0.1, 0.24))
+ let wHappy = (1 - wSqueeze) * (1 - wClosed) * PetCurve.ramp(happy, 0.5, 0.72)
+ let wBean = max(0, 1 - wSqueeze - wClosed - wHappy)
+
+ var e = ctx
+ e.translateBy(x: c.x, y: c.y)
+ e.rotate(by: .radians(tilt))
+
+ if wBean > 0.001 {
+ var b = e
+ b.opacity *= wBean
+ let eye = Path(ellipseIn: CGRect(x: -w / 2, y: -h / 2, width: w, height: h))
+ // The top lid comes down as a curve that follows the eyeball; `slant` drops its outer end.
+ // For a happy squint the bottom lid rises instead.
+ let lidY = -h / 2 + (1 - lid) * h
+ let slope = 0.42 * slant * (1 - 0.4 * (1 - lid))
+ let bow = 0.45 * s * (1 - lid) + 0.1 * s
+ func lidEdge(_ x: Double) -> Double { lidY + slope * x * side }
+ var visible = Path()
+ visible.move(to: CGPoint(x: -w, y: lidEdge(-w)))
+ visible.addQuadCurve(to: CGPoint(x: w, y: lidEdge(w)), control: CGPoint(x: 0, y: lidY + 2 * bow))
+ visible.addLine(to: CGPoint(x: w, y: h))
+ visible.addLine(to: CGPoint(x: -w, y: h))
+ visible.closeSubpath()
+ if lid < 0.995 || abs(slant) > 0.01 { b.clip(to: visible) }
+ if happy > 0.001 {
+ let rise = happy * h * 0.95
+ let r = w * 1.05
+ b.clip(to: Path(ellipseIn: CGRect(x: -r, y: h / 2 - rise, width: 2 * r, height: 2 * r)), options: .inverse)
+ }
+ b.fill(eye, with: .color(ink))
+ // Catchlights: the big one up and toward the light, a small one opposite.
+ let big = 0.62 * s * min(1, w / (3.3 * s) + 0.15)
+ b.fill(Path(ellipseIn: CGRect(x: w * 0.12 - big, y: -h * 0.26 - big, width: 2 * big, height: 2 * big)),
+ with: .color(.white.opacity(0.95)))
+ let small = 0.3 * s
+ b.fill(Path(ellipseIn: CGRect(x: -w * 0.2 - small, y: h * 0.2 - small, width: 2 * small, height: 2 * small)),
+ with: .color(.white.opacity(0.7)))
+ if lid < 0.97 {
+ // The lid's edge, a little wider than the eye where it crosses it.
+ let halfW = w / 2 * max(0, 1 - pow(2 * lidY / h, 2)).squareRoot() + 0.45 * s
+ var lp = Path()
+ lp.move(to: CGPoint(x: -halfW, y: lidEdge(-halfW) + bow * 0.25))
+ lp.addQuadCurve(to: CGPoint(x: halfW, y: lidEdge(halfW) + bow * 0.25), control: CGPoint(x: 0, y: lidY + 1.6 * bow))
+ var lc = e
+ lc.opacity *= wBean * PetCurve.ramp(1 - lid, 0.03, 0.15)
+ lc.stroke(lp, with: .color(ink), style: StrokeStyle(lineWidth: 1.0 * s, lineCap: .round))
+ }
+ }
+ if wClosed > 0.001 {
+ // Shut: a soft downward bow.
+ var l = e
+ l.opacity *= wClosed
+ var path = Path()
+ let y0 = h * 0.24 + 0.42 * slant * w * 0.3
+ path.move(to: CGPoint(x: -w * 0.62, y: y0 + 0.42 * slant * (-w * 0.62) * side * 0.6))
+ path.addQuadCurve(to: CGPoint(x: w * 0.62, y: y0 + 0.42 * slant * (w * 0.62) * side * 0.6),
+ control: CGPoint(x: 0, y: h * 0.5))
+ l.stroke(path, with: .color(ink), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
+ }
+ if wHappy > 0.001 {
+ // Happy: an upward arc.
+ var l = e
+ l.opacity *= wHappy
+ var path = Path()
+ path.move(to: CGPoint(x: -w * 0.62, y: h * 0.12))
+ path.addQuadCurve(to: CGPoint(x: w * 0.62, y: h * 0.12), control: CGPoint(x: 0, y: -h * 0.42))
+ l.stroke(path, with: .color(ink), style: StrokeStyle(lineWidth: lineWidth * 1.05, lineCap: .round))
+ }
+ if wSqueeze > 0.001 {
+ // Squeezed shut with effort: a chevron pointing at the nose.
+ var l = e
+ l.opacity *= wSqueeze
+ let d = -side
+ var path = Path()
+ path.move(to: CGPoint(x: -d * w * 0.55, y: -h * 0.3))
+ path.addLine(to: CGPoint(x: d * w * 0.45, y: 0))
+ path.addLine(to: CGPoint(x: -d * w * 0.55, y: h * 0.3))
+ l.stroke(path, with: .color(ink), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
+ }
+ }
+
+ static func drawBrows(_ g: PetBodyGeometry, _ pal: PetPalette.Colors, in ctx: inout GraphicsContext) {
+ let s = g.s, p = g.body
+ // Below scale 1 the brows grow a little bolder: at 0.80 and 0.82 they carry the face, since at that
+ // size the angle of the brows is what still shows the mood.
+ let bold = 1 + 0.25 * PetCurve.clamp((1 - s) / 0.2, 0, 1)
+ for side in [-1.0, 1.0] {
+ let f = g.facePoint(dTheta: side * 0.40, dv: 0.40)
+ let lift = (side < 0 ? p.browLiftL : p.browLiftR) * s
+ let tiltIn = side < 0 ? p.browTiltL : p.browTiltR
+ let length = 5.0 * s * max(0.3, f.fx) * g.featureScaleX
+ let c = CGPoint(x: f.p.x, y: f.p.y - lift)
+ let inward = -side
+ let angle = tiltIn + p.tilt * inward
+ let dx = cos(angle) * length / 2, dy = sin(angle) * length / 2
+ let inner = CGPoint(x: c.x + inward * dx, y: c.y - dy)
+ let outer = CGPoint(x: c.x - inward * dx, y: c.y + dy + 0.35 * s)
+ let arch = (0.75 - 0.9 * max(0, tiltIn)) * s
+ let control = CGPoint(x: (inner.x + outer.x) / 2, y: (inner.y + outer.y) / 2 - arch)
+ // A brush stroke: thick at the inner end, tapering outward, round at both ends.
+ let thick = g.featureScaleY * s * bold
+ ctx.fill(taperedStroke(from: outer, control: control, to: inner, w0: 0.6 * thick, w1: 1.02 * thick),
+ with: .color(pal.ink.color(opacity: PetCurve.ramp(f.fx, 0.04, 0.32))))
+ }
+ }
+
+ /// A quadratic curve outlined with a half-width going from `w0` to `w1`, with round caps.
+ static func taperedStroke(from a: CGPoint, control c: CGPoint, to b: CGPoint, w0: Double, w1: Double) -> Path {
+ let n = 12
+ func q(_ u: Double) -> CGPoint {
+ let v = 1 - u
+ return CGPoint(x: v * v * a.x + 2 * v * u * c.x + u * u * b.x, y: v * v * a.y + 2 * v * u * c.y + u * u * b.y)
+ }
+ func d(_ u: Double) -> CGPoint {
+ let x = 2 * (1 - u) * (c.x - a.x) + 2 * u * (b.x - c.x), y = 2 * (1 - u) * (c.y - a.y) + 2 * u * (b.y - c.y)
+ let l = max(hypot(x, y), 1e-6)
+ return CGPoint(x: x / l, y: y / l)
+ }
+ var left: [CGPoint] = [], right: [CGPoint] = []
+ for i in 0...n {
+ let u = Double(i) / Double(n)
+ let p = q(u), t = d(u), normal = CGPoint(x: -t.y, y: t.x)
+ let w = PetCurve.mix(w0, w1, u)
+ left.append(CGPoint(x: p.x + normal.x * w, y: p.y + normal.y * w))
+ right.append(CGPoint(x: p.x - normal.x * w, y: p.y - normal.y * w))
+ }
+ var path = Path()
+ path.move(to: left[0])
+ for pt in left.dropFirst() { path.addLine(to: pt) }
+ let tb = d(1), nb = CGPoint(x: -tb.y, y: tb.x)
+ for k in 1..<8 {
+ let phi = Double.pi * Double(k) / 8
+ path.addLine(to: CGPoint(x: b.x + w1 * (cos(phi) * nb.x + sin(phi) * tb.x), y: b.y + w1 * (cos(phi) * nb.y + sin(phi) * tb.y)))
+ }
+ for pt in right.reversed() { path.addLine(to: pt) }
+ let ta = d(0), na = CGPoint(x: -ta.y, y: ta.x)
+ for k in 1..<8 {
+ let phi = Double.pi * Double(k) / 8
+ path.addLine(to: CGPoint(x: a.x + w0 * (-cos(phi) * na.x - sin(phi) * ta.x), y: a.y + w0 * (-cos(phi) * na.y - sin(phi) * ta.y)))
+ }
+ path.closeSubpath()
+ return path
+ }
+
+ static func drawMouth(_ g: PetBodyGeometry, _ pal: PetPalette.Colors, in ctx: inout GraphicsContext) {
+ let s = g.s, p = g.body
+ let f = g.facePoint(dTheta: 0, dv: -0.36)
+ let open = PetCurve.clamp(p.mouthOpen, 0, 1.2)
+ let w = (3.4 + 6.2 * open) * s * p.mouthWide * max(0.35, f.fx) * g.featureScaleX
+ let depth = 6.6 * open * s * g.featureScaleY
+ let smileK = p.smile * 1.25 * s * (1 - 0.55 * min(1, open))
+ var m = ctx
+ m.opacity *= PetCurve.ramp(f.fx, 0.04, 0.32)
+ m.translateBy(x: f.p.x, y: f.p.y)
+ m.rotate(by: .radians(p.tilt))
+ let left = CGPoint(x: -w / 2, y: -smileK), right = CGPoint(x: w / 2, y: -smileK)
+ let lineWidth = 1.05 * s
+ let ink = pal.ink.color()
+ if open < 0.035 {
+ var path = Path()
+ if p.chew > 0.01 {
+ // A working mouth: the line ripples.
+ for i in 0...10 {
+ let u = Double(i) / 10
+ let x = PetCurve.mix(left.x, right.x, u)
+ let y = -smileK + 4 * smileK * u * (1 - u) + p.chew * 0.6 * s * sin(u * 2 * .pi + p.chewPhase)
+ if i == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) }
+ }
+ } else {
+ path.move(to: left)
+ path.addQuadCurve(to: right, control: CGPoint(x: 0, y: smileK))
+ }
+ m.stroke(path, with: .color(ink), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
+ } else {
+ var path = Path()
+ path.move(to: left)
+ path.addQuadCurve(to: right, control: CGPoint(x: 0, y: -smileK * 0.35 - depth * 0.14))
+ path.addCurve(to: left, control1: CGPoint(x: right.x - w * 0.06, y: right.y + depth * 1.33),
+ control2: CGPoint(x: left.x + w * 0.06, y: left.y + depth * 1.33))
+ path.closeSubpath()
+ m.fill(path, with: .color(pal.mouth.color()))
+ var t = m
+ t.clip(to: path)
+ let ty = -smileK + depth * 0.95
+ t.fill(Path(ellipseIn: CGRect(x: -w * 0.24, y: ty - depth * 0.32, width: w * 0.48, height: depth * 0.6)),
+ with: .color(pal.tongue.color()))
+ m.stroke(path, with: .color(ink), style: StrokeStyle(lineWidth: lineWidth * 0.85, lineJoin: .round))
+ }
+ if p.tongue > 0.01 {
+ // A lick at the corner nearest the food.
+ let corner = p.turn <= 0 ? left : right
+ let dir = p.turn <= 0 ? -1.0 : 1.0
+ let k = p.tongue
+ let c = CGPoint(x: corner.x + dir * 0.7 * s * k, y: corner.y + 0.55 * s)
+ let tw = 1.9 * s * k, th = 1.5 * s * k
+ m.fill(Path(ellipseIn: CGRect(x: c.x - tw / 2, y: c.y - th / 2, width: tw, height: th)), with: .color(pal.tongue.color()))
+ }
+ }
+
+ // MARK: Arms
+
+ static func drawArms(_ g: PetBodyGeometry, front: Bool, _ pal: PetPalette.Colors, in ctx: inout GraphicsContext) {
+ let s = g.s
+ for side in [-1.0, 1.0] {
+ let a = g.arm(side)
+ guard a.front == front else { continue }
+ var l = ctx
+ l.translateBy(x: a.centre.x, y: a.centre.y)
+ l.rotate(by: .radians(a.angle))
+ let arm = Path(ellipseIn: CGRect(x: -a.length / 2, y: -a.thickness / 2, width: a.length, height: a.thickness))
+ l.fill(arm, with: .linearGradient(Gradient(colors: [pal.lightMid.color(), pal.limb.color(), pal.limbDark.color()]),
+ startPoint: CGPoint(x: 0, y: -a.thickness / 2), endPoint: CGPoint(x: 0, y: a.thickness / 2)))
+ // A soft dark edge, so an arm reads against the bright body when it crosses it.
+ l.stroke(arm, with: .color(pal.shade.color(opacity: 0.42)), lineWidth: 0.55 * s)
+ }
+ }
+
+ // MARK: - Particles and the food
+
+ static func tint(_ t: PetParticle.Tint, _ pal: PetPalette.Colors) -> Color {
+ switch t {
+ case .crumbLight: return pal.crumbLight.color()
+ case .crumbWarm: return pal.crumbWarm.color()
+ case .sparkle: return pal.sparkle.color()
+ case .zzz: return pal.zzz.color()
+ case .ring: return pal.ring.color()
+ case .rumble: return pal.rumble.color()
+ case .bubble: return pal.bubble.color()
+ case .burst: return pal.burst.color()
+ case .dot(let severity): return dotColor(severity)
+ }
+ }
+
+ static func drawParticles(_ particles: [PetParticle], opacity: Double, _ look: Look, in ctx: inout GraphicsContext) {
+ let pal = look.palette
+ for q in particles where q.opacity > 0.003 {
+ var c = ctx
+ c.opacity *= PetEase.clamp01(opacity)
+ let color = tint(q.tint, pal)
+ let o = PetEase.clamp01(q.opacity)
+ switch q.kind {
+ case .crumb:
+ c.blendMode = .plusLighter
+ let r = q.size
+ if abs(q.streak.dx) + abs(q.streak.dy) > 0.3 {
+ var p = Path()
+ p.move(to: CGPoint(x: q.position.x + q.streak.dx, y: q.position.y + q.streak.dy))
+ p.addLine(to: q.position)
+ c.stroke(p, with: .color(color.opacity(o * 0.55)), style: StrokeStyle(lineWidth: r * 1.5, lineCap: .round))
+ }
+ c.fill(Path(ellipseIn: CGRect(x: q.position.x - r, y: q.position.y - r, width: 2 * r, height: 2 * r)),
+ with: .color(color.opacity(o)))
+ case .sparkle:
+ c.blendMode = .plusLighter
+ if !look.motion.reduceTransparency {
+ let halo = q.size * 1.7
+ c.fill(Path(ellipseIn: CGRect(x: q.position.x - halo, y: q.position.y - halo, width: 2 * halo, height: 2 * halo)),
+ with: .radialGradient(Gradient(colors: [color.opacity(0.35 * o), color.opacity(0)]),
+ center: q.position, startRadius: 0, endRadius: halo))
+ }
+ c.translateBy(x: q.position.x, y: q.position.y)
+ c.rotate(by: .radians(q.angle))
+ c.fill(star(q.size), with: .color(color.opacity(o)))
+ case .zzz:
+ c.translateBy(x: q.position.x, y: q.position.y)
+ c.rotate(by: .radians(q.angle))
+ let w = q.size, h = q.size
+ var p = Path()
+ p.move(to: CGPoint(x: -w / 2, y: -h / 2))
+ p.addLine(to: CGPoint(x: w / 2, y: -h / 2))
+ p.addLine(to: CGPoint(x: -w / 2, y: h / 2))
+ p.addLine(to: CGPoint(x: w / 2, y: h / 2))
+ if !look.motion.reduceTransparency {
+ c.stroke(p, with: .color(color.opacity(o * 0.22)),
+ style: StrokeStyle(lineWidth: max(0.8, q.size * 0.2) + 1.4, lineCap: .round, lineJoin: .round))
+ }
+ c.stroke(p, with: .color(color.opacity(o)),
+ style: StrokeStyle(lineWidth: max(0.8, q.size * 0.2), lineCap: .round, lineJoin: .round))
+ case .ring:
+ // A soft pulse of light: a radial gradient that is bright only in a band, not a drawn circle.
+ c.blendMode = .plusLighter
+ let r = q.size
+ c.translateBy(x: q.position.x, y: q.position.y)
+ c.scaleBy(x: 1, y: 0.82)
+ c.fill(Path(ellipseIn: CGRect(x: -r - 1.5, y: -r - 1.5, width: 2 * r + 3, height: 2 * r + 3)),
+ with: .radialGradient(Gradient(stops: [.init(color: color.opacity(0), location: 0),
+ .init(color: color.opacity(0), location: 0.62),
+ .init(color: color.opacity(o), location: 0.86),
+ .init(color: color.opacity(0), location: 1)]),
+ center: .zero, startRadius: 0, endRadius: r + 1.5))
+ case .rumble:
+ c.translateBy(x: q.position.x, y: q.position.y)
+ c.rotate(by: .radians(q.angle))
+ var p = Path()
+ p.move(to: CGPoint(x: 0, y: -q.size))
+ p.addQuadCurve(to: CGPoint(x: 0, y: q.size), control: CGPoint(x: q.size * 0.7, y: 0))
+ c.stroke(p, with: .color(color.opacity(o)), style: StrokeStyle(lineWidth: 0.8, lineCap: .round))
+ case .bubble:
+ c.blendMode = .plusLighter
+ let r = q.size
+ c.stroke(Path(ellipseIn: CGRect(x: q.position.x - r, y: q.position.y - r, width: 2 * r, height: 2 * r)),
+ with: .color(color.opacity(o)), lineWidth: 0.6)
+ c.fill(Path(ellipseIn: CGRect(x: q.position.x - r * 0.45, y: q.position.y - r * 0.6, width: r * 0.5, height: r * 0.5)),
+ with: .color(.white.opacity(o * 0.8)))
+ case .mote:
+ c.blendMode = .plusLighter
+ let r = q.size
+ c.fill(Path(ellipseIn: CGRect(x: q.position.x - 2 * r, y: q.position.y - 2 * r, width: 4 * r, height: 4 * r)),
+ with: .radialGradient(Gradient(colors: [color.opacity(o), color.opacity(0.3 * o), color.opacity(0)]),
+ center: q.position, startRadius: 0, endRadius: 2 * r))
+ }
+ }
+ }
+
+ /// A four-point star of half-span `size`.
+ static func star(_ size: Double) -> Path {
+ let l = size, w = size * 0.22
+ var p = Path()
+ p.move(to: CGPoint(x: 0, y: -l))
+ p.addQuadCurve(to: CGPoint(x: l, y: 0), control: CGPoint(x: w, y: -w))
+ p.addQuadCurve(to: CGPoint(x: 0, y: l), control: CGPoint(x: w, y: w))
+ p.addQuadCurve(to: CGPoint(x: -l, y: 0), control: CGPoint(x: -w, y: w))
+ p.addQuadCurve(to: CGPoint(x: 0, y: -l), control: CGPoint(x: -w, y: -w))
+ return p
+ }
+
+ static func drawBead(_ b: PetBead, _ look: Look, in ctx: inout GraphicsContext) {
+ let pal = look.palette
+ guard b.opacity > 0.003 else { return }
+ var g = ctx
+ g.blendMode = .plusLighter
+ let warm = pal.crumbWarm.color()
+ let dot = dotColor(b.dot)
+ // The streak: the bead's last few hundredths of a second, thinning and fading.
+ var previous = b.position
+ let n = Double(max(1, b.trail.count))
+ for (k, q) in b.trail.enumerated() {
+ let f = 1 - Double(k) / n
+ var segment = Path()
+ segment.move(to: previous)
+ segment.addLine(to: q)
+ g.stroke(segment, with: .color(warm.opacity(0.42 * f * f * b.opacity)),
+ style: StrokeStyle(lineWidth: 2 * b.radius * (0.25 + 0.75 * f), lineCap: .round))
+ previous = q
+ }
+ if !look.motion.reduceTransparency {
+ let gr = b.radius * 3.6
+ g.fill(Path(ellipseIn: CGRect(x: b.position.x - gr, y: b.position.y - gr, width: 2 * gr, height: 2 * gr)),
+ with: .radialGradient(Gradient(colors: [dot.opacity(0.30 * b.opacity), warm.opacity(0.10 * b.opacity), warm.opacity(0)]),
+ center: b.position, startRadius: 0, endRadius: gr))
+ }
+ var s = ctx
+ s.translateBy(x: b.position.x, y: b.position.y)
+ s.rotate(by: .radians(b.angle))
+ s.scaleBy(x: b.stretch, y: 1 / b.stretch)
+ let r = b.radius
+ s.fill(Path(ellipseIn: CGRect(x: -r, y: -r, width: 2 * r, height: 2 * r)),
+ with: .radialGradient(Gradient(colors: [.white.opacity(b.opacity), pal.crumbLight.color().opacity(b.opacity),
+ warm.opacity(b.opacity)]),
+ center: CGPoint(x: -r * 0.25, y: -r * 0.3), startRadius: 0, endRadius: r * 1.1))
+ }
+
+ static func drawGlint(_ glint: PetGlint, _ look: Look, in ctx: inout GraphicsContext) {
+ guard glint.opacity > 0.003 else { return }
+ var c = ctx
+ c.blendMode = .plusLighter
+ let color = look.palette.sparkle.color()
+ if !look.motion.reduceTransparency {
+ // Faint: it lies over the digit, and a brighter one greys the black inside the glyph.
+ let halo = glint.size * 1.4
+ c.fill(Path(ellipseIn: CGRect(x: glint.position.x - halo, y: glint.position.y - halo, width: 2 * halo, height: 2 * halo)),
+ with: .radialGradient(Gradient(colors: [color.opacity(0.10 * glint.opacity), color.opacity(0)]),
+ center: glint.position, startRadius: 0, endRadius: halo))
+ }
+ c.translateBy(x: glint.position.x, y: glint.position.y)
+ c.rotate(by: .radians(glint.angle))
+ c.fill(star(glint.size), with: .color(color.opacity(glint.opacity)))
+ }
+
+ static func drawDoorLight(_ door: PetDoorLight, _ look: Look, in ctx: inout GraphicsContext) {
+ let pal = look.palette
+ let o = door.opacity * pal.glowGain
+ guard o > 0.003 else { return }
+ let rect = CGRect(x: door.minX, y: door.y, width: door.maxX - door.minX, height: PetTuning.Den.doorLight)
+ ctx.fill(Path(rect), with: .linearGradient(Gradient(stops: [.init(color: pal.glow.color(opacity: 0), location: 0),
+ .init(color: pal.glow.color(opacity: o), location: 0.3),
+ .init(color: pal.glow.color(opacity: o), location: 0.7),
+ .init(color: pal.glow.color(opacity: 0), location: 1)]),
+ startPoint: CGPoint(x: door.minX, y: door.y), endPoint: CGPoint(x: door.maxX, y: door.y)))
+ if !look.motion.reduceTransparency {
+ // A faint spill under the line.
+ let spill = CGRect(x: door.minX, y: door.y, width: door.maxX - door.minX, height: 3)
+ ctx.fill(Path(spill), with: .linearGradient(Gradient(colors: [pal.glow.color(opacity: 0.35 * o), pal.glow.color(opacity: 0)]),
+ startPoint: CGPoint(x: 0, y: door.y), endPoint: CGPoint(x: 0, y: door.y + 3)))
+ }
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetController.swift b/Sources/BrowKit/Pet/PetController.swift
new file mode 100644
index 0000000..e895a22
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetController.swift
@@ -0,0 +1,676 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import AppKit
+import Combine
+import Foundation
+
+// The pet in the app: the one place that reads the clocks, hears Brow and the Mac, keeps the brain, and
+// hands the layer and the panel what they draw. The brain and the rig stay pure; every side effect of
+// the pet lives here or in `PetSenses`.
+
+// MARK: - What the views read
+
+/// Everything the pet layer poses a frame from, held for it by `PetController` and read by
+/// `PetLayerView` at every tick of its `TimelineView`. Only what should rebuild the view is published:
+/// the frame rate, a redraw while paused, and the VoiceOver value. The timeline, the tracks and the
+/// zones change far more often than that, and every tick reads them afresh anyway.
+@MainActor
+public final class PetLayer: ObservableObject {
+ /// The `TimelineView`'s schedule. It changes at behaviour boundaries and gate changes, a few times a
+ /// minute.
+ @Published public internal(set) var rate: PetFrameRate = .paused
+ /// Bumped to draw one more frame while paused: something changed under a frame that would
+ /// otherwise stay on screen as it is (new zones, a new plan, the pet turned off).
+ @Published public internal(set) var redraws = 0
+ /// The strip element's VoiceOver value.
+ @Published public internal(set) var accessibilityValue = ""
+ /// `showPet`: off, the strip has no pet element for VoiceOver.
+ @Published public internal(set) var isOn = true
+
+ var timeline = PetTimeline()
+ var pointer = PetPointerTrack()
+ var dock = PetDockTrack()
+ var zones: PetZones?
+ var seed: UInt64 = 0
+ var motion = PetMotion()
+ var palette: PetPalette = .standard
+ /// The instant a paused layer shows: where the behaviour it rests in has settled, so the frame left
+ /// on screen is the doze or the den, not the first frame of a blend into it.
+ var frozenAt: TimeInterval?
+ /// The rig's clock, `ProcessInfo.systemUptime`: never `TimelineView`'s `context.date`, which is wall
+ /// time. Offscreen renders pin it.
+ var now: () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
+ /// `-petPerfLog`'s counters; nil unless it was asked for.
+ var perf: PetPerfLog?
+ let rig = PetRig()
+
+ public init() {}
+
+ /// The frame to draw now, nil for none.
+ func frame() -> PetFrame? {
+ frame(at: rate.paused ? max(now(), frozenAt ?? -.infinity) : now())
+ }
+
+ func frame(at t: TimeInterval) -> PetFrame? {
+ // Before the brain has heard the readings it has planned nothing, and the rig poses an empty
+ // timeline as a content pet at the anchor: nothing is drawn until there is a plan.
+ guard !timeline.segments.isEmpty, let zones else { return nil }
+ let frame = rig.pose(timeline, at: t, pointer: pointer, dock: dock, zones: zones, seed: seed, motion: motion)
+ return frame.isEmpty ? nil : frame
+ }
+
+ /// The frame class of what plays at `t`, for `-petPerfLog`.
+ func frameClass(at t: TimeInterval) -> PetFrameClass {
+ guard let segment = timeline.segments.last(where: { $0.start <= t }) else { return .off }
+ return segment.behaviour.frameClass(elapsed: t - segment.start)
+ }
+}
+
+/// What the panel's header row shows: the state word, the detail line, the moment line, the meter and
+/// the VoiceOver value. `PetController` publishes them at most once a second, and only while someone can
+/// read them: the panel open, or VoiceOver running.
+@MainActor
+public final class PetPanelModel: ObservableObject {
+ @Published public internal(set) var header = PetText.Header(word: "", detail: "", moment: nil)
+ /// 0 to 100.
+ @Published public internal(set) var meter: Double = 0
+ @Published public internal(set) var accessibilityValue = ""
+ /// `PanelView` reports where the Overall line's "5h" figure is, in window coordinates: where the
+ /// pet's food comes from in the panel.
+ var fiveHourFrameChanged: (CGRect) -> Void = { _ in }
+ /// VoiceOver's "Pet" action on the header.
+ var boop: () -> Void = {}
+
+ public init() {}
+}
+
+// MARK: - The controller
+
+/// Wires the store, the senses, the brain, the frame policy and the layer. It reads both clocks once per
+/// event, sends the brain what Brow and the Mac say, sets one timer for whatever is due next, keeps
+/// `pet.json`, and publishes only coarse state: the frame rate when it changes, and the words at most
+/// once a second.
+@MainActor
+public final class PetController {
+ public let layer = PetLayer()
+ public let panel = PetPanelModel()
+
+ private let store: LimitsStore
+ private let petStore: PetStore
+ private let clock: () -> PetTime
+ private let usesSenses: Bool
+ private let defaults: UserDefaults
+ private(set) var brain: PetBrain
+ private var saves = PetSaveSchedule()
+ /// What `pet.json` was last written with, so a save that would change nothing but `savedAt` is
+ /// skipped.
+ private var lastWritten: PetSave?
+ private var senses: PetSenses?
+ private var watchers: PetHistoryWatchers?
+ private var cancellables: Set = []
+ private var voiceOverObservation: NSKeyValueObservation?
+
+ // What the controller last heard, mirrored for the gates, `.visible` and the zones.
+ private var started = false
+ /// `isOn`: the pet's own switch, and Brow in the notch.
+ private(set) var showPet = true
+ private var showEars = true
+ private(set) var expanded = false
+ private var windowVisible = true
+ private var presence = PetPresence()
+ private var systemAwake = true
+ private var power = PetPower()
+ private var motion = PetMotion()
+ /// What the brain was last told, so a change is sent once.
+ private var sentVisible: Bool?
+ private var sentAccounts: [PetAccount]?
+ private var names: [String: String] = [:]
+ private var watchedDirs: [String] = []
+ private var layout: Layout?
+ private var fiveHourFrame: CGRect?
+ /// When the last expand, collapse or relayout began, and how long its frames run.
+ private var dockMotionUntil: TimeInterval = -.infinity
+ private var timer: DispatchSourceTimer?
+ /// The `PetTime.mono` the timer is set for, for the tests; nil with no timer.
+ private(set) var timerDue: TimeInterval?
+ private var wordsTimer: Timer?
+ private var lastEarlyCheck: TimeInterval = -.infinity
+
+ /// - Parameters:
+ /// - petStore: where `pet.json` lives; tests pass a temporary directory.
+ /// - clock: both clocks, read once per event.
+ /// - senses: false in tests, which send what the Mac would say themselves: no notification
+ /// observers, no presence timer, no file watchers.
+ public init(store: LimitsStore, petStore: PetStore = PetStore(), clock: @escaping () -> PetTime = { PetTime.now() },
+ senses: Bool = true, defaults: UserDefaults = .standard) {
+ self.store = store
+ self.petStore = petStore
+ self.clock = clock
+ self.usesSenses = senses
+ self.defaults = defaults
+ let (save, origin) = petStore.load(now: clock().wall)
+ brain = PetBrain(restoring: save)
+ if origin == .unreadable { BrowLog.pet.info("starting a new pet; pet.json was unreadable") }
+ layer.seed = brain.seed
+ if defaults.bool(forKey: "petPerfLog") { layer.perf = PetPerfLog() }
+ panel.fiveHourFrameChanged = { [weak self] in self?.fiveHourFrame(changed: $0) }
+ panel.boop = { [weak self] in self?.boop() }
+ }
+
+ // MARK: Launch and quit
+
+ /// Sends the brain the whole state in one main-actor turn: the setting, the
+ /// senses' first readings, whether the pet can be seen, the accounts. Called once the panel is on
+ /// screen, so the first frame has its zones.
+ public func start() {
+ guard !started else { return }
+ started = true
+ showPet = Self.isOn(store.settings)
+ showEars = store.settings.showEars
+ layer.isOn = showPet
+ handle(.showPet(showPet))
+ if showPet { startSensing() }
+ updateVisible()
+ sendReadings(force: true)
+ // `-petForceState idle|asleep|hibernating` pins the condition for a measurement.
+ if let raw = defaults.string(forKey: "petForceState") {
+ if let state = PetForcedState(rawValue: raw) {
+ handle(.forceState(state))
+ } else {
+ BrowLog.pet.error("-petForceState \(raw, privacy: .public) is not idle, asleep or hibernating; ignored")
+ }
+ }
+ // `objectWillChange` fires before the values change: read them a turn later, as the panel and
+ // `LimitNotifier` do.
+ store.objectWillChange.sink { [weak self] _ in
+ Task { @MainActor in self?.storeChanged() }
+ }.store(in: &cancellables)
+ if usesSenses {
+ voiceOverObservation = NSWorkspace.shared.observe(\.isVoiceOverEnabled) { [weak self] _, _ in
+ Task { @MainActor in self?.updateWordsTimer() }
+ }
+ layer.perf?.start()
+ }
+ updateWordsTimer()
+ }
+
+ /// Writes `pet.json` whatever the schedule says: Brow is quitting.
+ public func flush() {
+ save(at: clock())
+ timer?.cancel()
+ timer = nil
+ timerDue = nil
+ wordsTimer?.invalidate()
+ wordsTimer = nil
+ senses?.stop()
+ watchers?.stop()
+ layer.perf?.stop()
+ cancellables.removeAll()
+ }
+
+ // MARK: From the panel controller
+
+ /// `NotchPanelController.render()`: the frames and readouts the strip and the panel are drawn with.
+ /// A new placement, display or `showEars` is a relayout: the pet fades out at the old place and in
+ /// at the new one. A change of readout width is not: the zone follows the digits.
+ public func layout(frames: NotchFrames, aggregate: LimitsAggregate, showEars: Bool) {
+ let new = Layout(frames: frames, aggregate: aggregate, showEars: showEars)
+ let old = layout
+ layout = new
+ let zones = PetLayout.zones(frames: frames, aggregate: aggregate, showEars: showEars, fiveHour: fiveHourFrame)
+ let now = clock()
+ if let old, let oldZones = layer.zones, old.key != new.key {
+ appendDock(PetDockChange(t: now.mono, kind: .relayout(from: oldZones)), at: now.mono)
+ dockMotionUntil = max(dockMotionUntil, now.mono + PetTuning.Hop.relayoutFadeOut + PetTuning.Hop.relayoutFadeIn)
+ }
+ let changed = zones != layer.zones
+ layer.zones = zones
+ if showEars != self.showEars {
+ self.showEars = showEars
+ updateVisible()
+ }
+ if changed { afterChange(at: now, redraw: true) }
+ }
+
+ /// The panel's "5h" figure moved (`PanelView`'s preference).
+ func fiveHourFrame(changed rect: CGRect) {
+ guard rect != fiveHourFrame, rect.width > 0 else { return }
+ fiveHourFrame = rect
+ if let layout { self.layout(frames: layout.frames, aggregate: layout.aggregate, showEars: layout.showEars) }
+ }
+
+ /// `NotchPanelController.mouseMoved`, the pointer in window coordinates. The rig follows the track;
+ /// the brain hears the distance from the eyes of the zone the pet is in.
+ public func pointerMoved(to location: CGPoint) {
+ guard showPet else { return }
+ let now = clock()
+ layer.pointer.samples.append(PetPointerSample(t: now.mono, location: location))
+ // The last 2 s and the one sample before them, for the gaze springs to start from.
+ let horizon = now.mono - PetPointerTrack.history
+ if let keep = layer.pointer.samples.lastIndex(where: { $0.t <= horizon }), keep > 0 {
+ layer.pointer.samples.removeFirst(keep)
+ }
+ // A moving pointer is someone at the Mac: ask the senses now rather than at the next 5 s check,
+ // so a settled or dozing pet answers the hand that came back, not five seconds later.
+ if presence.inputIdle >= PetTuning.Presence.settled, now.mono - lastEarlyCheck >= 1 {
+ lastEarlyCheck = now.mono
+ senses?.checkPresence()
+ }
+ guard let eyes = eyes() else { return }
+ let distance = hypot(Double(location.x - eyes.x), Double(location.y - eyes.y))
+ handle(.pointer(distance: distance), at: now)
+ }
+
+ /// `setExpanded`, at the instant the outline's spring starts: the hop, glide or crossfade.
+ public func dockChanged(expanded: Bool) {
+ guard expanded != self.expanded else { return }
+ self.expanded = expanded
+ let now = clock()
+ appendDock(PetDockChange(t: now.mono, kind: expanded ? .expand : .collapse), at: now.mono)
+ // The crouch, the flight, the landing and the spring settle within a second; the readouts-off
+ // fade and Reduce Motion's crossfade are shorter.
+ dockMotionUntil = now.mono + Self.dockFrames
+ updateVisible()
+ if expanded { publishWords(at: now) }
+ updateWordsTimer()
+ afterChange(at: now, redraw: true)
+ }
+
+ /// The panel window's occlusion state.
+ public func windowVisibility(_ visible: Bool) {
+ guard visible != windowVisible else { return }
+ windowVisible = visible
+ updateVisible()
+ afterChange(at: clock())
+ }
+
+ /// A click in the open panel, in window coordinates: a boop when it lands on the pet's body or
+ /// within `PetTuning.Boop.hitSlop` of it.
+ @discardableResult
+ public func click(at point: CGPoint) -> Bool {
+ guard showPet, expanded, let frame = layer.frame(at: clock().mono) else { return false }
+ let slop = PetTuning.Boop.hitSlop
+ guard frame.bodyBox.insetBy(dx: -slop, dy: -slop).contains(point) else { return false }
+ boop()
+ return true
+ }
+
+ /// A boop: a click on the pet, or VoiceOver's "Pet" action.
+ public func boop() {
+ guard showPet else { return }
+ handle(.boop)
+ }
+
+ // MARK: From the store
+
+ private func storeChanged() {
+ let on = Self.isOn(store.settings)
+ if on != showPet {
+ showPet = on
+ layer.isOn = showPet
+ handle(.showPet(showPet))
+ if showPet {
+ startSensing()
+ // On again, the data is judged afresh: what was used while it was off is a catch-up meal.
+ sendReadings(force: true)
+ } else {
+ stopSensing()
+ }
+ updateWordsTimer()
+ }
+ sendReadings()
+ }
+
+ /// Whether the pet runs: its own switch, and Brow in the notch at all. Out of the notch there is
+ /// nowhere to see it, so it is off as if its switch were: no frames, no timers, no file watchers,
+ /// `pet.json` kept, and the time it was off is away time.
+ static func isOn(_ settings: BrowSettings) -> Bool {
+ settings.showPet && settings.showNotch
+ }
+
+ /// The visible accounts, when they differ from what the brain last heard. With no row yet and no
+ /// scan landed, nothing: an empty list is then "not looked yet", and telling the brain "no
+ /// accounts" would send a new pet into the den for its no-accounts intro seconds before its
+ /// accounts arrive. The brain waits, and so does the pet.
+ private func sendReadings(force: Bool = false) {
+ let rows = store.rows
+ guard !rows.isEmpty || store.hasScanned else { return }
+ names = Dictionary(rows.map { ($0.id, $0.name) }, uniquingKeysWith: { first, _ in first })
+ let dirs = rows.flatMap { [$0.account.configDir] + $0.account.aliasDirs }
+ if dirs != watchedDirs, showPet {
+ watchedDirs = dirs
+ watchers?.watch(configDirs: dirs)
+ }
+ let accounts = rows.map(PetAccount.init(row:))
+ guard force || accounts != sentAccounts else { return }
+ sentAccounts = accounts
+ handle(.readings(accounts))
+ }
+
+ // MARK: The senses
+
+ private func startSensing() {
+ guard usesSenses else { return }
+ if senses == nil {
+ senses = PetSenses(onEvent: { [weak self] event, time in self?.sensed(event, at: time) },
+ onPower: { [weak self] power in self?.powerChanged(power) })
+ }
+ if watchers == nil {
+ watchers = PetHistoryWatchers(onPrompt: { [weak self] in self?.handle(.prompt) })
+ }
+ watchedDirs = []
+ senses?.start()
+ }
+
+ private func stopSensing() {
+ senses?.stop()
+ watchers?.stop()
+ watchedDirs = []
+ }
+
+ /// What `PetSenses` heard, stamped with the instant it heard it.
+ func sensed(_ event: PetEvent, at time: PetTime) {
+ switch event {
+ case .presence(let p): presence = p
+ case .motion(let m): motion = m; layer.motion = m
+ case .systemSleep: systemAwake = false
+ case .systemWake: systemAwake = true
+ default: break
+ }
+ handle(event, at: time)
+ updateVisible()
+ if case .systemSleep = event { save(at: time) }
+ }
+
+ private func powerChanged(_ power: PetPower) {
+ self.power = power
+ refreshRate(at: clock())
+ }
+
+ // MARK: The brain
+
+ private func handle(_ event: PetEvent) {
+ handle(event, at: clock())
+ }
+
+ private func handle(_ event: PetEvent, at time: PetTime) {
+ let planned = brain.handle(event, at: time)
+ layer.timeline = brain.timeline
+ if case .pointer = event, planned.isEmpty {
+ // The brain's fast path: at most a cue. The rate may change (the eyes follow the pointer at
+ // the action rate), nothing else does.
+ let before = layer.rate
+ refreshRate(at: time)
+ // The eyes took the action rate: the timer brings it back down half a second after the last
+ // move near them. Each firing re-arms it from the latest sample, so a pointer that keeps
+ // moving keeps the rate.
+ if layer.rate != before { schedule(after: time) }
+ return
+ }
+ afterChange(at: time, redraw: !planned.isEmpty)
+ }
+
+ /// `.visible`: the window on screen and not occluded, the display awake and
+ /// unlocked, the session active, the Mac awake, and either the strip showing the pet or the panel
+ /// open.
+ private func updateVisible() {
+ guard started else { return }
+ let visible = windowVisible && !presence.displayAsleep && !presence.screenLocked && !presence.sessionInactive
+ && systemAwake && (showEars || expanded)
+ guard visible != sentVisible else { return }
+ sentVisible = visible
+ handle(.visible(visible))
+ updateWordsTimer()
+ }
+
+ /// After every event but the pointer's: the save, the frame rate, the timer. `redraw`: what a paused
+ /// layer shows may have changed (a new plan, new zones), so it draws its one frame again.
+ private func afterChange(at now: PetTime, redraw: Bool = false) {
+ guard started else { return }
+ saveIfDue(at: now)
+ let frozen = layer.frozenAt
+ refreshRate(at: now)
+ if layer.rate.paused, redraw || layer.frozenAt != frozen { layer.redraws &+= 1 }
+ schedule(after: now)
+ }
+
+ // MARK: Frames
+
+ /// How long an expand or collapse keeps the layer at the hop's rate: the expand's crouch, its
+ /// longest flight, the landing and the spring's settle, with a little to spare.
+ static let dockFrames: TimeInterval = 1.1
+
+ /// The layer's schedule now: the policy's rate for the behaviour playing, raised while the pet hops,
+ /// glides or fades between the strip and the panel, and held a moment before it pauses so the frame
+ /// left on screen is the one the behaviour settles into.
+ private func refreshRate(at now: PetTime) {
+ let gates = frameGates(at: now.mono)
+ var conditions = PetFrameConditions()
+ conditions.onAC = power.onAC
+ conditions.lowPower = power.lowPower
+ conditions.reduceMotion = motion.reduceMotion
+ conditions.inputIdle = presence.inputIdle
+ if let eyes = eyes() {
+ conditions.pointerNear = PetFramePolicy.pointerIsNear(layer.pointer, eyes: eyes, at: now.mono)
+ }
+ var rate = PetFramePolicy.rate(brain.timeline, at: now.mono, gates: gates, conditions: conditions)
+ // The hop between the strip and the panel, and the idle's pounce: the two leaps the behaviour's
+ // own class does not cover, drawn in the fast class.
+ if now.mono < dockMotionUntil || idleFastWindow(after: now.mono)?.contains(now.mono) == true {
+ rate = Self.faster(rate, PetFramePolicy.rate(for: .fast, gates: gates, conditions: conditions))
+ }
+ layer.frozenAt = rate.paused ? restingInstant(after: now.mono) : nil
+ if rate != layer.rate { layer.rate = rate }
+ }
+
+ private func frameGates(at mono: TimeInterval) -> PetFrameGates {
+ var gates = PetFrameGates()
+ // Turned off, the pet still fades out over its 0.2 s: the `.off` segment's own class.
+ gates.petOn = showPet || isFadingOut(at: mono)
+ gates.displayAwake = !presence.displayAsleep
+ gates.unlocked = !presence.screenLocked
+ gates.sessionActive = !presence.sessionInactive
+ gates.systemAwake = systemAwake
+ gates.windowVisible = windowVisible
+ // With the readouts hidden the pet lives in the panel only, and fades out with it.
+ gates.showsPet = showEars || expanded || mono < dockMotionUntil
+ // Away by input, the frames stop whatever the pet is doing, but only after the 2.0 s doze-in that
+ // the same presence check started: someone reading without touching the Mac sees the pet settle
+ // into its doze instead of jumping into it.
+ gates.present = presence.inputIdle < PetTuning.Presence.away || playing(at: mono) == .dozeIn
+ return gates
+ }
+
+ private func playing(at mono: TimeInterval) -> PetBehaviour? {
+ brain.timeline.segments.last(where: { $0.start <= mono })?.behaviour
+ }
+
+ /// The pounce's wind-up and leap in the idle playing at `mono`, or the next one's
+ /// (`PetRigIdle.fastWindow`).
+ private func idleFastWindow(after mono: TimeInterval) -> Range? {
+ guard let segment = brain.timeline.segments.last(where: { $0.start <= mono }) else { return nil }
+ return PetRigIdle.fastWindow(after: mono, segment: segment, seed: brain.seed, reduceMotion: motion.reduceMotion)
+ }
+
+ private func isFadingOut(at mono: TimeInterval) -> Bool {
+ guard let last = brain.timeline.segments.last(where: { $0.start <= mono }), case .off = last.behaviour else {
+ return false
+ }
+ return mono - last.start < PetTuning.Behaviour.fadeOut
+ }
+
+ /// Where the behaviour that will be resting has settled: the first open-ended segment from now on,
+ /// past its blend in. A doze is shown dozing, a pet curled in the den curled, never caught between.
+ /// One fixed instant per resting segment, so a presence check while paused leaves the frame as it
+ /// is instead of drawing it again; the layer never poses before now (`PetLayer.frame()`), and the
+ /// brain keeps the segment playing at its memory horizon, so the rig can still pose it.
+ private func restingInstant(after mono: TimeInterval) -> TimeInterval {
+ let segments = brain.timeline.segments
+ let current = segments.lastIndex(where: { $0.start <= mono }) ?? 0
+ for segment in segments[current...] where segment.behaviour.duration == nil {
+ return segment.start + Self.settle
+ }
+ return mono
+ }
+
+ /// Longer than any posture's blend into a behaviour that pauses the layer (`doze` 0.5 s, `denWait`
+ /// 0.4 s).
+ static let settle: TimeInterval = 0.6
+
+ static func faster(_ a: PetFrameRate, _ b: PetFrameRate) -> PetFrameRate {
+ switch (a, b) {
+ case (.native, _), (_, .native): return .native
+ case (.paused, let other), (let other, .paused): return other
+ case (.fps(let x), .fps(let y)): return .fps(max(x, y))
+ }
+ }
+
+ /// The pet's eyes in the zone it is in, for the pointer's distance: `Pointer.eyeHeight` above the
+ /// anchor, at the zone's scale.
+ private func eyes() -> CGPoint? {
+ guard let zones = layer.zones, let zone = expanded ? zones.panel : zones.strip else { return nil }
+ return CGPoint(x: zone.anchor.x, y: zone.anchor.y - CGFloat(PetTuning.Pointer.eyeHeight) * zone.scale)
+ }
+
+ private func appendDock(_ change: PetDockChange, at mono: TimeInterval) {
+ var changes = layer.dock.changes
+ changes.append(change)
+ // The last expand or collapse however old, and every change of the last 2 s.
+ let horizon = mono - PetDockTrack.history
+ let lastMove = changes.lastIndex { $0.kind == .expand || $0.kind == .collapse }
+ changes = changes.enumerated().filter { $0.element.t > horizon || $0.offset == lastMove }.map(\.element)
+ layer.dock.changes = changes
+ }
+
+ // MARK: The timer
+
+ /// One one-shot timer at whatever is due first: the brain's deadline, a change of frame class, the
+ /// pointer leaving the eyes' range, the end of a hop, the next look at the save schedule. Every
+ /// firing is a tick for the brain, whichever of these it was set for: a tick with nothing due
+ /// changes nothing, and one that comes late only plays the brain's deadlines at their own instants,
+ /// later than they should have been drawn.
+ private func schedule(after now: PetTime) {
+ timer?.cancel()
+ timer = nil
+ timerDue = nil
+ guard showPet || isFadingOut(at: now.mono) else { return }
+ var due: [TimeInterval] = []
+ if let deadline = brain.nextDeadline(after: now) { due.append(deadline.mono) }
+ if let change = PetFramePolicy.nextChange(brain.timeline, after: now.mono) { due.append(change) }
+ if let near = layer.pointer.samples.last, now.mono - near.t <= PetTuning.Frames.pointerRecent {
+ due.append(near.t + PetTuning.Frames.pointerRecent + 0.01)
+ }
+ if now.mono < dockMotionUntil { due.append(dockMotionUntil) }
+ if let leap = idleFastWindow(after: now.mono) { due += [leap.lowerBound, leap.upperBound] }
+ if let save = saves.nextCheck(brain: brain) { due.append(save) }
+ guard let next = due.filter({ $0 > now.mono }).min() else { return }
+ timerDue = next
+ // Strict: what the brain commits at a deadline starts at the deadline's own instant, so a tick
+ // that comes late is a beat cut short on screen, and `asyncAfter` lets the system defer a timer
+ // by a tenth of its delay.
+ let source = DispatchSource.makeTimerSource(flags: .strict, queue: .main)
+ source.schedule(deadline: .now() + (next - now.mono), leeway: .milliseconds(1))
+ source.setEventHandler { [weak self] in MainActor.assumeIsolated { self?.timerFired() } }
+ source.resume()
+ timer = source
+ }
+
+ /// The one-shot timer's work; tests call it at a clock they moved, rather than waiting for it.
+ func timerFired() {
+ timer?.cancel()
+ timer = nil
+ timerDue = nil
+ let now = clock()
+ // Always a tick: `nextDeadline(after: now)` names the deadline after the one that fired, so it
+ // can never say that one is due. A tick with nothing due changes nothing.
+ _ = brain.handle(.tick, at: now)
+ layer.timeline = brain.timeline
+ // A change of frame class, the pointer gone, a hop landed: what a paused layer shows may have
+ // changed too.
+ afterChange(at: now, redraw: true)
+ }
+
+ // MARK: pet.json
+
+ /// The schedule's saves. One that would write the file as it already is, but for `savedAt`, is
+ /// skipped: while the pet rests, paused or unseen, nothing else changes, and the every-10-minutes
+ /// save would otherwise be the one thing waking the disk.
+ private func saveIfDue(at now: PetTime) {
+ guard saves.isDue(at: now.mono, brain: brain) else { return }
+ let save = brain.save(at: now)
+ if var written = lastWritten {
+ written.savedAt = save.savedAt
+ if written == save {
+ saves.saved(at: now.mono, brain: brain)
+ return
+ }
+ }
+ write(save, at: now)
+ }
+
+ /// Writes `pet.json` whatever it holds: on `willSleep` and on quit.
+ private func save(at now: PetTime) {
+ write(brain.save(at: now), at: now)
+ }
+
+ private func write(_ save: PetSave, at now: PetTime) {
+ do {
+ try petStore.save(save)
+ lastWritten = save
+ } catch {
+ BrowLog.pet.error("pet.json not saved: \(String(describing: error), privacy: .public)")
+ }
+ saves.saved(at: now.mono, brain: brain)
+ }
+
+ // MARK: The words
+
+ /// Once a second while someone can read them: the panel open, or VoiceOver running while the strip
+ /// can be seen (not with the display asleep or the screen locked, when nobody can reach it).
+ private func updateWordsTimer() {
+ let wanted = showPet
+ && (expanded || (usesSenses && NSWorkspace.shared.isVoiceOverEnabled && sentVisible == true))
+ if wanted, wordsTimer == nil {
+ publishWords(at: clock())
+ let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
+ MainActor.assumeIsolated { guard let self else { return }; self.publishWords(at: self.clock()) }
+ }
+ timer.tolerance = 0.1
+ RunLoop.main.add(timer, forMode: .common)
+ wordsTimer = timer
+ } else if !wanted {
+ wordsTimer?.invalidate()
+ wordsTimer = nil
+ }
+ }
+
+ /// The header's words, the meter and the VoiceOver value at `now`. Each is published only when it
+ /// changed, so an open panel re-renders when the words do, not every second.
+ func publishWords(at now: PetTime) {
+ let status = brain.status(at: now)
+ let header = PetText.header(status, now: now.wall, timeZone: .current, calendar: .current, names: names)
+ let value = PetText.voiceOverValue(status, now: now.wall, timeZone: .current, calendar: .current)
+ if panel.header != header { panel.header = header }
+ if panel.meter != status.meter { panel.meter = status.meter }
+ if panel.accessibilityValue != value { panel.accessibilityValue = value }
+ if layer.accessibilityValue != value { layer.accessibilityValue = value }
+ }
+}
+
+extension PetController {
+ /// What a relayout is judged on: the strip's place and shape and whether the readouts show. The
+ /// expanded height is not in it: the window hangs from the top edge, so a taller panel moves no zone.
+ struct Layout {
+ var frames: NotchFrames
+ var aggregate: LimitsAggregate
+ var showEars: Bool
+
+ var key: [CGFloat] {
+ let notch = frames.notch ?? .zero
+ return [frames.collapsed.minX, frames.collapsed.minY, frames.collapsed.width, frames.collapsed.height,
+ frames.expanded.minX, frames.expanded.maxY, frames.expanded.width, notch.minX, notch.width,
+ frames.placement == .below ? 1 : 0, frames.hasNotch ? 1 : 0, showEars ? 1 : 0]
+ }
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetFood.swift b/Sources/BrowKit/Pet/PetFood.swift
new file mode 100644
index 0000000..c78134b
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetFood.swift
@@ -0,0 +1,218 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+// What the pet eats: the growth of each visible account's 5-hour window inside one period, weighted
+// into the units of the left readout. Not the delta of the aggregate: that drops at every reset, jumps
+// when an account gets its first reading or is shown again, and moves when the set of weights changes,
+// and every one of those would be fake food or fake fasting.
+
+/// The two windows that feed the pet or put it to rest. The model weekly window (Fable, Opus) is
+/// neither: other models still work when it is used up.
+public enum PetWindowKind: String, Codable, Sendable, CaseIterable {
+ case fiveHour, sevenDay
+}
+
+/// One (account, window) period as the pet last saw it. Kept in `pet.json`, so what was used while Brow
+/// was not running is still food when it comes back.
+public struct PetPeriod: Codable, Sendable, Equatable {
+ /// The period's reset instant, as `Formatting.resetInstant` rounds it.
+ public var resetAt: Date
+ /// The highest reading seen in the period. A lower one is jitter (a statusline 97 beside the
+ /// endpoint's 98) and changes nothing: nothing is ever un-eaten.
+ public var high: Double
+ /// Ended, by a reading of a newer period or by the clock at `resetAt`. A late reading of a closed
+ /// period is ignored, and the clock going back cannot open it again.
+ public var closed: Bool
+
+ public init(resetAt: Date, high: Double, closed: Bool = false) {
+ self.resetAt = resetAt
+ self.high = high
+ self.closed = closed
+ }
+
+ private enum CodingKeys: String, CodingKey { case resetAt, high, closed }
+
+ /// Only the reset instant is required: without it the period cannot be matched to a reading and
+ /// is dropped. A missing mark is a baseline of 0, a missing flag an open period.
+ public init(from decoder: any Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ resetAt = try c.decode(Date.self, forKey: .resetAt)
+ high = (try? c.decodeIfPresent(Double.self, forKey: .high)) ?? 0
+ closed = (try? c.decodeIfPresent(Bool.self, forKey: .closed)) ?? false
+ }
+}
+
+/// One account's two periods.
+public struct PetPeriods: Codable, Sendable, Equatable {
+ public var fiveHour: PetPeriod?
+ public var sevenDay: PetPeriod?
+
+ public init(fiveHour: PetPeriod? = nil, sevenDay: PetPeriod? = nil) {
+ self.fiveHour = fiveHour
+ self.sevenDay = sevenDay
+ }
+
+ private enum CodingKeys: String, CodingKey { case fiveHour, sevenDay }
+
+ /// Each window on its own: an unreadable weekly tracker costs the good week, not the 5-hour one.
+ public init(from decoder: any Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ fiveHour = (try? c.decodeIfPresent(PetPeriod.self, forKey: .fiveHour)) ?? nil
+ sevenDay = (try? c.decodeIfPresent(PetPeriod.self, forKey: .sevenDay)) ?? nil
+ }
+
+ public subscript(window: PetWindowKind) -> PetPeriod? {
+ get { window == .fiveHour ? fiveHour : sevenDay }
+ set { if window == .fiveHour { fiveHour = newValue } else { sevenDay = newValue } }
+ }
+}
+
+/// A period that ended: the weekly close is where a good week is judged, the 5-hour close where a fresh
+/// window is.
+public struct PetPeriodClose: Sendable, Equatable {
+ public var account: String
+ public var window: PetWindowKind
+ /// The period's own reset instant: closes are handled in its order and dated by it, even when
+ /// several are found at once after the Mac slept through them.
+ public var resetAt: Date
+ public var high: Double
+}
+
+extension PetReading {
+ public subscript(window: PetWindowKind) -> PetWindow? {
+ window == .fiveHour ? fiveHour : sevenDay
+ }
+}
+
+extension PetAccount {
+ /// The pet's view of one `LimitsStore` row: the organisation, its tier's weight in the aggregate
+ /// (`RateLimitModel`, the same table the readouts use) and the 5-hour and weekly windows of its
+ /// snapshot, with the reset instants rounded as the panel rounds them. The model weekly window is
+ /// left out: other models still work when it is used up.
+ public init(row: AccountRow) {
+ self.init(id: row.id, weight: RateLimitModel.weight(forTier: row.account.tier).value,
+ reading: row.snapshot.map(PetReading.init(snapshot:)))
+ }
+}
+
+extension PetReading {
+ public init(snapshot: LimitSnapshot) {
+ func window(_ captured: CapturedWindow?) -> PetWindow? {
+ captured.map { PetWindow(used: $0.usedPercentage, resetAt: Formatting.resetInstant($0.resetsAt)) }
+ }
+ self.init(fetchedAt: snapshot.fetchedAt, fiveHour: window(snapshot.fiveHour),
+ sevenDay: window(snapshot.sevenDay))
+ }
+}
+
+/// The trackers, and the food a reading brings. Pure: readings in, points and closed periods out.
+public struct PetFood: Sendable, Equatable {
+ /// By organisation. A hidden account's trackers are deleted, so when it is shown again its first
+ /// reading is a baseline and the time it was hidden is not food.
+ public var trackers: [String: PetPeriods]
+
+ public init(trackers: [String: PetPeriods] = [:]) {
+ self.trackers = trackers
+ }
+
+ /// What one set of readings brought.
+ public struct Intake: Sendable, Equatable {
+ /// Food points: one point is one point of the left readout.
+ public var points: Double
+ /// Periods a reading of a newer period ended, in the accounts' order.
+ public var closes: [PetPeriodClose]
+ }
+
+ /// Each visible account's share of the aggregate, `w / W`, where `W` sums the weights of the
+ /// visible accounts that have a reading: the same set `LimitsAggregate.compute` averages over.
+ public static func shares(_ accounts: [PetAccount]) -> [String: Double] {
+ let total = accounts.filter { $0.reading != nil }.reduce(0) { $0 + $1.weight }
+ guard total > 0 else { return [:] }
+ var shares: [String: Double] = [:]
+ for account in accounts where account.reading != nil { shares[account.id] = account.weight / total }
+ return shares
+ }
+
+ /// Takes in the visible accounts' readings. Only the 5-hour window feeds: one prompt moves both
+ /// numbers, and counting both would count it twice. The weekly trackers are kept for the good
+ /// week.
+ public mutating func take(_ accounts: [PetAccount]) -> Intake {
+ let visible = Set(accounts.map(\.id))
+ trackers = trackers.filter { visible.contains($0.key) }
+ let shares = Self.shares(accounts)
+ var points = 0.0
+ var closes: [PetPeriodClose] = []
+ for account in accounts {
+ guard let reading = account.reading, let share = shares[account.id] else { continue }
+ var periods = trackers[account.id] ?? PetPeriods()
+ for kind in PetWindowKind.allCases {
+ // Not started, or a window that carries no date: it counts toward the aggregate,
+ // never as food.
+ guard let window = reading[kind], let reset = window.resetAt else { continue }
+ // A reading of a window that had already closed when it was fetched.
+ guard reset > reading.fetchedAt.addingTimeInterval(-PetTuning.Food.closedReadingSlack) else { continue }
+ let used = window.used
+ guard let period = periods[kind] else {
+ // The first reading ever: a silent baseline. What was used before the pet
+ // looked is not food.
+ periods[kind] = PetPeriod(resetAt: reset, high: used)
+ continue
+ }
+ if abs(reset.timeIntervalSince(period.resetAt)) <= PetTuning.Food.samePeriod {
+ // The same period (this absorbs the endpoint's `…:59.64` beside `:00`).
+ guard !period.closed, used > period.high else { continue }
+ if kind == .fiveHour { points += (used - period.high) * share }
+ periods[kind]?.high = used
+ } else if reset > period.resetAt {
+ // A newer period: everything used since its reset is new.
+ if !period.closed {
+ closes.append(PetPeriodClose(account: account.id, window: kind,
+ resetAt: period.resetAt, high: period.high))
+ }
+ if kind == .fiveHour { points += used * share }
+ periods[kind] = PetPeriod(resetAt: reset, high: used)
+ }
+ // An older period than the one already seen says nothing new, and is not a reason
+ // to close the newer one.
+ }
+ trackers[account.id] = periods
+ }
+ return Intake(points: points, closes: closes)
+ }
+
+ /// Closes, by the clock, every open period whose reset instant has come without a reading of a
+ /// newer one, oldest first: after a night asleep they are judged in the order they ended.
+ public mutating func closeElapsed(now: Date) -> [PetPeriodClose] {
+ var closes: [PetPeriodClose] = []
+ // Sorted, because a dictionary's order changes from run to run and the brain must not.
+ for id in trackers.keys.sorted() {
+ for kind in PetWindowKind.allCases {
+ guard let period = trackers[id]?[kind], !period.closed, period.resetAt <= now else { continue }
+ trackers[id]?[kind]?.closed = true
+ closes.append(PetPeriodClose(account: id, window: kind, resetAt: period.resetAt, high: period.high))
+ }
+ }
+ return closes.sorted {
+ ($0.resetAt, $0.window == .fiveHour ? 0 : 1, $0.account) < ($1.resetAt, $1.window == .fiveHour ? 0 : 1, $1.account)
+ }
+ }
+
+ /// The next reset instant at which an open period will close by the clock.
+ public func nextClose(after now: Date) -> Date? {
+ trackers.values.flatMap { [$0.fiveHour, $0.sevenDay] }
+ .compactMap { $0.flatMap { !$0.closed && $0.resetAt > now ? $0.resetAt : nil } }
+ .min()
+ }
+
+ /// Whether the period that resets at `resetAt` has already been closed, by a newer reading or by
+ /// the clock. A closed period stays closed when the clock goes back.
+ public func isClosed(account: String, window: PetWindowKind, resetAt: Date) -> Bool {
+ guard let period = trackers[account]?[window] else { return false }
+ if abs(resetAt.timeIntervalSince(period.resetAt)) <= PetTuning.Food.samePeriod { return period.closed }
+ // A reading of an older period than the tracker's: that one is over.
+ return resetAt < period.resetAt
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetFramePolicy.swift b/Sources/BrowKit/Pet/PetFramePolicy.swift
new file mode 100644
index 0000000..6a1f25f
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetFramePolicy.swift
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// How often the pet layer's `TimelineView` draws.
+public enum PetFrameRate: Sendable, Equatable {
+ /// No frames at all.
+ case paused
+ /// At most this many frames a second. 30, 15 and 10 divide 60 and 120 Hz evenly, so
+ /// `.animation(minimumInterval:)` keeps them on the display link without judder.
+ case fps(Double)
+ /// The display's own rate (120 Hz on ProMotion): `minimumInterval: nil`.
+ case native
+
+ /// For `TimelineView(.animation(minimumInterval: minimumInterval, paused: paused))`.
+ public var minimumInterval: TimeInterval? {
+ if case .fps(let fps) = self { return 1 / fps }
+ return nil
+ }
+
+ public var paused: Bool { self == .paused }
+}
+
+/// What can stop the frames altogether: with any one of them closed, nobody can see the pet and the
+/// layer is paused.
+public struct PetFrameGates: Sendable, Equatable {
+ public var petOn = true
+ /// Between `screensDidSleepNotification` and `screensDidWakeNotification`, false.
+ public var displayAwake = true
+ /// `com.apple.screenIsLocked` on `DistributedNotificationCenter`.
+ public var unlocked = true
+ /// Fast user switching: `sessionDidResignActiveNotification`.
+ public var sessionActive = true
+ /// From `willSleepNotification` until `didWakeNotification`, false.
+ public var systemAwake = true
+ /// The panel window's `occlusionState` contains `.visible`.
+ public var windowVisible = true
+ /// Something shows the pet: the readouts are on, or the panel is open. Collapsed with the readouts
+ /// off there is nothing to draw.
+ public var showsPet = true
+ /// Input in the last 600 s: away is no frames, whatever the pet is doing.
+ public var present = true
+
+ public init() {}
+
+ var open: Bool {
+ petOn && displayAwake && unlocked && sessionActive && systemAwake && windowVisible && showsPet && present
+ }
+}
+
+/// The rest of what sets the rate: the power source, the display options and the person.
+public struct PetFrameConditions: Sendable, Equatable {
+ /// `IOPSGetProvidingPowerSourceType` says AC power.
+ public var onAC = true
+ /// `ProcessInfo.isLowPowerModeEnabled`.
+ public var lowPower = false
+ public var reduceMotion = false
+ /// Seconds since the last input.
+ public var inputIdle: TimeInterval = 0
+ /// The pointer moved within 160 pt of the pet's eyes in the last 0.5 s
+ /// (`PetFramePolicy.pointerIsNear`): the eyes are tracking it.
+ public var pointerNear = false
+
+ public init() {}
+}
+
+/// Frame classes to rates, the caps and the gates. Pure: the rate changes only at behaviour boundaries
+/// (`nextChange`) and when a gate or a condition changes, a few times a minute.
+public enum PetFramePolicy {
+
+ /// The rate for the behaviour playing at `t`.
+ public static func rate(_ timeline: PetTimeline, at t: TimeInterval, gates: PetFrameGates,
+ conditions: PetFrameConditions) -> PetFrameRate {
+ let segment = timeline.segments.last { $0.start <= t }
+ let frameClass = segment.map { $0.behaviour.frameClass(elapsed: t - $0.start) } ?? .off
+ let eyesFollow = segment.map { followsThePointer($0.behaviour) } ?? false
+ return rate(for: frameClass, gates: gates, conditions: conditions, eyesFollow: eyesFollow)
+ }
+
+ /// The rate for a frame class. `eyesFollow`: the behaviour watches the pointer when it moves near,
+ /// which needs the action rate; asleep, in the den or dozing it does not.
+ public static func rate(for frameClass: PetFrameClass, gates: PetFrameGates, conditions: PetFrameConditions,
+ eyesFollow: Bool = true) -> PetFrameRate {
+ guard gates.open, frameClass != .off else { return .paused }
+ typealias F = PetTuning.Frames
+ var effective = frameClass
+ if conditions.pointerNear, eyesFollow, effective < .action { effective = .action }
+ // nil is the display's own rate.
+ var fps: Double?
+ switch effective {
+ case .off: return .paused
+ case .still: fps = F.still
+ case .sleep: fps = F.sleep
+ case .idle: fps = F.idle
+ case .action: fps = F.action
+ case .fast: fps = nil
+ }
+ func cap(_ limit: Double) { fps = min(fps ?? .infinity, limit) }
+ // The caps: each only ever lowers the rate, so the order they come in changes nothing.
+ if !conditions.onAC, effective == .fast { cap(F.batteryFast) }
+ if conditions.lowPower {
+ switch effective {
+ case .idle: cap(F.lowPowerIdle)
+ case .sleep: cap(F.lowPowerSleep)
+ case .action, .fast: cap(F.lowPowerAction)
+ default: break
+ }
+ }
+ if conditions.reduceMotion {
+ switch effective {
+ case .idle: cap(F.reducedIdle)
+ case .action, .fast: cap(F.reducedAction)
+ default: break
+ }
+ }
+ if conditions.inputIdle >= PetTuning.Presence.settled && conditions.inputIdle < PetTuning.Presence.away {
+ cap(F.settledAtMost)
+ }
+ return fps.map { .fps($0) } ?? .native
+ }
+
+ /// The next instant after `t` at which the frame class changes without an event: the next
+ /// segment's start, or a change inside the one playing (the wake's shake, a streak's twirl, the end
+ /// of the fade out). The controller reads the rate again then.
+ public static func nextChange(_ timeline: PetTimeline, after t: TimeInterval) -> TimeInterval? {
+ typealias B = PetTuning.Behaviour
+ var times = timeline.segments.first(where: { $0.start > t }).map { [$0.start] } ?? []
+ if let segment = timeline.segments.last(where: { $0.start <= t }) {
+ switch segment.behaviour {
+ case .wake: times.append(segment.start + B.wakeStir + B.wakeStretch)
+ case .celebrate(.streak): times += [segment.start + B.streakTwirl.lowerBound, segment.start + B.streakTwirl.upperBound]
+ case .off: times.append(segment.start + B.fadeOut)
+ default: break
+ }
+ }
+ return times.filter { $0 > t }.min()
+ }
+
+ /// Whether the pointer moved within `PetTuning.Pointer.notice` of `eyes` in the last
+ /// `PetTuning.Frames.pointerRecent`: every sample is a move.
+ public static func pointerIsNear(_ track: PetPointerTrack, eyes: CGPoint, at t: TimeInterval) -> Bool {
+ track.samples.contains { sample in
+ t - sample.t <= PetTuning.Frames.pointerRecent && t >= sample.t
+ && hypot(Double(sample.location.x - eyes.x), Double(sample.location.y - eyes.y)) <= PetTuning.Pointer.notice
+ }
+ }
+
+ /// Behaviours whose eyes are the pointer's when it comes near. Asleep, in the den, yawning or
+ /// dozing they are not, and the pointer is no reason for more frames.
+ static func followsThePointer(_ behaviour: PetBehaviour) -> Bool {
+ switch behaviour {
+ case .fallAsleep, .asleep, .drowsy, .rollOver, .enterDen, .hibernating, .stirring, .denWait, .peek, .dozeIn, .doze,
+ .off:
+ return false
+ default:
+ return true
+ }
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetHunger.swift b/Sources/BrowKit/Pet/PetHunger.swift
new file mode 100644
index 0000000..6da0fe4
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetHunger.swift
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// The hunger meter's number, H. It rises while the hunger clock runs, at 20 points an hour slowed by
+/// the week's pace, and falls by 6 a point of food. Its range is −50 to 100, so a big meal is
+/// remembered for a while (2 h 30 min from the floor back to 0); the meter shows it clamped to 0...100.
+///
+/// Whether the clock runs (the pet on, someone at the Mac, awake, fresh numbers) is the brain's
+/// call; this only does the arithmetic, so the tests can drive it directly.
+public struct PetHunger: Sendable, Equatable {
+ public var value: Double
+
+ public init(value: Double = PetTuning.Hunger.newPet) {
+ self.value = min(max(value, PetTuning.Hunger.minimum), PetTuning.Hunger.maximum)
+ }
+
+ /// Points per second while the clock runs at pace `f`.
+ public static func rate(pace f: Double) -> Double {
+ PetTuning.Hunger.perHour * min(max(f, 0), 1) / 3600
+ }
+
+ /// `seconds` of hunger time at pace `f`. Nothing happens at 100 but staying Hungry.
+ public mutating func pass(_ seconds: TimeInterval, pace f: Double) {
+ guard seconds > 0 else { return }
+ value = min(PetTuning.Hunger.maximum, value + Self.rate(pace: f) * seconds)
+ }
+
+ /// `points` of food, at once: the meter waits for the swallow, H does not. Returns how far H fell,
+ /// after the floor: the part the meter holds back as `pendingDrop` until the pet is seen eating it.
+ @discardableResult
+ public mutating func eat(_ points: Double) -> Double {
+ let before = value
+ value = max(PetTuning.Hunger.minimum, value - PetTuning.Hunger.perPoint * max(points, 0))
+ return before - value
+ }
+
+ /// The mood of a meter value (H + pendingDrop, before the clamp), coming from `previous`. A mood is
+ /// entered at its threshold on the way up and left 5 points below it on the way down, so a pet
+ /// on the line does not flicker between two faces.
+ public static func mood(_ value: Double, from previous: PetMood?) -> PetMood {
+ guard var mood = previous else { return moodOnTheWayUp(value) }
+ typealias H = PetTuning.Hunger
+ // A big meal can drop it several moods at once, a debug value raise it several: step until
+ // the mood holds.
+ for _ in 0.. H.contentAbove ? .content : .full
+ case .content:
+ next = value >= H.peckishFrom ? .peckish
+ : value <= H.contentAbove - H.moodHysteresis ? .full : .content
+ case .peckish:
+ next = value >= H.hungryFrom ? .hungry
+ : value < H.peckishFrom - H.moodHysteresis ? .content : .peckish
+ case .hungry:
+ next = value < H.hungryFrom - H.moodHysteresis ? .peckish : .hungry
+ }
+ if next == mood { break }
+ mood = next
+ }
+ return mood
+ }
+
+ /// The mood a value gives with no history: a new pet's, or one restored from `pet.json`.
+ public static func moodOnTheWayUp(_ value: Double) -> PetMood {
+ typealias H = PetTuning.Hunger
+ if value >= H.hungryFrom { return .hungry }
+ if value >= H.peckishFrom { return .peckish }
+ if value > H.contentAbove { return .content }
+ return .full
+ }
+
+ /// The next value above `value` at which the mood changes on the way up; nil when Hungry, which
+ /// has nowhere higher to go.
+ public static func nextThreshold(above value: Double, mood: PetMood) -> Double? {
+ typealias H = PetTuning.Hunger
+ switch mood {
+ case .full: return max(value, H.contentAbove)
+ case .content: return max(value, H.peckishFrom)
+ case .peckish: return max(value, H.hungryFrom)
+ case .hungry: return nil
+ }
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetLayerView.swift b/Sources/BrowKit/Pet/PetLayerView.swift
new file mode 100644
index 0000000..af8fa67
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetLayerView.swift
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import SwiftUI
+
+/// The pet's layer in `NotchRootView`: a `TimelineView` at the rate `PetFramePolicy` asks for, posing
+/// the pet afresh at every tick and drawing it on its own small canvas, which moves with it. The layer
+/// covers the window, in the window's coordinates, inside the outline's clip: the shape that grows on
+/// hover cuts the pet exactly as it cuts the panel, and the canvas masks the camera itself. It takes no
+/// click; the boop is the panel controller's hit test against the posed body.
+struct PetLayerView: View {
+ @ObservedObject var layer: PetLayer
+ /// `frames.expanded.size`.
+ let size: CGSize
+ let expanded: Bool
+
+ var body: some View {
+ ZStack(alignment: .topLeading) {
+ TimelineView(.animation(minimumInterval: layer.rate.minimumInterval, paused: layer.rate.paused)) { _ in
+ PetLayerFrame(frame: posed(), palette: layer.palette, measure: measure)
+ }
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ stripElement
+ }
+ .frame(width: size.width, height: size.height, alignment: .topLeading)
+ // The rig owns every move the pet makes: no SwiftUI animation may tween between two of its
+ // frames, not even the outline's spring, inside whose transaction the layer is rebuilt on hover.
+ .transaction { $0.animation = nil }
+ }
+
+ /// The pose for this tick. Reading `redraws` here is what makes a bump of it draw a paused frame
+ /// again.
+ private func posed() -> PetFrame? {
+ _ = layer.redraws
+ guard let perf = layer.perf else { return layer.frame() }
+ let start = ContinuousClock.now
+ let frame = layer.frame()
+ perf.frame(layer.frameClass(at: layer.now()), body: ContinuousClock.now - start)
+ return frame
+ }
+
+ private var measure: ((Duration) -> Void)? {
+ layer.perf.map { perf in { perf.draw($0) } }
+ }
+
+ /// One VoiceOver element for the pet in the strip: over its zone, never announcing itself. In the
+ /// panel the header row is the pet's element instead.
+ @ViewBuilder
+ private var stripElement: some View {
+ if layer.isOn, !expanded, let zone = layer.zones?.strip {
+ // The element first, then placed by padding rather than an offset, so that its accessibility
+ // frame is the zone and nothing more.
+ Color.clear
+ .frame(width: zone.bounds.width, height: zone.bounds.height)
+ .contentShape(Rectangle())
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(PetText.label)
+ .accessibilityValue(layer.accessibilityValue)
+ .accessibilityAddTraits(.updatesFrequently)
+ .padding(.leading, zone.bounds.minX)
+ .padding(.top, zone.bounds.minY)
+ .allowsHitTesting(false)
+ }
+ }
+}
+
+/// One drawn frame: the pet's canvas at `frame.canvas`, in a layer the size of the window.
+private struct PetLayerFrame: View {
+ let frame: PetFrame?
+ let palette: PetPalette
+ let measure: ((Duration) -> Void)?
+
+ var body: some View {
+ ZStack(alignment: .topLeading) {
+ Color.clear
+ if let frame {
+ PetCanvas(frame: frame, palette: palette, measure: measure)
+ .offset(x: frame.canvas.minX, y: frame.canvas.minY)
+ }
+ }
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetLayout.swift b/Sources/BrowKit/Pet/PetLayout.swift
new file mode 100644
index 0000000..d2a0052
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetLayout.swift
@@ -0,0 +1,309 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// Where the pet may be: its zones, worked out from the same frames and metrics the strip and the panel
+/// are drawn with, so a zone cannot drift from what is on screen; and the paths between the strip and the
+/// panel. Pure: the controller calls it on every render.
+///
+/// Window coordinates throughout: points from the top-left corner of `frames.expanded`, y down.
+/// `NotchFrames` are Cocoa screen coordinates (y up), converted here.
+public enum PetLayout {
+ // MARK: - Zones
+
+ /// The zones for one render. `leftWidth` and `rightWidth` are the readouts' width classes,
+ /// `rightInitial` whether the right one shows a model initial (it narrows the pill's zone),
+ /// `panelTop` is `PanelView`'s top padding (`max(contentTopInset, 14)`), `fiveHour` the frame of the
+ /// panel's Overall "5h" figure in window coordinates (nil until reported), `dot` the 5-hour status
+ /// dot's severity (nil: grey).
+ public static func zones(frames: NotchFrames, leftWidth: PetReadoutWidth, rightWidth: PetReadoutWidth,
+ rightInitial: Bool, showEars: Bool, panelTop: CGFloat, fiveHour: CGRect?,
+ dot: Severity?) -> PetZones {
+ let strip = showEars ? stripZone(frames: frames, left: leftWidth, right: rightWidth,
+ rightInitial: rightInitial, dot: dot) : nil
+ return PetZones(strip: strip, panel: panelZone(frames: frames, panelTop: panelTop, fiveHour: fiveHour, dot: dot),
+ notch: frames.notch.map { window($0, in: frames) })
+ }
+
+ /// The same from what `NotchPanelController.render()` has at hand: the aggregate behind the readouts.
+ public static func zones(frames: NotchFrames, aggregate: LimitsAggregate, showEars: Bool, fiveHour: CGRect?) -> PetZones {
+ zones(frames: frames,
+ leftWidth: readoutWidth(aggregate.leftEar, hasData: aggregate.hasData),
+ rightWidth: readoutWidth(aggregate.rightEar, hasData: aggregate.hasData),
+ rightInitial: aggregate.hasData && aggregate.rightEar.modelInitial != nil,
+ showEars: showEars, panelTop: max(frames.contentTopInset, PanelView.padding), fiveHour: fiveHour,
+ dot: dotSeverity(aggregate.leftEar, hasData: aggregate.hasData))
+ }
+
+ /// The width class of what `EarsView` prints for a readout (`PanelText.earsText`).
+ public static func readoutWidth(_ ear: EarReadout, hasData: Bool) -> PetReadoutWidth {
+ let text = PanelText.earsText(ear, hasData: hasData)
+ if text == "—" { return .dash }
+ switch text.filter(\.isNumber).count {
+ case ...1: return .oneDigit
+ case 2: return .twoDigits
+ default: return .threeDigits
+ }
+ }
+
+ /// The colour of the readout's dot: nil for `EarsView`'s grey (stale, or no data).
+ public static func dotSeverity(_ ear: EarReadout, hasData: Bool) -> Severity? {
+ guard hasData, !ear.stale else { return nil }
+ return ear.severity
+ }
+
+ static func textWidth(_ w: PetReadoutWidth) -> CGFloat {
+ typealias R = PetTuning.Readout
+ switch w {
+ case .dash: return R.dashWidth
+ case .oneDigit: return R.oneDigitWidth
+ case .twoDigits: return R.twoDigitWidth
+ case .threeDigits: return R.threeDigitWidth
+ }
+ }
+
+ /// A screen rect (Cocoa) in window coordinates.
+ static func window(_ r: CGRect, in frames: NotchFrames) -> CGRect {
+ CGRect(x: r.minX - frames.expanded.minX, y: frames.expanded.maxY - r.maxY, width: r.width, height: r.height)
+ }
+
+ /// The collapsed strip's zone: beside the notch, in the strip below it, or in the pill.
+ static func stripZone(frames: NotchFrames, left: PetReadoutWidth, right: PetReadoutWidth, rightInitial: Bool,
+ dot: Severity?) -> PetZone {
+ typealias Z = PetTuning.Zone
+ typealias M = EarsMetrics
+ let gap = PetTuning.Readout.gap
+ let strip = window(frames.collapsed, in: frames)
+ let leftText = textWidth(left)
+ if frames.hasNotch, let notchScreen = frames.notch {
+ let notch = window(notchScreen, in: frames)
+ switch frames.placement {
+ case .beside:
+ // The left wing: padding, dot, spacing, then the text hugging the wing's outer edge.
+ let wingMinX = notch.minX - frames.earWidth
+ let textMinX = wingMinX + M.padding + M.dotSize + M.dotSpacing
+ let bounds = CGRect(x: textMinX + leftText + gap, y: strip.minY + Z.edgeInset,
+ width: notch.minX - (textMinX + leftText + gap), height: notch.height - 2 * Z.edgeInset)
+ return PetZone(kind: .beside, bounds: bounds,
+ anchor: CGPoint(x: notch.minX - Z.besideAnchorFromNotch, y: notch.maxY - Z.besideFeetAboveBottom),
+ scale: Z.besideScale, maxStretch: Z.besideMaxStretch, den: .side(edgeX: notch.minX),
+ foodSource: CGPoint(x: textMinX + leftText / 2, y: notch.midY), foodDot: dot)
+ case .below:
+ // The strip under the notch; the left ear hugs the middle: spacer, dot, text, padding.
+ let room = CGRect(x: notch.minX, y: notch.maxY, width: notch.width, height: NotchGeometry.belowStripHeight)
+ let textMaxX = notch.midX - M.padding
+ let dotMinX = textMaxX - leftText - M.dotSpacing - M.dotSize
+ let minX = notch.minX + Z.belowLeftFromNotch
+ let bounds = CGRect(x: minX, y: room.minY + Z.edgeInset, width: dotMinX - gap - minX,
+ height: room.height - 2 * Z.edgeInset)
+ return PetZone(kind: .below, bounds: bounds,
+ anchor: CGPoint(x: notch.minX + Z.belowAnchorFromNotch, y: room.maxY - Z.belowFeetAboveBottom),
+ scale: Z.belowScale, maxStretch: Z.belowMaxStretch, den: .overhead(notch),
+ foodSource: CGPoint(x: textMaxX - leftText / 2, y: room.midY), foodDot: dot)
+ }
+ }
+ // The pill: two ears of (pill − 2 × padding) / 2, each readout hugging its outer edge.
+ let earMinX = strip.minX + NotchGeometry.pillPadding
+ let leftTextMinX = earMinX + M.padding + M.dotSize + M.dotSpacing
+ let rightEarMaxX = strip.maxX - NotchGeometry.pillPadding
+ let rightTextMaxX = rightEarMaxX - M.padding - M.dotSize - M.dotSpacing
+ let rightInk = textWidth(right) + (rightInitial ? PetTuning.Readout.initialWidth + M.initialSpacing : 0)
+ let minX = leftTextMinX + leftText + gap
+ let maxX = rightTextMaxX - rightInk - gap
+ let bounds = CGRect(x: minX, y: strip.minY + Z.edgeInset, width: maxX - minX, height: strip.height - 2 * Z.edgeInset)
+ return PetZone(kind: .pill, bounds: bounds,
+ anchor: CGPoint(x: strip.midX, y: strip.maxY - Z.pillFeetAboveBottom),
+ scale: Z.pillScale, maxStretch: Z.pillMaxStretch, den: .none,
+ foodSource: CGPoint(x: leftTextMinX + leftText / 2, y: strip.midY), foodDot: dot)
+ }
+
+ /// The panel header's stage: x 14 to 78, the row's top to its bottom, and the black above it up to
+ /// the particle ceiling.
+ static func panelZone(frames: NotchFrames, panelTop: CGFloat, fiveHour: CGRect?, dot: Severity?) -> PetZone {
+ typealias P = PetTuning.Panel
+ let stage = CGRect(x: PanelView.padding, y: panelTop, width: P.stageWidth, height: P.stageHeight)
+ let bounds = CGRect(x: stage.minX, y: P.particleCeiling, width: stage.width, height: stage.maxY - P.particleCeiling)
+ let source = fiveHour.map { CGPoint(x: $0.midX, y: $0.midY) }
+ ?? CGPoint(x: frames.expanded.width - 120, y: stage.maxY + 18)
+ return PetZone(kind: .panel, bounds: bounds,
+ anchor: CGPoint(x: stage.minX + P.anchorFromStageLeft, y: stage.minY + P.anchorFromStageTop),
+ scale: P.scale, maxStretch: panelMaxStretch, den: .none, foodSource: source, foodDot: dot)
+ }
+
+ /// The panel has no stretch limit of its own: it takes the one beside the notch, and the black above
+ /// the stage leaves room for it.
+ static let panelMaxStretch: CGFloat = PetTuning.Zone.besideMaxStretch
+
+ /// Past these the pet's light stays under 3 %, so a number or a word keeps its own colour: the
+ /// readouts' near edges beside and below the zone, and in the panel the header's words.
+ static func guards(_ zone: PetZone) -> (min: CGFloat?, max: CGFloat?) {
+ let gap = PetTuning.Readout.gap
+ switch zone.kind {
+ case .beside: return (zone.bounds.minX - gap, nil)
+ case .below: return (nil, zone.bounds.maxX + gap)
+ case .pill: return (zone.bounds.minX - gap, zone.bounds.maxX + gap)
+ case .panel: return (nil, zone.bounds.maxX + PetTuning.Panel.rowSpacing)
+ }
+ }
+
+ // MARK: - Between the strip and the panel
+
+ /// How long the flight from `a` to `b` takes.
+ public static func flightTime(from a: CGPoint, to b: CGPoint) -> TimeInterval {
+ typealias H = PetTuning.Hop
+ let distance = hypot(b.x - a.x, b.y - a.y)
+ return min(max(H.flightBase + H.flightPerPoint * distance, H.flightMinimum), H.flightMaximum)
+ }
+
+ /// Where the pet is during a hop or a glide: its feet, its scale, and the stretch of its body.
+ struct Flight {
+ /// The feet, window coordinates.
+ var feet: CGPoint
+ var scale: Double
+ /// Vertical squash (< 1) or stretch (> 1) of the body, area kept.
+ var squash: Double
+ /// The speed stretch along the velocity.
+ var smear: Double
+ var smearAngle: Double
+ /// 0 on the ground ... 1 in the air: how much the arms fly up and the feet dangle.
+ var airborne: Double
+ /// True once the pet stands at the far end and only the landing settles.
+ var landed: Bool
+ }
+
+ /// The expand hop: a crouch, a ballistic flight from the strip anchor `a` to the header anchor `b`
+ /// with the scale eased in, a landing squash and a spring to rest. `tau` is seconds since
+ /// `setExpanded(true)`. `stepsOff`: below and on the pill the pet starts at the top of its room and
+ /// steps off with no upward speed; beside it rises 3 pt first.
+ static func expand(a: CGPoint, b: CGPoint, scaleA: Double, scaleB: Double, stepsOff: Bool, tau: Double) -> Flight {
+ typealias H = PetTuning.Hop
+ let T = flightTime(from: a, to: b)
+ if tau < H.crouch {
+ // The crouch: down to 0.88 and back up into the takeoff stretch of 1.06.
+ let u = tau / H.crouch
+ let down = PetEase.outQuad(min(1, u / 0.6))
+ let up = PetEase.inOutSine(max(0, (u - 0.6) / 0.4))
+ let squash = PetCurve.mix(PetCurve.mix(1, H.crouchSquash, down), H.takeoffStretch, up)
+ return Flight(feet: a, scale: scaleA, squash: squash, smear: 1, smearAngle: 0, airborne: 0, landed: false)
+ }
+ let t = tau - H.crouch
+ if t < T {
+ let u = t / T
+ let p = ballistic(a: a, b: b, T: T, apex: stepsOff ? nil : H.expandApexBeside, t: t)
+ let scale = PetCurve.mix(scaleA, scaleB, PetEase.inCubic(u))
+ // The takeoff stretch gives way to the speed stretch along the velocity, which in turn gives
+ // way to the landing squash: each hands over smoothly, so nothing pops at either end.
+ let takeoff = PetCurve.mix(H.takeoffStretch, 1, PetEase.outQuad(min(1, u / 0.35)))
+ let speed = hypot(p.vx, p.vy)
+ let smear = 1 + min(H.speedStretchMaximum, speed / H.speedStretchSpeed) * PetCurve.envelope(u, 0, 0.18, 0.8, 1)
+ return Flight(feet: p.point, scale: scale, squash: takeoff, smear: smear, smearAngle: atan2(p.vy, p.vx),
+ airborne: PetCurve.envelope(u, 0, 0.25, 0.8, 1), landed: false)
+ }
+ return landing(at: b, scale: scaleB, squash: H.landingSquash, hold: H.landing, tau: t - T)
+ }
+
+ /// The collapse hop, no crouch: x eases out over `collapseX`, y flies over `collapseY` to an apex
+ /// just above the strip anchor, the scale eases out so the pet is small before it reaches the strip.
+ static func collapse(a: CGPoint, b: CGPoint, scaleA: Double, scaleB: Double, beside: Bool, tau: Double) -> Flight {
+ typealias H = PetTuning.Hop
+ // Here `b` is the header anchor the pet leaves and `a` the strip anchor it lands on.
+ if tau < H.collapseY {
+ let ux = min(1, tau / H.collapseX)
+ let x = PetCurve.mix(b.x, a.x, PetEase.outCubic(ux))
+ let apex = beside ? H.collapseApexBeside : H.collapseApexOther
+ let p = ballistic(a: b, b: a, T: H.collapseY, apexAboveEnd: apex, t: tau)
+ let scale = PetCurve.mix(scaleB, scaleA, PetEase.outCubic(tau / H.collapseY))
+ let dx = ux < 1 ? (a.x - b.x) * 3 * (1 - ux) * (1 - ux) / H.collapseX : 0
+ let speed = hypot(dx, p.vy)
+ let smear = 1 + min(H.speedStretchMaximum, speed / H.speedStretchSpeed)
+ * PetCurve.envelope(tau / H.collapseY, 0, 0.15, 0.8, 1)
+ return Flight(feet: CGPoint(x: x, y: p.point.y), scale: scale, squash: 1, smear: smear,
+ smearAngle: atan2(p.vy, dx), airborne: PetCurve.envelope(tau / H.collapseY, 0, 0.2, 0.75, 1),
+ landed: false)
+ }
+ return landing(at: a, scale: scaleA, squash: H.landingSquash, hold: H.collapseLanding, tau: tau - H.collapseY)
+ }
+
+ /// A glide: asleep, drowsy or hibernating, the pet slides along the straight line, its pose kept.
+ static func glide(from a: CGPoint, to b: CGPoint, scaleA: Double, scaleB: Double, tau: Double) -> Flight {
+ let u = PetEase.inOutCubic(tau / PetTuning.Hop.glide)
+ return Flight(feet: CGPoint(x: PetCurve.mix(a.x, b.x, u), y: PetCurve.mix(a.y, b.y, u)),
+ scale: PetCurve.mix(scaleA, scaleB, u), squash: 1, smear: 1, smearAngle: 0, airborne: 0,
+ landed: tau >= PetTuning.Hop.glide)
+ }
+
+ /// The landing: a squash held for `hold`, then a springy settle back to 1 (ω 28, ζ 0.45:
+ /// `PetTuning.Hop.settleOmega` and `settleZeta`).
+ static func landing(at p: CGPoint, scale: Double, squash: Double, hold: Double, tau: Double) -> Flight {
+ typealias H = PetTuning.Hop
+ let value: Double
+ if tau < hold {
+ value = PetCurve.mix(1, squash, PetEase.outQuad(tau / (hold * 0.4)))
+ } else {
+ let spring = PetSpring(omega: H.settleOmega, zeta: H.settleZeta)
+ value = 1 - (1 - squash) * spring.stepRemainder(tau - hold)
+ }
+ return Flight(feet: p, scale: scale, squash: value, smear: 1, smearAngle: 0, airborne: 0, landed: true)
+ }
+
+ /// A ballistic arc from `a` to `b` in `T`: x linear, y under constant gravity. `apex`: how far above
+ /// `a` it rises (nil: it steps off with no upward speed).
+ static func ballistic(a: CGPoint, b: CGPoint, T: Double, apex: Double?, t: Double)
+ -> (point: CGPoint, vx: Double, vy: Double) {
+ let dy = b.y - a.y
+ let v: Double, g: Double
+ if let h = apex, h > 0 {
+ // Rise h above a, then fall to b: v² = 2gh, and a + vT + gT²/2 = b. With s = vT:
+ // s + s²/(4h) = dy, so s = 2h(−1 − √(1 + dy/h)).
+ let s = 2 * h * (-1 - (1 + dy / h).squareRoot())
+ v = s / T
+ g = v * v / (2 * h)
+ } else {
+ v = 0
+ g = 2 * dy / (T * T)
+ }
+ let vx = (b.x - a.x) / T
+ return (CGPoint(x: a.x + vx * t, y: a.y + v * t + 0.5 * g * t * t), vx, v + g * t)
+ }
+
+ /// A ballistic arc from `a` (rising) to `b` in `T` whose apex is `apexAboveEnd` above `b`.
+ static func ballistic(a: CGPoint, b: CGPoint, T: Double, apexAboveEnd h: Double, t: Double)
+ -> (point: CGPoint, vx: Double, vy: Double) {
+ // y(t) = a.y + v t + g t²/2, y(T) = b.y, and the lowest y is b.y − h: the same arc as a fall
+ // from the apex to b, run backward. Solve for the time of the apex t₁ = T − √(2h/g).
+ let dy = b.y - a.y // negative: it rises
+ // From the apex the pet falls h in T − t₁ and rose (b.y − h − a.y) = dy − h in t₁:
+ // √(2(h − dy)/g) + √(2h/g) = T, so √(2/g) = T / (√(h − dy) + √h).
+ let k = T / ((h - dy).squareRoot() + h.squareRoot())
+ let g = 2 / (k * k)
+ let t1 = k * (h - dy).squareRoot()
+ let v = -g * t1
+ let vx = (b.x - a.x) / T
+ return (CGPoint(x: a.x + vx * t, y: a.y + v * t + 0.5 * g * t * t), vx, v + g * t)
+ }
+
+ // MARK: - The outline the pet flies inside
+
+ /// SwiftUI's `spring(response:dampingFraction:)` stepping from 0 to 1, `tau` seconds after it starts:
+ /// how far Brow's outline has opened (or closed) at that instant.
+ public static func springProgress(_ tau: Double, response: Double, damping: Double) -> Double {
+ guard tau > 0 else { return 0 }
+ let spring = PetSpring(omega: 2 * .pi / response, zeta: damping)
+ return 1 - spring.stepRemainder(tau)
+ }
+
+ /// The visible black of Brow's animated outline at progress `p` (0 collapsed, 1 the panel), window
+ /// coordinates: centred in the window, hanging from its top, inside the flares. Collapsed it is what
+ /// `NotchRootView.outlineSize` draws: the strip, or with the readouts off the physical notch, and on a
+ /// display without a notch nothing at all (the pill's width at no height), so the panel unrolls from
+ /// the top edge.
+ public static func outline(frames: NotchFrames, progress p: Double, showEars: Bool = true) -> CGRect {
+ let collapsed = NotchRootView.outlineSize(frames: frames, expanded: false, showEars: showEars)
+ let w = PetCurve.mix(collapsed.width, frames.expanded.width, p)
+ let h = PetCurve.mix(collapsed.height, frames.expanded.height, p)
+ let minX = (frames.expanded.width - w) / 2
+ return CGRect(x: minX + frames.flare, y: 0, width: w - 2 * frames.flare, height: h)
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetLimits.swift b/Sources/BrowKit/Pet/PetLimits.swift
new file mode 100644
index 0000000..516a3c2
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetLimits.swift
@@ -0,0 +1,225 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// The numbers that put the pet to rest and pace its hunger. Pure: the visible accounts and `now` in,
+/// numbers out.
+public enum PetLimits {
+
+ /// One account's effective used % of a window: a window whose reset instant has passed counts as
+ /// reset, whatever the last reading said, so a three-day-old 100 % does not keep the pet asleep. Two
+ /// refinements of that rule, for a clock that jumps:
+ ///
+ /// - `closed`: a period the pet has already seen end stays reset when the clock goes back, so a
+ /// backward jump never puts it to sleep without a fresh reading.
+ /// - A reading fetched more than a minute after its own reset instant that still reports that
+ /// window means the server still has it open and the Mac's clock is ahead (a forward jump past
+ /// the reset): its number counts, so the fresh reading that still says 100 % puts the pet back
+ /// to sleep. Within the minute it is the endpoint lagging at the boundary, and the window has
+ /// reset.
+ static func effective(_ window: PetWindow?, fetchedAt: Date, now: Date, closed: Bool) -> Double {
+ guard let window else { return 0 } // not started: 0 % used
+ guard let reset = window.resetAt else { return window.used }
+ if fetchedAt.timeIntervalSince(reset) > PetTuning.Food.closedReadingSlack { return window.used }
+ return reset <= now || closed ? 0 : window.used
+ }
+
+ /// `E(window, now)`: the tier-weighted average of the effective used % over the visible accounts
+ /// that have a reading, the set the readouts average over; nil when there is none.
+ public static func aggregate(_ accounts: [PetAccount], _ window: PetWindowKind, now: Date,
+ food: PetFood? = nil) -> Double? {
+ aggregate(accounts, window, now: now, food: food, zeroed: [])
+ }
+
+ private static func aggregate(_ accounts: [PetAccount], _ window: PetWindowKind, now: Date, food: PetFood?,
+ zeroed: Set) -> Double? {
+ var used = 0.0
+ var weights = 0.0
+ for account in accounts {
+ guard let reading = account.reading else { continue }
+ weights += account.weight
+ guard !zeroed.contains(account.id) else { continue }
+ let closed = reading[window]?.resetAt.map {
+ food?.isClosed(account: account.id, window: window, resetAt: $0) ?? false
+ } ?? false
+ used += account.weight * effective(reading[window], fetchedAt: reading.fetchedAt, now: now, closed: closed)
+ }
+ return weights > 0 ? used / weights : nil
+ }
+
+ /// The earliest future reset at which `E(window)` drops below the stay line, found by resetting the
+ /// accounts one by one in the order their windows reset; nil when no known reset gets it there (it
+ /// "wakes when the limit resets").
+ public static func wakeInstant(_ accounts: [PetAccount], _ window: PetWindowKind, now: Date,
+ food: PetFood? = nil) -> Date? {
+ guard let current = aggregate(accounts, window, now: now, food: food), current >= PetTuning.Limits.stay
+ else { return nil }
+ let resets = accounts.compactMap { account -> (id: String, at: Date)? in
+ guard let reset = account.reading?[window]?.resetAt, reset > now else { return nil }
+ return (account.id, reset)
+ }.sorted { ($0.at, $0.id) < ($1.at, $1.id) }
+ var zeroed: Set = []
+ for reset in resets {
+ zeroed.insert(reset.id)
+ if let left = aggregate(accounts, window, now: now, food: food, zeroed: zeroed),
+ left < PetTuning.Limits.stay {
+ return reset.at
+ }
+ }
+ return nil
+ }
+
+ /// `e7`, an even week's pace in percent: each account's share of the week gone since its weekly
+ /// window started, weighted like the aggregate; 0 for an account whose weekly window has not started.
+ public static func evenPace(_ accounts: [PetAccount], now: Date) -> Double {
+ var elapsed = 0.0
+ var weights = 0.0
+ for account in accounts {
+ guard let reading = account.reading else { continue }
+ weights += account.weight
+ guard let reset = reading.sevenDay?.resetAt else { continue }
+ let start = reset.addingTimeInterval(-PetTuning.Hunger.week)
+ elapsed += account.weight * min(max(now.timeIntervalSince(start) / PetTuning.Hunger.week, 0), 1)
+ }
+ return weights > 0 ? elapsed / weights * 100 : 0
+ }
+
+ /// Pace damping `f`: 1 on or behind an even pace, falling to 0 thirty points ahead of it, so the pet
+ /// never asks for food the week cannot afford and never pushes into the weekly wall. Never above 1: a
+ /// light week does not make it hungrier than the base rate.
+ public static func pace(_ accounts: [PetAccount], now: Date, food: PetFood? = nil) -> Double {
+ let used = aggregate(accounts, .sevenDay, now: now, food: food) ?? 0
+ let ahead = used - evenPace(accounts, now: now)
+ return min(max(1 - ahead / PetTuning.Hunger.paceStop, 0), 1)
+ }
+}
+
+/// One window's rest: asleep at the 5-hour limit, hibernating at the weekly one, by the same rules.
+/// Pure and mutating; the brain feeds it the effective aggregate at each update.
+public struct PetRest: Sendable, Equatable {
+ public enum Phase: Sendable, Equatable {
+ case awake
+ /// At rest since `since` (`PetTime.mono`).
+ case resting(since: TimeInterval)
+ /// The reset at `reset` passed while someone could see the pet: drowsy (in the den, stirring)
+ /// since `since`, waiting up to `PetTuning.Limits.confirmationWait` for fresh numbers.
+ case waiting(since: TimeInterval, reset: Date)
+ }
+
+ /// What an update changed.
+ public enum Change: Sendable, Equatable {
+ /// Full: asleep, or into the den.
+ case rest
+ /// The reset passed: drowsy.
+ case wait
+ /// Up again; `reset` says whether a reset did it, or something else (an account added or
+ /// shown with room), which needs the minimum stay.
+ case wake(reset: Bool)
+ /// A fresh reading in the drowsy wait still says full: back to rest.
+ case rollOver
+ }
+
+ public private(set) var phase: Phase = .awake
+ /// When it last woke (`PetTime.mono`); the 5-hour rest does not start again sooner than
+ /// `afterWake` after it.
+ public private(set) var lastWake: TimeInterval?
+ /// While at rest: when a reset will take the aggregate below the stay line, if one will.
+ public private(set) var wakesAt: Date?
+ /// 30 s for the 5-hour window, none for the weekly one.
+ public let afterWake: TimeInterval
+
+ public init(afterWake: TimeInterval) {
+ self.afterWake = afterWake
+ }
+
+ public var isResting: Bool {
+ if case .awake = phase { return false }
+ return true
+ }
+
+ public var isWaiting: Bool {
+ if case .waiting = phase { return true }
+ return false
+ }
+
+ /// Re-evaluates the rest at `t`.
+ /// - Parameters:
+ /// - effective: the window's effective aggregate now; nil without a single reading.
+ /// - wakeInstant: `PetLimits.wakeInstant` now.
+ /// - newestFetch: the newest `fetchedAt` among the visible readings: a reading fetched at or after
+ /// the reset confirms it.
+ /// - seen: whether anyone can see the pet. Nobody watching, a reset wakes it at once, with no
+ /// drowsy wait: the stretch plays when it is next seen.
+ public mutating func update(effective: Double?, wakeInstant: Date?, newestFetch: Date?, seen: Bool,
+ at t: PetTime) -> Change? {
+ typealias L = PetTuning.Limits
+ let used = effective ?? 0
+ switch phase {
+ case .awake:
+ guard used >= L.enter else { return nil }
+ if let lastWake, t.mono - lastWake < afterWake { return nil }
+ phase = .resting(since: t.mono)
+ wakesAt = wakeInstant
+ return .rest
+ case .resting(let since):
+ if used >= L.stay {
+ wakesAt = wakeInstant
+ return nil
+ }
+ // Below the stay line: either the reset it was waiting for came, which wakes it however
+ // soon, or something else did it, which must wait out the minimum stay.
+ if let reset = wakesAt, t.wall >= reset {
+ if seen {
+ phase = .waiting(since: t.mono, reset: reset)
+ return .wait
+ }
+ wake(at: t)
+ return .wake(reset: true)
+ }
+ guard t.mono - since >= L.minimumStay else { return nil }
+ wake(at: t)
+ return .wake(reset: false)
+ case .waiting(let since, let reset):
+ let confirmed = newestFetch.map { $0 >= reset } ?? false
+ guard confirmed || !seen || t.mono - since >= L.confirmationWait else { return nil }
+ if used >= L.enter {
+ phase = .resting(since: t.mono)
+ wakesAt = wakeInstant
+ return .rollOver
+ }
+ wake(at: t)
+ return .wake(reset: true)
+ }
+ }
+
+ /// The next instant at which `update` could change something with the readings it has, so the
+ /// brain asks for a tick then: the end of the wait after a wake, the minimum stay of an exit
+ /// that is not a reset, the drowsy limit. Wake instants are wall-clock and the brain adds them.
+ public func deadline(effective: Double?) -> TimeInterval? {
+ typealias L = PetTuning.Limits
+ let used = effective ?? 0
+ switch phase {
+ case .awake:
+ guard used >= L.enter, let lastWake else { return nil }
+ return lastWake + afterWake
+ case .resting(let since):
+ return used < L.stay ? since + L.minimumStay : nil
+ case .waiting(let since, _):
+ return since + L.confirmationWait
+ }
+ }
+
+ /// Back to the start: no account is visible any more, or the pet was turned off. The next
+ /// update judges the numbers afresh.
+ public mutating func forget() {
+ phase = .awake
+ wakesAt = nil
+ }
+
+ private mutating func wake(at t: PetTime) {
+ phase = .awake
+ lastWake = t.mono
+ wakesAt = nil
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetMoments.swift b/Sources/BrowKit/Pet/PetMoments.swift
new file mode 100644
index 0000000..e44c66a
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetMoments.swift
@@ -0,0 +1,173 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// The fed-day streak as `pet.json` keeps it.
+public struct PetStreak: Codable, Sendable, Equatable {
+ /// Fed days in the streak. It survives two unfed days in a row; the third ends it, silently, and
+ /// that is judged when the next fed day comes.
+ public var count: Int
+ /// `yyyy-MM-dd`, the local date of the streak's last fed day.
+ public var lastFedDay: String?
+ /// Milestones of this streak already reached, each once, whether it was celebrated or skipped.
+ public var celebrated: [Int]
+
+ public init(count: Int = 0, lastFedDay: String? = nil, celebrated: [Int] = []) {
+ self.count = count
+ self.lastFedDay = lastFedDay
+ self.celebrated = celebrated
+ }
+
+ private enum CodingKeys: String, CodingKey { case count, lastFedDay, celebrated }
+
+ public init(from decoder: any Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ count = max(0, (try? c.decodeIfPresent(Int.self, forKey: .count)) ?? 0)
+ lastFedDay = (try? c.decodeIfPresent(String.self, forKey: .lastFedDay)) ?? nil
+ celebrated = (try? c.decodeIfPresent([Int].self, forKey: .celebrated)) ?? []
+ }
+}
+
+/// A good week the panel mentions for a day: `Good week: 82 % of the weekly limit used`. Kept in
+/// `pet.json` so a relaunch inside the day does not lose the line.
+public struct PetGoodWeekLine: Codable, Sendable, Equatable {
+ /// The organisation; `PetText` gives it the account's panel name when there are several.
+ public var account: String
+ /// The period's high-water mark.
+ public var used: Double
+ /// When the week closed: its reset instant.
+ public var at: Date
+}
+
+/// A line the panel header's third row can show while it is live.
+public enum PetMomentLine: Sendable, Equatable {
+ /// `Streak: 7 fed days`, for the rest of the day the milestone was reached.
+ case streak(days: Int)
+ /// `Good week: 82 % of the weekly limit used`, for 24 hours.
+ case goodWeek(account: String, used: Double)
+}
+
+/// Days, the streak and good weeks. Nothing random and nothing lost: no moment depends on chance, and a
+/// broken streak or a light week ends without a word.
+public struct PetMoments: Sendable, Equatable {
+ /// Points eaten per local date (`yyyy-MM-dd`), the last `PetTuning.Store.keptDays` dates.
+ public var days: [String: Double] = [:]
+ public var streak = PetStreak()
+ /// `org@epochOfReset` of every good week seen, the last `PetTuning.Store.keptGoodWeeks`: each
+ /// account's period is a good week once, across relaunches.
+ public var goodWeeks: [String] = []
+ /// At most one good-week celebration per `PetTuning.Moments.celebrationEvery`.
+ public var lastGoodWeekCelebratedAt: Date?
+ public var goodWeekLines: [PetGoodWeekLine] = []
+
+ public init() {}
+
+ /// The local date `date` falls on in `timeZone`, as `pet.json` stores it. Food is dated when it
+ /// arrives, and a time zone change never rewrites a date already stored.
+ public static func day(_ date: Date, in timeZone: TimeZone) -> String {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = timeZone
+ let parts = calendar.dateComponents([.year, .month, .day], from: date)
+ return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
+ }
+
+ /// The first instant of the local day after the one `date` falls on: when a milestone that could
+ /// not be shown that day is skipped.
+ public static func endOfDay(_ date: Date, in timeZone: TimeZone) -> Date {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = timeZone
+ let start = calendar.startOfDay(for: date)
+ return calendar.date(byAdding: .day, value: 1, to: start) ?? date.addingTimeInterval(86_400)
+ }
+
+ /// Whole days from one stored date to another, by the calendar (time zone free: the dates are
+ /// already local).
+ static func days(from earlier: String, to later: String) -> Int? {
+ let utc = TimeZone(identifier: "UTC")!
+ func parse(_ day: String) -> Date? {
+ let parts = day.split(separator: "-").compactMap { Int($0) }
+ guard parts.count == 3 else { return nil }
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = utc
+ return calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2]))
+ }
+ guard let a = parse(earlier), let b = parse(later) else { return nil }
+ return Int((b.timeIntervalSince(a) / 86_400).rounded())
+ }
+
+ /// Credits `points` of food to the local date at `date`. When this makes the day fed, the streak
+ /// grows (or starts again after three unfed days), and a milestone it reaches for the first time
+ /// in this streak is returned: the caller celebrates it at the meal, or at the first moment the
+ /// pet is seen that day.
+ public mutating func credit(_ points: Double, at date: Date, timeZone: TimeZone) -> Int? {
+ guard points > 0 else { return nil }
+ let today = Self.day(date, in: timeZone)
+ let before = days[today] ?? 0
+ days[today] = before + points
+ trimDays()
+ guard before < PetTuning.Moments.fedDay, before + points >= PetTuning.Moments.fedDay else { return nil }
+ // Fed today. A date already counted (seen twice after a time zone change) is never counted
+ // again, and a date before the last fed one (the zone moved west) adds nothing.
+ if let last = streak.lastFedDay {
+ guard let gap = Self.days(from: last, to: today), gap > 0 else { return nil }
+ if gap - 1 > PetTuning.Moments.restDays {
+ streak = PetStreak(count: 1, lastFedDay: today, celebrated: [])
+ } else {
+ streak.count += 1
+ streak.lastFedDay = today
+ }
+ } else {
+ streak = PetStreak(count: 1, lastFedDay: today, celebrated: [])
+ }
+ guard PetTuning.Moments.milestones.contains(streak.count), !streak.celebrated.contains(streak.count)
+ else { return nil }
+ streak.celebrated.append(streak.count)
+ return streak.count
+ }
+
+ /// An account's weekly period closed with its high-water mark at `used`. A good week (70 % or more)
+ /// is recorded once per account and period, gets its line for a day, and is returned as true when it
+ /// may be celebrated: at most one celebration a day, the others get only the line.
+ public mutating func weekClosed(account: String, resetAt: Date, used: Double) -> Bool {
+ guard used >= PetTuning.Moments.goodWeek else { return false }
+ let key = "\(account)@\(Int(resetAt.timeIntervalSince1970))"
+ guard !goodWeeks.contains(key) else { return false }
+ goodWeeks.append(key)
+ if goodWeeks.count > PetTuning.Store.keptGoodWeeks {
+ goodWeeks.removeFirst(goodWeeks.count - PetTuning.Store.keptGoodWeeks)
+ }
+ goodWeekLines.append(PetGoodWeekLine(account: account, used: used, at: resetAt))
+ goodWeekLines = goodWeekLines.filter { resetAt.timeIntervalSince($0.at) < PetTuning.Moments.goodWeekLine }
+ return true
+ }
+
+ /// Whether a good-week celebration may play at `now`, with none in the last day.
+ public func mayCelebrateGoodWeek(at now: Date) -> Bool {
+ guard let last = lastGoodWeekCelebratedAt else { return true }
+ return abs(now.timeIntervalSince(last)) >= PetTuning.Moments.celebrationEvery
+ }
+
+ /// The lines live at `now`, newest first: the streak's on the day its milestone was reached, and
+ /// each good week's for a day after it closed.
+ public func lines(at now: Date, timeZone: TimeZone) -> [PetMomentLine] {
+ var lines: [PetMomentLine] = []
+ if PetTuning.Moments.milestones.contains(streak.count), streak.celebrated.contains(streak.count),
+ streak.lastFedDay == Self.day(now, in: timeZone) {
+ lines.append(.streak(days: streak.count))
+ }
+ for line in goodWeekLines.sorted(by: { $0.at > $1.at }) {
+ let age = now.timeIntervalSince(line.at)
+ if age >= 0 && age < PetTuning.Moments.goodWeekLine {
+ lines.append(.goodWeek(account: line.account, used: line.used))
+ }
+ }
+ return lines
+ }
+
+ /// Keeps the newest `PetTuning.Store.keptDays` dates. `yyyy-MM-dd` sorts by date.
+ mutating func trimDays() {
+ guard days.count > PetTuning.Store.keptDays else { return }
+ for day in days.keys.sorted().prefix(days.count - PetTuning.Store.keptDays) { days[day] = nil }
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetNoise.swift b/Sources/BrowKit/Pet/PetNoise.swift
new file mode 100644
index 0000000..4bd77a9
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetNoise.swift
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// Where all of the pet's variety comes from. Neither the brain nor the rig calls `random()`: every
+/// phase, quirk and choice is `hash(seed, channel, index)`, so the same pet does the same thing at the
+/// same moment on every run and in every render, and two pets (two seeds) differ.
+///
+/// The arithmetic is integer only, and the fraction is the top 53 bits of the result, which a
+/// `Double` holds exactly: the numbers are the same on every Mac, and a pet keeps its quirks across
+/// launches and updates as long as this function does not change.
+public enum PetNoise {
+ /// One stream of numbers. Each use draws from a channel of its own, so a new use never shifts what
+ /// an existing one gets. The brain numbers its channels from 0x100, the rig from 0x200 and the
+ /// controller and views that host them from 0x300, each as a `static let` in its own files.
+ public struct Channel: Sendable, Hashable {
+ public let raw: UInt64
+ public init(_ raw: UInt64) { self.raw = raw }
+ }
+
+ /// A uniform number in [0, 1) for (seed, channel, index): SplitMix64 applied to each in turn.
+ public static func hash(_ seed: UInt64, _ channel: Channel, _ index: Int) -> Double {
+ let mixed = splitMix64(splitMix64(splitMix64(seed) ^ channel.raw) ^ UInt64(bitPattern: Int64(index)))
+ return Double(mixed >> 11) * 0x1p-53
+ }
+
+ /// One SplitMix64 output for the state `x`: the golden-ratio step, then the finaliser. A
+ /// bijection, so different inputs never collide, and one changed input bit changes about half
+ /// of the output's.
+ static func splitMix64(_ x: UInt64) -> UInt64 {
+ var z = x &+ 0x9E37_79B9_7F4A_7C15
+ z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
+ z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
+ return z ^ (z >> 31)
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetPalette.swift b/Sources/BrowKit/Pet/PetPalette.swift
new file mode 100644
index 0000000..cc4d276
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetPalette.swift
@@ -0,0 +1,139 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import SwiftUI
+
+/// An sRGB colour as three numbers, because the canvas blends them: the pet's body mixes its light
+/// with its drained shell by how hungry it is.
+public struct PetRGB: Sendable, Hashable {
+ public var red: Double
+ public var green: Double
+ public var blue: Double
+
+ public init(red: Double, green: Double, blue: Double) {
+ self.red = red
+ self.green = green
+ self.blue = blue
+ }
+
+ /// `0xFFC65C` is (255, 198, 92).
+ public init(hex: UInt32) {
+ self.init(red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255,
+ blue: Double(hex & 0xFF) / 255)
+ }
+
+ public func color(opacity: Double = 1) -> Color {
+ Color(.sRGB, red: red, green: green, blue: blue, opacity: min(max(opacity, 0), 1))
+ }
+}
+
+/// The colours the pet is drawn in, one case per look, so a look can change without the rig changing.
+/// Each is a whole set: the ink and the shade are matched to the light's hue, because an orange ink's
+/// soft edges on a gold body read as orange.
+public enum PetPalette: String, Sendable, Hashable, CaseIterable {
+ /// The first look: honey-orange light in an amber shell, terracotta-brown when hungry. Kept to
+ /// compare the others with.
+ case original
+ /// Gold light; hungry, a muted gold shell, so hunger reads as a lamp turned down rather than as
+ /// brown; rose cheeks. Its glow is clearly not Brow's orange warning dot, which the original's
+ /// nearly is.
+ case paleGold
+ /// A warm white with little saturation; hungry, a faded ivory. The least warm of the looks.
+ case cream
+ /// Yellow-green light; hungry, a pale sage-cream, the lantern out.
+ case firefly
+
+ public var colors: Colors {
+ switch self {
+ case .original: return .original
+ case .paleGold: return .paleGold
+ case .cream: return .cream
+ case .firefly: return .firefly
+ }
+ }
+
+ /// Every colour the canvas uses, by what it is for.
+ public struct Colors: Sendable, Hashable {
+ /// The light inside, from its hot core out to its edge: the pet fed.
+ public var lightCore: PetRGB
+ public var lightMid: PetRGB
+ public var lightEdge: PetRGB
+ /// The shell where the light has drained: the pet hungry.
+ public var dimCore: PetRGB
+ public var dimEdge: PetRGB
+ /// The darker edge of a self-lit jelly.
+ public var shade: PetRGB
+ public var rim: PetRGB
+ /// The glow around the body and the light on the floor under it. Also the panel meter's fill:
+ /// the pet's accent, never a severity colour, so hunger never reads as a limit.
+ public var glow: PetRGB
+ public var limb: PetRGB
+ public var limbDark: PetRGB
+ /// The eyes, the brows and the line of the mouth.
+ public var ink: PetRGB
+ public var mouth: PetRGB
+ public var tongue: PetRGB
+ public var blush: PetRGB
+ /// The satisfied flare as a bite lands in the belly, and the pulse of light it sends out.
+ public var flare: PetRGB
+ public var ring: PetRGB
+ /// Bits of light: crumbs in two tones, sparkles, Zzz, the hungry tummy's rumble lines, the hiccup's
+ /// bubble and its pop.
+ public var crumbLight: PetRGB
+ public var crumbWarm: PetRGB
+ public var sparkle: PetRGB
+ public var zzz: PetRGB
+ public var rumble: PetRGB
+ public var bubble: PetRGB
+ public var burst: PetRGB
+ /// Pale light is more luminous than orange at the same opacity, so each look scales its halo,
+ /// floor light and trails by this, to throw the same light onto the digits as the original
+ /// (`PetTuning.Light.glowAtReadout`).
+ public var glowGain: Double
+ }
+}
+
+extension PetPalette.Colors {
+ public static let original = PetPalette.Colors(
+ lightCore: PetRGB(hex: 0xFFEDB0), lightMid: PetRGB(hex: 0xFFC65C), lightEdge: PetRGB(hex: 0xF0922E),
+ dimCore: PetRGB(hex: 0xCB7430), dimEdge: PetRGB(hex: 0x8E4219), shade: PetRGB(hex: 0x5C2208),
+ rim: PetRGB(hex: 0xFFF4D6), glow: PetRGB(hex: 0xFFA83A), limb: PetRGB(hex: 0xE58A2C),
+ limbDark: PetRGB(hex: 0xB0581C), ink: PetRGB(hex: 0x2A1206), mouth: PetRGB(hex: 0x5A160C),
+ tongue: PetRGB(hex: 0xFF7A6A), blush: PetRGB(hex: 0xFF5F57), flare: PetRGB(hex: 0xFFEDB0),
+ ring: PetRGB(hex: 0xFFC968), crumbLight: PetRGB(hex: 0xFFF6DF), crumbWarm: PetRGB(hex: 0xFFD98E),
+ sparkle: PetRGB(hex: 0xFFF3D0), zzz: PetRGB(hex: 0xFFE6BC), rumble: PetRGB(hex: 0xFFB45A),
+ bubble: PetRGB(hex: 0xFFE2A8), burst: PetRGB(hex: 0xFFE7B5), glowGain: 1.0)
+
+ public static let paleGold = PetPalette.Colors(
+ lightCore: PetRGB(hex: 0xFFF7D1), lightMid: PetRGB(hex: 0xFFEA8A), lightEdge: PetRGB(hex: 0xF2CF52),
+ dimCore: PetRGB(hex: 0xDBBF58), dimEdge: PetRGB(hex: 0xA38934), shade: PetRGB(hex: 0x4D3D11),
+ rim: PetRGB(hex: 0xFFF9E0), glow: PetRGB(hex: 0xFFDD61), limb: PetRGB(hex: 0xF0CC54),
+ limbDark: PetRGB(hex: 0xB89637), ink: PetRGB(hex: 0x2A2410), mouth: PetRGB(hex: 0x4E1A24),
+ tongue: PetRGB(hex: 0xFF8C9C), blush: PetRGB(hex: 0xFF7C92), flare: PetRGB(hex: 0xFFF7D1),
+ ring: PetRGB(hex: 0xFFE27A), crumbLight: PetRGB(hex: 0xFFFBEA), crumbWarm: PetRGB(hex: 0xFFE68A),
+ sparkle: PetRGB(hex: 0xFFFAE2), zzz: PetRGB(hex: 0xFFF0C0), rumble: PetRGB(hex: 0xF2D46A),
+ bubble: PetRGB(hex: 0xFFF0B8), burst: PetRGB(hex: 0xFFF4CC), glowGain: 0.80)
+
+ /// Cream, with a drained shell (`0xDCD0B6 → 0x9C907A`) deep enough for hunger, the meter the strip
+ /// shows, to read at real size: a paler one (`0xECE2CA → 0xB5AA90`) sat so close to the light that
+ /// it barely did. Still an ivory, not a taupe, which reads as grey stone.
+ public static let cream = PetPalette.Colors(
+ lightCore: PetRGB(hex: 0xFFFFF6), lightMid: PetRGB(hex: 0xFFF4D8), lightEdge: PetRGB(hex: 0xF5E2BC),
+ dimCore: PetRGB(hex: 0xDCD0B6), dimEdge: PetRGB(hex: 0x9C907A), shade: PetRGB(hex: 0x4A4334),
+ rim: PetRGB(hex: 0xFFFFFF), glow: PetRGB(hex: 0xFFEDC8), limb: PetRGB(hex: 0xF6E8C8),
+ limbDark: PetRGB(hex: 0xCDBC96), ink: PetRGB(hex: 0x2A2218), mouth: PetRGB(hex: 0x4C1A26),
+ tongue: PetRGB(hex: 0xFF8C9E), blush: PetRGB(hex: 0xFF7F98), flare: PetRGB(hex: 0xFFFFF6),
+ ring: PetRGB(hex: 0xFFF3D6), crumbLight: PetRGB(hex: 0xFFFFFF), crumbWarm: PetRGB(hex: 0xFFF1D0),
+ sparkle: PetRGB(hex: 0xFFFFFF), zzz: PetRGB(hex: 0xFFF6E2), rumble: PetRGB(hex: 0xEFDDB4),
+ bubble: PetRGB(hex: 0xFFF6E0), burst: PetRGB(hex: 0xFFFFFF), glowGain: 0.75)
+
+ public static let firefly = PetPalette.Colors(
+ lightCore: PetRGB(hex: 0xFCFFE6), lightMid: PetRGB(hex: 0xEEF7A2), lightEdge: PetRGB(hex: 0xD2E36C),
+ dimCore: PetRGB(hex: 0xDCE2B4), dimEdge: PetRGB(hex: 0xA0AA78), shade: PetRGB(hex: 0x333A1E),
+ rim: PetRGB(hex: 0xFBFFE8), glow: PetRGB(hex: 0xDCF07C), limb: PetRGB(hex: 0xD8E87E),
+ limbDark: PetRGB(hex: 0xA2B256), ink: PetRGB(hex: 0x1E2410), mouth: PetRGB(hex: 0x3E1A22),
+ tongue: PetRGB(hex: 0xFF8C9E), blush: PetRGB(hex: 0xFF8CA0), flare: PetRGB(hex: 0xFCFFE6),
+ ring: PetRGB(hex: 0xE6F59E), crumbLight: PetRGB(hex: 0xFBFFEC), crumbWarm: PetRGB(hex: 0xE8F4A0),
+ sparkle: PetRGB(hex: 0xFCFFEA), zzz: PetRGB(hex: 0xF2FAD0), rumble: PetRGB(hex: 0xD8EC8A),
+ bubble: PetRGB(hex: 0xF0F8C8), burst: PetRGB(hex: 0xF8FFE0), glowGain: 0.78)
+}
diff --git a/Sources/BrowKit/Pet/PetPerfLog.swift b/Sources/BrowKit/Pet/PetPerfLog.swift
new file mode 100644
index 0000000..1d5e893
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetPerfLog.swift
@@ -0,0 +1,85 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Darwin
+import Foundation
+
+/// `open Brow.app --args -petPerfLog YES`: once a minute, to `BrowLog.pet`, the frames drawn in each
+/// frame class, the main thread's time per frame (the layer's body with its pose, and the canvas drawing
+/// it), and the process CPU over the minute. Nothing here exists unless it was asked for.
+///
+/// log stream --predicate 'subsystem == "dev.artemefimov.brow" AND category == "pet"'
+///
+/// Fed from the main thread only (the layer's `TimelineView` and its `Canvas`), so it needs no lock.
+final class PetPerfLog: @unchecked Sendable {
+ private var frames: [PetFrameClass: Int] = [:]
+ private var bodies: [Double] = []
+ private var draws: [Double] = []
+ private var timer: Timer?
+ private var since = Date()
+ private var cpuSince = PetPerfLog.processCPU()
+
+ func start() {
+ guard timer == nil else { return }
+ since = Date()
+ cpuSince = Self.processCPU()
+ let timer = Timer(timeInterval: PetTuning.Budget.logEvery, repeats: true) { [weak self] _ in self?.flush() }
+ RunLoop.main.add(timer, forMode: .common)
+ self.timer = timer
+ }
+
+ func stop() {
+ timer?.invalidate()
+ timer = nil
+ }
+
+ /// One frame of the layer: its class and how long its body took, the pose included.
+ func frame(_ frameClass: PetFrameClass, body: Duration) {
+ frames[frameClass, default: 0] += 1
+ bodies.append(Self.seconds(body))
+ }
+
+ /// How long the canvas took to draw one frame.
+ func draw(_ duration: Duration) {
+ draws.append(Self.seconds(duration))
+ }
+
+ private func flush() {
+ let now = Date()
+ let cpu = Self.processCPU()
+ let wall = max(now.timeIntervalSince(since), 1)
+ let percent = 100 * (cpu - cpuSince) / wall
+ let counts = PetFrameClass.allCases.map { "\($0) \(frames[$0, default: 0])" }.joined(separator: ", ")
+ let line = String(format: "frames %@; body mean %.3f ms p95 %.3f ms; draw mean %.3f ms p95 %.3f ms; cpu %.2f %% of a core",
+ counts, 1000 * Self.mean(bodies), 1000 * Self.p95(bodies), 1000 * Self.mean(draws),
+ 1000 * Self.p95(draws), percent)
+ BrowLog.pet.info("perf: \(line, privacy: .public)")
+ frames = [:]
+ bodies = []
+ draws = []
+ since = now
+ cpuSince = cpu
+ }
+
+ /// User plus system CPU time of the whole process, in seconds (`getrusage`).
+ static func processCPU() -> Double {
+ var usage = rusage()
+ guard getrusage(RUSAGE_SELF, &usage) == 0 else { return 0 }
+ func seconds(_ t: timeval) -> Double { Double(t.tv_sec) + Double(t.tv_usec) / 1_000_000 }
+ return seconds(usage.ru_utime) + seconds(usage.ru_stime)
+ }
+
+ static func seconds(_ d: Duration) -> Double {
+ Double(d.components.seconds) + Double(d.components.attoseconds) * 1e-18
+ }
+
+ static func mean(_ values: [Double]) -> Double {
+ values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count)
+ }
+
+ static func p95(_ values: [Double]) -> Double {
+ guard !values.isEmpty else { return 0 }
+ let sorted = values.sorted()
+ return sorted[min(sorted.count - 1, Int(Double(sorted.count) * 0.95))]
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetPose.swift b/Sources/BrowKit/Pet/PetPose.swift
new file mode 100644
index 0000000..86c8fde
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetPose.swift
@@ -0,0 +1,220 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// One posed instant of the pet: everything `PetCanvas` draws, and what the layer and the hit test need
+/// from it. Every position is in window coordinates, like `PetZone`'s. Two frames of the same arguments
+/// are equal bit for bit, which is what the determinism tests compare.
+public struct PetFrame: Sendable, Equatable {
+ /// The pet.
+ public var pet: PetBody
+ /// A second, fading pet: only while Reduce Motion crossfades the strip's pet out and the header's
+ /// in, which overlap for 0.05 s. nil otherwise.
+ public var echo: PetBody?
+ /// Crumbs, sparkles, Zzz, rumble lines, the splash's ring: at most `PetTuning.Particles.maximumAlive`.
+ public var particles: [PetParticle] = []
+ /// The food: a bead of light that pops out of the number and is inhaled along an arc.
+ public var bead: PetBead?
+ /// A glint on the ripe digit before a meal: light added over the readout, never a moved or hidden
+ /// glyph.
+ public var glint: PetGlint?
+ /// Below, hibernating: the light under the door, a line of glow along the notch's bottom edge.
+ public var doorLight: PetDoorLight?
+ /// The box of the body, arms and feet: the boop's hit test (with `PetTuning.Boop.hitSlop`) and the
+ /// zone tests. `.null` while nothing is drawn.
+ public var bodyBox: CGRect = .null
+ /// Where the pet's own canvas sits: 64 × 44 pt at scale 1 up to 128 × 112 at scale 2, centred on
+ /// the pet and grown only when a particle or the food needs more room.
+ public var canvas: CGRect = .zero
+ /// The camera: nothing is drawn inside it, in any dock state.
+ public var notch: CGRect?
+ /// Beside, the den's edge (`notch.minX`): the side of the pet next to it falls into the camera's
+ /// shadow, so going in reads as going behind something.
+ public var denEdge: CGFloat?
+ /// The readouts' near edges, left and right of the zone: the pet's light stays under
+ /// `PetTuning.Light.glowAtReadout` past them, so it never washes over the numbers.
+ public var guardMinX: CGFloat?
+ public var guardMaxX: CGFloat?
+ /// Where food comes from and the colour of its dot (nil: grey), for the crumbs and the trace of that
+ /// colour on the near rim.
+ public var foodSource: CGPoint?
+ public var foodDot: Severity?
+ /// The display options the drawing follows: Reduce Motion, Reduce Transparency, Increase Contrast.
+ public var motion = PetMotion()
+
+ public init(pet: PetBody) {
+ self.pet = pet
+ }
+
+ /// Nothing to draw: the pet is off, dozing, or has nowhere to be.
+ public var isEmpty: Bool { pet.opacity <= 0.001 && (echo?.opacity ?? 0) <= 0.001 }
+}
+
+/// The pet's pose in one frame: Glim's rig channels, posed by `PetRig` and drawn by `PetCanvas`. Lengths
+/// are points at scale 1 times `size`.
+public struct PetBody: Sendable, Equatable {
+ // MARK: Placement
+
+ /// The point between the feet, in window coordinates.
+ public var x: Double = 0
+ /// Where the feet stand.
+ public var floorY: Double = 0
+ /// How far the feet are above the floor (a hop, the lunge).
+ public var lift: Double = 0
+ /// The zone's scale: 1 beside, 0.80 below, 0.82 on the pill, 2 in the panel.
+ public var size: Double = 1
+ /// 0 transparent ... 1 opaque.
+ public var opacity: Double = 1
+
+ // MARK: Body
+
+ /// Vertical scale; the width follows so the area is kept (`sx = 1 / sy`).
+ public var squash: Double = 1
+ /// A real change of size: a full belly, a deep breath.
+ public var girth: Double = 1
+ /// Shear: how far the top moves per point of height (+ is right).
+ public var lean: Double = 0
+ /// Rotation about the contact point, radians (+ puts the top to the right).
+ public var tilt: Double = 0
+ /// Lying down: 0 sitting up ... 1 curled into a low loaf, asleep (at most 14 pt tall beside).
+ public var curl: Double = 0
+ /// 0 ... 1: full cheeks bulge the outline; `cheekBias` -1 the left is fuller ... 1 the right.
+ public var cheekPuff: Double = 0
+ public var cheekBias: Double = 0
+ /// A swallowed bite on its way down, 0 at the mouth ... 1 in the belly; below 0, none.
+ public var lump: Double = -1
+ /// A fine shiver of the outline, already a smooth wave (a stretch's effort, a rumble).
+ public var tremor: Double = 0
+ /// The speed stretch along fast travel, area kept: `smear` along `smearAngle` (radians), 1 / smear
+ /// across it.
+ public var smear: Double = 1
+ public var smearAngle: Double = 0
+
+ // MARK: Face, painted on a sphere
+
+ /// -1 the face looks left ... 1 right; `nod` -1 down ... 1 up; `spin` a whole turn about the
+ /// vertical axis, radians (a twirl shows its back).
+ public var turn: Double = 0
+ public var nod: Double = 0
+ public var spin: Double = 0
+ /// The eyes inside the face, -1 ... 1.
+ public var lookX: Double = 0
+ public var lookY: Double = 0
+ /// 1 open ... 0 shut.
+ public var lidL: Double = 1
+ public var lidR: Double = 1
+ /// + the outer corners droop, - they lift.
+ public var lidSlant: Double = 0
+ /// 0 bean eyes ... 1 closed happy arcs.
+ public var happy: Double = 0
+ /// 0 ... 1 eyes squeezed shut with effort.
+ public var squeeze: Double = 0
+ public var eyeSize: Double = 1
+ /// Points up; tilts in radians, + raises the inner end.
+ public var browLiftL: Double = 0
+ public var browLiftR: Double = 0
+ public var browTiltL: Double = 0
+ public var browTiltR: Double = 0
+ /// 0 ... 1 (1 is a bite half the body wide); `mouthWide` multiplies the width.
+ public var mouthOpen: Double = 0
+ public var mouthWide: Double = 1
+ /// -1 frown ... 1 grin.
+ public var smile: Double = 0.4
+ /// 0 ... 1 a closed, working mouth, rippling with `chewPhase`.
+ public var chew: Double = 0
+ public var chewPhase: Double = 0
+ /// 0 ... 1 a lick at the corner of the mouth.
+ public var tongue: Double = 0
+ public var blush: Double = 0.4
+
+ // MARK: Limbs
+
+ /// Radians away from the arm's rest angle, + raised; the feet's lift in points.
+ public var armL: Double = 0
+ public var armR: Double = 0
+ public var footL: Double = 0
+ public var footR: Double = 0
+
+ // MARK: Light: the hunger meter
+
+ /// 0 empty ... 1 full ... up to 1.12 overfull (below a hunger of 0).
+ public var fullness: Double = 0.75
+ /// The light's surface: its tilt (radians, world space) and a ripple on it.
+ public var slosh: Double = 0
+ public var wave: Double = 0
+ public var wavePhase: Double = 0
+ /// Emissive multiplier: the mood's and behaviour's glow (eager 1.2, asleep 0.6, hibernating 0.3).
+ public var glow: Double = 1
+ /// The satisfied flare as a bite lands in the belly.
+ public var flash: Double = 0
+
+ public init() {}
+}
+
+/// A bit of light around the pet, already placed for the frame.
+public struct PetParticle: Sendable, Equatable {
+ public enum Kind: Sendable, Equatable {
+ /// A crumb of light; with a `streak` it trails along its motion.
+ case crumb
+ /// A four-point star.
+ case sparkle
+ /// A "z", drawn as strokes, not text.
+ case zzz
+ /// The soft pulse of light where a bite lands.
+ case ring
+ /// The hungry tummy's rumble line.
+ case rumble
+ /// A hiccup's bubble.
+ case bubble
+ /// A dust mote drifting by, which the eyes may follow.
+ case mote
+ }
+
+ /// Which colour of the palette it takes; `dot` is the food source's status dot (nil: grey).
+ public enum Tint: Sendable, Equatable {
+ case crumbLight, crumbWarm, sparkle, zzz, ring, rumble, bubble, burst
+ case dot(Severity?)
+ }
+
+ public var kind: Kind
+ public var position: CGPoint
+ public var size: Double
+ public var opacity: Double
+ public var angle: Double = 0
+ /// Where it was a moment ago, relative to `position`: a crumb's streak.
+ public var streak: CGVector = .zero
+ public var tint: Tint
+}
+
+/// The food in flight: a bead of light popped out of the number, stretched by its speed, with a short
+/// streak behind it.
+public struct PetBead: Sendable, Equatable {
+ public var position: CGPoint
+ public var radius: Double
+ /// Along the direction of travel, area kept.
+ public var stretch: Double
+ public var angle: Double
+ public var opacity: Double
+ /// Where it was over the last few hundredths of a second, newest first.
+ public var trail: [CGPoint]
+ /// The source readout's dot colour; nil is grey.
+ public var dot: Severity?
+}
+
+/// A glint on the ripe digit: a four-point twinkle of added light.
+public struct PetGlint: Sendable, Equatable {
+ public var position: CGPoint
+ public var size: Double
+ public var opacity: Double
+ public var angle: Double
+}
+
+/// Below, hibernating: a 1 pt line of glow along the notch's bottom edge, above where the pet went.
+public struct PetDoorLight: Sendable, Equatable {
+ public var minX: Double
+ public var maxX: Double
+ public var y: Double
+ public var opacity: Double
+}
diff --git a/Sources/BrowKit/Pet/PetRig.swift b/Sources/BrowKit/Pet/PetRig.swift
new file mode 100644
index 0000000..fbd0a9b
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetRig.swift
@@ -0,0 +1,453 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// The pose function: a timeline and an instant in, one `PetFrame` out. Pure: the same arguments always
+/// give the same frame, bit for bit, whatever was posed before, so any frame of any script can be rendered
+/// on its own and in any order. It is a class only so that it can keep a memo of work already done, such as
+/// an action's integrated follow-through; a memo may make a pose cheaper, never different.
+///
+/// A pose is built in layers, in this order:
+/// 1. the base: the behaviour's posture, cross-faded from the one before it, and its action keyed by
+/// time since its segment started (`PetRigActions`);
+/// 2. idle life: breath, sway, drift, blinks, glances and rare idles, from `PetNoise` (`PetRigIdle`);
+/// 3. secondary motion: closed-form steady state for the idle cycles, plus each action's transient,
+/// integrated from its start (`PetRigSecondary`);
+/// 4. gaze: the pointer, the glances, the hop's destination (`PetRigGaze`);
+/// 5. the dock: the hop, glide or crossfade between the strip and the panel (`PetLayout`);
+/// 6. constraints: area kept, the zone clamp, the notch mask (drawn by `PetCanvas`);
+/// 7. particles and the food's light (`PetRigParticles`).
+public final class PetRig {
+ let memo = PetRigMemo()
+
+ public init() {}
+
+ /// - Parameters:
+ /// - t: on the timeline's clock, `PetTime.mono` in the app.
+ /// - seed: the pet's, `PetBrain.seed`.
+ public func pose(_ timeline: PetTimeline, at t: TimeInterval, pointer: PetPointerTrack, dock: PetDockTrack,
+ zones: PetZones, seed: UInt64, motion: PetMotion) -> PetFrame {
+ let placement = PetRigDock.placement(at: t, timeline: timeline, dock: dock, zones: zones, motion: motion)
+ guard let zone = placement.zone else {
+ var empty = PetFrame(pet: PetBody())
+ empty.pet.opacity = 0
+ empty.notch = zones.notch
+ empty.motion = motion
+ return empty
+ }
+ let poser = PetRigPoser(timeline: timeline, t: t, pointer: pointer, zone: zone, seed: seed, motion: motion,
+ memo: memo, flight: placement.flight, flightSource: placement.source,
+ lookAt: placement.lookAt)
+ var frame = poser.frame()
+ frame.pet.opacity *= placement.opacity
+ if let echo = placement.echo {
+ // Reduce Motion's crossfade: the pet in the zone it is leaving, fading out.
+ let other = PetRigPoser(timeline: timeline, t: t, pointer: pointer, zone: echo.zone, seed: seed,
+ motion: motion, memo: memo, flight: nil, flightSource: nil, lookAt: nil)
+ var pet = other.frame().pet
+ pet.opacity *= echo.opacity
+ frame.echo = pet
+ if pet.opacity > 0.001 {
+ frame.canvas = frame.canvas.union(PetRigPoser.canvas(for: pet, extras: []))
+ }
+ }
+ frame.notch = zones.notch
+ return frame
+ }
+}
+
+/// Noise channels of the rig, 0x200 to 0x2FF. One per use, so a new use never shifts an old one.
+/// `PetDrift` takes a channel's second octave from the same channel with bit 0x80 set, so the drift
+/// channels stay below 0x280.
+enum PetRigChannel {
+ static let breathPhase = PetNoise.Channel(0x200)
+ static let swayPhase = PetNoise.Channel(0x201)
+ static let shiftPhase = PetNoise.Channel(0x202)
+ static let blinkRate = PetNoise.Channel(0x203)
+ static let blinkAt = PetNoise.Channel(0x204)
+ static let blinkDouble = PetNoise.Channel(0x205)
+ static let glanceAt = PetNoise.Channel(0x207)
+ static let glanceTarget = PetNoise.Channel(0x208)
+ static let glanceHold = PetNoise.Channel(0x209)
+ static let rareAt = PetNoise.Channel(0x20A)
+ static let rareKind = PetNoise.Channel(0x20B)
+ static let smallAt = PetNoise.Channel(0x20C)
+ static let smallKind = PetNoise.Channel(0x20D)
+ static let rumbleAt = PetNoise.Channel(0x20E)
+ static let bounceAt = PetNoise.Channel(0x20F)
+ static let particle = PetNoise.Channel(0x210)
+ static let tiltAt = PetNoise.Channel(0x211)
+ static let drowsyBlink = PetNoise.Channel(0x212)
+ static let side = PetNoise.Channel(0x213)
+ // Drift, first octaves (0x240 to 0x27F); their second octaves are 0x2C0 to 0x2FF.
+ static let driftTurn = PetNoise.Channel(0x240)
+ static let driftNod = PetNoise.Channel(0x241)
+ static let driftLookX = PetNoise.Channel(0x242)
+ static let driftLookY = PetNoise.Channel(0x243)
+ static let driftBrowL = PetNoise.Channel(0x244)
+ static let driftBrowR = PetNoise.Channel(0x245)
+ static let driftArmL = PetNoise.Channel(0x246)
+ static let driftArmR = PetNoise.Channel(0x247)
+ static let driftLean = PetNoise.Channel(0x248)
+ static let driftBreath = PetNoise.Channel(0x249)
+ static let driftMote = PetNoise.Channel(0x24A)
+}
+
+/// One pose, in one zone: everything `PetRig.pose` needs while it builds the frame.
+struct PetRigPoser {
+ let timeline: PetTimeline
+ let t: Double
+ let pointer: PetPointerTrack
+ let zone: PetZone
+ let seed: UInt64
+ let motion: PetMotion
+ let memo: PetRigMemo
+ /// The hop or glide in progress, overlaying position, scale and squash.
+ let flight: PetLayout.Flight?
+ let flightSource: PetRigSource?
+ /// Where the eyes go during a flight: its destination.
+ let lookAt: CGPoint?
+
+ var s: Double { Double(zone.scale) }
+
+ /// -1 when the food is to the left (beside, the pill), 1 to the right (below, the panel).
+ var foodSide: Double { Double(zone.foodSource.x) < Double(zone.anchor.x) ? -1 : 1 }
+
+ /// The index of the segment playing at the instant; nil before the first one.
+ var segmentIndex: Int? { PetRigTimeline.index(timeline.segments, at: t) }
+
+ func frame() -> PetFrame {
+ let segments = timeline.segments
+ let index = segmentIndex
+ let beat = PetRigBeat(segments: segments, index: index, t: t)
+ var posture = PetRigTimeline.posture(segments, index: index, at: t, scene: self)
+ if motion.reduceMotion { posture.breathAmp *= PetTuning.Reduced.breath }
+
+ // 1. The base: the posture, then the behaviour's action.
+ var body = posture.body
+ body.x = Double(zone.anchor.x)
+ body.floorY = Double(zone.anchor.y)
+ body.size = s
+ body.fullness = PetRigMeter.fullness(timeline.meter, segments: segments, at: t, reduceMotion: motion.reduceMotion)
+ body.flash = PetRigMeter.flare(timeline.meter, segments: segments, at: t, reduceMotion: motion.reduceMotion)
+ body.wavePhase = t * 2 * .pi * 1.3
+ PetRigActions.place(&body, beat: beat, scene: self)
+ PetRigActions.apply(&body, beat: beat, scene: self)
+ PetRigActions.fadeCut(&body, beat: beat, scene: self)
+ PetRigActions.feastBelly(&body, segments: segments, t: t)
+ if !motion.reduceMotion { PetRigActions.smear(&body, beat: beat, scene: self) }
+
+ // 2. Idle life.
+ PetRigIdle.apply(&body, posture: posture, beat: beat, scene: self)
+
+ // 3. Secondary motion.
+ if !motion.reduceMotion {
+ PetRigSecondary.apply(&body, posture: posture, beat: beat, scene: self)
+ }
+
+ // 4. Gaze.
+ PetRigGaze.apply(&body, posture: posture, beat: beat, scene: self)
+
+ // 5. The dock's flight.
+ if let flight {
+ body.x += flight.feet.x - Double(zone.anchor.x)
+ body.floorY = flight.feet.y
+ body.size = flight.scale
+ body.squash *= flight.squash
+ body.smear = flight.smear
+ body.smearAngle = flight.smearAngle
+ body.armL += 0.8 * flight.airborne
+ body.armR += 0.9 * flight.airborne
+ body.footL -= 0.8 * flight.airborne
+ body.footR -= 0.6 * flight.airborne
+ }
+
+ // 6. Constraints.
+ if motion.reduceMotion { PetRigConstraints.reduce(&body) }
+ let hiding = PetRigGaze.hideStart(scene: self, beat: beat).map { t - $0 < PetRigGaze.hideLength } ?? false
+ let den = PetRigActions.denPose(beat) || hiding
+ PetRigConstraints.clamp(&body, zone: zone, allowDen: den, flying: flight != nil && !(flight?.landed ?? true))
+ if beat.behaviour.isOff { body.opacity = PetRigActions.offOpacity(beat) }
+ body.opacity *= PetRigActions.fadeInAfterOff(beat)
+ // Over the black of the strip and the panel, drawing the pet fainter is drawing it dimmer.
+ body.opacity *= posture.light
+
+ // 7. Particles and the food.
+ let extras = PetRigParticles.make(body: body, beat: beat, scene: self)
+
+ var frame = PetFrame(pet: body)
+ frame.particles = extras.particles
+ frame.bead = extras.bead
+ frame.glint = extras.glint
+ frame.doorLight = extras.door
+ let geometry = PetBodyGeometry(body)
+ frame.bodyBox = body.opacity > 0.001 ? geometry.box() : .null
+ frame.canvas = Self.canvas(for: body, extras: extras.points)
+ if case .side(let edge) = zone.den { frame.denEdge = edge }
+ let guards = PetLayout.guards(zone)
+ frame.guardMinX = guards.min
+ frame.guardMaxX = guards.max
+ frame.foodSource = zone.foodSource
+ frame.foodDot = zone.foodDot
+ frame.motion = motion
+ return frame
+ }
+
+ /// The pet's own canvas: 64 × 44 pt at scale 1, 128 × 112 at scale 2, centred on the body and grown
+ /// to hold the whole halo and whatever particle or bead is out beyond it, so no soft edge is ever cut
+ /// by the canvas's own.
+ static func canvas(for body: PetBody, extras: [CGRect]) -> CGRect {
+ typealias C = PetTuning.Canvas
+ let s = body.size
+ let k = PetCurve.clamp(s - 1, 0, 1)
+ let width = s < 1 ? C.width * s : PetCurve.mix(C.width, C.panelWidth, k)
+ let height = s < 1 ? C.height * s : PetCurve.mix(C.height, C.panelHeight, k)
+ let bottom = body.floorY + 6 * s
+ var rect = CGRect(x: body.x - width / 2, y: bottom - height, width: width, height: height)
+ rect = rect.union(PetCanvas.lightBox(PetBodyGeometry(body)))
+ for r in extras { rect = rect.union(r) }
+ return rect.integral
+ }
+}
+
+/// The segment playing at an instant, its neighbours and the time into it.
+struct PetRigBeat {
+ let behaviour: PetBehaviour
+ /// When it started; `-infinity` before the first segment.
+ let start: Double
+ /// When the next segment starts, if one is planned.
+ let end: Double?
+ let elapsed: Double
+ let previous: PetSegment?
+ let index: Int?
+
+ init(segments: [PetSegment], index: Int?, t: Double) {
+ self.index = index
+ if let i = index {
+ behaviour = segments[i].behaviour
+ start = segments[i].start
+ end = i + 1 < segments.count ? segments[i + 1].start : nil
+ previous = i > 0 ? segments[i - 1] : nil
+ } else {
+ // Before anything is planned the pet simply idles, content.
+ behaviour = .idle(.content)
+ start = -.infinity
+ end = segments.first?.start
+ previous = nil
+ }
+ elapsed = start.isFinite ? t - start : .infinity
+ }
+
+ /// How long this segment lasts: its planned duration, cut short by the next segment.
+ var length: Double? {
+ let own = behaviour.duration
+ if let end, start.isFinite { return min(own ?? .infinity, end - start) }
+ return own
+ }
+}
+
+extension PetBehaviour {
+ var isOff: Bool { if case .off = self { return true }; return false }
+}
+
+/// Finding segments and blending their postures.
+enum PetRigTimeline {
+ /// The last segment that has started by `t`.
+ static func index(_ segments: [PetSegment], at t: Double) -> Int? {
+ guard let first = segments.first, first.start <= t else { return nil }
+ var lo = 0, hi = segments.count - 1
+ while lo < hi {
+ let mid = (lo + hi + 1) / 2
+ if segments[mid].start <= t { lo = mid } else { hi = mid - 1 }
+ }
+ return lo
+ }
+
+ /// The posture at `t`: the segment's own, cross-faded in from wherever the one before it had got to
+ /// when it started. A short segment's predecessor may still have been blending, so this looks back
+ /// a few segments; past that, a posture has long arrived.
+ static func posture(_ segments: [PetSegment], index: Int?, at t: Double, scene: PetRigPoser, depth: Int = 0) -> PetPosture {
+ guard let i = index else { return PetRigActions.posture(.idle(.content), elapsed: .infinity, scene: scene) }
+ let segment = segments[i]
+ let own = PetRigActions.posture(segment.behaviour, elapsed: t - segment.start, scene: scene)
+ guard i > 0, depth < 4 else { return own }
+ let blend = PetRigActions.blendIn(segment.behaviour, after: segments[i - 1].behaviour)
+ let u = (t - segment.start) / blend
+ guard u < 1 else { return own }
+ let from = posture(segments, index: i - 1, at: segment.start, scene: scene, depth: depth + 1)
+ return PetPosture.mixed(from, own, PetEase.smoother(u))
+ }
+}
+
+/// The hunger meter as the pet's light.
+enum PetRigMeter {
+ /// What the meter shows at `t`: H + pendingDrop, before the clamp. No key at all reads as a new pet.
+ static func value(_ keys: [PetMeterKey], at t: Double) -> Double {
+ guard let first = keys.first else { return PetTuning.Hunger.newPet }
+ guard first.t <= t else { return first.value }
+ var lo = 0, hi = keys.count - 1
+ while lo < hi {
+ let mid = (lo + hi + 1) / 2
+ if keys[mid].t <= t { lo = mid } else { hi = mid - 1 }
+ }
+ let k = keys[lo]
+ return k.value + k.rate * (t - k.t)
+ }
+
+ /// How much light the pet holds: 1 fed to the crown at H = 0, a little overfull below it, a low glow
+ /// at H = 100. A meal's drop (keyed at its swallow) is eased in as the swallowed light splashes into
+ /// the belly, rather than cut; any other jump eases over 0.6 s; Reduce Motion takes it at once.
+ static func fullness(_ keys: [PetMeterKey], segments: [PetSegment], at t: Double, reduceMotion: Bool) -> Double {
+ var h = value(keys, at: t)
+ if !reduceMotion, let jump = lastJump(keys, at: t) {
+ let food = jump.delta < 0
+ let delay = food ? splash(at: jump.t, segments: segments) : 0
+ let duration = food ? 0.3 : 0.6
+ let u = (t - jump.t - delay) / duration
+ if u < 1 { h -= jump.delta * (1 - (food ? PetEase.outCubic(u) : PetEase.smooth(u))) }
+ }
+ return light(h)
+ }
+
+ /// From a meal's swallow to the moment its light lands in the belly. The meal is the `eat` whose
+ /// swallow the key marks; any other drop lands a quarter second in.
+ static func splash(at keyTime: Double, segments: [PetSegment]) -> Double {
+ for seg in segments.reversed() where seg.start <= keyTime {
+ if case .eat(let meal) = seg.behaviour, !meal.reduceMotion,
+ abs(seg.start + meal.swallowStart - keyTime) < 1e-6 {
+ let m = PetRigActions.timing(meal)
+ return m.splash - m.swallowStart
+ }
+ if keyTime - seg.start > PetRigActions.longestAction { break }
+ }
+ return 0.25
+ }
+
+ /// The satisfied flare as the light lands: it eases in over a quarter of a second, 15
+ /// frames at 60 fps, to a soft peak and dies away over half a second, so the pet's light swells rather
+ /// than flashes; with the fuller light it keeps and the splash's ring, the region round it brightens
+ /// by about a tenth, never more than a couple of percent in one frame. It follows the meter's drop, so
+ /// it carries on across the end of the `eat` into whatever follows it.
+ static func flare(_ keys: [PetMeterKey], segments: [PetSegment], at t: Double, reduceMotion: Bool) -> Double {
+ guard !reduceMotion, let jump = lastJump(keys, at: t), jump.delta < 0 else { return 0 }
+ let start = jump.t + splash(at: jump.t, segments: segments)
+ let size = PetCurve.clamp(-jump.delta / 30, 0, 1)
+ return (0.05 + 0.05 * size) * PetCurve.envelope(t, start, start + 0.25, start + 0.3, start + 0.8,
+ up: PetEase.inOutSine, down: PetEase.inOutSine)
+ }
+
+ static func light(_ h: Double) -> Double {
+ let hunger = PetCurve.clamp(h, PetTuning.Hunger.minimum, PetTuning.Hunger.maximum)
+ return hunger >= 0 ? PetCurve.mix(1.0, 0.12, hunger / 100) : PetCurve.mix(1.0, 1.12, -hunger / 50)
+ }
+
+ /// The most recent key within the last 1.4 s whose value is not where the piece before it had got to:
+ /// a step, and how big.
+ static func lastJump(_ keys: [PetMeterKey], at t: Double) -> (t: Double, delta: Double)? {
+ guard keys.count > 1 else { return nil }
+ var i = keys.count - 1
+ while i > 0 && keys[i].t > t { i -= 1 }
+ while i > 0 && t - keys[i].t < 1.4 {
+ let before = keys[i - 1]
+ let reached = before.value + before.rate * (keys[i].t - before.t)
+ let delta = keys[i].value - reached
+ if abs(delta) > 1e-9 { return (keys[i].t, delta) }
+ i -= 1
+ }
+ return nil
+ }
+}
+
+/// The pose's extremes (lean, squash and stretch), the zone it stays inside, and Reduce Motion's limits.
+enum PetRigConstraints {
+ /// Lean is a shear and tilt a rotation; together they lean the body at most this far.
+ static let maxLean = PetTuning.Zone.maxLeanDegrees * .pi / 180
+
+ /// Reduce Motion: no squash or stretch beyond ±2 %, no speed stretch, a still outline.
+ static func reduce(_ body: inout PetBody) {
+ let r = PetTuning.Reduced.squash
+ body.squash = PetCurve.clamp(body.squash, 1 - r, 1 + r)
+ body.smear = 1
+ body.tremor = 0
+ body.lift = 0
+ body.lean = PetCurve.clamp(body.lean, -0.04, 0.04)
+ body.tilt = PetCurve.clamp(body.tilt, -0.05, 0.05)
+ body.spin = 0
+ }
+
+ /// Keeps the pose inside the zone: the extremes first, then the box. Reach is given up first (the
+ /// body slides back), then lift and stretch, then squash's width; den poses may pass the bounds on
+ /// the den's side. Deterministic, at most six passes.
+ static func clamp(_ body: inout PetBody, zone: PetZone, allowDen: Bool, flying: Bool) {
+ let maxStretch = Double(zone.maxStretch)
+ body.squash = PetCurve.clamp(body.squash, PetTuning.Zone.minimumSquash, maxStretch)
+ // The speed stretch along the travel, and its squash on contact, keep the hop's limits: the
+ // stretch at most 1 + `PetTuning.Hop.speedStretchMaximum`, the squash no deeper than
+ // `PetTuning.Zone.minimumSquash`.
+ body.smear = PetCurve.clamp(body.smear, PetTuning.Zone.minimumSquash, 1 + PetTuning.Hop.speedStretchMaximum)
+ let angle = atan(body.lean) + body.tilt
+ if abs(angle) > maxLean {
+ let k = maxLean / abs(angle)
+ body.lean = tan(atan(body.lean) * k)
+ body.tilt *= k
+ }
+ // In flight the hop's own path keeps the pet inside the opening outline; the zone is where it
+ // lands, not where it flies.
+ guard !flying else { return }
+ let bounds = zone.bounds
+ for _ in 0..<6 {
+ let box = PetBodyGeometry(body).box()
+ var moved = false
+ let denRight: Bool, denUp: Bool
+ switch zone.den {
+ case .side: denRight = allowDen; denUp = false
+ case .overhead: denRight = false; denUp = allowDen
+ case .none: denRight = false; denUp = false
+ }
+ if box.minX < bounds.minX - 0.001 {
+ body.x += Double(bounds.minX - box.minX)
+ moved = true
+ }
+ if !denRight, box.maxX > bounds.maxX + 0.001 {
+ body.x -= Double(box.maxX - bounds.maxX)
+ moved = true
+ }
+ if !denUp, box.minY < bounds.minY - 0.001 {
+ let over = Double(bounds.minY - box.minY)
+ // Give up lift first, then stretch, then the raised arms' reach.
+ let fromLift = min(body.lift, over)
+ body.lift -= fromLift
+ let rest = over - fromLift
+ if rest > 0 {
+ let height = Double(box.height)
+ // The speed stretch, or the bite's squash along its line, makes it taller: ease it, and
+ // give up the stretch in the same pass.
+ if abs(body.smear - 1) > 0.005 { body.smear = 1 + (body.smear - 1) * 0.5 }
+ if body.squash > 1 {
+ body.squash = max(1, body.squash * (1 - rest / max(height, 1)) - 0.002)
+ } else {
+ // Standing at full height and still too tall: it is the raised arms.
+ body.armL = min(body.armL, max(0.4, body.armL - 0.3))
+ body.armR = min(body.armR, max(0.4, body.armR - 0.3))
+ }
+ }
+ moved = true
+ }
+ if box.maxY > bounds.maxY + 0.001 {
+ body.floorY -= Double(box.maxY - bounds.maxY)
+ moved = true
+ }
+ if box.width > bounds.width && !denRight {
+ // Too wide for the zone at all: give up the squash's width, then the cheeks, then the
+ // belly's roundness.
+ body.squash = min(1, body.squash + 0.05)
+ body.cheekPuff *= 0.8
+ body.girth = max(1, body.girth - 0.02)
+ moved = true
+ }
+ if !moved { break }
+ }
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetRigActions.swift b/Sources/BrowKit/Pet/PetRigActions.swift
new file mode 100644
index 0000000..e5dbddc
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetRigActions.swift
@@ -0,0 +1,990 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// The room an action has in its zone: what its movement depends on besides time.
+struct PetRigRoom: Hashable {
+ var scale: Double
+ /// -1 the food is to the left, 1 to the right.
+ var foodSide: Double
+ /// How far the lunge can carry the body toward the food before its box meets the zone's edge.
+ var reach: Double
+ /// Beside, from the anchor to the notch's edge (the den is to the right); below, from the feet up to
+ /// the notch's bottom (the den is overhead). 0 where there is no den.
+ var denX: Double
+ var denY: Double
+
+ init(scale: Double, foodSide: Double, reach: Double, denX: Double, denY: Double) {
+ self.scale = scale
+ self.foodSide = foodSide
+ self.reach = reach
+ self.denX = denX
+ self.denY = denY
+ }
+
+ init(zone: PetZone) {
+ let s = Double(zone.scale)
+ let anchor = zone.anchor
+ let side: Double = Double(zone.foodSource.x) < Double(anchor.x) ? -1 : 1
+ let edge = side < 0 ? Double(zone.bounds.minX) : Double(zone.bounds.maxX)
+ // The body's half box at rest (arms included) and the lunge's speed stretch.
+ let half = 13.8 * s + 1.5 * s
+ let reach = max(0, abs(edge - Double(anchor.x)) - half)
+ var denX = 0.0, denY = 0.0
+ switch zone.den {
+ case .side(let edgeX): denX = Double(edgeX - anchor.x)
+ case .overhead(let notch): denY = Double(anchor.y - notch.maxY)
+ case .none: break
+ }
+ self.init(scale: s, foodSide: side, reach: reach, denX: denX, denY: denY)
+ }
+
+ var hasSideDen: Bool { denX > 0 }
+ var hasOverheadDen: Bool { denY > 0 }
+
+ /// Beside: where the body sits hibernating, three quarters of it past the edge, so that at least 70 %
+ /// of it is out of sight in the den.
+ var hibernatingX: Double { denX + 0.4 * 13.8 * scale }
+ /// Beside: out of sight in the den, between peeks.
+ var hiddenX: Double { denX + 16 * scale }
+ /// Beside: out just far enough for one eye.
+ var peekX: Double { denX + 3.5 * scale }
+ /// Below: lifted all the way into the notch.
+ var hiddenLift: Double { denY + 23 * scale }
+ /// Below: hanging just low enough for the eyes to show under the notch's edge, the rest of the body
+ /// still up inside it.
+ var peekLift: Double { max(0, denY - 10 * scale - 2) }
+}
+
+/// What each behaviour does: its posture, its movement and its acting, keyed by the time since its
+/// segment started. The movement (`root`) is also what drives its secondary motion, so the follow-through
+/// always matches what is seen.
+enum PetRigActions {
+ /// No action is longer than `leaveDen` (3.7 s) or a feast's satisfied (4.0 s).
+ static let longestAction: Double = 4.2
+
+ typealias M = PetTuning.Meal
+ typealias B = PetTuning.Behaviour
+
+ // MARK: - Postures
+
+ static func posture(_ b: PetBehaviour, elapsed e: Double, scene: PetRigPoser) -> PetPosture {
+ let room = PetRigRoom(zone: scene.zone)
+ switch b {
+ case .intro: return keyed(e, [(0, .curious, 0.2), (1.9, .content, 0.6), (2.8, .happy, 0.3), (3.3, .content, 0.4)])
+ case .idle(let m), .settled(let m), .unsureIdle(let m): return .mood(m)
+ case .perk, .eager: return .eager
+ case .eat(let meal):
+ let snap = meal.reduceMotion ? PetTuning.Reduced.mealFade : timing(meal).snap
+ return keyed(e, [(0, .eager, 0.1), (snap, .munch, 0.12)])
+ case .satisfied(.meal): return .satisfied
+ case .satisfied(.feast): return keyed(e, [(0, .stuffed, 0.3), (2.6, .satisfied, 1.0)])
+ case .satisfied(.lick): return .content
+ case .boop: return .content
+ case .celebrate, .freshWindow: return .happy
+ case .fallAsleep: return keyed(e, [(0, .stuffed, 0.25), (B.fallAsleepYawn, .drowsy, 0.5),
+ (B.fallAsleepYawn + B.fallAsleepDroop, .asleep, B.fallAsleepCurl)])
+ case .asleep, .rollOver: return .asleep
+ case .drowsy: return .drowsy
+ case .wake: return keyed(e, [(0, .drowsy, 0.1), (0.35, .content, 0.5), (B.wakeStir + B.wakeStretch, .happy, 0.3)])
+ case .enterDen(let retreat):
+ if retreat { return .content }
+ return keyed(e, [(0, .curious, 0.3), (0.5, .drowsy, 0.8), (B.retreat, curled(room), 1.2)])
+ case .hibernating: return curled(room)
+ case .stirring: return stirring(room)
+ case .leaveDen(let stretch):
+ if !stretch { return keyed(e, [(0, .curious, 0.2), (0.8, .content, 0.4)]) }
+ return keyed(e, [(0, curled(room), 0.1), (0.05, .drowsy, 0.5), (0.8, .content, 0.4),
+ (B.leaveDenPeek + B.leaveDenOut + B.leaveDenStretch, .happy, 0.3)])
+ case .denWait: return room.hasSideDen || room.hasOverheadDen ? .content : dim(.content)
+ case .peek: return room.hasSideDen || room.hasOverheadDen ? .curious : dim(.curious)
+ case .dozeIn: return keyed(e, [(0, .content, 0.1), (0.2, .dozing, 1.7)])
+ case .doze: return .dozing
+ case .welcomeBack: return keyed(e, [(0, .dozing, 0.1), (0.25, .content, 0.5)])
+ case .off: return .content
+ }
+ }
+
+ /// Hibernating on the pill or in the panel, where there is no den: curled into a ball, the glow at
+ /// 30 %. A rounder, smaller ball than the 5-hour sleep's low loaf, with its own light turned down and
+ /// no Zzz, so the weekly limit and the 5-hour one never look the same at a glance.
+ static func curled(_ room: PetRigRoom) -> PetPosture {
+ guard !room.hasSideDen, !room.hasOverheadDen else { return .hibernating }
+ var p = PetPosture.hibernating
+ p.body.glow = PetTuning.Den.pillGlow
+ p.body.curl = 0.55
+ p.body.squash = 0.95
+ p.body.girth = 0.9
+ p.light = 0.75
+ return p
+ }
+
+ /// The weekly reset passed and the pet stirs in the den: still curled where it hibernated, the light
+ /// brightening, one eye barely open at the edge. All of it is posture, which cross-fades, and none of
+ /// it is movement or acting, which an open behaviour would drop in one frame when `leaveDen` cuts it:
+ /// so `leaveDen` starts exactly where this leaves off. Beside, the face turns toward the notch's edge
+ /// so that the one eye clears it; below, the pet is up inside the notch and only the light under the
+ /// door shows it; on the pill, the ball's near eye opens.
+ static func stirring(_ room: PetRigRoom) -> PetPosture {
+ var p = curled(room)
+ p.body.glow = room.hasSideDen || room.hasOverheadDen ? 0.55 : 0.5
+ p.light = 1
+ p.body.curl -= 0.1
+ p.body.lidL = 0.2
+ p.body.browLiftL = -0.15
+ p.body.smile = 0.28
+ if room.hasSideDen {
+ p.body.turn = -0.6
+ p.body.lookX = -0.5
+ } else {
+ p.body.lookX = 0.4 * room.foodSide
+ }
+ return p
+ }
+
+ /// Waiting with no account on the pill: sitting curled and dim.
+ static func dim(_ base: PetPosture) -> PetPosture {
+ var p = PetPosture.mixed(base, .hibernating, 0.7)
+ p.body.lidL = 0.7
+ p.body.lidR = 0.7
+ p.body.glow = PetTuning.Den.pillGlow
+ p.lookAtFood = 0
+ return p
+ }
+
+ static func keyed(_ e: Double, _ keys: [(Double, PetPosture, Double)]) -> PetPosture {
+ var p = keys[0].1
+ for k in keys.dropFirst() where e > k.0 {
+ p = PetPosture.mixed(p, k.1, PetEase.smoother((e - k.0) / k.2))
+ }
+ return p
+ }
+
+ /// How long a behaviour takes to settle into its posture from the one before it.
+ static func blendIn(_ b: PetBehaviour, after previous: PetBehaviour) -> Double {
+ switch b {
+ case .off: return 1e9 // turned off, it holds whatever it was doing and fades
+ case .idle, .settled, .unsureIdle: return 0.6
+ case .perk: return 0.12
+ case .eager: return 0.25
+ case .eat: return 0.12
+ case .satisfied(.lick): return 0.2
+ case .satisfied: return 0.25
+ case .boop: return 0.08
+ case .celebrate, .freshWindow: return 0.2
+ case .fallAsleep: return 0.3
+ case .asleep: return 0.6
+ case .drowsy: return 1.8
+ case .wake: return 0.5
+ case .welcomeBack: return 0.12
+ case .rollOver: return 1.0
+ case .enterDen, .leaveDen: return 0.3
+ case .hibernating: return 0.8
+ case .stirring: return 2.0
+ case .denWait: return 0.4
+ case .peek: return 0.2
+ case .dozeIn: return 0.3
+ case .doze: return 0.5
+ case .intro: return 0.2
+ }
+ }
+
+ /// A streak's twirl, times into the celebration: its crouch starts off the first hop's landing, and
+ /// all of it, crouch to landing squash, lies inside `PetTuning.Behaviour.streakTwirl`, the part drawn
+ /// in the fast class.
+ struct Twirl {
+ let crouch = 1.0
+ let takeoff = 1.14
+ let air = 0.34
+ var land: Double { takeoff + air }
+ }
+
+ static let twirl = Twirl()
+
+ // MARK: - The meal's clock
+
+ /// The meal's beats, from `PetTuning.Meal`: the rig hits them to the frame.
+ struct MealTiming {
+ var anticipation: Double, lunge: Double, hold: Double, recoil: Double, chew: Double, swallow: Double
+ var chews: Int
+ var full: Bool
+ var feast: Bool
+ var snap: Double { anticipation + lunge }
+ var recoilStart: Double { snap + hold }
+ var chewStart: Double { recoilStart + recoil }
+ var swallowStart: Double { chewStart + Double(chews) * chew }
+ var end: Double { swallowStart + swallow }
+ /// The swallowed light lands in the belly this far into the swallow.
+ var splash: Double { swallowStart + (feast ? 0.5 : swallow * 0.72) }
+ }
+
+ static func timing(_ meal: PetMeal) -> MealTiming {
+ if meal.size == .full {
+ return MealTiming(anticipation: M.fullAnticipation, lunge: M.fullLunge, hold: M.fullHold, recoil: M.fullRecoil,
+ chew: M.fullChew, swallow: M.fullSwallow + (meal.feast ? M.feastSwallowExtra : 0),
+ chews: meal.chews, full: true, feast: meal.feast)
+ }
+ return MealTiming(anticipation: M.nibbleAnticipation, lunge: M.nibbleLunge, hold: M.nibbleHold,
+ recoil: M.nibbleRecoil, chew: M.nibbleChew, swallow: M.nibbleSwallow, chews: meal.chews,
+ full: false, feast: false)
+ }
+
+ // MARK: - Movement
+
+ /// The behaviour's own movement at `e`: the root's offset across (points, + right), its lift, and
+ /// its squash beyond 1. The pose adds exactly this, and the transients are driven by exactly this.
+ static func root(_ b: PetBehaviour, elapsed e: Double, room: PetRigRoom) -> (dx: Double, lift: Double, squash: Double) {
+ let s = room.scale
+ switch b {
+ case .eat(let meal):
+ return meal.reduceMotion ? (0, 0, 0) : eatRoot(timing(meal), e, room)
+ case .perk(let kind):
+ let pop = PetCurve.envelope(e, 0.04, 0.2, 0.24, kind == .prompt ? 0.46 : 0.7, up: { PetEase.outBack($0) })
+ return (0, 0.4 * s * PetCurve.bump(e, 0.06, 0.36), 0.05 * pop)
+ case .satisfied(.meal):
+ let sigh = PetCurve.envelope(e, 0.2, 0.6, 0.62, 1.05, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ return (0, 0, 0.05 * sigh - 0.02 * (PetCurve.bump(e, 1.1, 1.36) + PetCurve.bump(e, 1.6, 1.86)))
+ case .satisfied(.feast):
+ let hic = PetCurve.envelope(e, 1.4, 1.48, 1.5, 1.66, up: PetEase.outQuad, down: PetEase.inOutSine)
+ let sigh = PetCurve.envelope(e, 2.6, 3.0, 3.05, 3.5, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ return (0, 0.9 * s * hic, 0.04 * PetCurve.bump(e, 1.4, 1.6) + 0.04 * sigh)
+ case .satisfied(.lick):
+ return (0, 0, -0.015 * PetCurve.bump(e, 0.32, 0.46))
+ case .boop(let giggle):
+ let squish = -0.14 * PetCurve.envelope(e, 0, 0.05, 0.08, 0.3, up: PetEase.outQuad, down: PetEase.inOutSine)
+ + 0.05 * PetCurve.bump(e, 0.24, 0.5)
+ guard giggle else { return (0, 0, squish) }
+ var lift = 0.0
+ for c in [0.12, 0.42, 0.72] { lift += 1.1 * s * PetCurve.bump(e, c, c + 0.2) }
+ return (0, lift, squish - 0.03 * sin(2 * .pi * 7 * e) * PetCurve.envelope(e, 0.1, 0.2, 0.9, 1.1))
+ case .celebrate(.streak):
+ // A happy hop, then off its landing a higher one with a twirl (`twirl`), in the fast class.
+ return hopRoot(e, at: 0.2, crouch: 0.26, air: 0.5, height: 3.0 * s, dx: 0, k: 0.6)
+ + hopRoot(e, at: twirl.crouch, crouch: twirl.takeoff - twirl.crouch, air: twirl.air,
+ height: 3.5 * s, dx: 0, k: 0.8)
+ case .celebrate(.goodWeek):
+ var drum = 0.0
+ for c in [0.3, 0.6, 0.9, 1.2] { drum -= 0.025 * PetCurve.bump(e, c - 0.08, c + 0.16) }
+ return hopRoot(e, at: 1.5, crouch: 0.24, air: 0.48, height: 2.7 * s, dx: 0, k: 0.6) + (0, 0, drum)
+ case .freshWindow:
+ return hopRoot(e, at: 0.24, crouch: 0.18, air: 0.4, height: 2.1 * s, dx: 0, k: 0.6)
+ case .fallAsleep:
+ let inhale = PetCurve.envelope(e, 0, 0.35, 0.6, 0.85, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ let sink = PetCurve.ramp(e, 1.0, 1.8) * (1 - PetCurve.ramp(e, 1.8, 2.6))
+ return (0, 0, 0.08 * inhale - 0.04 * sink)
+ case .wake:
+ return stretchRoot(e - B.wakeStir, s: s) + (0, 0, 0.02 * sin(2 * .pi * 2 * e) * PetCurve.envelope(e, 0.1, 0.2, 0.4, 0.55))
+ case .rollOver:
+ let roll = PetCurve.envelope(e, 0, 0.45, 0.5, 1.0, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ return (room.foodSide * -0.8 * s * roll, 0, -0.02 * roll)
+ case .enterDen(let retreat):
+ return denRoot(enter: true, retreat: retreat, e, room)
+ case .leaveDen(let stretch):
+ var r = denRoot(enter: false, retreat: !stretch, e, room)
+ if stretch { r = r + stretchRoot(e - B.leaveDenPeek - B.leaveDenOut, s: s) }
+ return r
+ case .peek:
+ return peekRoot(e, room)
+ case .intro(let noAccounts):
+ return introRoot(e, noAccounts: noAccounts, room)
+ case .dozeIn:
+ return (0, 0, -0.03 * PetCurve.ramp(e, 0.3, 1.8))
+ case .doze:
+ return (0, 0, -0.03)
+ case .welcomeBack:
+ let stretch = PetCurve.envelope(e, 0.35, 0.75, 0.95, 1.35, up: PetEase.outCubic, down: PetEase.inOutSine)
+ return (0, 0, -0.03 * (1 - PetCurve.ramp(e, 0, 0.3)) + 0.09 * stretch - 0.03 * PetCurve.bump(e, 1.3, 1.6))
+ case .drowsy:
+ let yawn = PetCurve.envelope(e, 0.9, 1.55, 2.0, 2.55, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ return (0, 0, 0.03 * yawn)
+ case .idle, .settled, .eager, .asleep, .unsureIdle, .denWait, .hibernating, .stirring, .off:
+ // Where the open behaviours sit is `restOffset`'s; they have no movement of their own.
+ return (0, 0, 0)
+ }
+ }
+
+ /// The meal: rear back, lunge and snap at the food, hold, recoil to the seat with an overshoot, a
+ /// whole-body beat per chew, and the swallow's long neck.
+ static func eatRoot(_ m: MealTiming, _ e: Double, _ room: PetRigRoom) -> (dx: Double, lift: Double, squash: Double) {
+ let s = room.scale, dir = room.foodSide
+ let big = m.full ? 1.0 : 0.6
+ let rear = -dir * 2.0 * s * big
+ let reach = dir * room.reach * (m.full ? 1 : 0.6)
+ var dx = 0.0, lift = 0.0, squash = 0.0
+ if e < m.anticipation {
+ let u = e / m.anticipation
+ dx = rear * PetEase.outQuad(u)
+ squash = -0.12 * big * PetEase.outQuad(u)
+ } else if e < m.snap {
+ let u = (e - m.anticipation) / m.lunge
+ dx = PetCurve.mix(rear, reach, PetEase.inQuad(u))
+ lift = 2.5 * s * big * sin(.pi * u * 0.8)
+ squash = PetCurve.mix(-0.12 * big, 0.10 * big, PetEase.outQuad(min(1, u * 1.6)))
+ } else if e < m.recoilStart {
+ let u = (e - m.snap) / max(m.hold, 1e-6)
+ dx = reach
+ lift = 2.5 * s * big * sin(.pi * 0.8) * (1 - u)
+ squash = PetCurve.mix(0.10 * big, 0.02 * big, PetEase.outQuad(min(1, u * 2)))
+ } else if e < m.chewStart {
+ let u = (e - m.recoilStart) / m.recoil
+ // Back to the seat, a touch past it, and home.
+ dx = reach * (1 - PetEase.outBack(u, 1.2))
+ lift = (m.hold > 0 ? 0 : 2.5 * s * big * sin(.pi * 0.8)) * (1 - PetEase.outQuad(min(1, u * 1.5)))
+ squash = PetCurve.mix(0.02 * big, -0.04 * big, PetEase.inOutSine(min(1, u * 1.6))) * (1 - PetEase.inOutSine((u - 0.5) * 2))
+ } else if e < m.swallowStart {
+ let i = Int((e - m.chewStart) / m.chew)
+ let u = (e - m.chewStart) / m.chew - Double(i)
+ // The bite-down squashes the whole body, the jaw coming up lets it go: a beat that reads at
+ // arm's length.
+ squash = -0.05 * big * sin(.pi * u) * (i == m.chews - 1 ? 1.15 : 1)
+ dx = 0.25 * s * (i % 2 == 0 ? -1 : 1) * sin(.pi * u)
+ } else {
+ let u = (e - m.swallowStart) / m.swallow
+ let k = m.feast ? 0.36 / m.swallow : 1
+ squash = -0.04 * PetCurve.bump(u, 0, 0.2 * k) + 0.075 * PetCurve.envelope(u, 0.18 * k, 0.32 * k, 0.45 * k, 0.7 * k)
+ + (m.feast ? 0.06 * PetCurve.envelope(u, 0.5, 0.65, 0.75, 0.95) : 0)
+ }
+ return (dx, lift, squash)
+ }
+
+ /// One hop in place or across: a crouch, a ballistic arc stretched at both ends and round at the
+ /// apex, a squash on landing. `k` scales the squash and stretch. The transitions are long enough that
+ /// a hop inside an action-class behaviour keeps to 1 pt a frame at 60 fps, the most that class's rate
+ /// draws smoothly.
+ static func hopRoot(_ e: Double, at t0: Double, crouch: Double, air: Double, height: Double, dx: Double,
+ k: Double = 1) -> (dx: Double, lift: Double, squash: Double) {
+ let takeoff = t0 + crouch, land = takeoff + air
+ guard e > t0 else { return (0, 0, 0) }
+ guard e < land + 0.4 else { return (dx, 0, 0) }
+ var r = (dx: 0.0, lift: 0.0, squash: 0.0)
+ r.squash -= 0.09 * k * PetCurve.envelope(e, t0, t0 + crouch * 0.75, takeoff - 0.05, takeoff + 0.06,
+ up: PetEase.outQuad, down: PetEase.inOutSine)
+ r.squash += 0.06 * k * (PetCurve.envelope(e, takeoff - 0.05, takeoff + 0.07, takeoff + air * 0.3, takeoff + air * 0.5)
+ + PetCurve.envelope(e, land - air * 0.45, land - air * 0.2, land - 0.03, land + 0.04))
+ if e > takeoff && e < land {
+ let u = (e - takeoff) / air
+ r.lift = 4 * height * u * (1 - u)
+ r.dx = dx * PetCurve.mix(u, PetEase.inOutSine(u), 0.3)
+ } else if e >= land {
+ r.dx = dx
+ }
+ r.squash -= 0.08 * k * PetCurve.envelope(e, land - 0.02, land + 0.06, land + 0.09, land + 0.36,
+ up: PetEase.outQuad, down: PetEase.inOutSine)
+ return r
+ }
+
+ /// A step-out: the body slides `dx` over `length` on two little steps, its weight rocking.
+ static func stepRoot(_ e: Double, at t0: Double, length: Double, dx: Double) -> (dx: Double, lift: Double, squash: Double) {
+ guard e > t0 else { return (0, 0, 0) }
+ guard e < t0 + length + 0.2 else { return (dx, 0, 0) }
+ let u = PetEase.inOutSine((e - t0) / length)
+ let bob = PetCurve.bump(e, t0 + 0.05 * length, t0 + 0.5 * length) + PetCurve.bump(e, t0 + 0.45 * length, t0 + 0.95 * length)
+ return (dx * u, 0.5 * bob, -0.02 * bob)
+ }
+
+ /// The big stretch of the wake and of leaving the den: a crouch, up to the zone's stretch limit with
+ /// a trembling hold, a release that overshoots.
+ static func stretchRoot(_ e: Double, s: Double) -> (dx: Double, lift: Double, squash: Double) {
+ guard e > 0 else { return (0, 0, 0) }
+ let crouch = PetCurve.envelope(e, 0, 0.12, 0.13, 0.22, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ let up = PetCurve.envelope(e, 0.12, 0.62, 1.1, 1.4, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ let after = PetCurve.envelope(e, 1.3, 1.42, 1.44, 1.58, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ let tremble = 0.008 * sin(2 * .pi * 14 * e) * PetCurve.envelope(e, 0.62, 0.72, 1.0, 1.1)
+ return (0, 0.7 * s * PetCurve.envelope(e, 0.25, 0.6, 1.05, 1.35), -0.08 * crouch + 0.20 * up - 0.05 * after + tremble)
+ }
+
+ /// Into the den (enter) or out of it. Beside, sideways past the notch's edge; below, up into the notch
+ /// and down from it; on the pill and in the panel, nowhere: it curls up where it is.
+ static func denRoot(enter: Bool, retreat: Bool, _ e: Double, _ room: PetRigRoom) -> (dx: Double, lift: Double, squash: Double) {
+ if room.hasSideDen {
+ if enter {
+ // Steps toward the edge, a look back, then a slow squeeze past it; a retreat is the squeeze
+ // alone.
+ if retreat {
+ let u = PetEase.inOutSine(e / (B.retreat - 0.1))
+ return (room.hiddenX * u, 0, -0.04 * PetCurve.bump(e, 0.2, B.retreat))
+ }
+ let walk = stepRoot(e, at: 0.45, length: 0.9, dx: 0.6 * room.denX)
+ let slide = PetEase.inOutSine((e - B.retreat) / (B.enterDen - B.retreat - 0.15))
+ let dx = e < B.retreat ? walk.dx : PetCurve.mix(0.6 * room.denX, room.hibernatingX, slide)
+ return (dx, walk.lift, walk.squash - 0.04 * PetCurve.bump(e, 1.6, 3.2))
+ }
+ // Out: an eye at the edge, then two steps out to the anchor; from denWait it starts hidden.
+ let from = retreat ? room.hiddenX : room.hibernatingX
+ let peek = PetCurve.mix(from, room.peekX, PetEase.inOutSine(e / 0.6))
+ if e < B.leaveDenPeek { return (peek, 0, 0) }
+ let out = stepRoot(e, at: B.leaveDenPeek, length: B.leaveDenOut - 0.05, dx: -room.peekX)
+ return (room.peekX + out.dx, out.lift, out.squash)
+ }
+ if room.hasOverheadDen {
+ if enter {
+ // Below: a crouch, a look up, and up into the notch; it stays up there.
+ let start = retreat ? 0.0 : 1.2
+ let crouch = PetCurve.envelope(e, start, start + 0.22, start + 0.26, start + 0.36, up: PetEase.outQuad, down: PetEase.inOutSine)
+ let rise = PetEase.inOutSine((e - start - 0.3) / 0.6)
+ return (0, e > start + 0.3 ? room.hiddenLift * rise : 0, -0.07 * crouch + 0.05 * PetCurve.bump(e, start + 0.3, start + 0.8))
+ }
+ // Out: hanging down to peek, then down to the anchor, landing soft.
+ if e < B.leaveDenPeek { return (0, PetCurve.mix(room.hiddenLift, room.peekLift, PetEase.inOutSine(e / 0.6)), 0) }
+ let u = (e - B.leaveDenPeek) / 0.45
+ let lift = room.peekLift * (1 - PetEase.inOutSine(u))
+ let land = -0.06 * PetCurve.envelope(e, B.leaveDenPeek + 0.4, B.leaveDenPeek + 0.5, B.leaveDenPeek + 0.52,
+ B.leaveDenPeek + 0.75)
+ return (0, max(0, lift), land)
+ }
+ // No den: settle down into a ball, or get up out of one.
+ let settle = enter ? PetCurve.envelope(e, 0.4, 0.8, 1.6, 2.4) : PetCurve.bump(e, 0, 0.6)
+ return (0, 0, -0.04 * settle)
+ }
+
+ /// One look out of the den, 2.2 s.
+ static func peekRoot(_ e: Double, _ room: PetRigRoom) -> (dx: Double, lift: Double, squash: Double) {
+ let out = PetCurve.envelope(e, 0, 0.55, 1.6, 2.15, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ if room.hasSideDen { return (PetCurve.mix(room.hiddenX, room.peekX, out), 0, 0) }
+ if room.hasOverheadDen { return (0, PetCurve.mix(room.hiddenLift, room.peekLift, out), 0) }
+ return (0, 0, 0)
+ }
+
+ /// The first time the pet is seen: it peeks out, looks around, notices the readout, steps out and
+ /// sits; with no accounts it goes back in.
+ static func introRoot(_ e: Double, noAccounts: Bool, _ room: PetRigRoom) -> (dx: Double, lift: Double, squash: Double) {
+ if room.hasSideDen {
+ let peek = PetCurve.mix(room.hiddenX, room.peekX, PetEase.inOutSine(e / 0.6))
+ if noAccounts {
+ let back = PetEase.inOutSine((e - 1.6) / 0.55)
+ return (PetCurve.mix(peek, room.hiddenX, back), 0, 0)
+ }
+ if e < 2.0 { return (peek, 0, 0) }
+ let out = stepRoot(e, at: 2.0, length: 0.85, dx: -room.peekX)
+ return (room.peekX + out.dx, out.lift, out.squash - 0.03 * PetCurve.bump(e, 2.95, 3.45))
+ }
+ if room.hasOverheadDen {
+ let peek = PetCurve.mix(room.hiddenLift, room.peekLift, PetEase.inOutSine(e / 0.6))
+ if noAccounts { return (0, PetCurve.mix(peek, room.hiddenLift, PetEase.inOutSine((e - 1.6) / 0.55)), 0) }
+ if e < 2.0 { return (0, peek, 0) }
+ let u = (e - 2.0) / 0.5
+ let land = -0.06 * PetCurve.envelope(e, 2.45, 2.55, 2.57, 2.8)
+ return (0, max(0, room.peekLift * (1 - PetEase.inOutSine(u))), land)
+ }
+ // No den: it rises into being and has a look round.
+ return (0, 0, -0.05 * (1 - PetEase.smooth(e / 0.5)))
+ }
+
+ /// Kicks to the secondary springs at the action's beats.
+ static func impulses(_ b: PetBehaviour) -> [(Double, PetRigSecondary.Spring, Double)] {
+ switch b {
+ case .eat(let meal):
+ guard !meal.reduceMotion else { return [] }
+ let m = timing(meal)
+ let big = m.full ? 1.0 : 0.6
+ var out: [(Double, PetRigSecondary.Spring, Double)] = [
+ (m.snap + 0.001, .jiggle, -1.2 * big), (m.snap, .brows, -8 * big), (m.chewStart, .jiggle, -0.9 * big),
+ (m.swallowStart + 0.06, .jiggle, 0.9), (m.splash, .wave, 2.6), (m.splash, .belly, m.feast ? 0.3 : 0.14),
+ ]
+ for i in 0.. PetRigSource? {
+ switch segment.behaviour {
+ case .idle, .settled, .eager, .asleep, .hibernating, .stirring, .denWait, .doze, .unsureIdle, .off:
+ return nil
+ case .eat(let meal) where meal.reduceMotion:
+ return nil
+ default:
+ break
+ }
+ let posture = posture(segment.behaviour, elapsed: 0.5, scene: scene)
+ let room = PetRigRoom(zone: scene.zone)
+ var source = PetRigSource(kind: .segment(segment.behaviour, length: length), start: segment.start,
+ scale: room.scale, foodSide: room.foodSide, jellyHz: posture.jellyHz,
+ jellyZeta: posture.jellyZeta)
+ source.reach = room.reach
+ source.denX = room.denX
+ source.denY = room.denY
+ return source
+ }
+
+ // MARK: - Placing the body
+
+ /// Where a behaviour puts the body before it moves: in the den for the den behaviours.
+ static func place(_ body: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ let room = PetRigRoom(zone: scene.zone)
+ let rest = restOffset(beat.behaviour, room: room)
+ body.x += rest.dx
+ body.lift += rest.lift
+ }
+
+ /// The offset of a behaviour's resting place from the anchor: inside the den for hibernating and for
+ /// waiting with no account.
+ static func restOffset(_ b: PetBehaviour, room: PetRigRoom) -> (dx: Double, lift: Double) {
+ switch b {
+ case .hibernating, .stirring:
+ if room.hasSideDen { return (room.hibernatingX, 0) }
+ if room.hasOverheadDen { return (0, room.hiddenLift) }
+ return (0, 0)
+ case .denWait:
+ if room.hasSideDen { return (room.hiddenX, 0) }
+ if room.hasOverheadDen { return (0, room.hiddenLift) }
+ return (0, 0)
+ default:
+ return (0, 0)
+ }
+ }
+
+ /// Where the feet stand in a zone for a behaviour at rest: the start of a glide out of the den.
+ static func restFeet(_ b: PetBehaviour, zone: PetZone) -> CGPoint {
+ let r = restOffset(b, room: PetRigRoom(zone: zone))
+ return CGPoint(x: Double(zone.anchor.x) + r.dx, y: Double(zone.anchor.y) - r.lift)
+ }
+
+ /// Poses that may pass the zone on the den's side.
+ static func denPose(_ beat: PetRigBeat) -> Bool {
+ switch beat.behaviour {
+ case .enterDen, .hibernating, .stirring, .leaveDen, .denWait, .peek, .intro: return true
+ default: return false
+ }
+ }
+
+ // MARK: - Acting
+
+ static func apply(_ body: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ guard beat.start.isFinite else { return }
+ let room = PetRigRoom(zone: scene.zone)
+ let e = beat.elapsed
+ let r = root(beat.behaviour, elapsed: e, room: room)
+ body.x += r.dx
+ body.lift += r.lift
+ body.squash += r.squash
+ act(beat.behaviour, &body, e: e, room: room, scene: scene)
+ }
+
+ /// The face, the limbs and the light of each behaviour, on top of its posture and movement.
+ static func act(_ b: PetBehaviour, _ p: inout PetBody, e: Double, room: PetRigRoom, scene: PetRigPoser) {
+ let dir = room.foodSide
+ switch b {
+ case .eat(let meal):
+ if meal.reduceMotion { reducedMeal(meal, &p, e: e) } else { eat(timing(meal), &p, e: e, dir: dir) }
+ case .perk(let kind):
+ // The signature: a cheeky double flick of the brows, then everything up, eyes on the food.
+ for (i, c) in [0.0, 0.13].enumerated() {
+ let k = PetCurve.envelope(e, c, c + 0.05, c + 0.06, c + 0.12, up: PetEase.outQuad, down: PetEase.inOutSine)
+ p.browLiftL += (i == 0 ? 0.9 : 0.8) * k
+ p.browLiftR += (i == 0 ? 0.75 : 0.9) * k
+ }
+ p.eyeSize += 0.1 * PetCurve.envelope(e, 0.02, 0.08, 0.3, kind == .prompt ? 0.5 : 0.8)
+ p.smile += 0.25 * PetCurve.envelope(e, 0.02, 0.08, 0.2, 0.3)
+ case .satisfied(.meal):
+ p.nod += 0.2 * PetCurve.envelope(e, 0.2, 0.6, 0.62, 1.05)
+ pats(&p, e: e, at: [1.2, 1.7])
+ p.happy = max(p.happy, 0.85 * (1 - PetCurve.ramp(e, 2.6, 3.0)))
+ case .satisfied(.feast):
+ pats(&p, e: e, at: [0.6, 1.05])
+ let hic = PetCurve.envelope(e, 1.4, 1.43, 1.48, 1.6)
+ p.lidL = PetCurve.mix(p.lidL, 1, 0.7 * hic); p.lidR = PetCurve.mix(p.lidR, 1, 0.7 * hic)
+ p.happy *= 1 - hic
+ p.browLiftL += 0.7 * hic; p.browLiftR += 0.7 * hic
+ p.mouthOpen = max(p.mouthOpen, 0.22 * PetCurve.bump(e, 1.4, 1.5))
+ p.mouthWide *= 1 - 0.4 * hic
+ case .satisfied(.lick):
+ lick(&p, e: e, dir: dir)
+ case .boop(let giggle):
+ p.squeeze = max(p.squeeze, PetCurve.envelope(e, 0, 0.03, 0.16, 0.3))
+ p.blush += 0.4 * PetCurve.envelope(e, 0, 0.1, 0.4, 0.6)
+ p.smile += 0.4 * PetCurve.envelope(e, 0.1, 0.2, 0.4, 0.6)
+ p.mouthOpen = max(p.mouthOpen, 0.25 * PetCurve.envelope(e, 0.12, 0.2, 0.35, 0.5))
+ if giggle {
+ p.happy = max(p.happy, PetCurve.envelope(e, 0.15, 0.25, 0.95, 1.15))
+ p.tremor += 0.35 * sin(2 * .pi * 9 * e) * PetCurve.envelope(e, 0.15, 0.25, 0.9, 1.1)
+ p.mouthOpen = max(p.mouthOpen, 0.35 * PetCurve.envelope(e, 0.2, 0.3, 0.9, 1.1))
+ p.armL += 0.6 * PetCurve.envelope(e, 0.2, 0.3, 0.9, 1.1)
+ p.armR += 0.6 * PetCurve.envelope(e, 0.2, 0.3, 0.9, 1.1)
+ }
+ case .celebrate(.streak):
+ // A happy hop, arms up, then the twirl: a whole turn in the air, the face going round the back
+ // and coming in from the other side. A whole turn looks like none, so the spin keeps its 2π
+ // after landing and never jumps back. Eyes squeezed with joy throughout.
+ let up = PetCurve.envelope(e, 0.3, 0.6, 0.85, 1.1)
+ + PetCurve.envelope(e, twirl.takeoff - 0.08, twirl.takeoff + 0.06, twirl.land - 0.04, twirl.land + 0.24)
+ p.armL += 0.9 * up; p.armR += 1.0 * up
+ p.spin += 2 * .pi * PetEase.inOutSine((e - twirl.takeoff) / twirl.air)
+ p.happy = max(p.happy, 0.9 * PetCurve.envelope(e, 0.5, 0.7, 2.0, 2.35))
+ p.smile += 0.3 * PetCurve.envelope(e, 0.3, 0.5, 2.0, 2.3)
+ p.footL -= 0.6 * min(1, up); p.footR -= 0.6 * min(1, up)
+ case .celebrate(.goodWeek):
+ // A drum roll on the belly, a hop, arms up.
+ for (i, c) in [0.3, 0.6, 0.9, 1.2].enumerated() {
+ let hit = PetCurve.envelope(e, c - 0.14, c, c + 0.02, c + 0.16, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ if i % 2 == 0 { p.armL -= 1.3 * hit } else { p.armR -= 1.3 * hit }
+ }
+ p.happy = max(p.happy, 0.8 * PetCurve.envelope(e, 0.15, 0.35, 1.3, 1.55))
+ let up = PetCurve.envelope(e, 1.6, 1.9, 2.15, 2.45)
+ p.armL += 0.9 * up; p.armR += 0.9 * up
+ p.happy = max(p.happy, 0.9 * PetCurve.envelope(e, 2.1, 2.3, 2.55, 2.8))
+ case .freshWindow:
+ p.turn += 0.4 * dir * PetCurve.envelope(e, 0, 0.12, 0.3, 0.5)
+ p.lookX += 0.8 * dir * PetCurve.envelope(e, 0, 0.08, 0.3, 0.45)
+ p.browLiftL += 0.8 * PetCurve.envelope(e, 0.05, 0.12, 0.25, 0.4)
+ p.browLiftR += 0.8 * PetCurve.envelope(e, 0.05, 0.12, 0.25, 0.4)
+ let up = PetCurve.envelope(e, 0.35, 0.55, 0.65, 0.85)
+ p.armL += 0.7 * up; p.armR += 0.7 * up
+ case .fallAsleep:
+ fallAsleep(&p, e: e)
+ case .drowsy:
+ yawn(&p, e: e, at: 0.9, scale: 0.6, slow: 1.8)
+ case .wake:
+ wake(&p, e: e, stir: true)
+ case .rollOver:
+ let roll = PetCurve.envelope(e, 0, 0.45, 0.5, 1.0, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ p.tilt += -dir * 0.07 * roll
+ p.smile += 0.15 * roll
+ case .enterDen(let retreat):
+ if !retreat {
+ p.turn += 0.55 * PetCurve.envelope(e, 0.05, 0.35, 1.3, 1.6) * (room.hasSideDen ? 1 : 0)
+ p.nod += 0.5 * PetCurve.envelope(e, 0.1, 0.4, 1.1, 1.4) * (room.hasOverheadDen ? 1 : 0)
+ yawn(&p, e: e, at: 1.55, scale: 0.7)
+ } else {
+ p.turn += 0.5 * PetCurve.envelope(e, 0, 0.2, 1.2, 1.5) * (room.hasSideDen ? 1 : 0)
+ }
+ case .leaveDen(let stretch):
+ // An eye at the edge, a look, out.
+ p.lidL = min(p.lidL, PetCurve.mix(0.2, 1, PetCurve.ramp(e, 0.2, 0.5)))
+ p.turn -= 0.45 * PetCurve.envelope(e, 0.2, 0.45, 0.7, 0.95) * (room.hasSideDen ? 1 : 0)
+ if stretch {
+ stretchFace(&p, e: e - B.leaveDenPeek - B.leaveDenOut)
+ shake(&p, e: e - B.leaveDenPeek - B.leaveDenOut - B.leaveDenStretch, strength: 0.4)
+ }
+ case .peek:
+ let look = PetCurve.envelope(e, 0.5, 0.7, 1.5, 1.7)
+ p.turn += (-0.6 + 0.9 * PetEase.inOutSine((e - 0.7) / 0.7)) * look * (room.hasSideDen ? 1 : 0.6)
+ p.lookX += (-0.8 + 1.2 * PetEase.inOutSine((e - 0.65) / 0.7)) * look
+ p.browLiftL += 0.5 * look
+ case .intro(let noAccounts):
+ let look = PetCurve.envelope(e, 0.6, 0.8, 1.5, 1.7)
+ p.turn += (-0.5 + 0.8 * PetEase.inOutSine((e - 0.7) / 0.8)) * look
+ p.lookX += (-0.8 + 1.4 * PetEase.inOutSine((e - 0.65) / 0.8)) * look
+ let notice = PetCurve.envelope(e, 1.6, 1.68, 1.85, 2.0)
+ p.browLiftL += 1.1 * notice; p.browLiftR += 0.9 * notice
+ p.eyeSize += 0.12 * notice
+ p.lookX += dir * 0.9 * PetCurve.envelope(e, 1.6, 1.7, noAccounts ? 1.65 : 2.3, noAccounts ? 1.75 : 2.5)
+ case .dozeIn:
+ p.nod -= 0.1 * PetCurve.ramp(e, 0.4, 1.8)
+ let heavy = IdleBlink.shape(e - 0.5, length: 0.4) * (1 - PetCurve.ramp(e, 0.9, 1.0))
+ p.lidL *= heavy; p.lidR *= heavy
+ case .welcomeBack:
+ let blink = IdleBlink.shape(e - 0.15, length: 0.18)
+ p.lidL *= blink; p.lidR *= blink
+ let stretch = PetCurve.envelope(e, 0.35, 0.75, 0.95, 1.35, up: PetEase.outCubic, down: PetEase.inOutSine)
+ p.armL += 1.0 * stretch; p.armR += 0.85 * stretch
+ p.squeeze = max(p.squeeze, 0.8 * PetCurve.envelope(e, 0.45, 0.6, 0.9, 1.1))
+ p.mouthOpen = max(p.mouthOpen, 0.35 * PetCurve.envelope(e, 0.5, 0.65, 0.9, 1.1))
+ p.mouthWide = PetCurve.mix(p.mouthWide, 0.6, PetCurve.envelope(e, 0.5, 0.65, 0.9, 1.1))
+ p.smile += 0.3 * PetCurve.ramp(e, 1.2, 1.6)
+ case .asleep, .hibernating:
+ p.glow += (b == .asleep ? 0.18 : 0.1) * sin(2 * .pi * e / (b == .asleep ? 4.6 : PetTuning.Den.glowCycle) - 0.3)
+ case .unsureIdle:
+ // A puzzled head tilt every 20 to 40 s.
+ let period = PetTuning.Stale.tiltMinimum + PetTuning.Stale.tiltMaximum
+ let k = Int(floor(e / (period / 2)))
+ let at = Double(k) * period / 2 + 4 + 8 * PetNoise.hash(scene.seed, PetRigChannel.tiltAt, k)
+ let tilt = PetCurve.envelope(e, at, at + 0.35, at + 1.3, at + 1.8)
+ let side = PetNoise.hash(scene.seed, PetRigChannel.side, k) < 0.5 ? -1.0 : 1.0
+ p.tilt += side * 0.13 * tilt
+ p.browLiftL += (side < 0 ? 0.6 : -0.1) * tilt
+ p.browLiftR += (side > 0 ? 0.6 : -0.1) * tilt
+ p.mouthWide = PetCurve.mix(p.mouthWide, 0.5, 0.6 * tilt)
+ case .idle, .settled, .eager, .stirring, .denWait, .doze, .off:
+ break
+ }
+ }
+
+ // MARK: The meal
+
+ static func eat(_ m: MealTiming, _ p: inout PetBody, e: Double, dir: Double) {
+ let big = m.full ? 1.0 : 0.65
+ // Anticipation: lean back, eyes wide on the food, the mouth opening wide.
+ let rear = PetCurve.envelope(e, 0, m.anticipation * 0.8, m.anticipation, m.snap, up: PetEase.outQuad, down: PetEase.inQuad)
+ p.lean -= dir * 0.10 * big * rear
+ p.eyeSize += 0.14 * PetCurve.envelope(e, 0, m.anticipation * 0.5, m.snap - 0.02, m.snap + 0.02)
+ p.browLiftL += 0.9 * PetCurve.envelope(e, 0, m.anticipation * 0.4, m.snap - 0.05, m.snap)
+ p.browLiftR += 0.9 * PetCurve.envelope(e, 0, m.anticipation * 0.4, m.snap - 0.05, m.snap)
+ let open = PetCurve.envelope(e, m.anticipation * 0.15, m.anticipation, m.snap - 0.012, m.snap + 0.012,
+ up: PetEase.outCubic, down: PetEase.inQuad)
+ p.mouthOpen = max(p.mouthOpen, (m.full ? 1.0 : 0.7) * open)
+ p.mouthWide *= 1 + 0.12 * open
+ p.smile -= 0.3 * open
+ p.armL += -0.45 * rear
+ p.armR += -0.45 * rear
+ // The lunge: stretched toward the food, face up, arms out.
+ let strike = PetCurve.envelope(e, m.anticipation - 0.01, m.anticipation + m.lunge * 0.5, m.snap, m.recoilStart + m.recoil * 0.5)
+ p.lean += dir * 0.24 * big * strike
+ p.nod += 0.45 * PetCurve.envelope(e, m.anticipation, m.snap - 0.04, m.snap, m.recoilStart + 0.1)
+ p.browTiltL -= 0.25 * strike; p.browTiltR -= 0.25 * strike
+ if dir < 0 { p.armL += 0.8 * strike; p.armR += 0.5 * strike } else { p.armR += 0.8 * strike; p.armL += 0.5 * strike }
+ p.turn += dir * 0.35 * PetCurve.envelope(e, 0, m.anticipation * 0.5, m.recoilStart, m.chewStart)
+ // The snap: eyes squeezed for a few frames, the cheeks full.
+ p.squeeze = max(p.squeeze, PetCurve.envelope(e, m.snap - 0.008, m.snap + 0.006, m.snap + 0.04, m.snap + 0.08))
+ p.cheekPuff = max(p.cheekPuff, PetCurve.ramp(e, m.snap, m.snap + 0.05) * (1 - PetCurve.ramp(e, m.swallowStart, m.swallowStart + 0.1)))
+ // The chews: the jaw down and up, the cheeks working side to side, brows on the off-beat.
+ if e >= m.chewStart && e < m.swallowStart {
+ let i = Int((e - m.chewStart) / m.chew)
+ let u = (e - m.chewStart) / m.chew - Double(i)
+ let jaw = sin(.pi * u)
+ p.chew = PetCurve.envelope(e, m.chewStart - 0.03, m.chewStart + 0.03, m.swallowStart - 0.04, m.swallowStart + 0.02)
+ p.chewPhase = 2 * .pi * u + Double(i) * 0.9
+ p.cheekBias = (i % 2 == 0 ? -1 : 1) * jaw
+ p.nod -= 0.16 * jaw
+ p.browLiftL += 0.4 * sin(2 * .pi * u - 0.8)
+ p.browLiftR += 0.4 * sin(2 * .pi * u - 1.1)
+ p.cheekPuff = max(p.cheekPuff, 1.0)
+ p.lean += 0.02 * (i % 2 == 0 ? -1 : 1) * jaw
+ }
+ // The swallow: the bite moves from the cheeks to the throat, a long neck and chin up, then the
+ // light drops through the body and splashes in; the eyes follow it down.
+ if e >= m.swallowStart - 0.05 {
+ let u = e - m.swallowStart
+ let gulp = PetCurve.envelope(u, 0.04, 0.1, 0.16, 0.26)
+ p.nod += 0.3 * gulp
+ p.smile -= 0.35 * gulp
+ p.happy -= 0.6 * PetCurve.envelope(u, 0, 0.06, 0.2, 0.3)
+ let drop = m.splash - m.swallowStart - 0.08
+ if u >= 0.08 && u <= 0.08 + drop { p.lump = (u - 0.08) / drop }
+ p.lookY -= 0.9 * PetCurve.envelope(u, 0.08, 0.12, drop + 0.08, drop + 0.3)
+ p.lookX -= dir * 0.2 * PetCurve.envelope(u, 0.08, 0.12, drop + 0.08, drop + 0.3)
+ }
+ // From the snap to the end the mouth stays shut on its bite, whatever the posture's mouth was.
+ if e > m.snap - 0.012 {
+ p.mouthOpen *= 1 - PetCurve.ramp(e, m.snap - 0.012, m.snap + 0.012)
+ }
+ }
+
+ /// Reduce Motion's meal: a crossfade to a chewing pose, one chew per point up to three, a crossfade
+ /// back. No lunge, no squash beyond ±2 %.
+ static func reducedMeal(_ meal: PetMeal, _ p: inout PetBody, e: Double) {
+ let fade = PetTuning.Reduced.mealFade
+ let chew = PetTuning.Reduced.chew
+ let end = fade + Double(meal.chews) * chew
+ let on = PetCurve.ramp(e, 0, fade) * (1 - PetCurve.ramp(e, end, end + fade))
+ p.cheekPuff = max(p.cheekPuff, on)
+ p.chew = on
+ p.happy = max(p.happy, on)
+ if e > fade && e < end {
+ let u = (e - fade) / chew
+ p.chewPhase = 2 * .pi * u
+ p.cheekBias = sin(.pi * u.truncatingRemainder(dividingBy: 1)) * (Int(u) % 2 == 0 ? -1 : 1)
+ }
+ }
+
+ /// The belly pats of `satisfied`: the right arm swings across the belly and back.
+ static func pats(_ p: inout PetBody, e: Double, at times: [Double]) {
+ for c in times {
+ p.armR -= 1.55 * PetCurve.envelope(e, c - 0.1, c + 0.1, c + 0.12, c + 0.32, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ }
+ }
+
+ /// The glutton's tic: an eye on the number, a lick of the lips, a little chew. `turn` is how far the
+ /// head goes with the eyes.
+ static func lick(_ p: inout PetBody, e: Double, dir: Double, turn: Double = 0.3) {
+ p.lookX += dir * 0.6 * PetCurve.envelope(e, 0, 0.05, 0.4, 0.55)
+ p.turn += dir * turn * PetCurve.envelope(e, 0.03, 0.12, 0.4, 0.58)
+ p.tongue = max(p.tongue, PetCurve.envelope(e, 0.12, 0.18, 0.24, 0.33))
+ p.browLiftL += 0.3 * PetCurve.envelope(e, 0.05, 0.15, 0.3, 0.45)
+ p.chew = max(p.chew, 0.5 * PetCurve.envelope(e, 0.34, 0.37, 0.42, 0.48))
+ p.chewPhase = 2 * .pi * (e - 0.34) / 0.12
+ p.happy = max(p.happy, 0.6 * PetCurve.envelope(e, 0.3, 0.38, 0.5, 0.6))
+ }
+
+ // MARK: Sleep
+
+ /// A yawn: an inhale with brows up, eyes shut, a tall "O", a tremble, a smack. `slow` stretches it
+ /// in time: drowsy's yawn plays at 15 fps.
+ static func yawn(_ p: inout PetBody, e: Double, at t0: Double, scale: Double = 1, slow: Double = 1) {
+ let t = (e - t0) / slow
+ guard t > 0, t < 1.25 else { return }
+ let inhale = PetCurve.envelope(t, 0, 0.3, 0.55, 0.8, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ p.nod += 0.3 * inhale * scale
+ p.browLiftL += 0.8 * inhale * scale; p.browLiftR += 0.8 * inhale * scale
+ p.browTiltL += 0.28 * inhale; p.browTiltR += 0.28 * inhale
+ p.armL += 0.4 * inhale * scale; p.armR += 0.4 * inhale * scale
+ let shut = PetCurve.envelope(t, 0.06, 0.14, 0.62, 0.74)
+ p.lidL *= 1 - shut; p.lidR *= 1 - shut
+ p.happy *= 1 - shut
+ let open = PetCurve.envelope(t, 0.1, 0.25, 0.55, 0.75, up: PetEase.outQuad, down: PetEase.inOutSine)
+ p.mouthOpen = max(p.mouthOpen, 0.95 * open * scale)
+ p.mouthWide = PetCurve.mix(p.mouthWide, 0.5, open)
+ p.smile = PetCurve.mix(p.smile, 0, open)
+ p.tremor += 0.4 * sin(2 * .pi * 13 * t) * PetCurve.envelope(t, 0.3, 0.34, 0.46, 0.52) * scale
+ p.chew = max(p.chew, 0.55 * PetCurve.envelope(t, 0.78, 0.8, 0.84, 0.9))
+ p.chewPhase = 2 * .pi * (t - 0.78) / 0.1
+ }
+
+ /// Stuffed, it yawns, droops with a head that lolls and jerks back once, and curls up (yawn 1.0 s,
+ /// droop 0.8 s, curl 0.8 s).
+ static func fallAsleep(_ p: inout PetBody, e: Double) {
+ yawn(&p, e: e, at: 0.05)
+ let droopStart = B.fallAsleepYawn, curlStart = droopStart + B.fallAsleepDroop
+ // The head lolls to one side, jerks back up past upright, and gives in.
+ let drop = PetCurve.envelope(e, droopStart + 0.1, droopStart + 0.38, droopStart + 0.4, droopStart + 0.46,
+ up: PetEase.inQuad, down: PetEase.outQuad)
+ p.nod -= 0.7 * drop
+ p.tilt += 0.1 * drop
+ let jerk = PetCurve.envelope(e, droopStart + 0.41, droopStart + 0.46, droopStart + 0.5, droopStart + 0.62)
+ p.browLiftL += 0.7 * jerk; p.browLiftR += 0.7 * jerk
+ p.lidL = PetCurve.mix(p.lidL, 0.55, jerk); p.lidR = PetCurve.mix(p.lidR, 0.5, jerk)
+ p.tilt -= 0.04 * PetCurve.envelope(e, droopStart + 0.44, droopStart + 0.48, droopStart + 0.52, droopStart + 0.62)
+ let give = PetCurve.ramp(e, droopStart + 0.55, curlStart + 0.2, PetEase.inOutSine)
+ p.lidL *= 1 - give; p.lidR *= 1 - give
+ }
+
+ /// The wake (stir 0.6 s, stretch 1.6 s, shake 0.5 s): one eye cracks open and looks, then both; the
+ /// big stretch; a shake that the face follows a beat late.
+ static func wake(_ p: inout PetBody, e: Double, stir: Bool) {
+ if stir {
+ // One eye cracks open and looks; then both pop open.
+ let one = PetCurve.ramp(e, 0.08, 0.2), both = PetCurve.ramp(e, 0.42, 0.5)
+ p.lidL = PetCurve.mix(min(p.lidL, 0.1), 1, both)
+ p.lidR = PetCurve.mix(PetCurve.mix(min(p.lidR, 0.1), 0.5, one), 1, both)
+ p.lookX += -0.5 * one * (1 - both)
+ p.browLiftR += 0.8 * one * (1 - both)
+ p.browLiftL += 1.0 * PetCurve.envelope(e, 0.4, 0.46, 0.52, 0.62)
+ p.browLiftR += 1.0 * PetCurve.envelope(e, 0.4, 0.46, 0.52, 0.62)
+ p.eyeSize += 0.12 * PetCurve.envelope(e, 0.4, 0.46, 0.52, 0.62)
+ p.happy *= both
+ }
+ stretchFace(&p, e: e - B.wakeStir)
+ shake(&p, e: e - B.wakeStir - B.wakeStretch)
+ }
+
+ /// The stretch's face and arms: squeezed eyes, an open mouth, arms up (one higher), leaning to a side.
+ static func stretchFace(_ p: inout PetBody, e: Double) {
+ guard e > 0, e < 1.6 else { return }
+ let crouch = PetCurve.envelope(e, 0, 0.12, 0.13, 0.2, up: PetEase.outQuad, down: PetEase.inQuad)
+ let up = PetCurve.envelope(e, 0.12, 0.62, 1.1, 1.4, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ p.armL += -0.3 * crouch + 1.8 * up
+ p.armR += -0.3 * crouch + 1.4 * up
+ p.squeeze = max(p.squeeze, PetCurve.envelope(e, 0.1, 0.2, 1.15, 1.3))
+ let o = PetCurve.envelope(e, 0.2, 0.32, 1.0, 1.2)
+ p.mouthOpen = max(p.mouthOpen, 0.5 * o)
+ p.mouthWide = PetCurve.mix(p.mouthWide, 0.55, o)
+ p.tilt += 0.09 * PetCurve.envelope(e, 0.2, 0.5, 1.0, 1.3)
+ p.lean -= 0.05 * PetCurve.envelope(e, 0.2, 0.5, 1.0, 1.3)
+ p.nod += 0.3 * up
+ p.tremor += 0.5 * sin(2 * .pi * 14 * e) * PetCurve.envelope(e, 0.6, 0.7, 1.05, 1.15)
+ }
+
+ /// Shaking itself off: the body sways at 8 Hz and dies away, the face lags, the arms flap. The wake's
+ /// shake runs in the fast class; leaving the den, at the action rate, it is held to `strength`.
+ static func shake(_ p: inout PetBody, e: Double, strength: Double = 1) {
+ guard e > 0, e < 0.5 else { return }
+ let d = strength * exp(-e / 0.13) * PetEase.smooth(e / (strength < 1 ? 0.08 : 0.03)) * (1 - PetCurve.ramp(e, 0.38, 0.5))
+ let w = 2 * Double.pi * 8 * e
+ p.lean += 0.2 * sin(w) * d
+ p.tilt -= 0.04 * sin(w - 0.4) * d
+ p.squash += 0.025 * sin(2 * w) * d
+ p.turn += 0.32 * sin(w - 1.0) * d
+ p.armL += 0.55 * sin(w - 0.5) * d
+ p.armR -= 0.55 * sin(w - 0.5) * d
+ p.browLiftL += 0.5 * sin(w - 1.5) * d
+ p.browLiftR += 0.5 * sin(w - 1.7) * d
+ p.squeeze = max(p.squeeze, 0.9 * PetCurve.envelope(e, 0, 0.04, 0.24, 0.32))
+ }
+
+ /// The speed stretch along fast travel, area kept: the leap at the number reads as a smear toward it
+ /// at 60 fps. Measured from the action's own movement, so it is exactly as fast as what is seen.
+ static func smear(_ p: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ guard beat.start.isFinite, case .eat(let meal) = beat.behaviour, !meal.reduceMotion else { return }
+ let room = PetRigRoom(zone: scene.zone)
+ let m = timing(meal)
+ let e = beat.elapsed
+ let h = 1.0 / 240
+ let a = root(beat.behaviour, elapsed: e - h, room: room)
+ let b = root(beat.behaviour, elapsed: e + h, room: room)
+ let vx = (b.dx - a.dx) / (2 * h), vy = -(b.lift - a.lift) / (2 * h)
+ let speed = hypot(vx, vy) / max(room.scale, 0.1)
+ // Stretched along the leap toward the food, then, on the snap, squashed along the same line: the
+ // body hits the bite and gives, then springs back as it lets go.
+ let toward = vx * room.foodSide > 0
+ let stretch = toward && speed > 20 ? min(PetTuning.Hop.speedStretchMaximum, (speed - 20) / 1100) : 0
+ let contact = PetCurve.envelope(e, m.snap - 0.01, m.snap + 0.025, m.recoilStart, m.recoilStart + m.recoil * 0.6,
+ up: PetEase.outQuad, down: PetEase.inOutSine)
+ p.smear = 1 + stretch - (m.full ? 0.10 : 0.06) * contact
+ p.smearAngle = stretch > 0 ? atan2(vy, vx) : (room.foodSide < 0 ? .pi : 0)
+ }
+
+ // MARK: - Cuts, fades and the belly
+
+ /// A segment that cut the one before it short: the cut action's offsets fade out over 0.2 s rather
+ /// than vanish. A segment that followed its predecessor's own end has nothing to fade. An open
+ /// behaviour always ends by being cut, so what it was doing at that instant fades too: the asleep
+ /// and hibernating glow's slow pulse, the unsure head tilt, the drowsy yawn.
+ static func fadeCut(_ p: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ guard let prev = beat.previous, beat.start.isFinite, beat.elapsed < 0.2 else { return }
+ let own = prev.behaviour.duration ?? .infinity
+ let length = beat.start - prev.start
+ guard length < own - 1e-6, length > 0 else { return }
+ let room = PetRigRoom(zone: scene.zone)
+ var base = p
+ base.cheekPuff = 0; base.mouthOpen = 0; base.squeeze = 0; base.chew = 0; base.tongue = 0; base.flash = 0
+ base.lump = -1
+ var cut = base
+ let r = root(prev.behaviour, elapsed: length, room: room)
+ cut.x += r.dx; cut.lift += r.lift; cut.squash += r.squash
+ act(prev.behaviour, &cut, e: length, room: room, scene: scene)
+ let k = 1 - PetEase.smooth(beat.elapsed / 0.2)
+ for key in fadeChannels { p[keyPath: key] += (cut[keyPath: key] - base[keyPath: key]) * k }
+ }
+
+ static let fadeChannels: [WritableKeyPath] = [
+ \.x, \.lift, \.squash, \.lean, \.tilt, \.nod, \.turn, \.cheekPuff, \.mouthOpen, \.mouthWide, \.smile, \.squeeze,
+ \.eyeSize, \.browLiftL, \.browLiftR, \.browTiltL, \.browTiltR, \.armL, \.armR, \.chew, \.tongue, \.happy, \.flash,
+ \.glow,
+ ]
+
+ /// A feast leaves the belly round, easing back over 20 minutes.
+ static func feastBelly(_ p: inout PetBody, segments: [PetSegment], t: Double) {
+ let decay = M.feastBellyDecay
+ for seg in segments.reversed() {
+ if seg.start < t - decay - 10 { break }
+ guard seg.start <= t, case .eat(let meal) = seg.behaviour, meal.feast else { continue }
+ let m = timing(meal)
+ let since = t - (seg.start + m.splash)
+ let k = PetCurve.ramp(since, 0, 0.5) * (1 - PetEase.smooth(since / decay))
+ p.girth *= 1 + 0.07 * M.feastBelly * k
+ return
+ }
+ }
+
+ /// `off`: 0.2 s of fade, then nothing.
+ static func offOpacity(_ beat: PetRigBeat) -> Double {
+ 1 - PetEase.smooth(beat.elapsed / B.fadeOut)
+ }
+
+ /// Whatever follows `off` fades in over 0.3 s.
+ static func fadeInAfterOff(_ beat: PetRigBeat) -> Double {
+ guard let prev = beat.previous, prev.behaviour.isOff, beat.start.isFinite else { return 1 }
+ return PetEase.smooth(beat.elapsed / B.fadeIn)
+ }
+}
+
+/// Adds two root movements.
+fileprivate func + (a: (dx: Double, lift: Double, squash: Double), b: (dx: Double, lift: Double, squash: Double))
+ -> (dx: Double, lift: Double, squash: Double) {
+ (a.dx + b.dx, a.lift + b.lift, a.squash + b.squash)
+}
diff --git a/Sources/BrowKit/Pet/PetRigDock.swift b/Sources/BrowKit/Pet/PetRigDock.swift
new file mode 100644
index 0000000..0e092e5
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetRigDock.swift
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// Where the pet is between the strip and the panel: which zone it is posed in, and the hop, glide or
+/// crossfade that is carrying it there.
+struct PetRigPlacement {
+ /// nil: nowhere to draw it (collapsed with the readouts hidden).
+ var zone: PetZone?
+ var opacity: Double = 1
+ var flight: PetLayout.Flight?
+ var source: PetRigSource?
+ var lookAt: CGPoint?
+ var expanded: Bool
+ /// Reduce Motion's crossfade: the pet in the zone it is leaving, and how much of it is left.
+ var echo: (zone: PetZone, opacity: Double)?
+}
+
+enum PetRigDock {
+ /// The hop's crouch, flight, landing and settle all fit in this; the spring has settled by then.
+ static let settle: Double = 0.45
+
+ static func placement(at t: Double, timeline: PetTimeline, dock: PetDockTrack, zones: PetZones,
+ motion: PetMotion) -> PetRigPlacement {
+ let last = dock.changes.last { $0.t <= t && ($0.kind == .expand || $0.kind == .collapse) }
+ let expanded = last?.kind == .expand
+ let home = expanded ? zones.panel : zones.strip
+
+ // A layout change: out at the old zone, in at the new one.
+ if let change = dock.changes.last(where: { $0.t <= t }), case .relayout(let old) = change.kind {
+ let tau = t - change.t
+ typealias H = PetTuning.Hop
+ if tau < H.relayoutFadeOut {
+ let zone = expanded ? old.panel : old.strip
+ return PetRigPlacement(zone: zone, opacity: 1 - PetEase.smooth(tau / H.relayoutFadeOut), expanded: expanded)
+ }
+ if tau < H.relayoutFadeOut + H.relayoutFadeIn {
+ return PetRigPlacement(zone: home, opacity: PetEase.smooth((tau - H.relayoutFadeOut) / H.relayoutFadeIn),
+ expanded: expanded)
+ }
+ }
+
+ guard let change = last else { return PetRigPlacement(zone: home, expanded: expanded) }
+ let tau = t - change.t
+ guard let strip = zones.strip else {
+ // Readouts hidden: the pet lives in the panel only, fading in and out with it.
+ let fade = PetTuning.Hop.panelOnlyFade
+ if expanded { return PetRigPlacement(zone: zones.panel, opacity: PetEase.smooth(tau / fade), expanded: true) }
+ guard tau < fade else { return PetRigPlacement(zone: nil, expanded: false) }
+ return PetRigPlacement(zone: zones.panel, opacity: 1 - PetEase.smooth(tau / fade), expanded: false)
+ }
+ let panel = zones.panel
+
+ if motion.reduceMotion {
+ // No hop and no glide: the strip's pet fades out while the header's fades in, so nothing
+ // travels across the screen.
+ typealias H = PetTuning.Hop
+ let leaving = expanded ? strip : panel
+ let outOpacity = 1 - PetEase.smooth(tau / H.reducedFadeOut)
+ let inStart = H.reducedFadeOut - H.reducedOverlap
+ let inOpacity = PetEase.smooth((tau - inStart) / H.reducedFadeIn)
+ var p = PetRigPlacement(zone: home, opacity: inOpacity, expanded: expanded)
+ if outOpacity > 0.001 { p.echo = (leaving, outOpacity) }
+ return p
+ }
+
+ let behaviour = PetRigBeat(segments: timeline.segments, index: PetRigTimeline.index(timeline.segments, at: change.t),
+ t: change.t).behaviour
+ // Where the pet stood in the strip when the hop began: the anchor, or its place in the den.
+ let stripFeet = PetRigActions.restFeet(behaviour, zone: strip)
+ let a = stripFeet, b = panel.anchor
+ let sa = Double(strip.scale), sb = Double(panel.scale)
+ if glides(behaviour) {
+ guard tau < PetTuning.Hop.glide else { return PetRigPlacement(zone: home, expanded: expanded) }
+ let flight = expanded ? PetLayout.glide(from: a, to: b, scaleA: sa, scaleB: sb, tau: tau)
+ : PetLayout.glide(from: b, to: a, scaleA: sb, scaleB: sa, tau: tau)
+ var p = PetRigPlacement(zone: home, expanded: expanded)
+ p.flight = flight
+ return p
+ }
+ let duration = hopDuration(expand: expanded, a: a, b: b)
+ guard tau < duration else { return PetRigPlacement(zone: home, expanded: expanded) }
+ let stepsOff = strip.kind != .beside
+ let flight = expanded
+ ? PetLayout.expand(a: a, b: b, scaleA: sa, scaleB: sb, stepsOff: stepsOff, tau: tau)
+ : PetLayout.collapse(a: a, b: b, scaleA: sa, scaleB: sb, beside: strip.kind == .beside, tau: tau)
+ var p = PetRigPlacement(zone: home, expanded: expanded)
+ p.flight = flight
+ p.lookAt = flight.landed ? nil : (expanded ? b : a)
+ p.source = PetRigSource(kind: .dock(expand: expanded, ax: Double(a.x), ay: Double(a.y), bx: Double(b.x),
+ by: Double(b.y), sa: sa, sb: sb, stepsOff: stepsOff),
+ start: change.t, scale: expanded ? sb : sa, foodSide: 0, jellyHz: 4.0, jellyZeta: 0.3)
+ return p
+ }
+
+ /// Asleep, drowsy or hibernating, and in or near the den, the pet glides instead of hopping: it keeps
+ /// its pose and slides along a straight line, so a sleeping pet is carried, never made to jump.
+ static func glides(_ b: PetBehaviour) -> Bool {
+ switch b {
+ case .asleep, .drowsy, .hibernating, .stirring, .rollOver, .denWait, .doze, .peek, .enterDen, .leaveDen, .fallAsleep:
+ return true
+ default:
+ return false
+ }
+ }
+
+ /// From `setExpanded` to the landing spring settled.
+ static func hopDuration(expand: Bool, a: CGPoint, b: CGPoint) -> Double {
+ typealias H = PetTuning.Hop
+ if expand { return H.crouch + PetLayout.flightTime(from: a, to: b) + H.landing + settle }
+ return H.collapseY + H.collapseLanding + settle
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetRigGaze.swift b/Sources/BrowKit/Pet/PetRigGaze.swift
new file mode 100644
index 0000000..6156cf6
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetRigGaze.swift
@@ -0,0 +1,302 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// Where the pet looks. Priorities: the hop's destination, then the pointer, then the behaviour's own
+/// glances; a behaviour that needs its eyes (the snap, the yawn) keeps them. Every response is in closed
+/// form, a sum of the springs' step responses to each change of target, so a gaze at any instant needs no
+/// history beyond the track it is given.
+enum PetRigGaze {
+ /// A gaze: the eyes inside the face, and the head's turn and nod.
+ struct Look {
+ var x = 0.0, y = 0.0, turn = 0.0, nod = 0.0
+
+ static func + (a: Look, b: Look) -> Look { Look(x: a.x + b.x, y: a.y + b.y, turn: a.turn + b.turn, nod: a.nod + b.nod) }
+ static func - (a: Look, b: Look) -> Look { Look(x: a.x - b.x, y: a.y - b.y, turn: a.turn - b.turn, nod: a.nod - b.nod) }
+ static func * (a: Look, k: Double) -> Look { Look(x: a.x * k, y: a.y * k, turn: a.turn * k, nod: a.nod * k) }
+ }
+
+ /// Glances: the eyes dart, the head follows 60 ms later with a little overshoot.
+ static let eyeDart = PetSpring(omega: 40, zeta: 0.85)
+ static let headGlance = PetSpring(omega: 12, zeta: 0.7)
+ /// Watching the pointer: the eyes critically damped at ω 18, the head at ω 9, ζ 0.9.
+ static let eyeWatch = PetSpring(omega: PetTuning.Pointer.eyeOmega, zeta: 1)
+ static let headWatch = PetSpring(omega: PetTuning.Pointer.headOmega, zeta: PetTuning.Pointer.headZeta)
+
+ /// The eyes, in window coordinates: the controller measures the pointer's distance from the same
+ /// point.
+ static func eyes(_ body: PetBody) -> CGPoint {
+ CGPoint(x: body.x, y: body.floorY - body.lift - PetTuning.Pointer.eyeHeight * 0.77 * body.size)
+ }
+
+ /// The gaze that looks at `target`: the eyes turn first; past 25° off straight ahead the head turns
+ /// too. "Straight ahead" is out of the screen, toward the viewer, `depth` points away.
+ static func toward(_ target: CGPoint, from e: CGPoint, scale s: Double) -> Look {
+ let dx = Double(target.x - e.x), dy = Double(target.y - e.y)
+ let distance = max(hypot(dx, dy), 0.001)
+ let depth = 60 * s
+ let angle = atan(distance / depth)
+ let reach = PetCurve.clamp(distance / (6 * s), 0, 1)
+ let ux = dx / distance, uy = dy / distance
+ let follow = PetTuning.Pointer.headFollowDegrees * .pi / 180
+ let head = PetCurve.clamp((angle - follow) / (.pi / 2 - follow), 0, 1)
+ return Look(x: ux * reach, y: -uy * reach, turn: ux * 0.75 * head, nod: -uy * 0.5 * head)
+ }
+
+ static func lookToward(_ p: inout PetBody, _ target: CGPoint, weight w: Double, scene: PetRigPoser) {
+ guard w > 0.001 else { return }
+ let l = toward(target, from: eyes(p), scale: p.size)
+ p.lookX = PetCurve.mix(p.lookX, l.x, w)
+ p.lookY = PetCurve.mix(p.lookY, l.y, w)
+ p.turn = PetCurve.mix(p.turn, l.turn, w)
+ p.nod = PetCurve.mix(p.nod, l.nod, w)
+ }
+
+ /// Behaviours whose own acting needs the eyes for their whole length.
+ static func ownsEyes(_ b: PetBehaviour) -> Bool {
+ switch b {
+ case .eat, .fallAsleep, .wake, .rollOver, .asleep, .hibernating, .stirring, .enterDen, .leaveDen, .intro, .peek,
+ .celebrate, .boop, .dozeIn, .doze, .denWait, .off:
+ return true
+ default:
+ return false
+ }
+ }
+
+ static func apply(_ p: inout PetBody, posture: PetPosture, beat: PetRigBeat, scene: PetRigPoser) {
+ // The eyes rest on the food source in the moods that want it: eager, hungry, unsure.
+ var foodWeight = posture.lookAtFood
+ if case .unsureIdle = beat.behaviour { foodWeight = max(foodWeight, 0.8) }
+ if foodWeight > 0.001 {
+ let base = Look(x: p.lookX, y: p.lookY, turn: p.turn, nod: p.nod)
+ let food = toward(scene.zone.foodSource, from: eyes(p), scale: p.size)
+ let l = base + (food - Look(x: 0, y: 0, turn: 0, nod: 0)) * foodWeight
+ p.lookX = l.x; p.lookY = l.y; p.turn = l.turn; p.nod = l.nod
+ }
+ glances(&p, beat: beat, scene: scene)
+ cues(&p, beat: beat, scene: scene)
+ // Under Reduce Motion too: following the pointer turns the eyes and the head, and moves nothing
+ // across the screen.
+ watch(&p, beat: beat, scene: scene)
+ hideAndPeek(&p, beat: beat, scene: scene)
+ if let target = scene.lookAt {
+ lookToward(&p, target, weight: 1, scene: scene)
+ }
+ }
+
+ // MARK: Glances
+
+ enum GlanceTarget: CaseIterable { case food, den, viewer, down }
+
+ /// Glances every 4 to 11 s, to the food source, the den, the viewer or down; hungry glances at the food
+ /// three times as often. Each holds 1 to 2.5 s.
+ static func glances(_ p: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ let allowed = PetRigIdle.profile(beat.behaviour, reduceMotion: scene.motion.reduceMotion).glances
+ let previous = beat.previous.map { PetRigIdle.profile($0.behaviour, reduceMotion: scene.motion.reduceMotion).glances } ?? false
+ let w = allowed ? PetCurve.ramp(beat.elapsed, 0, 0.3) : (previous ? 1 - PetCurve.ramp(beat.elapsed, 0, 0.3) : 0)
+ guard w > 0.001 else { return }
+ let t = scene.t, seed = scene.seed
+ let hungry: Bool
+ switch beat.behaviour {
+ case .idle(let m), .settled(let m): hungry = m == .hungry || m == .peckish
+ default: hungry = false
+ }
+ var eye = Look(), head = Look()
+ let e = eyes(p)
+ for (k, at) in PetRigIdle.glances.recent(at: t, lookBack: 4.5, seed: seed) {
+ let hold = 1.0 + 1.5 * PetNoise.hash(seed, PetRigChannel.glanceHold, k)
+ let target = glanceTarget(k, hungry: hungry, from: e, scene: scene, size: p.size)
+ for (start, sign) in [(at, 1.0), (at + hold, -1.0)] where start <= t {
+ eye = eye + target * (sign * (1 - eyeDart.stepRemainder(t - start)))
+ head = head + target * (sign * (1 - headGlance.stepRemainder(t - start - PetTuning.Idle.eyeLead)))
+ }
+ }
+ p.lookX += w * eye.x
+ p.lookY += w * eye.y
+ p.turn += w * head.turn
+ p.nod += w * head.nod
+ }
+
+ static func glanceTarget(_ k: Int, hungry: Bool, from e: CGPoint, scene: PetRigPoser, size: Double) -> Look {
+ let r = PetNoise.hash(scene.seed, PetRigChannel.glanceTarget, k)
+ // Food, den, viewer, down; hungry triples the food's share.
+ let weights: [Double] = hungry ? [3, 1, 1, 1] : [1, 1, 1, 1]
+ let total = weights.reduce(0, +)
+ var acc = 0.0, pick = GlanceTarget.viewer
+ for (i, w) in weights.enumerated() {
+ acc += w / total
+ if r < acc { pick = GlanceTarget.allCases[i]; break }
+ }
+ switch pick {
+ case .food:
+ return toward(scene.zone.foodSource, from: e, scale: size)
+ case .den:
+ if case .side(let edge) = scene.zone.den { return toward(CGPoint(x: edge + 12, y: e.y - 4), from: e, scale: size) }
+ if case .overhead(let notch) = scene.zone.den { return toward(CGPoint(x: e.x + 4, y: notch.midY), from: e, scale: size) }
+ return Look(x: -scene.foodSide * 0.8, y: 0.2, turn: -scene.foodSide * 0.3, nod: 0.05)
+ case .viewer:
+ return Look(x: 0, y: 0.05, turn: 0, nod: 0.08)
+ case .down:
+ return Look(x: 0.15, y: -0.7, turn: 0.05, nod: -0.22)
+ }
+ }
+
+ // MARK: Cues
+
+ static func cues(_ p: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ let t = scene.t
+ for cue in scene.timeline.cues.reversed() {
+ if cue.t > t { continue }
+ let tau = t - cue.t
+ if tau > 3 { break }
+ switch cue.kind {
+ case .notice:
+ // Noticed (0.25 s): the eyes widen, the brows pop, a little flinch.
+ guard !ownsEyes(beat.behaviour) else { continue }
+ let pop = PetCurve.envelope(tau, 0, 0.05, 0.12, PetTuning.Behaviour.notice + 0.1)
+ p.browLiftL += 0.8 * pop
+ p.browLiftR += 0.6 * pop
+ p.eyeSize += 0.12 * PetCurve.envelope(tau, 0, 0.06, 0.8, 1.2)
+ p.squash -= 0.04 * PetCurve.envelope(tau, 0, 0.04, 0.06, 0.18)
+ p.mouthOpen = max(p.mouthOpen, 0.16 * PetCurve.envelope(tau, 0.02, 0.08, 0.5, 0.8))
+ p.mouthWide = PetCurve.mix(p.mouthWide, 0.45, PetCurve.envelope(tau, 0.02, 0.08, 0.5, 0.8))
+ case .release:
+ continue
+ case .twitch:
+ // Glim has no ears: a brow twitches in its sleep.
+ let k = PetCurve.bump(tau, 0, 0.12) + 0.7 * PetCurve.bump(tau, 0.14, 0.26)
+ p.browLiftR += 0.7 * k
+ p.tremor += 0.15 * PetCurve.bump(tau, 0, 0.2)
+ case .sleepyEye:
+ // Booped asleep: one eye opens halfway, looks, and closes (0.9 s).
+ let open = PetCurve.envelope(tau, 0.1, 0.28, 0.6, PetTuning.Boop.sleepyEye)
+ p.lidR = max(p.lidR, 0.5 * open)
+ p.browLiftR += 0.6 * open
+ case .glowPulse:
+ // Booped hibernating: the glow pulses once (0.6 s).
+ p.glow += 0.35 * PetCurve.bump(tau, 0, PetTuning.Boop.glowPulse)
+ }
+ }
+ }
+
+ // MARK: The pointer
+
+ /// How much the pointer holds the gaze: from a `notice` until 0.6 s after the `release`; in the panel,
+ /// whenever the pointer has moved in the last moments.
+ static func watchWeight(beat: PetRigBeat, scene: PetRigPoser) -> Double {
+ let t = scene.t
+ if scene.zone.kind == .panel {
+ guard let last = scene.pointer.samples.last(where: { $0.t <= t }) else { return 0 }
+ return 1 - PetCurve.ramp(t - last.t, 1.5, 2.0)
+ }
+ let cues = scene.timeline.cues
+ guard let notice = cues.last(where: { $0.kind == .notice && $0.t <= t }) else { return 0 }
+ var w = PetCurve.ramp(t - notice.t, 0, 0.12)
+ if let release = cues.last(where: { $0.kind == .release && $0.t <= t }), release.t > notice.t {
+ w *= 1 - PetCurve.ramp(t - release.t - PetTuning.Pointer.releaseHold, 0, 0.35)
+ }
+ return w
+ }
+
+ static func watch(_ p: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ var w = watchWeight(beat: beat, scene: scene)
+ if ownsEyes(beat.behaviour) { w *= 1 - PetCurve.ramp(beat.elapsed, 0, 0.15) }
+ if let prev = beat.previous, ownsEyes(prev.behaviour), !ownsEyes(beat.behaviour) {
+ w *= PetCurve.ramp(beat.elapsed, 0, 0.3)
+ }
+ guard w > 0.001 else { return }
+ let samples = scene.pointer.samples
+ guard let lastIndex = samples.lastIndex(where: { $0.t <= scene.t }) else { return }
+ let e = eyes(p)
+ let s = p.size
+ let eye = respond(samples, through: lastIndex, at: scene.t, window: 0.8, spring: eyeWatch) {
+ toward($0, from: e, scale: s)
+ }
+ let head = respond(samples, through: lastIndex, at: scene.t, window: 1.6, spring: headWatch) {
+ toward($0, from: e, scale: s)
+ }
+ p.lookX = PetCurve.mix(p.lookX, eye.x, w)
+ p.lookY = PetCurve.mix(p.lookY, eye.y, w)
+ p.turn = PetCurve.mix(p.turn, head.turn, w)
+ p.nod = PetCurve.mix(p.nod, head.nod, w)
+ // Watching, one brow stays up: curious, not alarmed.
+ p.browLiftL += 0.35 * w
+ }
+
+ /// A spring's response at `t` to the pointer's path as a series of steps: it starts settled on the
+ /// last sample before `window`, then each later sample is a step of the target. At most the 120 most
+ /// recent samples count; older ones have long settled.
+ static func respond(_ samples: [PetPointerSample], through last: Int, at t: Double, window: Double,
+ spring: PetSpring, look: (CGPoint) -> Look) -> Look {
+ var first = last
+ while first > 0, samples[first].t > t - window, last - first < 120 { first -= 1 }
+ var value = look(samples[first].location)
+ var previous = value
+ if first < last {
+ for j in (first + 1)...last {
+ let target = look(samples[j].location)
+ value = value + (target - previous) * (1 - spring.stepRemainder(t - samples[j].t))
+ previous = target
+ }
+ }
+ return value
+ }
+
+ // MARK: The rare hide-and-peek
+
+ /// After a long calm, a pointer that comes at the pet fast sends it behind the camera for a moment:
+ /// it ducks into the den, peeks out with one eye, and comes back (a rare reaction, not the default).
+ /// Derived from the pointer track alone, never from chance: nothing that reacts to the person is
+ /// random. Beside only, where there is a den to duck into.
+ static let calm: Double = 15
+ static let hideLength: Double = 1.6
+
+ static func hideStart(scene: PetRigPoser, beat: PetRigBeat) -> Double? {
+ guard scene.zone.kind == .beside, !scene.motion.reduceMotion else { return nil }
+ switch beat.behaviour {
+ case .idle, .settled, .unsureIdle: break
+ default: return nil
+ }
+ let samples = scene.pointer.samples
+ let t = scene.t
+ let anchor = scene.zone.anchor
+ let eye = CGPoint(x: anchor.x, y: anchor.y - PetTuning.Pointer.eyeHeight * scene.zone.scale)
+ func distance(_ q: CGPoint) -> Double { Double(hypot(q.x - eye.x, q.y - eye.y)) }
+ for j in samples.indices.reversed() where j > 0 {
+ let sj = samples[j]
+ if sj.t > t { continue }
+ if sj.t < t - hideLength - 0.3 { break }
+ guard sj.t - samples[j - 1].t >= calm else { continue }
+ // The approach: the next quarter second, closing fast to within the notice distance. A new idle
+ // segment (a mood change) does not cancel the reaction; anything else is not idle, above.
+ let trigger = sj.t + 0.25
+ guard trigger <= t else { continue }
+ guard let end = samples.last(where: { $0.t <= trigger }) else { continue }
+ let closed = distance(sj.location) - distance(end.location)
+ if closed >= 100, distance(end.location) <= PetTuning.Pointer.notice { return trigger }
+ }
+ return nil
+ }
+
+ static func hideAndPeek(_ p: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ guard let start = hideStart(scene: scene, beat: beat) else { return }
+ let tau = scene.t - start
+ guard tau < hideLength else { return }
+ let room = PetRigRoom(zone: scene.zone)
+ let s = scene.s
+ // Flinch, duck behind the camera (all but one eye), peek, come back out.
+ let flinch = PetCurve.envelope(tau, 0, 0.04, 0.06, 0.14)
+ p.squash -= 0.1 * flinch
+ p.eyeSize += 0.15 * PetCurve.envelope(tau, 0, 0.05, 0.3, 0.45)
+ p.browLiftL += 0.9 * flinch; p.browLiftR += 0.8 * flinch
+ let duck = PetCurve.envelope(tau, 0.06, 0.32, 1.05, 1.5, up: PetEase.outCubic, down: PetEase.inOutCubic)
+ p.x += room.peekX * duck
+ p.lift += 2.0 * s * PetCurve.bump(tau, 0.06, 0.32)
+ p.lean -= 0.18 * PetCurve.envelope(tau, 0.4, 0.55, 0.95, 1.1)
+ p.tilt -= 0.06 * PetCurve.envelope(tau, 0.4, 0.55, 0.95, 1.1)
+ p.mouthOpen = max(p.mouthOpen, 0.15 * PetCurve.envelope(tau, 0.45, 0.55, 0.9, 1.0))
+ p.mouthWide = PetCurve.mix(p.mouthWide, 0.42, PetCurve.envelope(tau, 0.45, 0.55, 0.9, 1.0))
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetRigIdle.swift b/Sources/BrowKit/Pet/PetRigIdle.swift
new file mode 100644
index 0000000..21811df
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetRigIdle.swift
@@ -0,0 +1,526 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// One blink's shape: shut fast, a short hold, open slower (close 0.05 s, hold 0.02 s, open 0.07 s),
+/// stretched to `length`. 1 open, 0 shut.
+enum IdleBlink {
+ static func shape(_ tau: Double, length: Double = PetTuning.Idle.blink) -> Double {
+ typealias I = PetTuning.Idle
+ guard tau > 0, tau < length else { return 1 }
+ let k = length / I.blink
+ let close = I.blinkClose * k, hold = I.blinkHold * k
+ if tau < close { return 1 - PetEase.inQuad(tau / close) }
+ if tau < close + hold { return 0 }
+ return PetEase.outQuad((tau - close - hold) / (length - close - hold))
+ }
+}
+
+/// A schedule of moments with random access: moment k falls somewhere in [k·period, (k + 1)·period −
+/// minimumGap], placed by `PetNoise`, so consecutive moments are `minimumGap` to `2·period − minimumGap`
+/// apart. Blinks every 2.4 to 6.5 s, glances every 4 to 11 s and rare idles every 45 to 120 s are all this,
+/// and finding the ones near any instant costs two hashes, whatever the uptime.
+struct PetRigSchedule {
+ var period: Double
+ var minimumGap: Double
+ var channel: PetNoise.Channel
+
+ func time(_ k: Int, seed: UInt64) -> Double {
+ Double(k) * period + (period - minimumGap) * PetNoise.hash(seed, channel, k)
+ }
+
+ /// The moments that started in the last `lookBack` seconds, newest last.
+ func recent(at t: Double, lookBack: Double, seed: UInt64) -> [(k: Int, at: Double)] {
+ let last = Int(floor(t / period))
+ let first = Int(floor((t - lookBack) / period)) - 1
+ var out: [(Int, Double)] = []
+ for k in first...last {
+ let at = time(k, seed: seed)
+ if at <= t && at > t - lookBack { out.append((k, at)) }
+ }
+ return out
+ }
+}
+
+/// Idle life: breath, sway and weight shift on periods that never line up, drift on value noise, blinks,
+/// glances, small and rare idles. All of it from `PetNoise`, so it is the same on every run and never
+/// loops: the schedules are aperiodic and the noise is not a cycle.
+enum PetRigIdle {
+ // MARK: Breath
+
+ /// The breath's oscillators. A posture breathes on the one nearest its period.
+ static let breathBank: [Double] = [1.6, 2.0, 2.8, 3.7, 4.2, 4.6, 6.0]
+
+ static func bankWeights(_ period: Double) -> [Double] {
+ var w = [Double](repeating: 0, count: breathBank.count)
+ let i = breathBank.indices.min { abs(breathBank[$0] - period) < abs(breathBank[$1] - period) } ?? 3
+ w[i] = 1
+ return w
+ }
+
+ /// The idle cycles a pose is driven by, for the closed-form steady state of the springs.
+ struct Cycles {
+ var breathW: Double
+ var breathPhase: Double
+ var breathAmp: Double
+ var shiftW: Double
+ var shiftAmp: Double
+ var shiftPhase: Double
+ }
+
+ static func cycles(posture: PetPosture, scene: PetRigPoser) -> Cycles {
+ // The strongest oscillator stands for the breath; the weaker one of a cross-fade is a fraction of
+ // a percent of squash and is left out of the springs' drive.
+ let i = posture.breath.indices.max { posture.breath[$0] < posture.breath[$1] } ?? 3
+ let amplitude = profile(scene).breath * posture.breathAmp * posture.breath[i]
+ return Cycles(breathW: 2 * .pi / breathBank[i], breathPhase: breathPhase(i, seed: scene.seed),
+ breathAmp: amplitude, shiftW: 2 * .pi / PetTuning.Idle.shiftPeriod,
+ shiftAmp: profile(scene).amplitude * PetTuning.Idle.shiftAmplitude * scene.s,
+ shiftPhase: 2 * .pi * PetNoise.hash(scene.seed, PetRigChannel.shiftPhase, 0))
+ }
+
+ static func breathPhase(_ i: Int, seed: UInt64) -> Double {
+ 2 * .pi * PetNoise.hash(seed, PetRigChannel.breathPhase, i)
+ }
+
+ /// The breath at `t`: in a little quicker than out, its phase wandering slowly so no two minutes match.
+ static func breath(_ posture: PetPosture, t: Double, seed: UInt64) -> Double {
+ let wander = 0.6 * PetDrift.drift(t, period: 23, seed: seed, channel: PetRigChannel.driftBreath)
+ var b = 0.0
+ for (i, w) in posture.breath.enumerated() where w > 0.0001 {
+ let ph = 2 * .pi * t / breathBank[i] + breathPhase(i, seed: seed) + wander
+ b += w * (sin(ph) + 0.22 * sin(2 * ph - 0.6))
+ }
+ return b
+ }
+
+ // MARK: What each behaviour allows
+
+ struct Profile {
+ /// How far the idle layers wander: 1 at rest, 0.5 settled.
+ var amplitude: Double
+ /// How much of the posture's breath shows.
+ var breath: Double
+ var blinks: Bool
+ var glances: Bool
+ var small: Bool
+ var rare: Bool
+ }
+
+ /// Blinks and glances are kept out of acting: an action owns the face.
+ static func profile(_ scene: PetRigPoser) -> Profile {
+ let beat = PetRigBeat(segments: scene.timeline.segments, index: scene.segmentIndex, t: scene.t)
+ return profile(beat.behaviour, reduceMotion: scene.motion.reduceMotion)
+ }
+
+ static func profile(_ b: PetBehaviour, reduceMotion: Bool) -> Profile {
+ var p: Profile
+ switch b {
+ case .idle: p = Profile(amplitude: 1, breath: 1, blinks: true, glances: true, small: true, rare: true)
+ case .settled: p = Profile(amplitude: PetTuning.Presence.settledAmplitude, breath: 1, blinks: true, glances: true,
+ small: false, rare: false)
+ case .eager: p = Profile(amplitude: 0.6, breath: 1, blinks: true, glances: false, small: true, rare: false)
+ case .unsureIdle: p = Profile(amplitude: 0.7, breath: 1, blinks: true, glances: false, small: false, rare: false)
+ case .satisfied(.meal), .satisfied(.feast): p = Profile(amplitude: 0.4, breath: 1, blinks: false, glances: false,
+ small: false, rare: false)
+ case .asleep, .hibernating, .stirring, .rollOver: p = Profile(amplitude: 0, breath: 1, blinks: false, glances: false,
+ small: false, rare: false)
+ case .drowsy: p = Profile(amplitude: 0.15, breath: 1, blinks: false, glances: false, small: false, rare: false)
+ case .denWait: p = Profile(amplitude: 0.5, breath: 1, blinks: true, glances: false, small: false, rare: false)
+ case .doze, .off: p = Profile(amplitude: 0, breath: 0, blinks: false, glances: false, small: false, rare: false)
+ default: p = Profile(amplitude: 0.25, breath: 1, blinks: false, glances: false, small: false, rare: false)
+ }
+ if reduceMotion {
+ // Reduce Motion: idle is breathing at half amplitude, and blinks.
+ p.amplitude = 0
+ p.small = false
+ p.rare = false
+ p.glances = false
+ }
+ return p
+ }
+
+ // MARK: Schedules
+
+ static let blinks = PetRigSchedule(period: (PetTuning.Idle.blinkBase + PetTuning.Idle.blinkSpread / 2),
+ minimumGap: PetTuning.Idle.blinkBase, channel: PetRigChannel.blinkAt)
+ static let glances = PetRigSchedule(period: PetTuning.Idle.glanceBase + PetTuning.Idle.glanceSpread / 2,
+ minimumGap: PetTuning.Idle.glanceBase, channel: PetRigChannel.glanceAt)
+ static let rares = PetRigSchedule(period: PetTuning.Idle.rareBase + PetTuning.Idle.rareSpread / 2,
+ minimumGap: PetTuning.Idle.rareBase, channel: PetRigChannel.rareAt)
+ /// The glutton's small habits between the rare idles: a lick at the number, a brow flick, a squish.
+ static let smalls = PetRigSchedule(period: 20, minimumGap: 9, channel: PetRigChannel.smallAt)
+ static let rumbles = PetRigSchedule(period: (PetTuning.Idle.rumbleMinimum + PetTuning.Idle.rumbleMaximum) / 2,
+ minimumGap: PetTuning.Idle.rumbleMinimum, channel: PetRigChannel.rumbleAt)
+ static let bounces = PetRigSchedule(period: (PetTuning.Eager.bounceMinimum + PetTuning.Eager.bounceMaximum) / 2,
+ minimumGap: PetTuning.Eager.bounceMinimum, channel: PetRigChannel.bounceAt)
+ static let drowsyBlinks = PetRigSchedule(period: (PetTuning.Limits.drowsyBlinkMinimum + PetTuning.Limits.drowsyBlinkMaximum) / 2,
+ minimumGap: PetTuning.Limits.drowsyBlinkMinimum, channel: PetRigChannel.drowsyBlink)
+
+ /// A pet's own blink rate, within ±15 %, from its seed: every interval of its schedule is scaled by it.
+ static func blinkRate(seed: UInt64) -> Double {
+ 1 + PetTuning.Idle.blinkRateSpread * (2 * PetNoise.hash(seed, PetRigChannel.blinkRate, 0) - 1)
+ }
+
+ /// How open the eyes are under the blink schedule at `t`. 12 % of blinks are double.
+ static func blinkOpen(at t: Double, seed: UInt64) -> Double {
+ let rate = blinkRate(seed: seed)
+ let local = t / rate
+ var open = 1.0
+ for (k, at) in blinks.recent(at: local, lookBack: 0.6 / rate, seed: seed) {
+ let start = at * rate
+ open = min(open, IdleBlink.shape(t - start))
+ if PetNoise.hash(seed, PetRigChannel.blinkDouble, k) < PetTuning.Idle.doubleBlinkShare {
+ open = min(open, IdleBlink.shape(t - start - 0.24))
+ }
+ }
+ return open
+ }
+
+ /// The blinks' start times in [from, to], for the tests and the showreel's last frame.
+ static func blinkStarts(from: Double, to: Double, seed: UInt64) -> [Double] {
+ let rate = blinkRate(seed: seed)
+ var out: [Double] = []
+ let first = Int(floor(from / rate / blinks.period)) - 1, last = Int(floor(to / rate / blinks.period))
+ for k in first...last {
+ let at = blinks.time(k, seed: seed) * rate
+ if at >= from && at <= to { out.append(at) }
+ }
+ return out
+ }
+
+ // MARK: - The layer
+
+ static func apply(_ body: inout PetBody, posture: PetPosture, beat: PetRigBeat, scene: PetRigPoser) {
+ let profile = profile(beat.behaviour, reduceMotion: scene.motion.reduceMotion)
+ let t = scene.t, seed = scene.seed, s = scene.s
+ let a = profile.amplitude * posture.drift
+
+ // Breath: squash and girth, the light rising and falling with it.
+ let b = breath(posture, t: t, seed: seed) * profile.breath
+ body.squash += posture.breathAmp * b
+ body.girth += 0.35 * posture.breathAmp * b
+ body.fullness += 0.012 * b
+ body.glow += 0.05 * b
+
+ // Sway, weight shift and appendage drift: three cycles of 7.3, 11.9 and 5.3 s, which never line up.
+ typealias I = PetTuning.Idle
+ let sway = sin(2 * .pi * t / I.swayPeriod + 2 * .pi * PetNoise.hash(seed, PetRigChannel.swayPhase, 0))
+ body.lean += a * tan(I.swayDegrees * .pi / 180) * sway
+ let shift = sin(2 * .pi * t / I.shiftPeriod + 2 * .pi * PetNoise.hash(seed, PetRigChannel.shiftPhase, 0))
+ body.x += a * I.shiftAmplitude * s * shift
+ let limbs = sin(2 * .pi * t / I.appendagePeriod + 2 * .pi * PetNoise.hash(seed, PetRigChannel.shiftPhase, 1))
+ body.armL += a * 0.08 * limbs
+ body.armR -= a * 0.08 * limbs
+
+ // Drift: the head, the eyes and the brows each wander on their own noise.
+ body.tilt += a * 0.02 * PetDrift.drift(t, period: 5.3, seed: seed, channel: PetRigChannel.driftLean)
+ body.turn += a * 0.07 * PetDrift.drift(t, period: 2.9, seed: seed, channel: PetRigChannel.driftTurn)
+ body.nod += a * 0.06 * PetDrift.drift(t, period: 3.4, seed: seed, channel: PetRigChannel.driftNod)
+ body.lookX += a * 0.10 * PetDrift.drift(t, period: 1.9, seed: seed, channel: PetRigChannel.driftLookX)
+ body.lookY += a * 0.08 * PetDrift.drift(t, period: 2.3, seed: seed, channel: PetRigChannel.driftLookY)
+ body.browLiftL += a * 0.14 * PetDrift.drift(t, period: 2.2, seed: seed, channel: PetRigChannel.driftBrowL)
+ body.browLiftR += a * 0.14 * PetDrift.drift(t, period: 2.5, seed: seed, channel: PetRigChannel.driftBrowR)
+ body.armL += a * 0.12 * PetDrift.drift(t, period: 2.6, seed: seed, channel: PetRigChannel.driftArmL)
+ body.armR += a * 0.12 * PetDrift.drift(t, period: 2.8, seed: seed, channel: PetRigChannel.driftArmR)
+
+ // Blinks.
+ if profile.blinks {
+ let open = blinkOpen(at: t, seed: seed)
+ body.lidL *= open
+ body.lidR *= open
+ }
+ if case .drowsy = beat.behaviour {
+ // Drowsy: slow, heavy blinks every 3 to 5 s, and a yawn at once and every 40 s.
+ for (_, at) in drowsyBlinks.recent(at: t, lookBack: 0.8, seed: seed) where at > beat.start + 1.2 {
+ let open = IdleBlink.shape(t - at, length: 0.6)
+ body.lidL *= open
+ body.lidR *= open
+ }
+ let every = PetTuning.Limits.drowsyYawnEvery
+ let k = floor((t - beat.start) / every)
+ if k >= 1 { PetRigActions.yawn(&body, e: t - beat.start - k * every, at: 0) }
+ }
+ if case .stirring = beat.behaviour {
+ // Stirring in the den: the one open eye blinks as slowly as a drowsy pet's, with no yawn.
+ for (_, at) in drowsyBlinks.recent(at: t, lookBack: 0.8, seed: seed) where at > beat.start + 2.0 {
+ body.lidL *= IdleBlink.shape(t - at, length: 0.6)
+ }
+ }
+
+ // The glutton's small habits, the rare idles, the hungry tummy.
+ if profile.small { smallIdle(&body, beat: beat, scene: scene) }
+ if profile.rare { rareIdle(&body, beat: beat, scene: scene) }
+ if case .idle(let mood) = beat.behaviour, mood == .peckish || mood == .hungry {
+ rumble(&body, beat: beat, scene: scene)
+ }
+ if case .settled(let mood) = beat.behaviour, mood == .hungry {
+ rumble(&body, beat: beat, scene: scene)
+ }
+
+ // Eager's bounces on its toes.
+ if case .eager = beat.behaviour, !scene.motion.reduceMotion {
+ for bounce in eagerBounces(beat: beat, scene: scene) {
+ let r = bounceRoot(t - bounce.start, height: bounce.height, air: bounce.air, scale: s)
+ body.lift += r.lift
+ body.squash += r.squash
+ let up = PetCurve.envelope(t - bounce.start, 0.1, 0.25, bounce.air, bounce.air + 0.2)
+ body.armL += 0.1 * up
+ body.armR += 0.12 * up
+ }
+ }
+ }
+
+ // MARK: Small and rare idles
+
+ enum Small: CaseIterable { case lick, browFlick, squish, sigh, shuffle }
+
+ /// Which small habit the k-th one is. Eager only licks its lips, and hungry never sighs: it licks.
+ static func smallKind(_ k: Int, seed: UInt64, behaviour: PetBehaviour) -> Small {
+ if case .eager = behaviour { return .lick }
+ let kind = Small.allCases[Int(PetNoise.hash(seed, PetRigChannel.smallKind, k) * Double(Small.allCases.count))]
+ if case .idle(.hungry) = behaviour, kind == .sigh { return .lick }
+ return kind
+ }
+
+ /// The small habits are drawn at the idle's 30 fps, where ambient motion keeps to half a point a frame:
+ /// each one is timed to move no faster, or it ticks instead of moving, every 20 s all day.
+ static func smallIdle(_ p: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ let t = scene.t, seed = scene.seed
+ let dir = scene.foodSide
+ for (k, at) in smalls.recent(at: t, lookBack: 1.4, seed: seed) where at > beat.start + 1 {
+ let tau = t - at
+ switch smallKind(k, seed: seed, behaviour: beat.behaviour) {
+ case .lick:
+ // The nibble's lick at half its pace, the head turning less.
+ PetRigActions.lick(&p, e: tau / 2, dir: dir, turn: 0.16)
+ case .browFlick:
+ p.browLiftL += 0.9 * PetCurve.envelope(tau, 0, 0.1, 0.12, 0.26)
+ p.browLiftR += 0.85 * PetCurve.envelope(tau, 0.01, 0.11, 0.13, 0.27)
+ case .squish:
+ p.squash -= 0.04 * PetCurve.envelope(tau, 0, 0.16, 0.2, 0.44)
+ p.happy = max(p.happy, 0.7 * PetCurve.envelope(tau, 0.04, 0.16, 0.5, 0.7))
+ p.smile += 0.3 * PetCurve.envelope(tau, 0.04, 0.16, 0.5, 0.7)
+ case .sigh:
+ p.squash += 0.03 * PetCurve.envelope(tau, 0, 0.35, 0.36, 1.0, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ p.nod += 0.12 * PetCurve.envelope(tau, 0, 0.35, 0.36, 1.0)
+ case .shuffle:
+ let side = PetNoise.hash(seed, PetRigChannel.side, k) < 0.5 ? -1.0 : 1.0
+ p.footL += 0.8 * PetCurve.envelope(tau, 0, 0.12, 0.16, 0.3) * (side < 0 ? 1 : 0.4)
+ p.footR += 0.8 * PetCurve.envelope(tau, 0.24, 0.36, 0.4, 0.54) * (side > 0 ? 1 : 0.4)
+ p.lean += 0.04 * side * PetCurve.envelope(tau, 0, 0.3, 0.5, 1.2)
+ }
+ }
+ }
+
+ enum Rare: CaseIterable { case lookAround, yawn, scratch, mote, pounce }
+
+ /// Which rare idle the k-th one is. The pounce is the joke, and a joke told every few minutes stops
+ /// being one: about one rare idle in twelve, the others sharing the rest.
+ static func rareKind(_ k: Int, seed: UInt64) -> Rare {
+ let h = PetNoise.hash(seed, PetRigChannel.rareKind, k)
+ guard h < 1 - pounceShare else { return .pounce }
+ let others: [Rare] = [.lookAround, .yawn, .scratch, .mote]
+ return others[min(others.count - 1, Int(h / (1 - pounceShare) * Double(others.count)))]
+ }
+
+ static let pounceShare = 1.0 / 12
+
+ /// The rare idle that started last, if it is still playing.
+ static func rare(beat: PetRigBeat, scene: PetRigPoser) -> (kind: Rare, at: Double, k: Int)? {
+ guard profile(beat.behaviour, reduceMotion: scene.motion.reduceMotion).rare else { return nil }
+ for (k, at) in rares.recent(at: scene.t, lookBack: 4, seed: scene.seed).reversed() where at > beat.start + 2 {
+ let kind = rareKind(k, seed: scene.seed)
+ if scene.t - at < length(kind) { return (kind, at, k) }
+ }
+ return nil
+ }
+
+ /// The pounce's wind-up wiggle and its leap, to the landing squash, as times into it: faster than
+ /// ambient motion may move at 30 fps, so the layer draws them in the fast class.
+ static let pounceFast = 0.3..<1.6
+
+ /// The fast window of the pounce playing in `segment` at `t`, or of the next one to come in it: the
+ /// controller raises the frame rate for exactly this long. nil where no rare idle plays, or none is a
+ /// pounce in the next hour and a half.
+ static func fastWindow(after t: Double, segment: PetSegment, seed: UInt64, reduceMotion: Bool) -> Range? {
+ guard profile(segment.behaviour, reduceMotion: reduceMotion).rare else { return nil }
+ let first = Int(floor((max(t, segment.start) - pounceFast.upperBound) / rares.period)) - 1
+ for k in first...(first + 64) {
+ let at = rares.time(k, seed: seed)
+ guard at > segment.start + 2, rareKind(k, seed: seed) == .pounce else { continue }
+ let window = (at + pounceFast.lowerBound)..<(at + pounceFast.upperBound)
+ if window.upperBound > t { return window }
+ }
+ return nil
+ }
+
+ static func length(_ r: Rare) -> Double {
+ switch r {
+ case .lookAround: return 2.4
+ case .yawn: return 1.3
+ case .scratch: return 1.85
+ case .mote: return 3.2
+ case .pounce: return pounceLength
+ }
+ }
+
+ static func rareIdle(_ p: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ guard let r = rare(beat: beat, scene: scene) else { return }
+ let tau = scene.t - r.at
+ switch r.kind {
+ case .lookAround:
+ let env = PetCurve.envelope(tau, 0, 0.3, 2.0, 2.4)
+ p.turn += 0.45 * sin(2 * .pi * tau / 2.4) * env
+ p.lookX += 0.7 * sin(2 * .pi * tau / 2.4 + 0.3) * env
+ case .yawn:
+ PetRigActions.yawn(&p, e: tau, at: 0, scale: 0.55)
+ case .scratch:
+ // A scratch behind the side: one arm up, a few slow rubs, and down again. Slow enough for the
+ // 30 fps the idle is drawn at, where a quick rub only shivers and a quick arm only ticks.
+ let up = PetCurve.envelope(tau, 0, 0.65, 1.15, 1.8)
+ p.armR += 1.5 * up + 0.12 * sin(2 * .pi * 3.5 * tau) * PetCurve.envelope(tau, 0.55, 0.7, 1.05, 1.2)
+ p.tilt += 0.05 * up
+ p.happy = max(p.happy, 0.8 * up)
+ p.smile += 0.2 * up
+ case .mote:
+ // A dust mote drifts past: the eyes follow it (the particle is `PetRigParticles.mote`).
+ let m = mote(at: tau, seed: scene.seed, k: r.k, scene: scene)
+ PetRigGaze.lookToward(&p, m, weight: PetCurve.envelope(tau, 0.2, 0.5, 2.6, 3.0), scene: scene)
+ p.browLiftL += 0.4 * PetCurve.envelope(tau, 0.3, 0.5, 1.5, 1.8)
+ case .pounce:
+ pounce(&p, tau: tau, scene: scene)
+ }
+ }
+
+ /// Where the drifting mote is, `tau` into its rare idle: across the pet's view, above it, slowly.
+ static func mote(at tau: Double, seed: UInt64, k: Int, scene: PetRigPoser) -> CGPoint {
+ let s = scene.s
+ let side = PetNoise.hash(seed, PetRigChannel.side, k + 7) < 0.5 ? -1.0 : 1.0
+ let x = Double(scene.zone.anchor.x) + side * (16 - 10 * tau) * s
+ let y = Double(scene.zone.anchor.y) - (24 + 3 * sin(tau * 2.1)) * s
+ + 2 * s * PetDrift.noise(tau * 1.3, seed: seed, channel: PetRigChannel.driftMote)
+ return CGPoint(x: x, y: y)
+ }
+
+ /// The rare comic failure: it eyes a mote, wiggles, pounces, and falls short; a sheepish look after.
+ /// Its wind-up and leap are drawn in the fast class (`pounceFast`).
+ static let pounceLength = 2.6
+
+ static func pounceRoot(_ tau: Double, scale s: Double, side: Double) -> (dx: Double, lift: Double, squash: Double) {
+ let wiggle = 0.03 * sin(2 * .pi * 6 * tau) * PetCurve.envelope(tau, 0.3, 0.45, 0.8, 0.9)
+ let crouch = -0.12 * PetCurve.envelope(tau, 0.3, 0.5, 0.85, 0.92, up: PetEase.outQuad, down: PetEase.inQuad)
+ var dx = 0.0, lift = 0.0, squash = wiggle + crouch
+ if tau > 0.92 && tau < 1.32 {
+ let u = (tau - 0.92) / 0.4
+ lift = 4 * 3.5 * s * u * (1 - u)
+ squash += 0.14 * abs(1 - 2 * u) * PetCurve.envelope(u, 0, 0.1, 0.85, 1)
+ dx = side * 3 * s * u
+ } else if tau >= 1.32 {
+ dx = side * 3 * s * (1 - PetEase.inOutSine((tau - 1.9) / 0.6))
+ squash -= 0.12 * PetCurve.envelope(tau, 1.32, 1.36, 1.38, 1.6, up: PetEase.outQuad)
+ }
+ return (dx, lift, squash)
+ }
+
+ static let pounceImpulses: [(Double, PetRigSecondary.Spring, Double)] = [(1.32, .jiggle, -2.0), (1.32, .brows, -8)]
+
+ static func pounce(_ p: inout PetBody, tau: Double, scene: PetRigPoser) {
+ let side = pounceSide(scene)
+ let r = pounceRoot(tau, scale: scene.s, side: side)
+ p.x += r.dx
+ p.lift += r.lift
+ p.squash += r.squash
+ let up = PetCurve.envelope(tau, 0.95, 1.05, 1.2, 1.32)
+ p.armL += 1.2 * up
+ p.armR += 1.3 * up
+ p.eyeSize += 0.15 * PetCurve.envelope(tau, 0.2, 0.35, 1.2, 1.4)
+ p.lookY += 0.8 * PetCurve.envelope(tau, 0.1, 0.3, 1.3, 1.5)
+ p.lookX += side * 0.5 * PetCurve.envelope(tau, 0.1, 0.3, 1.3, 1.5)
+ // Missed: brows up and worried, a sheepish smile, a blush.
+ let sheepish = PetCurve.envelope(tau, 1.4, 1.55, 2.2, 2.6)
+ p.browTiltL += 0.25 * sheepish; p.browTiltR += 0.25 * sheepish
+ p.browLiftL += 0.5 * sheepish; p.browLiftR += 0.6 * sheepish
+ p.smile += 0.2 * sheepish
+ p.blush += 0.4 * sheepish
+ p.lookX -= side * 0.6 * sheepish
+ }
+
+ /// The pounce goes where there is room: away from the den and the readout's edge, toward the middle.
+ static func pounceSide(_ scene: PetRigPoser) -> Double {
+ let room = PetRigRoom(zone: scene.zone)
+ return room.reach > 6 * scene.s ? room.foodSide : -room.foodSide
+ }
+
+ /// Peckish and hungry, a tummy rumble every 40 to 70 s: a shiver of the outline, a look down at the
+ /// belly, and back to the number, hopeful.
+ static func rumble(_ p: inout PetBody, beat: PetRigBeat, scene: PetRigPoser) {
+ for (_, at) in rumbles.recent(at: scene.t, lookBack: 1.2, seed: scene.seed) where at > beat.start + 1.5 {
+ let tau = scene.t - at
+ // The shiver is the outline's own ripple; the clench under it is slow enough for 30 fps.
+ p.tremor += 0.8 * PetCurve.ring(tau, 0, frequency: 15, decay: 0.09)
+ p.squash -= 0.02 * PetCurve.envelope(tau, 0, 0.06, 0.1, 0.22)
+ p.lookY -= 0.7 * PetCurve.envelope(tau, 0.05, 0.15, 0.45, 0.65)
+ p.nod -= 0.15 * PetCurve.envelope(tau, 0.05, 0.15, 0.45, 0.65)
+ p.browLiftL += 0.3 * PetCurve.envelope(tau, 0.05, 0.12, 0.4, 0.6)
+ p.mouthWide = PetCurve.mix(p.mouthWide, 0.45, PetCurve.envelope(tau, 0.05, 0.12, 0.4, 0.6))
+ }
+ }
+
+ /// The last rumble's start, if one is on: the rumble lines are drawn from it.
+ static func rumbleStart(beat: PetRigBeat, scene: PetRigPoser) -> Double? {
+ let mood: PetMood?
+ switch beat.behaviour {
+ case .idle(let m), .settled(let m): mood = m
+ default: mood = nil
+ }
+ guard let m = mood, m == .hungry || (m == .peckish && isIdle(beat)) else { return nil }
+ return rumbles.recent(at: scene.t, lookBack: 0.4, seed: scene.seed).last { $0.at > beat.start + 1.5 }?.at
+ }
+
+ static func isIdle(_ beat: PetRigBeat) -> Bool { if case .idle = beat.behaviour { return true }; return false }
+
+ // MARK: Bounces
+
+ /// Eager's small bounces on its toes every 1.6 to 2.2 s, inside the eager segment.
+ static func eagerBounces(beat: PetRigBeat, scene: PetRigPoser) -> [(start: Double, height: Double, air: Double)] {
+ guard case .eager = beat.behaviour else { return [] }
+ let air = 0.34
+ let length = 0.12 + air + 0.24
+ return bounces.recent(at: scene.t, lookBack: length + PetTuning.Secondary.tail, seed: scene.seed)
+ .filter { $0.at > beat.start + 0.4 && ($0.at + length < (beat.end ?? .infinity)) }
+ .map { (start: $0.at, height: 0.6 * scene.s, air: air) }
+ }
+
+ /// One of eager's bounces on its toes: small and soft, because eager runs at the idle rate, 30 fps,
+ /// where ambient motion keeps to half a point a frame.
+ static func bounceRoot(_ tau: Double, height: Double, air: Double, scale: Double) -> (dx: Double, lift: Double, squash: Double) {
+ var lift = 0.0, squash = 0.0
+ let takeoff = 0.12
+ squash -= 0.018 * PetCurve.envelope(tau, 0, 0.08, 0.09, takeoff + 0.1, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ if tau > takeoff && tau < takeoff + air {
+ let u = (tau - takeoff) / air
+ lift = 4 * height * u * (1 - u)
+ }
+ squash -= 0.02 * PetCurve.envelope(tau, takeoff + air - 0.04, takeoff + air + 0.06, takeoff + air + 0.08,
+ takeoff + air + 0.24, up: PetEase.inOutSine, down: PetEase.inOutSine)
+ return (0, lift, squash)
+ }
+
+ /// The idle movements that drive transients: eager's bounces and the pounce.
+ static func motionSources(beat: PetRigBeat, scene: PetRigPoser) -> [PetRigSource] {
+ var out: [PetRigSource] = []
+ let posture = PetRigActions.posture(beat.behaviour, elapsed: 1, scene: scene)
+ for b in eagerBounces(beat: beat, scene: scene) {
+ out.append(PetRigSource(kind: .bounce(height: b.height, air: b.air), start: b.start, scale: scene.s,
+ foodSide: 0, jellyHz: posture.jellyHz, jellyZeta: posture.jellyZeta))
+ }
+ if let r = rare(beat: beat, scene: scene), r.kind == .pounce {
+ out.append(PetRigSource(kind: .pounce, start: r.at, scale: scene.s, foodSide: pounceSide(scene),
+ jellyHz: posture.jellyHz, jellyZeta: posture.jellyZeta))
+ }
+ return out
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetRigMotion.swift b/Sources/BrowKit/Pet/PetRigMotion.swift
new file mode 100644
index 0000000..b4a468d
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetRigMotion.swift
@@ -0,0 +1,150 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+// The rig's small motion toolkit: easings, envelopes, value noise and damped springs. Everything here
+// is a pure function of its arguments, which is what lets `PetRig.pose` be one too. The names carry a
+// `Pet` prefix because the pet's two halves share one module.
+
+/// Named easings only, never a linear tween, which starts and stops at full speed and reads as
+/// mechanical. Each takes and clamps a fraction.
+enum PetEase {
+ static func clamp01(_ x: Double) -> Double { min(max(x, 0), 1) }
+
+ /// Cubic smoothstep: slow in, slow out.
+ static func smooth(_ t: Double) -> Double { let x = clamp01(t); return x * x * (3 - 2 * x) }
+
+ /// Quintic smootherstep, for curves no spring smooths afterwards: its acceleration is continuous
+ /// too, so a channel that follows it never shows a kink at 60 fps.
+ static func smoother(_ t: Double) -> Double {
+ let x = clamp01(t)
+ return x * x * x * (x * (x * 6 - 15) + 10)
+ }
+
+ static func inOutSine(_ t: Double) -> Double { 0.5 - 0.5 * cos(.pi * clamp01(t)) }
+ static func inQuad(_ t: Double) -> Double { let x = clamp01(t); return x * x }
+ static func outQuad(_ t: Double) -> Double { let x = 1 - clamp01(t); return 1 - x * x }
+ static func inCubic(_ t: Double) -> Double { let x = clamp01(t); return x * x * x }
+ static func outCubic(_ t: Double) -> Double { let x = 1 - clamp01(t); return 1 - x * x * x }
+
+ /// Ease-in-out cubic, the easing for moves.
+ static func inOutCubic(_ t: Double) -> Double {
+ let x = clamp01(t)
+ if x < 0.5 { return 4 * x * x * x }
+ let y = -2 * x + 2
+ return 1 - y * y * y / 2
+ }
+
+ /// Ease-out-back, the easing for pops: it overshoots by `PetTuning.Idle.popOvershoot`.
+ static func outBack(_ t: Double, _ overshoot: Double = PetTuning.Idle.popOvershoot) -> Double {
+ let x = clamp01(t) - 1
+ return 1 + x * x * ((overshoot + 1) * x + overshoot)
+ }
+}
+
+/// Envelopes keyed by time: the base layer of every action is made of these.
+enum PetCurve {
+ @inline(__always) static func mix(_ a: Double, _ b: Double, _ t: Double) -> Double { a + (b - a) * t }
+
+ @inline(__always) static func clamp(_ x: Double, _ lo: Double, _ hi: Double) -> Double { min(max(x, lo), hi) }
+
+ /// 0 before `a`, 1 after `b`, eased in between.
+ static func ramp(_ t: Double, _ a: Double, _ b: Double, _ ease: (Double) -> Double = PetEase.smooth) -> Double {
+ if t <= a { return 0 }
+ if t >= b { return 1 }
+ return ease((t - a) / (b - a))
+ }
+
+ /// Rises over [a, b], holds, falls over [c, d]; 0 outside [a, d].
+ static func envelope(_ t: Double, _ a: Double, _ b: Double, _ c: Double, _ d: Double,
+ up: (Double) -> Double = PetEase.smooth,
+ down: (Double) -> Double = PetEase.smooth) -> Double {
+ if t <= a || t >= d { return 0 }
+ if t < b { return up((t - a) / (b - a)) }
+ if t <= c { return 1 }
+ return 1 - down((t - c) / (d - c))
+ }
+
+ /// A single smooth bump over [a, b]: up and down with no hold, its peak in the middle.
+ static func bump(_ t: Double, _ a: Double, _ b: Double) -> Double {
+ guard t > a, t < b else { return 0 }
+ let x = (t - a) / (b - a)
+ let s = sin(.pi * x)
+ return s * s
+ }
+
+ /// A decaying oscillation that starts at `t0` (0 before it), faded in over `attack` so it never
+ /// starts with a jump in speed: a tremble, a rumble, a shiver.
+ static func ring(_ t: Double, _ t0: Double, frequency: Double, decay: Double, attack: Double = 0.025,
+ phase: Double = 0) -> Double {
+ let tau = t - t0
+ guard tau > 0 else { return 0 }
+ let a = attack > 0 ? PetEase.smooth(tau / attack) : 1
+ return a * sin(2 * .pi * frequency * tau + phase) * exp(-tau / decay)
+ }
+}
+
+/// Smooth value noise from `PetNoise`: random access in O(1), never periodic, and the same for the same
+/// seed on every Mac. Idle drift is made of it, so a day of idle never repeats and any instant of it
+/// can be posed on its own.
+enum PetDrift {
+ /// Value noise in [-1, 1] at `x`, interpolated with the quintic smootherstep (C²: no kinks).
+ static func noise(_ x: Double, seed: UInt64, channel: PetNoise.Channel) -> Double {
+ let i = floor(x)
+ let f = x - i
+ let k = Int(i)
+ let a = PetNoise.hash(seed, channel, k) * 2 - 1
+ let b = PetNoise.hash(seed, channel, k &+ 1) * 2 - 1
+ return a + (b - a) * (f * f * f * (f * (f * 6 - 15) + 10))
+ }
+
+ /// Two octaves of noise over `period` seconds: an idle wander that never loops.
+ static func drift(_ t: Double, period: Double, seed: UInt64, channel: PetNoise.Channel) -> Double {
+ 0.7 * noise(t / period, seed: seed, channel: channel)
+ + 0.3 * noise(t / (period * 0.43) + 17.3, seed: seed, channel: PetNoise.Channel(channel.raw ^ 0x80))
+ }
+}
+
+/// A linear damped spring, x'' + 2ζω x' + ω² x = force, as the secondary motion uses it: in closed form
+/// where the drive is known (a sinusoid's steady state, a step, an impulse), and stepped with
+/// semi-implicit Euler where it is not (an action's transient).
+struct PetSpring: Sendable, Hashable {
+ var omega: Double
+ var zeta: Double
+
+ /// One semi-implicit Euler step: the velocity first, then the position from the new velocity.
+ /// Stable for ω·dt well under 1, which the rig's 1/240 s step keeps (ω ≤ 60 rad/s).
+ @inline(__always)
+ func step(_ x: inout Double, _ v: inout Double, force: Double, dt: Double) {
+ v += (force - omega * omega * x - 2 * zeta * omega * v) * dt
+ x += v * dt
+ }
+
+ /// The steady-state response to `amplitude · sin(w·t + phase)`: the spring's transfer function
+ /// gives its gain and its lag, so a periodic idle drive needs no integration at all.
+ func steadyState(amplitude: Double, w: Double, phase: Double, at t: Double) -> Double {
+ let re = omega * omega - w * w
+ let im = 2 * zeta * omega * w
+ let gain = amplitude / (re * re + im * im).squareRoot()
+ let lag = atan2(im, re)
+ return gain * sin(w * t + phase - lag)
+ }
+
+ /// From x = 1 at rest to the target 0, `tau` seconds after a step: what a gaze does after its target
+ /// jumps. 1 before the step. Exact for every damping ratio.
+ func stepRemainder(_ tau: Double) -> Double {
+ guard tau > 0 else { return 1 }
+ let a = zeta * omega
+ if zeta < 0.999 {
+ let wd = omega * (1 - zeta * zeta).squareRoot()
+ return exp(-a * tau) * (cos(wd * tau) + a / wd * sin(wd * tau))
+ }
+ if zeta > 1.001 {
+ let r = omega * (zeta * zeta - 1).squareRoot()
+ let s1 = -a + r, s2 = -a - r
+ return (s1 * exp(s2 * tau) - s2 * exp(s1 * tau)) / (s1 - s2)
+ }
+ return exp(-omega * tau) * (1 + omega * tau)
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetRigParticles.swift b/Sources/BrowKit/Pet/PetRigParticles.swift
new file mode 100644
index 0000000..11ed674
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetRigParticles.swift
@@ -0,0 +1,522 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// The bits of light around the pet: each a pure function of its spawn time on the timeline and its
+/// hashed parameters, so a particle is where it is whatever was drawn before. At most
+/// `PetTuning.Particles.maximumAlive` at once. Under Reduce Motion, Zzz and sparkles fade in place.
+enum PetRigParticles {
+ struct Extras {
+ var particles: [PetParticle] = []
+ var bead: PetBead?
+ var glint: PetGlint?
+ var door: PetDoorLight?
+ /// The boxes the canvas must hold besides the body.
+ var points: [CGRect] = []
+ }
+
+ static func make(body: PetBody, beat: PetRigBeat, scene: PetRigPoser) -> Extras {
+ var out = Extras()
+ let segments = scene.timeline.segments
+ guard let index = beat.index else { return out }
+ // The segment playing, however long ago it started (a sleep's Zzz and the light under the den's
+ // door go on for hours), and, since particles outlive their segment by a little, those that ended
+ // in the last few seconds.
+ let oldest = scene.t - 7
+ var i = index
+ while i >= 0 {
+ let seg = segments[i]
+ let end = i + 1 < segments.count ? segments[i + 1].start : nil
+ if i < index, let end, end < oldest { break }
+ spawn(seg, end: end, current: i == index, body: body, scene: scene, into: &out)
+ i -= 1
+ }
+ if let start = PetRigIdle.rumbleStart(beat: beat, scene: scene) { rumble(at: start, body: body, scene: scene, into: &out) }
+ if let r = PetRigIdle.rare(beat: beat, scene: scene), r.kind == .mote || r.kind == .pounce {
+ let tau = scene.t - r.at
+ let m = r.kind == .mote ? PetRigIdle.mote(at: tau, seed: scene.seed, k: r.k, scene: scene)
+ : pounceMote(tau: tau, scene: scene)
+ let o = PetCurve.envelope(tau, 0, 0.3, PetRigIdle.length(r.kind) - 0.5, PetRigIdle.length(r.kind))
+ out.particles.append(PetParticle(kind: .mote, position: m, size: 0.55 * scene.s, opacity: 0.8 * o, tint: .sparkle))
+ }
+ // Nothing but the food itself crosses into the readouts' gap: a rumble line or a sparkle that would
+ // is left out rather than drawn over a number.
+ let guards = PetLayout.guards(scene.zone)
+ let bounds = scene.zone.bounds
+ out.particles = out.particles.filter { p in
+ if case .dot = p.tint { return true }
+ let r = p.size * 1.2 + 0.5
+ if guards.min != nil, Double(p.position.x) - r < Double(bounds.minX) { return false }
+ if guards.max != nil, scene.zone.kind != .panel, Double(p.position.x) + r > Double(bounds.maxX) { return false }
+ return true
+ }
+ // Keep the brightest few.
+ let limit = PetTuning.Particles.maximumAlive
+ if out.particles.count > limit {
+ out.particles = Array(out.particles.sorted { $0.opacity > $1.opacity }.prefix(limit))
+ }
+ for p in out.particles where p.opacity > 0.003 {
+ let r = p.size * 2 + 2
+ out.points.append(CGRect(x: p.position.x - r, y: p.position.y - r, width: 2 * r, height: 2 * r))
+ }
+ if let b = out.bead {
+ let r = b.radius * 4
+ out.points.append(CGRect(x: b.position.x - r, y: b.position.y - r, width: 2 * r, height: 2 * r))
+ for q in b.trail { out.points.append(CGRect(x: q.x - 2, y: q.y - 2, width: 4, height: 4)) }
+ }
+ if let g = out.glint {
+ let r = g.size * 1.5
+ out.points.append(CGRect(x: g.position.x - r, y: g.position.y - r, width: 2 * r, height: 2 * r))
+ }
+ if let d = out.door {
+ out.points.append(CGRect(x: d.minX, y: d.y, width: d.maxX - d.minX, height: 4))
+ }
+ return out
+ }
+
+ static func segments(_ scene: PetRigPoser) -> [PetSegment] { scene.timeline.segments }
+
+ static func hash(_ seed: UInt64, _ start: Double, _ k: Int) -> Double {
+ PetNoise.hash(seed, PetRigChannel.particle, Int(truncatingIfNeeded: Int64((start * 240).rounded())) &* 31 &+ k)
+ }
+
+ /// The particles one segment leaves.
+ static func spawn(_ seg: PetSegment, end: Double?, current: Bool, body: PetBody, scene: PetRigPoser, into out: inout Extras) {
+ let t = scene.t, s = scene.s, seed = scene.seed
+ let tau = t - seg.start
+ let anchor = scene.zone.anchor
+ let crown = CGPoint(x: Double(anchor.x), y: Double(anchor.y) - 21 * s)
+ switch seg.behaviour {
+ case .eat(let meal):
+ guard current || tau < PetRigActions.timing(meal).end + 0.6 else { return }
+ food(meal, start: seg.start, body: body, current: current, scene: scene, into: &out)
+ case .satisfied(.meal):
+ for (k, at) in [0.12, 0.38, 0.62].enumerated() where (end ?? .infinity) > seg.start + at {
+ sparkle(at: seg.start + at, origin: CGPoint(x: crown.x + [-6, 5, -1][k] * s, y: crown.y + 2 * s),
+ seed: seed, k: k, size: 2.0 * s, scene: scene, into: &out)
+ }
+ case .satisfied(.feast):
+ for (k, at) in [0.2, 0.5, 0.8].enumerated() where (end ?? .infinity) > seg.start + at {
+ sparkle(at: seg.start + at, origin: CGPoint(x: crown.x + [6, -5, 2][k] * s, y: crown.y + 1 * s),
+ seed: seed, k: k, size: 1.5 * s, scene: scene, into: &out)
+ }
+ bubble(at: seg.start + 1.4, body: body, scene: scene, into: &out)
+ case .celebrate(let kind):
+ let times: [Double] = kind == .goodWeek ? [0.5, 0.8, 1.1, 1.6, 1.8] : [0.5, 0.62, 0.75, 1.3, 1.45, 1.6]
+ for (k, at) in times.enumerated() {
+ let a = 2 * Double.pi * hash(seed, seg.start, 40 + k)
+ let origin = CGPoint(x: crown.x + cos(a) * 9 * s, y: crown.y + (2 - 4 * hash(seed, seg.start, 50 + k)) * s)
+ let size = (PetTuning.Particles.sparkleSizeMinimum
+ + (PetTuning.Particles.sparkleSizeMaximum - PetTuning.Particles.sparkleSizeMinimum)
+ * hash(seed, seg.start, 60 + k)) * s
+ sparkle(at: seg.start + at, origin: origin, seed: seed, k: k, size: size, scene: scene, into: &out)
+ }
+ case .freshWindow:
+ for (k, at) in [0.5, 0.65].enumerated() {
+ sparkle(at: seg.start + at, origin: CGPoint(x: crown.x + [-5, 6][k] * s, y: crown.y - 2 * s),
+ seed: seed, k: k, size: 1.8 * s, scene: scene, into: &out)
+ }
+ case .boop(let giggle):
+ if giggle {
+ for (k, at) in [0.3, 0.7].enumerated() {
+ sparkle(at: seg.start + at, origin: CGPoint(x: crown.x + [-6, 6][k] * s, y: crown.y + 2 * s),
+ seed: seed, k: k, size: 1.6 * s, scene: scene, into: &out)
+ }
+ }
+ case .wake:
+ let at = PetTuning.Behaviour.wakeStir + PetTuning.Behaviour.wakeStretch + 0.35
+ // The Zzz still rising when it wakes pop as it starts: the sleep's own segment draws them, with
+ // its end, however long the sleep was.
+ for k in 0..<2 {
+ sparkle(at: seg.start + at + 0.1 * Double(k), origin: CGPoint(x: crown.x + [-7, 6][k] * s, y: crown.y),
+ seed: seed, k: k, size: 1.7 * s, scene: scene, into: &out)
+ }
+ case .fallAsleep:
+ // The first Zzz rises from the curl, and pops if the pet is woken before the next segment.
+ let at = seg.start + PetTuning.Behaviour.fallAsleep - 0.35
+ if at < (end ?? .infinity) {
+ let next = end.flatMap { e in segments(scene).first { $0.start == e } }
+ let woken = next.map { if case .asleep = $0.behaviour { return false } else { return true } } ?? false
+ zzz(at: at, k: 0, seg: seg, scene: scene, into: &out, until: woken ? end : nil)
+ }
+ case .asleep:
+ // Zzz rising 10 pt over 2.6 s, three alive at most; brisk for a minute, then slower.
+ let life = PetTuning.Particles.zzzLife
+ for k in max(0, asleepZzzIndex(at: tau - life))...max(0, asleepZzzIndex(at: tau)) {
+ zzz(at: seg.start + asleepZzz(k), k: k, seg: seg, scene: scene, into: &out, until: end)
+ }
+ case .hibernating:
+ // Curled into a ball where there is no den, it sleeps too deep for Zzz: that is what tells it
+ // from the 5-hour sleep on the pill at a glance.
+ if case .none = scene.zone.den { break }
+ let every = PetTuning.Den.zzzEvery
+ let life = PetTuning.Particles.zzzLife
+ let first = Int(floor((t - seg.start - 1.5 - life) / every))
+ for k in max(0, first)...max(0, Int(floor((t - seg.start - 1.5) / every))) {
+ zzz(at: seg.start + 1.5 + Double(k) * every, k: k, seg: seg, scene: scene, into: &out, until: end, den: true)
+ }
+ door(seg, end: end, scene: scene, into: &out)
+ case .enterDen, .stirring, .denWait, .peek, .leaveDen:
+ door(seg, end: end, scene: scene, into: &out)
+ default:
+ break
+ }
+ }
+
+ // MARK: The food
+
+ /// The bead of light and its crumbs: popped out of the number, inhaled along an arc, landing on the
+ /// snap; a glint on the ripe digit while the pet winds up; the pulse of light where the bite lands in
+ /// the belly.
+ static func food(_ meal: PetMeal, start: Double, body: PetBody, current: Bool, scene: PetRigPoser, into out: inout Extras) {
+ let tau = scene.t - start
+ let s = scene.s
+ let source = beadOrigin(scene.zone)
+ let dot = scene.zone.foodDot
+ if meal.reduceMotion {
+ // No flight under Reduce Motion: the light fades in at the mouth.
+ guard current else { return }
+ let mouth = PetBodyGeometry(body).facePoint(dTheta: 0, dv: -0.36).p
+ let o = PetCurve.envelope(tau, 0, 0.1, 0.12, PetTuning.Reduced.mealFade)
+ if o > 0.003 {
+ out.bead = PetBead(position: mouth, radius: 1.4 * s, stretch: 1, angle: 0, opacity: o, trail: [], dot: dot)
+ }
+ return
+ }
+ let m = PetRigActions.timing(meal)
+ // The glint, before the bite.
+ if let digit = ripeDigit(scene.zone), tau < m.snap {
+ let o = PetCurve.envelope(tau, 0, m.anticipation * 0.35, m.anticipation * 0.7, m.snap)
+ if o > 0.003 {
+ out.glint = PetGlint(position: digit, size: (1.6 + 1.0 * PetCurve.bump(tau, 0, m.snap)) * min(s, 1.2),
+ opacity: 0.9 * o, angle: 0.5 * tau)
+ }
+ }
+ // The bead's flight: 0.35 s to the snap, or as much of it as a nibble has.
+ let flight = min(PetTuning.Meal.crumbFlight, m.snap - 0.02)
+ let launch = m.snap - flight
+ if current, tau >= launch, tau <= m.snap + 0.03 {
+ let mouth = PetBodyGeometry(body).facePoint(dTheta: 0, dv: -0.36).p
+ // In the panel the "5h" figure is on the Overall line under the header, and an arc over the
+ // header's words would cross them, or the notch above them: there the bead runs along the gap
+ // between the header row and the Overall line and rises into the mouth from below.
+ let c = scene.zone.kind == .panel
+ ? CGPoint(x: Double(mouth.x) + 2 * s, y: Double(scene.zone.bounds.maxY) + 6 * s)
+ : CGPoint(x: PetCurve.mix(Double(source.x), Double(mouth.x), 0.45), y: min(Double(source.y), Double(mouth.y)) - 7 * s)
+ func position(_ u: Double) -> CGPoint {
+ let k = PetEase.inQuad(u)
+ let a = Double(source.x), b = Double(mouth.x)
+ let x = (1 - k) * (1 - k) * a + 2 * (1 - k) * k * Double(c.x) + k * k * b
+ let y = (1 - k) * (1 - k) * Double(source.y) + 2 * (1 - k) * k * Double(c.y) + k * k * Double(mouth.y)
+ return CGPoint(x: x, y: y)
+ }
+ let u = PetCurve.clamp((tau - launch) / flight, 0, 1)
+ let p = position(u)
+ let q = position(max(0, u - 1 / (240 * flight)))
+ let speed = hypot(Double(p.x - q.x), Double(p.y - q.y)) * 240
+ var trail: [CGPoint] = []
+ for k in 1...6 { trail.append(position(max(0, u - Double(k) / (200 * flight)))) }
+ let grow = PetEase.outBack(min(1, (tau - launch) / 0.08))
+ let swallow = 1 - 0.6 * PetCurve.ramp(tau, m.snap - 0.03, m.snap + 0.03)
+ let o = 1 - PetCurve.ramp(tau, m.snap, m.snap + 0.03)
+ out.bead = PetBead(position: p, radius: 1.5 * s * (m.full ? 1 : 0.8) * grow * swallow,
+ stretch: min(1.9, 1 + speed / 380), angle: atan2(Double(p.y - q.y), Double(p.x - q.x)),
+ opacity: o, trail: trail, dot: dot)
+ // Crumbs riding along on their own arcs, a hair behind.
+ let count = PetTuning.Meal.crumbsMinimum
+ + Int(hash(scene.seed, start, 1) * Double(PetTuning.Meal.crumbsMaximum - PetTuning.Meal.crumbsMinimum + 1))
+ for k in 0.. 0.02, uk < 1 else { continue }
+ var pk = position(uk)
+ // Below the bead's path rather than across the face, and gone before they reach it.
+ pk.y += (1.2 + 2.2 * hash(scene.seed, start, 20 + k)) * s * sin(.pi * uk)
+ let size = (PetTuning.Meal.crumbSizeMinimum
+ + (PetTuning.Meal.crumbSizeMaximum - PetTuning.Meal.crumbSizeMinimum) * hash(scene.seed, start, 30 + k)) * 0.5 * s
+ out.particles.append(PetParticle(kind: .crumb, position: pk, size: size,
+ opacity: PetTuning.Meal.crumbOpacity * (1 - PetCurve.ramp(uk, 0.5, 0.78)),
+ tint: .dot(dot)))
+ }
+ }
+ // A crumb or two flicked out by the second chew.
+ if m.chews >= 2 {
+ let at = m.chewStart + m.chew * 1.4
+ crumbs(at: start + at, origin: mouthGuess(scene), count: 2, side: -scene.foodSide * 0.6, seed: scene.seed,
+ key: start, scene: scene, into: &out)
+ }
+ // The pulse of light at the splash: a soft ring that swells within the halo and fades, never out
+ // across the strip toward the numbers. It is a band of light right on the body's edge, on top of
+ // the flare, so it comes up over 0.2 s and stays faint: brighter or quicker, and the two together
+ // flash in the corner of the eye at every meal.
+ let ringTau = tau - m.splash
+ if ringTau >= 0, ringTau < 0.45 {
+ let u = ringTau / 0.45
+ let anchor = scene.zone.anchor
+ out.particles.append(PetParticle(kind: .ring, position: CGPoint(x: Double(anchor.x), y: Double(anchor.y) - 9 * s),
+ size: 11.5 * s * PetCurve.mix(0.85, 1.2, PetEase.outCubic(u)),
+ opacity: (m.feast ? 0.14 : 0.08) * (1 - u) * PetCurve.ramp(ringTau, 0, 0.2),
+ tint: .ring))
+ }
+ }
+
+ /// Where the bead pops out: the end of the readout nearest the pet, so it leaves the digits as light
+ /// and never crosses them. Below, the dot comes first, so the bead pops out of the gap left of the
+ /// dot rather than between the dot and the digits, where it would fly back through the dot.
+ static func beadOrigin(_ zone: PetZone) -> CGPoint {
+ let source = zone.foodSource
+ switch zone.kind {
+ case .beside, .pill:
+ return CGPoint(x: zone.bounds.minX - PetTuning.Readout.gap + 1, y: source.y + 0.5)
+ case .below:
+ return CGPoint(x: zone.bounds.maxX + PetTuning.Readout.gap - 2, y: source.y + 0.5)
+ case .panel:
+ return CGPoint(x: source.x - 8, y: source.y)
+ }
+ }
+
+ static func mouthGuess(_ scene: PetRigPoser) -> CGPoint {
+ CGPoint(x: Double(scene.zone.anchor.x) + scene.foodSide * 2 * scene.s, y: Double(scene.zone.anchor.y) - 7.7 * scene.s)
+ }
+
+ /// Where the glint sits on the ripe digit, the readout's last one, which the food is about to come out
+ /// of: on its top-right shoulder, where a star catches the eye without filling the counter of a 0, 4,
+ /// 6, 8 or 9 and changing what the number reads. nil when there is no number (the `—`).
+ static func ripeDigit(_ zone: PetZone) -> CGPoint? {
+ typealias R = PetTuning.Readout
+ let digit = R.twoDigitWidth - R.oneDigitWidth
+ let percent = R.oneDigitWidth - digit
+ let source = zone.foodSource
+ let textEnd: CGFloat
+ switch zone.kind {
+ case .beside, .pill:
+ textEnd = zone.bounds.minX - R.gap
+ case .below:
+ // The left readout ends at the strip's middle; its text is centred on the food source.
+ let width = 2 * (source.x - zone.bounds.maxX - R.gap - EarsMetrics.dotSize - EarsMetrics.dotSpacing)
+ textEnd = source.x + width / 2
+ case .panel:
+ return CGPoint(x: source.x + 3, y: source.y - 1)
+ }
+ let width = 2 * (textEnd - source.x)
+ guard width > R.dashWidth + 1 else { return nil }
+ return CGPoint(x: textEnd - percent - digit * 0.12, y: source.y - 4.6)
+ }
+
+ /// A few crumbs thrown from `origin`: they fly, fall, bounce once on the floor and fade.
+ static func crumbs(at t0: Double, origin: CGPoint, count: Int, side: Double, seed: UInt64, key: Double,
+ scene: PetRigPoser, into out: inout Extras) {
+ let t = scene.t
+ guard t >= t0, t < t0 + 1.1 else { return }
+ let s = scene.s
+ let g = 190.0 * s, floor = Double(scene.zone.anchor.y) + 0.9 * s
+ for i in 0..= 0, tau < life else { continue }
+ let angle = -.pi / 2 + (r(1) - 0.5) * .pi * 0.5 + side * 0.75
+ let v = 26 * s * (0.45 + 0.55 * r(2))
+ var vx = cos(angle) * v, vy = sin(angle) * v
+ var x = Double(origin.x) + (r(4) - 0.5) * 1.5, y = Double(origin.y)
+ var tt = tau
+ let disc = vy * vy + 2 * g * (floor - y)
+ let hit = (-vy + max(disc, 0).squareRoot()) / g
+ if tt > hit && floor > y {
+ x += vx * hit; y = floor; tt -= hit
+ vy = -0.32 * (vy + g * hit); vx *= 0.5
+ }
+ x += vx * tt
+ y += vy * tt + 0.5 * g * tt * tt
+ if y > floor { y = floor }
+ let o = 1 - PetCurve.ramp(tau, life * 0.6, life)
+ out.particles.append(PetParticle(kind: .crumb, position: CGPoint(x: x, y: y), size: (0.45 + 0.3 * r(6)) * s,
+ opacity: o, streak: CGVector(dx: -vx / 110, dy: -(vy + g * tt) / 110),
+ tint: r(3) > 0.5 ? .crumbLight : .crumbWarm))
+ }
+ }
+
+ // MARK: Sparkles, Zzz, bubbles
+
+ /// A four-point star that twinkles for about 0.6 s, rising a little; in place under Reduce Motion.
+ static func sparkle(at t0: Double, origin: CGPoint, seed: UInt64, k: Int, size: Double, scene: PetRigPoser,
+ into out: inout Extras) {
+ let life = PetTuning.Particles.sparkleTwinkle + 0.12 * hash(seed, t0, 70 + k)
+ let tau = scene.t - t0
+ guard tau >= 0, tau < life else { return }
+ let u = tau / life
+ let still = scene.motion.reduceMotion
+ var p = CGPoint(x: Double(origin.x) + (still ? 0 : (hash(seed, t0, 80 + k) - 0.5) * 6 * scene.s * u),
+ y: Double(origin.y) - (still ? 0 : (2 + 7 * PetEase.outCubic(u)) * scene.s))
+ // Never cut in half by the strip's top edge: like the Zzz, a star stays below the zone's top, and
+ // it slows to a stop there rather than meeting it.
+ p.y = belowCeiling(Double(p.y), Double(scene.zone.bounds.minY) + 1 + size)
+ let twinkle = pow(sin(.pi * u), 0.7)
+ out.particles.append(PetParticle(kind: .sparkle, position: p, size: size * (still ? 1 : twinkle),
+ opacity: 0.95 * (still ? twinkle : 1), angle: 0.4 * u + hash(seed, t0, 90 + k),
+ tint: .sparkle))
+ }
+
+ /// `y` held under `ceiling` (in window coordinates, so above it is less), easing to a stop on it over
+ /// its last `soft` points instead of hitting it: the same value below that, and never above it.
+ static func belowCeiling(_ y: Double, _ ceiling: Double, soft: Double = 1.5) -> Double {
+ let knee = ceiling + soft
+ guard y < knee else { return y }
+ return ceiling + soft * exp((y - knee) / soft)
+ }
+
+ /// Asleep, the k-th Zzz rises this long after the segment starts: one every `zzzEvery` for the first
+ /// minute, then one every `Den.zzzEvery`, as in the den. A sleep lasts hours beside the camera while
+ /// the person keeps working, and a particle rising every two seconds all that time would keep pulling
+ /// at the eye.
+ static func asleepZzz(_ k: Int) -> Double {
+ let brisk = asleepBriskZzz
+ if k < brisk { return asleepZzzFirst + Double(k) * PetTuning.Particles.zzzEvery }
+ return asleepZzz(brisk - 1) + Double(k - brisk + 1) * PetTuning.Den.zzzEvery
+ }
+
+ /// The last Zzz to have started by `tau` into the sleep; -1 before the first. A Zzz's own start counts
+ /// as started, whatever the last bit of the division says.
+ static func asleepZzzIndex(at tau: Double) -> Int {
+ guard tau >= asleepZzzFirst else { return -1 }
+ let lastBrisk = asleepZzz(asleepBriskZzz - 1)
+ if tau < lastBrisk + PetTuning.Den.zzzEvery {
+ return min(asleepBriskZzz - 1, Int(floor((tau - asleepZzzFirst) / PetTuning.Particles.zzzEvery + 1e-9)))
+ }
+ return asleepBriskZzz - 1 + Int(floor((tau - lastBrisk) / PetTuning.Den.zzzEvery + 1e-9))
+ }
+
+ static let asleepZzzFirst: Double = 0.3
+ /// How many of them come at the brisk rate: those of the first minute.
+ static let asleepBriskZzz = Int((60 - asleepZzzFirst) / PetTuning.Particles.zzzEvery) + 1
+
+ /// One Zzz: drawn strokes, rising 10 pt over 2.6 s with ±2 pt of drift; popped if the pet wakes.
+ static func zzz(at t0: Double, k: Int, seg: PetSegment, scene: PetRigPoser, into out: inout Extras, until: Double?,
+ den: Bool = false) {
+ let life = PetTuning.Particles.zzzLife
+ let tau = scene.t - t0
+ guard tau >= 0, tau < life, t0 >= seg.start else { return }
+ if let until, t0 >= until { return }
+ let s = scene.s
+ let u = tau / life
+ var o = PetCurve.ramp(tau, 0, 0.25) * (1 - PetCurve.ramp(tau, life * 0.6, life))
+ var size = (2.4 + 2.0 * PetEase.outCubic(u)) * s
+ if let until, scene.t > until {
+ // Woken: the letters pop.
+ let pop = PetCurve.ramp(scene.t, until, until + 0.14)
+ o *= 1 - pop
+ size *= 1 + 0.5 * pop
+ }
+ let anchor = scene.zone.anchor
+ var origin = CGPoint(x: Double(anchor.x) + 4 * s, y: Double(anchor.y) - 12 * s)
+ if den, case .side(let edge) = scene.zone.den { origin = CGPoint(x: Double(edge) - 4 * s, y: Double(anchor.y) - 12 * s) }
+ if den, case .overhead = scene.zone.den { return }
+ let still = scene.motion.reduceMotion
+ let drift = PetTuning.Particles.zzzDrift * s * sin(2 * .pi * 0.7 * tau + 3 * hash(scene.seed, t0, 1))
+ let rise = PetTuning.Particles.zzzRise * s * PetEase.outQuad(u)
+ var p = CGPoint(x: Double(origin.x) + (still ? 0 : drift + (den ? -2 * s * u : 1.5 * s * u)),
+ y: Double(origin.y) - (still ? 0 : rise))
+ // Never into the black above the zone's top, nor into the notch.
+ p.y = max(p.y, Double(scene.zone.bounds.minY) + size / 2 + 0.5)
+ out.particles.append(PetParticle(kind: .zzz, position: p, size: size, opacity: 0.9 * o,
+ angle: -0.18 + 0.12 * sin(2 * .pi * 0.5 * tau), tint: .zzz))
+ }
+
+ /// The feast's hiccup: a bubble slips out of the corner of the mouth, floats off past the body, and pops.
+ static func bubble(at t0: Double, body: PetBody, scene: PetRigPoser, into out: inout Extras) {
+ let tau = scene.t - t0
+ guard tau >= 0, tau < 0.5 else { return }
+ let s = scene.s
+ let m = mouthGuess(scene)
+ // Away from the readout, so it never drifts toward a number.
+ let side = -scene.foodSide
+ let popAt = 0.34
+ func path(_ u: Double) -> CGPoint {
+ CGPoint(x: Double(m.x) + side * (2.5 + 30 * PetEase.outQuad(u / popAt) * popAt) * s,
+ y: Double(m.y) - (1 + 16 * u) * s)
+ }
+ if tau < popAt {
+ out.particles.append(PetParticle(kind: .bubble, position: path(tau),
+ size: (0.5 + 1.8 * PetEase.outCubic(tau / popAt)) * s, opacity: 0.85, tint: .bubble))
+ } else {
+ let c = path(popAt)
+ let u = min(1, (tau - popAt) / 0.15)
+ for i in 0..<3 {
+ let a = 2 * Double.pi * (Double(i) / 3 + 0.2)
+ let d = (1.2 + 4 * PetEase.outCubic(u)) * s
+ out.particles.append(PetParticle(kind: .crumb, position: CGPoint(x: Double(c.x) + cos(a) * d, y: Double(c.y) + sin(a) * d),
+ size: 0.45 * s, opacity: 1 - u, tint: .burst))
+ }
+ }
+ }
+
+ /// The hungry tummy's rumble lines, either side of the belly, for a third of a second.
+ static func rumble(at t0: Double, body: PetBody, scene: PetRigPoser, into out: inout Extras) {
+ let tau = scene.t - t0
+ guard tau >= 0, tau < 0.32 else { return }
+ let g = PetBodyGeometry(body)
+ let u = tau / 0.32
+ for side in [-1.0, 1.0] {
+ for k in 0..<2 {
+ let base = g.point(u: side * 1.0, v: -0.45)
+ let off = ((1.6 + Double(k) * 1.5) * side + 0.35 * sin(2 * .pi * 18 * scene.t + Double(k))) * body.size
+ out.particles.append(PetParticle(kind: .rumble, position: CGPoint(x: Double(base.x) + off, y: Double(base.y)),
+ size: (1.3 - 0.3 * Double(k)) * body.size,
+ opacity: 0.75 * PetCurve.envelope(u, 0, 0.15, 0.6, 1),
+ angle: side > 0 ? 0 : .pi, tint: .rumble))
+ }
+ }
+ }
+
+ /// The mote the pounce goes for, floating up just out of reach.
+ static func pounceMote(tau: Double, scene: PetRigPoser) -> CGPoint {
+ let s = scene.s, side = PetRigIdle.pounceSide(scene)
+ let anchor = scene.zone.anchor
+ let rise = 6 * s * PetEase.outCubic((tau - 1.0) / 0.8)
+ return CGPoint(x: Double(anchor.x) + side * (7 - 2 * tau) * s,
+ y: Double(anchor.y) - 25 * s + 1.5 * s * sin(2.4 * tau) - rise)
+ }
+
+ // MARK: The den's door
+
+ /// Below, the den is the notch overhead: once the pet is up inside it, a line of its light shows under
+ /// the door.
+ static func door(_ seg: PetSegment, end: Double?, scene: PetRigPoser, into out: inout Extras) {
+ guard case .overhead(let notch) = scene.zone.den else { return }
+ let t = scene.t, tau = t - seg.start
+ if let end, t >= end + 0.5 { return }
+ var o: Double
+ switch seg.behaviour {
+ case .enterDen(let retreat):
+ o = PetCurve.ramp(tau, retreat ? 0.5 : 1.7, retreat ? 0.9 : 2.1)
+ case .hibernating:
+ let breath = sin(2 * .pi * tau / PetTuning.Den.glowCycle)
+ o = 1 + 0.25 * breath
+ case .stirring:
+ // The reset passed: the light under the door brightens as the pet stirs behind it, well past
+ // the hibernating breath's brightest, so that it reads as waking and not as breathing.
+ o = PetCurve.mix(1, 2.2, PetEase.inOutSine(tau / 2.0))
+ case .denWait:
+ o = 0.6
+ case .peek:
+ o = 1 - PetCurve.envelope(tau, 0, 0.45, 1.7, 2.15)
+ case .leaveDen:
+ o = 1 - PetCurve.ramp(tau, 0.1, 0.5)
+ default:
+ return
+ }
+ if let end, t > end { o *= 1 - PetCurve.ramp(t, end, end + 0.5) }
+ let x = Double(scene.zone.anchor.x)
+ let half = 9 * scene.s
+ let level = PetTuning.Den.glowMaximum * 1.6
+ let light = PetDoorLight(minX: x - half, maxX: x + half, y: Double(notch.maxY), opacity: level * max(0, o))
+ // Segments are visited newest first, and the one that just ended fades out over half a second
+ // while the next one's light comes up: the brighter of the two is what shows, so the line never
+ // drops to dark and jumps back at a change of behaviour.
+ if let shown = out.door, shown.opacity >= light.opacity { return }
+ out.door = light
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetRigPostures.swift b/Sources/BrowKit/Pet/PetRigPostures.swift
new file mode 100644
index 0000000..cdae652
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetRigPostures.swift
@@ -0,0 +1,170 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// A resting expression and a way of moving: the stiffness and damping of the body's jelly, the breath,
+/// how much the idle drifts. Glim's personality lives in these numbers: the same landing reads springy
+/// when eager and heavy when stuffed.
+struct PetPosture: Sendable {
+ /// The posture's channels; everything else in it is ignored.
+ var body = PetBody()
+ var jellyHz: Double = 3.4
+ var jellyZeta: Double = 0.30
+ /// The breath: weights over `PetRigIdle.breathBank`, oscillators of fixed periods. A posture breathes
+ /// on one of them; a blend between two postures cross-fades their oscillators instead of bending one
+ /// period into another, which would wrap the phase at a large uptime.
+ var breath: [Double] = PetRigIdle.bankWeights(PetTuning.Idle.breathPeriod)
+ var breathAmp: Double = PetTuning.Idle.breathAmplitude
+ /// How far the idle layers wander: 1 at rest, 0 asleep.
+ var drift: Double = 1
+ /// How much the eyes rest on the food source (eager, hungry, unsure).
+ var lookAtFood: Double = 0
+ /// How bright the whole pet is drawn, 1 as its light has it: hibernating on the pill, where there is
+ /// no den, it turns itself down. A posture's, so it cross-fades with the rest of it.
+ var light: Double = 1
+
+ /// The channels a posture sets. `turn` and `lookX` are 0 in every posture but the den's stirring,
+ /// whose face turns toward the notch's edge.
+ static let channels: [WritableKeyPath] = [
+ \.squash, \.girth, \.curl, \.lean, \.tilt, \.nod, \.turn, \.lookX, \.lidL, \.lidR, \.lidSlant, \.happy,
+ \.eyeSize, \.browLiftL, \.browLiftR, \.browTiltL, \.browTiltR, \.smile, \.mouthOpen, \.mouthWide, \.blush,
+ \.armL, \.armR, \.glow,
+ ]
+
+ static func mixed(_ a: PetPosture, _ b: PetPosture, _ t: Double) -> PetPosture {
+ if t <= 0 { return a }
+ if t >= 1 { return b }
+ var m = a
+ for k in channels { m.body[keyPath: k] = PetCurve.mix(a.body[keyPath: k], b.body[keyPath: k], t) }
+ m.jellyHz = PetCurve.mix(a.jellyHz, b.jellyHz, t)
+ m.jellyZeta = PetCurve.mix(a.jellyZeta, b.jellyZeta, t)
+ m.breathAmp = PetCurve.mix(a.breathAmp, b.breathAmp, t)
+ m.drift = PetCurve.mix(a.drift, b.drift, t)
+ m.lookAtFood = PetCurve.mix(a.lookAtFood, b.lookAtFood, t)
+ m.light = PetCurve.mix(a.light, b.light, t)
+ for i in m.breath.indices { m.breath[i] = PetCurve.mix(a.breath[i], b.breath[i], t) }
+ return m
+ }
+
+ static func make(hz: Double, zeta: Double, breath: Double, amp: Double, drift: Double, food: Double = 0,
+ _ f: (inout PetBody) -> Void) -> PetPosture {
+ var p = PetBody()
+ p.smile = 0.42
+ p.blush = 0.45
+ p.lidL = 0.94
+ p.lidR = 0.94
+ f(&p)
+ return PetPosture(body: p, jellyHz: hz, jellyZeta: zeta, breath: PetRigIdle.bankWeights(breath), breathAmp: amp,
+ drift: drift, lookAtFood: food)
+ }
+}
+
+extension PetPosture {
+ /// At home, nothing needed: a soft smile, relaxed lids, a medium jelly.
+ static let content = make(hz: 3.4, zeta: 0.30, breath: PetTuning.Idle.breathPeriod,
+ amp: PetTuning.Idle.breathAmplitude, drift: 1) { _ in }
+
+ /// Fed past the brim: rounder, breathing slower, pleased. The lower lids smile up under open eyes
+ /// (happy stays below the cross-fade to closed arcs), and the brows sit easy and level: a mood people
+ /// live with for hours, so never the heavy upper lids and lowered brows that read smug at real size.
+ /// Heavy lids are for stuffed only.
+ static let full = make(hz: 3.0, zeta: 0.32, breath: PetTuning.Idle.fullBreathPeriod, amp: 0.018, drift: 0.8) { p in
+ p.girth = 1.04; p.lidL = 1; p.lidR = 1; p.happy = 0.4; p.smile = 0.62; p.blush = 0.62
+ p.browLiftL = 0.15; p.browLiftR = 0.2; p.browTiltL = 0; p.browTiltR = 0; p.glow = 1.08
+ }
+
+ /// Thinking of food: eyes a little wider and more often on the number.
+ static let peckish = make(hz: 3.2, zeta: 0.32, breath: 3.9, amp: 0.016, drift: 0.9, food: 0.35) { p in
+ p.eyeSize = 1.06; p.lidL = 1; p.lidR = 1; p.browLiftL = 0.35; p.browLiftR = 0.45; p.browTiltL = 0.06
+ p.browTiltR = 0.06; p.smile = 0.28; p.blush = 0.4
+ }
+
+ /// Hungry, and hopeful, never sad: a nudge, not a reproach. Big round eyes fixed on the number, brows
+ /// up with the inner ends lifted just enough to plead, the outer lids lifted rather than drooping, a
+ /// small open mouth, leaning 3° toward the food, a little lower. The dimming light is the meter.
+ static let hungry = make(hz: 2.6, zeta: 0.42, breath: 4.4, amp: 0.02, drift: 0.55, food: 0.9) { p in
+ p.squash = 0.96; p.eyeSize = 1.2; p.lidL = 1; p.lidR = 1; p.lidSlant = -0.12
+ p.browLiftL = 0.6; p.browLiftR = 0.75; p.browTiltL = 0.04; p.browTiltR = 0.07
+ p.smile = 0.42; p.mouthOpen = 0.1; p.mouthWide = 0.62; p.blush = 0.45; p.armL = 0.12; p.armR = 0.12
+ p.glow = 0.95
+ }
+
+ /// Claude Code is working: food is coming. Taller (1.04), stiff and springy, everything up.
+ static let eager = make(hz: 4.6, zeta: 0.24, breath: 2.1, amp: 0.018, drift: 0.5, food: 0.85) { p in
+ p.squash = PetTuning.Idle.eagerHeight; p.eyeSize = 1.14; p.lidL = 1; p.lidR = 1; p.browLiftL = 1.0
+ p.browLiftR = 1.1; p.smile = 0.75; p.mouthOpen = 0.1; p.mouthWide = 0.8; p.blush = 0.7; p.armL = 0.5; p.armR = 0.55
+ p.glow = PetTuning.Light.glowEager
+ }
+
+ /// The pointer came close: wide eyes, one brow up, a little "o".
+ static let curious = make(hz: 4.0, zeta: 0.34, breath: 2.6, amp: 0.012, drift: 0.4) { p in
+ p.squash = 1.03; p.eyeSize = 1.1; p.lidL = 1; p.lidR = 1; p.browLiftL = 1.2; p.browLiftR = 0.5
+ p.browTiltL = -0.05; p.browTiltR = 0.12; p.smile = -0.05; p.mouthOpen = 0.18; p.mouthWide = 0.42
+ p.armL = 0.2; p.armR = 0.3; p.blush = 0.55
+ }
+
+ /// Mid-meal: eyes shut in delight, cheeks full.
+ static let munch = make(hz: 3.8, zeta: 0.30, breath: 2.0, amp: 0.01, drift: 0.2) { p in
+ p.happy = 1; p.browLiftL = 0.5; p.browLiftR = 0.4; p.browTiltL = 0.08; p.browTiltR = 0.08
+ p.smile = 0.5; p.blush = 0.9; p.armL = 0.2; p.armR = 0.15; p.glow = 1.1
+ }
+
+ /// Fed and pleased: slow deep breaths, eyes closed in bliss.
+ static let satisfied = make(hz: 3.0, zeta: 0.34, breath: 2.8, amp: 0.022, drift: 0.6) { p in
+ p.girth = 1.03; p.happy = 0.85; p.browLiftL = 0.35; p.browLiftR = 0.35; p.browTiltL = 0.12; p.browTiltR = 0.12
+ p.smile = 0.85; p.blush = 0.9; p.armL = 0.05; p.armR = 0.05; p.glow = 1.15
+ }
+
+ /// Too full: round, heavy, a slow low wobble, heavy-lidded bliss.
+ static let stuffed = make(hz: 1.9, zeta: 0.26, breath: 1.6, amp: 0.02, drift: 0.2) { p in
+ p.squash = 0.96; p.girth = 1.1; p.lidL = 0.52; p.lidR = 0.52; p.lidSlant = 0.2; p.happy = 0.3
+ p.browLiftL = 0.1; p.browLiftR = 0.1; p.browTiltL = 0.14; p.browTiltR = 0.14; p.smile = 0.55
+ p.blush = 0.85; p.armL = 0.3; p.armR = 0.3; p.glow = 1.2
+ }
+
+ /// Up, not awake: sitting up out of the loaf, but only half way, heavy, eyes at 0.35. Half way, so
+ /// rolling back over into the loaf fits its one second at 15 fps.
+ static let drowsy = make(hz: 1.8, zeta: 0.55, breath: 3.0, amp: 0.02, drift: 0.15) { p in
+ p.curl = 0.45; p.squash = 0.97; p.girth = 1.03; p.lidL = PetTuning.Limits.drowsyEyes; p.lidR = PetTuning.Limits.drowsyEyes
+ p.lidSlant = 0.15; p.browLiftL = -0.25; p.browLiftR = -0.15; p.browTiltL = 0.08; p.browTiltR = 0.08
+ p.smile = 0.3; p.blush = 0.65; p.armL = -0.15; p.armR = -0.15; p.glow = 0.9
+ }
+
+ /// Asleep at the 5-hour limit: curled into a low loaf at the anchor, eyes shut, the light breathing
+ /// slowly like a sleeping Mac's, the glow at 0.6.
+ static let asleep = make(hz: 1.5, zeta: 0.8, breath: 4.6, amp: 0.03, drift: 0) { p in
+ p.curl = 1; p.girth = 1.0; p.tilt = 0.04; p.nod = -0.2; p.lidL = 0; p.lidR = 0; p.browLiftL = -0.45
+ p.browLiftR = -0.45; p.browTiltL = 0.05; p.browTiltR = 0.05; p.smile = 0.25; p.mouthOpen = 0.07
+ p.mouthWide = 0.45; p.blush = 0.55; p.armL = -0.3; p.armR = -0.3; p.glow = PetTuning.Light.glowAsleep
+ }
+
+ /// In the den at the weekly limit: curled, the glow down to 0.3, a slower breath still.
+ static let hibernating = make(hz: 1.4, zeta: 0.85, breath: PetTuning.Den.glowCycle, amp: 0.025, drift: 0) { p in
+ p.curl = 1; p.tilt = 0.03; p.nod = -0.25; p.lidL = 0; p.lidR = 0; p.browLiftL = -0.5; p.browLiftR = -0.5
+ p.smile = 0.2; p.mouthOpen = 0.05; p.mouthWide = 0.4; p.blush = 0.4; p.armL = -0.3; p.armR = -0.3
+ p.glow = PetTuning.Light.glowHibernating
+ }
+
+ /// After the reset, or a celebration: bright and bouncy.
+ static let happy = make(hz: 4.2, zeta: 0.28, breath: 2.0, amp: 0.018, drift: 0.7) { p in
+ p.squash = 1.03; p.lidL = 1; p.lidR = 1; p.happy = 0.35; p.eyeSize = 1.05; p.browLiftL = 0.7
+ p.browLiftR = 0.7; p.smile = 0.95; p.mouthOpen = 0.16; p.blush = 0.75; p.armL = 0.35; p.armR = 0.35; p.glow = 1.15
+ }
+
+ /// Dozing while you are away: eyes shut, head down, settled into itself, still.
+ static let dozing = make(hz: 1.6, zeta: 0.7, breath: 4.4, amp: 0.012, drift: 0) { p in
+ p.squash = 0.94; p.girth = 1.04; p.nod = -0.3; p.lidL = 0; p.lidR = 0; p.browLiftL = -0.35; p.browLiftR = -0.35
+ p.smile = 0.3; p.blush = 0.5; p.armL = -0.2; p.armR = -0.2; p.glow = 0.85
+ }
+
+ /// The posture of a mood, the open behaviours' base.
+ static func mood(_ m: PetMood) -> PetPosture {
+ switch m {
+ case .full: return .full
+ case .content: return .content
+ case .peckish: return .peckish
+ case .hungry: return .hungry
+ }
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetRigSecondary.swift b/Sources/BrowKit/Pet/PetRigSecondary.swift
new file mode 100644
index 0000000..199782c
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetRigSecondary.swift
@@ -0,0 +1,307 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+/// Secondary motion: the jelly of the body, the top lagging behind, the light sloshing, the brows and arms
+/// flopping, the belly. Linear damped springs, so their response is a sum: the closed-form steady state of
+/// the idle cycles that drive them, plus one transient per action, integrated with semi-implicit Euler at
+/// a fixed 1/240 s step from that action's own start, from rest, until 2 s after it ends. Nothing depends
+/// on which frames were drawn before: a transient at any instant is the same integration from the same
+/// start, which the memo only saves redoing.
+enum PetRigSecondary {
+ static let step = PetTuning.Secondary.step
+ static let tail = PetTuning.Secondary.tail
+
+ /// The springs, in the order the memo stores them.
+ enum Spring: Int, CaseIterable {
+ case jiggle, leanLag, slosh, wave, brows, armsUp, armsSide, belly
+ }
+
+ /// Brows are stiffer than the arms (ω 44 against the arms' 22, about 3.5 Hz): they are part of a
+ /// face, and at 3.5 Hz they would read as antennae. The arms and the belly take the springs of
+ /// `PetTuning.Secondary`.
+ static func springs(_ source: PetRigSource) -> [PetSpring] {
+ let jelly = 2 * Double.pi * source.jellyHz
+ return [
+ PetSpring(omega: jelly, zeta: source.jellyZeta),
+ PetSpring(omega: jelly * 0.8, zeta: source.jellyZeta + 0.05),
+ PetSpring(omega: 2 * .pi * 2.3, zeta: 0.16),
+ PetSpring(omega: 2 * .pi * 4.2, zeta: 0.14),
+ PetSpring(omega: 44, zeta: 0.42),
+ PetSpring(omega: PetTuning.Secondary.appendageOmega, zeta: PetTuning.Secondary.appendageZeta),
+ PetSpring(omega: PetTuning.Secondary.appendageOmega * 0.9, zeta: PetTuning.Secondary.appendageZeta),
+ PetSpring(omega: PetTuning.Secondary.bellyOmega, zeta: PetTuning.Secondary.bellyZeta),
+ ]
+ }
+
+ static func apply(_ body: inout PetBody, posture: PetPosture, beat: PetRigBeat, scene: PetRigPoser) {
+ var x = [Double](repeating: 0, count: Spring.allCases.count)
+ steadyState(&x, posture: posture, scene: scene)
+ for source in sources(beat: beat, scene: scene) {
+ let tau = scene.t - source.start
+ guard tau > 0 else { continue }
+ let state = scene.memo.state(source, at: tau)
+ for i in x.indices { x[i] += state[i] }
+ }
+ body.squash *= 1 + x[Spring.jiggle.rawValue]
+ body.lean += x[Spring.leanLag.rawValue]
+ body.slosh += x[Spring.slosh.rawValue]
+ body.wave += x[Spring.wave.rawValue]
+ body.browLiftL += x[Spring.brows.rawValue]
+ body.browLiftR += x[Spring.brows.rawValue] * 0.92
+ body.armL += x[Spring.armsUp.rawValue] - x[Spring.armsSide.rawValue]
+ body.armR += x[Spring.armsUp.rawValue] + x[Spring.armsSide.rawValue]
+ body.girth *= 1 + x[Spring.belly.rawValue]
+ }
+
+ /// The idle cycles' share, in closed form: the breath drives the jelly and the belly, the weight
+ /// shift drives the top's lag and the light's slosh. Each sinusoid's response is the spring's gain
+ /// and lag at that frequency (`PetSpring.steadyState`).
+ static func steadyState(_ x: inout [Double], posture: PetPosture, scene: PetRigPoser) {
+ let idle = PetRigIdle.cycles(posture: posture, scene: scene)
+ let probe = PetRigSource(kind: .idle, start: 0, scale: scene.s, foodSide: 0, jellyHz: posture.jellyHz,
+ jellyZeta: posture.jellyZeta)
+ let sp = springs(probe)
+ // The breath scales y by A·b(t), b = sin(wt + φ) + 0.22 sin(2wt + 2φ − 0.6); its second derivative
+ // is the force on the jelly, as an authored squash's is in a transient.
+ for (k, amplitude, phase) in [(1.0, 1.0, 0.0), (2.0, 0.22, -0.6)] {
+ let w = k * idle.breathW
+ let force = 0.30 * idle.breathAmp * amplitude * w * w
+ x[Spring.jiggle.rawValue] += sp[0].steadyState(amplitude: force, w: w, phase: k * idle.breathPhase + phase, at: scene.t)
+ x[Spring.belly.rawValue] += sp[7].steadyState(amplitude: 0.35 * force, w: w, phase: k * idle.breathPhase + phase,
+ at: scene.t)
+ }
+ // The weight shift moves the root by ±0.4 pt; its acceleration leans the top back and tilts the light.
+ let w = idle.shiftW
+ let accel = idle.shiftAmp * w * w
+ let h = PetBodyGeometry.restHeight
+ x[Spring.leanLag.rawValue] += sp[1].steadyState(amplitude: 0.30 * accel / h, w: w, phase: idle.shiftPhase, at: scene.t)
+ x[Spring.slosh.rawValue] += sp[2].steadyState(amplitude: 0.034 * accel, w: w, phase: idle.shiftPhase, at: scene.t)
+ }
+
+ /// Every source whose transient is still alive at the pose's instant.
+ static func sources(beat: PetRigBeat, scene: PetRigPoser) -> [PetRigSource] {
+ var out: [PetRigSource] = []
+ let segments = scene.timeline.segments
+ if let index = beat.index {
+ // Segments whose action, plus its tail, still reaches the instant. No action lasts long
+ // enough for one that started more than this long ago to matter.
+ let oldest = scene.t - PetRigActions.longestAction - tail
+ var i = index
+ while i >= 0, segments[i].start >= oldest {
+ let seg = segments[i]
+ let end = i + 1 < segments.count ? segments[i + 1].start : nil
+ let length = min(seg.behaviour.duration ?? .infinity, (end ?? .infinity) - seg.start)
+ if seg.start + length + tail > scene.t,
+ let source = PetRigActions.source(seg, length: length, scene: scene) {
+ out.append(source)
+ }
+ i -= 1
+ }
+ }
+ out += PetRigIdle.motionSources(beat: beat, scene: scene)
+ if let dock = scene.flightSource { out.append(dock) }
+ return out
+ }
+}
+
+/// One source of transient motion: an action's own movement, a bounce, a hop between the strip and the
+/// panel. Everything its integration depends on is in it, so it is also the memo's key.
+struct PetRigSource: Hashable {
+ enum Kind: Hashable {
+ /// A segment's action, cut at `length` seconds.
+ case segment(PetBehaviour, length: Double)
+ /// One of eager's small bounces, or an idle hop.
+ case bounce(height: Double, air: Double)
+ /// The rare idle pounce that falls short.
+ case pounce
+ /// The hop between the strip and the panel.
+ case dock(expand: Bool, ax: Double, ay: Double, bx: Double, by: Double, sa: Double, sb: Double, stepsOff: Bool)
+ /// The idle cycles' probe: never integrated.
+ case idle
+ }
+
+ var kind: Kind
+ var start: Double
+ var scale: Double
+ var foodSide: Double
+ var jellyHz: Double
+ var jellyZeta: Double
+ /// Where the action can go, in points: the lunge's reach toward the food, and the den's offset from
+ /// the anchor.
+ var reach: Double = 0
+ var denX: Double = 0
+ var denY: Double = 0
+
+ /// How long it pushes: after this, only its tail rings out.
+ var length: Double {
+ switch kind {
+ case .segment(_, let length): return length
+ case .bounce(_, let air): return air + 0.4
+ case .pounce: return PetRigIdle.pounceLength
+ case .dock(let expand, let ax, let ay, let bx, let by, _, _, _):
+ return PetRigDock.hopDuration(expand: expand, a: CGPoint(x: ax, y: ay), b: CGPoint(x: bx, y: by))
+ case .idle: return 0
+ }
+ }
+
+ /// The source's own movement at `tau` seconds in, in points: the root's offset across, its lift, and
+ /// its squash beyond 1.
+ func root(_ tau: Double) -> (dx: Double, lift: Double, squash: Double) {
+ switch kind {
+ case .segment(let behaviour, let length):
+ let room = PetRigRoom(scale: scale, foodSide: foodSide, reach: reach, denX: denX, denY: denY)
+ return PetRigActions.root(behaviour, elapsed: min(tau, length), room: room)
+ case .bounce(let height, let air):
+ return PetRigIdle.bounceRoot(tau, height: height, air: air, scale: scale)
+ case .pounce:
+ return PetRigIdle.pounceRoot(tau, scale: scale, side: foodSide)
+ case .dock(let expand, let ax, let ay, let bx, let by, let sa, let sb, let stepsOff):
+ let a = CGPoint(x: ax, y: ay), b = CGPoint(x: bx, y: by)
+ let f = expand ? PetLayout.expand(a: a, b: b, scaleA: sa, scaleB: sb, stepsOff: stepsOff, tau: tau)
+ : PetLayout.collapse(a: a, b: b, scaleA: sa, scaleB: sb, beside: !stepsOff, tau: tau)
+ let end = expand ? b : a
+ return (Double(f.feet.x - end.x), Double(end.y - f.feet.y), f.squash - 1)
+ case .idle:
+ return (0, 0, 0)
+ }
+ }
+
+ /// Kicks at given instants: a chomp, a landing, a gulp. (τ, spring, velocity).
+ var impulses: [(Double, PetRigSecondary.Spring, Double)] {
+ switch kind {
+ case .segment(let behaviour, let length):
+ return PetRigActions.impulses(behaviour).filter { $0.0 <= length }
+ case .bounce(let height, let air):
+ return [(0.12 + air, .jiggle, -0.02 * 4 * height / air)]
+ case .pounce:
+ return PetRigIdle.pounceImpulses
+ case .dock(let expand, let ax, let ay, let bx, let by, _, _, _):
+ let a = CGPoint(x: ax, y: ay), b = CGPoint(x: bx, y: by)
+ let land = expand ? PetTuning.Hop.crouch + PetLayout.flightTime(from: a, to: b) : PetTuning.Hop.collapseY
+ return [(land, .jiggle, -1.6), (land, .brows, -6)]
+ case .idle:
+ return []
+ }
+ }
+}
+
+/// Transients already integrated, by source. A pose reads the stored step at or before its instant and
+/// the one after it, so the result is the same whether the steps were stored a moment ago, an hour ago,
+/// or just now: a memo that makes a pose cheaper, never different.
+final class PetRigMemo {
+ private struct Track {
+ /// Spring positions and velocities, `count` per step.
+ var states: [Double]
+ /// The root's last two samples, to continue the backward differences.
+ var roots: [(dx: Double, lift: Double, squash: Double)]
+ var steps: Int
+ var springs: [PetSpring]
+ var impulses: [(Double, PetRigSecondary.Spring, Double)]
+ }
+
+ private var tracks: [PetRigSource: Track] = [:]
+ private var recent: [PetRigSource] = []
+ private static let keep = 48
+ private static let count = PetRigSecondary.Spring.allCases.count
+ /// A track whose tail ended this long before the instant being posed is let go when a new one
+ /// starts: the app's poses only move forward, so it would only hold memory. A pose that does go
+ /// back to it integrates it again, to the same bits.
+ private static let forgetAfter: Double = 5
+
+ /// The springs' positions `tau` seconds after `source` started, interpolated between the two
+ /// integration steps around it. After the source's length plus its tail it is at rest.
+ func state(_ source: PetRigSource, at tau: Double) -> [Double] {
+ let dt = PetRigSecondary.step
+ let horizon = source.length + PetRigSecondary.tail
+ guard tau < horizon else { return [Double](repeating: 0, count: Self.count) }
+ let k = Int((tau / dt).rounded(.down))
+ if tracks[source] == nil { forget(before: source.start + tau - Self.forgetAfter) }
+ var track = tracks[source] ?? Self.begin(source)
+ extend(&track, source: source, through: k + 1)
+ tracks[source] = track
+ touch(source)
+ let frac = tau / dt - Double(k)
+ var out = [Double](repeating: 0, count: Self.count)
+ let base = k * 2 * Self.count, next = (k + 1) * 2 * Self.count
+ for i in 0.. Track {
+ let r = source.root(0)
+ return Track(states: [Double](repeating: 0, count: 2 * count), roots: [r, r], steps: 0,
+ springs: PetRigSecondary.springs(source), impulses: source.impulses.sorted { $0.0 < $1.0 })
+ }
+
+ /// Integrates from the last stored step to step `k`.
+ private func extend(_ track: inout Track, source: PetRigSource, through k: Int) {
+ guard track.steps < k else { return }
+ let dt = PetRigSecondary.step
+ let n = Self.count
+ let s = max(source.scale, 0.1)
+ let h = PetBodyGeometry.restHeight * s
+ track.states.reserveCapacity((k + 1) * 2 * n)
+ var x = [Double](repeating: 0, count: n), v = [Double](repeating: 0, count: n)
+ let last = track.steps * 2 * n
+ for i in 0.. from && at <= tau {
+ v[spring.rawValue] += amount
+ }
+ for i in 0.. Self.keep {
+ let old = recent.removeFirst()
+ tracks[old] = nil
+ }
+ }
+
+ /// Lets go of every track whose source had come to rest by `instant`.
+ private func forget(before instant: Double) {
+ let done = Set(recent.filter { $0.start + $0.length + PetRigSecondary.tail < instant })
+ guard !done.isEmpty else { return }
+ for source in done { tracks[source] = nil }
+ recent.removeAll { done.contains($0) }
+ }
+
+ /// How many sources are held, for the tests.
+ var held: Int { tracks.count }
+}
diff --git a/Sources/BrowKit/Pet/PetScene.swift b/Sources/BrowKit/Pet/PetScene.swift
new file mode 100644
index 0000000..e28555b
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetScene.swift
@@ -0,0 +1,141 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import CoreGraphics
+import Foundation
+
+// What the rig poses the pet in, besides the timeline: where it may be (zones), where the pointer has
+// been, when the panel opened and closed (dock), and the display options. Every position is in window
+// coordinates: points from the top-left corner of `NotchFrames.expanded`, y down. That is the space of
+// `NotchRootView`, where the pet's layer lives, and the one `NotchPanelController.hostPoint(fromScreen:)`
+// converts the pointer into. The window never moves, so the strip and the panel share it. A screen x is
+// a window x plus `frames.expanded.minX`; on the pill, an x measured from the pill's left edge is a
+// window x minus that edge.
+
+/// Which of the pet's places a zone is.
+public enum PetZoneKind: String, Sendable, Hashable, CaseIterable {
+ /// In the left wing, between the 5-hour readout and the notch: the default.
+ case beside
+ /// In the 22 pt strip under the notch, left of the readouts.
+ case below
+ /// The 180 × 24 pt pill of a display without a notch.
+ case pill
+ /// The panel header's stage, at scale 2.
+ case panel
+}
+
+/// The left readout's width class. A strip zone is fixed per class, and with tabular digits every
+/// reading of a class is one width.
+public enum PetReadoutWidth: Sendable, Hashable, CaseIterable {
+ /// `—`: no reading yet.
+ case dash
+ /// `0%` to `9%`.
+ case oneDigit
+ /// `10%` to `99%`.
+ case twoDigits
+ /// `100%`: on screen only for the meal that fills the window and while asleep or drowsy.
+ case threeDigits
+}
+
+/// Where the den is, seen from a zone.
+public enum PetDen: Sendable, Equatable {
+ /// Beside: the notch starts at `edgeX`, to the zone's right, and the pet goes in sideways.
+ case side(edgeX: CGFloat)
+ /// Below: the notch is above the strip, and the pet hops up into it, leaving light under the door.
+ case overhead(CGRect)
+ /// The pill and the panel have none: the pet curls up where it is.
+ case none
+}
+
+/// One place the pet can be. `PetLayout` computes it from `NotchFrames` and the readouts; the controller
+/// recomputes it on every render.
+public struct PetZone: Sendable, Equatable {
+ public var kind: PetZoneKind
+ /// What every pose's box stays inside, the readouts' 4 pt gap already taken off. Only den poses
+ /// pass it, on the den's side.
+ public var bounds: CGRect
+ /// The feet's centre at rest.
+ public var anchor: CGPoint
+ /// 1.0 beside, 0.80 below, 0.82 on the pill, 2.0 in the panel.
+ public var scale: CGFloat
+ /// 1.20 beside, 1.10 below, 1.15 on the pill; the panel keeps the beside limit
+ /// (`PetLayout.panelMaxStretch`).
+ public var maxStretch: CGFloat
+ public var den: PetDen
+ /// Where food comes from: the left readout's text centre in the strip, the Overall line's "5h"
+ /// figure in the panel. The bite goes toward it.
+ public var foodSource: CGPoint
+ /// The colour of that readout's status dot, for the crumbs and the rim's tint; nil is the grey of
+ /// a stale reading or of none (`EarsView.color(for:)`).
+ public var foodDot: Severity?
+}
+
+/// The zones the rig needs at one instant.
+public struct PetZones: Sendable, Equatable {
+ /// The collapsed strip's zone; nil when nothing is drawn collapsed (readouts hidden), and the pet
+ /// appears only in the panel.
+ public var strip: PetZone?
+ public var panel: PetZone
+ /// The camera: nothing is ever drawn inside it, in any dock state; nil without a notch.
+ public var notch: CGRect?
+}
+
+/// Where the pointer was, and when.
+public struct PetPointerSample: Sendable, Equatable {
+ /// `PetTime.mono`.
+ public var t: TimeInterval
+ /// Window coordinates. Anywhere on the screen, so often outside the window.
+ public var location: CGPoint
+}
+
+/// The pointer's recent path, from the panel controller's mouse monitors. The eyes follow it with
+/// springs, so the rig needs a little history, not just the last point.
+public struct PetPointerTrack: Sendable, Equatable {
+ /// In time order. The controller keeps every sample of the last `history` seconds and the one
+ /// sample before them.
+ public var samples: [PetPointerSample] = []
+
+ /// Enough for the slowest gaze spring (the head, ω 9, ζ 0.9) to forget where it started.
+ public static let history: TimeInterval = 2
+}
+
+/// The panel opening or closing, or the strip changing shape.
+public struct PetDockChange: Sendable, Equatable {
+ public enum Kind: Sendable, Equatable {
+ /// `setExpanded(true)`, at the instant the outline's open spring starts: hop, glide or
+ /// crossfade from the strip to the header.
+ case expand
+ /// `setExpanded(false)`: back to the strip.
+ case collapse
+ /// The Ears picker, a display change or the readouts turned on or off: fade out at `from`,
+ /// fade in at the new zones.
+ case relayout(from: PetZones)
+ }
+
+ /// `PetTime.mono`.
+ public var t: TimeInterval
+ public var kind: Kind
+}
+
+/// When the pet moved between the strip and the panel. Expanded at an instant means the last
+/// `.expand` or `.collapse` at or before it was an `.expand`.
+public struct PetDockTrack: Sendable, Equatable {
+ /// In time order. The controller keeps the last `.expand` or `.collapse` however old, and every
+ /// change of the last `history` seconds.
+ public var changes: [PetDockChange] = []
+
+ /// Longer than any hop, glide or fade, with room for the landing spring to settle.
+ public static let history: TimeInterval = 2
+}
+
+/// The display options the pet follows, from `NSWorkspace.shared.accessibilityDisplayShouldReduceMotion`
+/// and its two neighbours.
+public struct PetMotion: Sendable, Hashable {
+ /// No squash or stretch beyond ±2 %, no hop, glide or lunge (crossfades instead), idle is
+ /// breathing at half amplitude and blinks, particles fade in place.
+ public var reduceMotion = false
+ /// No glow; the rim light stays.
+ public var reduceTransparency = false
+ /// The rim at 60 % and a 0.5 pt outline at 40 %.
+ public var increaseContrast = false
+}
diff --git a/Sources/BrowKit/Pet/PetSenses.swift b/Sources/BrowKit/Pet/PetSenses.swift
new file mode 100644
index 0000000..7823b9e
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetSenses.swift
@@ -0,0 +1,479 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import AppKit
+import CoreGraphics
+import Darwin
+import Foundation
+import IOKit.ps
+
+// What the pet senses of the Mac: the person, the display and the session, power, the display options,
+// the clock, and Claude Code being typed into. Everything here is outside the brain, so it may read
+// clocks: each event is stamped once, here, and the brain takes the stamp as the event's time.
+
+extension PetTime {
+ /// Both clocks, read once: the wall clock and `ProcessInfo.systemUptime`, which stops while the
+ /// Mac sleeps and never jumps.
+ public static func now() -> PetTime {
+ PetTime(wall: Date(), mono: ProcessInfo.processInfo.systemUptime)
+ }
+}
+
+extension PetTuning.Eager {
+ /// Writes to one `history.jsonl` this close together, each to the one before it, are one prompt: a
+ /// single append can reach the watcher as two events. Nobody types two prompts a quarter of a second
+ /// apart, and the brain would do nothing more with the second.
+ public static let promptBurst: TimeInterval = 0.25
+}
+
+/// The power the frame policy caps by.
+public struct PetPower: Sendable, Equatable {
+ public var onAC = true
+ public var lowPower = false
+
+ public init(onAC: Bool = true, lowPower: Bool = false) {
+ self.onAC = onAC
+ self.lowPower = lowPower
+ }
+
+ /// `IOPSGetProvidingPowerSourceType` and `ProcessInfo.isLowPowerModeEnabled`, now.
+ public static func current() -> PetPower {
+ var onAC = true
+ if let info = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
+ let type = IOPSGetProvidingPowerSourceType(info)?.takeUnretainedValue() {
+ onAC = (type as String) == kIOPMACPowerKey
+ }
+ return PetPower(onAC: onAC, lowPower: ProcessInfo.processInfo.isLowPowerModeEnabled)
+ }
+}
+
+/// Compares the two clocks between presence checks. The monotonic clock stops while the Mac sleeps; the
+/// wall clock does not, and it jumps when it is set. So a wall gap much longer than the monotonic one
+/// is a sleep whose wake notification was missed, and a smaller difference either way is the clock
+/// being set. A difference after `NSSystemClockDidChange` is the clock being set, however large.
+public struct PetClockWatch: Sendable, Equatable {
+ public enum Finding: Sendable, Equatable {
+ case steady
+ /// The wall clock moved this much more than the monotonic one: set by hand or by the network.
+ case jumped(by: TimeInterval)
+ /// More than `PetTuning.Presence.missedCheck` passed on the wall clock that the monotonic one
+ /// never saw: the Mac slept.
+ case slept(for: TimeInterval)
+ }
+
+ private var last: PetTime?
+ /// `NSSystemClockDidChange` came since the last check.
+ private var clockSet = false
+
+ public init() {}
+
+ public mutating func check(_ time: PetTime) -> Finding {
+ defer {
+ last = time
+ clockSet = false
+ }
+ guard let last else { return .steady }
+ let drift = time.wall.timeIntervalSince(last.wall) - (time.mono - last.mono)
+ if drift > PetTuning.Presence.missedCheck && !clockSet { return .slept(for: drift) }
+ if abs(drift) > PetTuning.Clock.jump { return .jumped(by: drift) }
+ return .steady
+ }
+
+ /// `NSSystemClockDidChange`: whatever the next check finds was the clock being set.
+ public mutating func clockWasSet() { clockSet = true }
+
+ /// After a wake the notification announced, so the first check after it is not a second one.
+ public mutating func reset() {
+ last = nil
+ clockSet = false
+ }
+}
+
+// MARK: - history.jsonl
+
+/// Watches the visible accounts' `history.jsonl`, which Claude Code appends to on every prompt typed.
+/// It asks macOS to say when the file is written and never opens it for reading: `O_EVTONLY` and a
+/// vnode source, so all it learns is "written now". One watcher per directory, the account's config dir
+/// and each alias dir, by their real path, so two spellings of one folder are one watcher.
+@MainActor
+public final class PetHistoryWatchers {
+ private let onPrompt: @MainActor () -> Void
+ private var watchers: [String: PetHistoryWatcher] = [:]
+
+ public init(onPrompt: @escaping @MainActor () -> Void) {
+ self.onPrompt = onPrompt
+ }
+
+ /// Follows the visible accounts: watchers for new directories, none left for gone ones.
+ public func watch(configDirs: [String]) {
+ let home = NSHomeDirectory()
+ let wanted = Set(configDirs.compactMap { ClaudeCodeActivity.canonicalConfigDir($0, cwd: nil, home: home) })
+ for (dir, watcher) in watchers where !wanted.contains(dir) {
+ watcher.cancel()
+ watchers[dir] = nil
+ }
+ for dir in wanted.sorted() where watchers[dir] == nil {
+ let watcher = PetHistoryWatcher(directory: dir) { [weak self] in self?.onPrompt() }
+ watchers[dir] = watcher
+ watcher.arm()
+ }
+ }
+
+ /// The pet off: every watcher closed.
+ public func stop() {
+ for watcher in watchers.values { watcher.cancel() }
+ watchers = [:]
+ }
+
+ var directories: [String] { watchers.keys.sorted() }
+}
+
+/// One directory's `history.jsonl`.
+@MainActor
+final class PetHistoryWatcher {
+ let directory: String
+ private let onPrompt: @MainActor () -> Void
+ private var file: DispatchSourceFileSystemObject?
+ private var folder: DispatchSourceFileSystemObject?
+ private var reopen: DispatchWorkItem?
+ /// The file did not exist when last looked at: its appearing is a prompt being written.
+ private var missing = false
+ /// `ProcessInfo.systemUptime` of the last write seen, whether it was passed on as a prompt or not.
+ private var lastWrite: TimeInterval = -.infinity
+
+ init(directory: String, onPrompt: @escaping @MainActor () -> Void) {
+ self.directory = directory
+ self.onPrompt = onPrompt
+ }
+
+ private var path: String { directory + "/history.jsonl" }
+
+ /// Watches the file if it is a regular file of this directory's own. Missing, the directory is
+ /// watched until it appears. A symlink is not watched: it is shared with other accounts and says
+ /// nothing about this one (`ClaudeCodeActivity.lastPrompt` explains), but the directory is, in
+ /// case it becomes a file of its own.
+ func arm() {
+ cancelSources()
+ switch kind() {
+ case .regular:
+ missing = false
+ let descriptor = open(path, O_EVTONLY)
+ guard descriptor >= 0 else { return watchFolder() }
+ let source = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: descriptor, eventMask: [.write, .extend, .delete, .rename], queue: .main)
+ source.setEventHandler { [weak self, weak source] in
+ guard let events = source?.data else { return }
+ MainActor.assumeIsolated { self?.fileChanged(events) }
+ }
+ source.setCancelHandler { close(descriptor) }
+ source.resume()
+ file = source
+ case .missing:
+ missing = true
+ watchFolder()
+ case .other:
+ missing = false
+ watchFolder()
+ }
+ }
+
+ func cancel() {
+ cancelSources()
+ reopen?.cancel()
+ reopen = nil
+ }
+
+ private enum Kind { case regular, missing, other }
+
+ private func kind() -> Kind {
+ var info = stat()
+ guard lstat(path, &info) == 0 else { return .missing }
+ return info.st_mode & S_IFMT == S_IFREG ? .regular : .other
+ }
+
+ private func fileChanged(_ events: DispatchSource.FileSystemEvent) {
+ if events.contains(.delete) || events.contains(.rename) {
+ // Replaced or moved away: look again in a second, when whatever replaced it is in place.
+ cancelSources()
+ let work = DispatchWorkItem { [weak self] in MainActor.assumeIsolated { self?.arm() } }
+ reopen = work
+ DispatchQueue.main.asyncAfter(deadline: .now() + PetTuning.Eager.watcherReopen, execute: work)
+ return
+ }
+ if events.contains(.write) || events.contains(.extend) { prompt() }
+ }
+
+ /// One append can reach the vnode source as two events, and a file created with its first line can
+ /// be seen appearing and then written: either way it is one prompt. Each write is measured from the
+ /// write before it, not from the prompt, so a burst stays one prompt however late its events arrive.
+ private func prompt() {
+ let now = ProcessInfo.processInfo.systemUptime
+ defer { lastWrite = now }
+ guard now - lastWrite >= PetTuning.Eager.promptBurst else { return }
+ onPrompt()
+ }
+
+ private func watchFolder() {
+ let descriptor = open(directory, O_EVTONLY)
+ guard descriptor >= 0 else { return } // no such directory: nothing to watch
+ let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: descriptor, eventMask: [.write], queue: .main)
+ source.setEventHandler { [weak self] in MainActor.assumeIsolated { self?.folderChanged() } }
+ source.setCancelHandler { close(descriptor) }
+ source.resume()
+ folder = source
+ }
+
+ /// Something in the directory changed: the file may have arrived.
+ private func folderChanged() {
+ guard kind() == .regular else { return }
+ let appeared = missing
+ arm()
+ // Claude Code creates the file with the first prompt in it; by the time it is open to watch,
+ // that write has happened.
+ if appeared { prompt() }
+ }
+
+ private func cancelSources() {
+ file?.cancel()
+ file = nil
+ folder?.cancel()
+ folder = nil
+ }
+}
+
+// MARK: - The Mac
+
+/// The person, the display and the session, power, the display options and the clock, as events for the
+/// brain (and power for the frame policy). Start it when the pet is on, stop it when it is off: off,
+/// nothing here runs.
+@MainActor
+public final class PetSenses {
+ public private(set) var presence = PetPresence()
+ public private(set) var power = PetPower()
+ public private(set) var motion = PetMotion()
+ /// False from `willSleepNotification` to `didWakeNotification`.
+ public private(set) var systemAwake = true
+
+ private let onEvent: @MainActor (PetEvent, PetTime) -> Void
+ private let onPower: @MainActor (PetPower) -> Void
+ private var observers: [(NotificationCenter, any NSObjectProtocol)] = []
+ private var timer: Timer?
+ private var powerSource: CFRunLoopSource?
+ /// The power source's own reference to the senses, released in `stop()`.
+ private var powerContext: Unmanaged?
+ private var clockWatch = PetClockWatch()
+ private static let jumpReport = OneShot()
+
+ /// - Parameters:
+ /// - onEvent: every event for the brain, with the instant it was sensed.
+ /// - onPower: the power source or Low Power Mode changed; read the frame policy again.
+ public init(onEvent: @escaping @MainActor (PetEvent, PetTime) -> Void,
+ onPower: @escaping @MainActor (PetPower) -> Void) {
+ self.onEvent = onEvent
+ self.onPower = onPower
+ }
+
+ /// Reads the current state, sends it (`presence`, `motion`, `timeZone`), and starts listening.
+ public func start() {
+ guard observers.isEmpty else { return }
+ let session = CGSessionCopyCurrentDictionary() as? [String: Any]
+ presence.screenLocked = session?["CGSSessionScreenIsLocked"] as? Bool ?? false
+ presence.sessionInactive = !(session?[kCGSessionOnConsoleKey as String] as? Bool ?? true)
+ presence.displayAsleep = CGDisplayIsAsleep(CGMainDisplayID()) != 0
+ power = PetPower.current()
+ motion = Self.currentMotion()
+
+ let workspace = NSWorkspace.shared.notificationCenter
+ observe(workspace, NSWorkspace.screensDidSleepNotification) { $0.displayChanged(asleep: true) }
+ observe(workspace, NSWorkspace.screensDidWakeNotification) { $0.displayChanged(asleep: false) }
+ observe(workspace, NSWorkspace.sessionDidResignActiveNotification) { $0.sessionChanged(active: false) }
+ observe(workspace, NSWorkspace.sessionDidBecomeActiveNotification) { $0.sessionChanged(active: true) }
+ observe(workspace, NSWorkspace.willSleepNotification) { $0.systemWillSleep() }
+ observe(workspace, NSWorkspace.didWakeNotification) { $0.systemDidWake() }
+ observe(workspace, NSWorkspace.accessibilityDisplayOptionsDidChangeNotification) { $0.displayOptionsChanged() }
+ let distributed = DistributedNotificationCenter.default()
+ observe(distributed, Notification.Name("com.apple.screenIsLocked")) { $0.lockChanged(locked: true) }
+ observe(distributed, Notification.Name("com.apple.screenIsUnlocked")) { $0.lockChanged(locked: false) }
+ let local = NotificationCenter.default
+ observe(local, .NSSystemClockDidChange) { senses in
+ senses.clockWatch.clockWasSet()
+ senses.send(.clockChanged)
+ }
+ observe(local, .NSSystemTimeZoneDidChange) { senses in
+ NSTimeZone.resetSystemTimeZone()
+ senses.send(.timeZone(TimeZone.current))
+ }
+ observe(local, .NSProcessInfoPowerStateDidChange) { $0.powerChanged() }
+ startPowerSource()
+
+ send(.motion(motion))
+ send(.timeZone(TimeZone.current))
+ check()
+ scheduleChecks()
+ }
+
+ public func stop() {
+ for (center, observer) in observers { center.removeObserver(observer) }
+ observers = []
+ timer?.invalidate()
+ timer = nil
+ if let powerSource {
+ CFRunLoopRemoveSource(CFRunLoopGetMain(), powerSource, .defaultMode)
+ CFRunLoopSourceInvalidate(powerSource)
+ }
+ powerSource = nil
+ powerContext?.release()
+ powerContext = nil
+ clockWatch.reset()
+ }
+
+ /// A presence check now, between the 5 s ones: the controller asks when the pointer moves while the
+ /// last check found the person settled or away, so the pet answers someone coming back at once.
+ public func checkPresence() {
+ guard !observers.isEmpty else { return }
+ check()
+ }
+
+ /// Seconds since the last input of any kind.
+ public static func inputIdle() -> TimeInterval {
+ guard let any = CGEventType(rawValue: ~0) else { return 0 }
+ return max(0, CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: any))
+ }
+
+ public static func currentMotion() -> PetMotion {
+ let workspace = NSWorkspace.shared
+ return PetMotion(reduceMotion: workspace.accessibilityDisplayShouldReduceMotion,
+ reduceTransparency: workspace.accessibilityDisplayShouldReduceTransparency,
+ increaseContrast: workspace.accessibilityDisplayShouldIncreaseContrast)
+ }
+
+ // MARK: Presence
+
+ /// One presence check: every 5 s while the display is awake and the session active, and at each
+ /// notification. A check that finds more wall time than monotonic time since the last one is a
+ /// sleep whose wake was missed, or the clock being set.
+ private func check() {
+ let now = PetTime.now()
+ switch clockWatch.check(now) {
+ case .steady:
+ break
+ case .slept(let gap):
+ BrowLog.pet.info("the Mac slept \(Int(gap), privacy: .public) s without a wake notification")
+ awake(at: now)
+ case .jumped(let drift):
+ if Self.jumpReport.fire() {
+ BrowLog.pet.info("the clock jumped \(Int(drift), privacy: .public) s; hunger and animation run on the monotonic clock")
+ }
+ onEvent(.clockChanged, now)
+ // The clock is often set right after a wake, so a jump this far forward may also hide a
+ // sleep whose wake notification never came. Saying the Mac is awake is harmless if it
+ // already knew, and without it a missed wake would leave the pet unseen until the next one.
+ if drift > PetTuning.Presence.missedCheck { awake(at: now) }
+ }
+ presence.inputIdle = Self.inputIdle()
+ onEvent(.presence(presence), now)
+ }
+
+ private func scheduleChecks() {
+ timer?.invalidate()
+ timer = nil
+ guard !presence.displayAsleep, !presence.sessionInactive, systemAwake else { return }
+ let timer = Timer(timeInterval: PetTuning.Presence.checkEvery, repeats: true) { [weak self] _ in
+ MainActor.assumeIsolated { self?.check() }
+ }
+ timer.tolerance = PetTuning.Presence.checkTolerance
+ RunLoop.main.add(timer, forMode: .common)
+ self.timer = timer
+ }
+
+ private func displayChanged(asleep: Bool) {
+ presence.displayAsleep = asleep
+ check()
+ scheduleChecks()
+ }
+
+ private func sessionChanged(active: Bool) {
+ presence.sessionInactive = !active
+ check()
+ scheduleChecks()
+ }
+
+ private func lockChanged(locked: Bool) {
+ presence.screenLocked = locked
+ check()
+ }
+
+ private func systemWillSleep() {
+ systemAwake = false
+ send(.systemSleep)
+ scheduleChecks()
+ }
+
+ private func systemDidWake() {
+ systemAwake = true
+ // The notification says it; the next check must not say it again from the clocks.
+ clockWatch.reset()
+ send(.systemWake)
+ check()
+ scheduleChecks()
+ }
+
+ /// The clocks say the Mac slept and no wake notification came. Since `willSleep` stopped the 5 s
+ /// checks, a missed wake would also leave them stopped: they start again here.
+ private func awake(at now: PetTime) {
+ onEvent(.systemWake, now)
+ guard !systemAwake else { return }
+ systemAwake = true
+ scheduleChecks()
+ }
+
+ // MARK: Display options, power
+
+ private func displayOptionsChanged() {
+ let new = Self.currentMotion()
+ guard new != motion else { return }
+ motion = new
+ send(.motion(new))
+ }
+
+ private func powerChanged() {
+ let new = PetPower.current()
+ guard new != power else { return }
+ power = new
+ onPower(new)
+ }
+
+ /// The run-loop source holds a retained reference to the senses until `stop()` removes it and lets
+ /// go, so its callback can never reach a `PetSenses` that was released without being stopped.
+ private func startPowerSource() {
+ let retained = Unmanaged.passRetained(self)
+ guard let source = IOPSNotificationCreateRunLoopSource({ context in
+ guard let context else { return }
+ let senses = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ MainActor.assumeIsolated { senses.powerChanged() }
+ }, retained.toOpaque())?.takeRetainedValue() else {
+ retained.release()
+ return
+ }
+ CFRunLoopAddSource(CFRunLoopGetMain(), source, .defaultMode)
+ powerSource = source
+ powerContext = retained
+ }
+
+ // MARK: Plumbing
+
+ private func send(_ event: PetEvent) {
+ onEvent(event, PetTime.now())
+ }
+
+ private func observe(_ center: NotificationCenter, _ name: Notification.Name,
+ _ handler: @escaping @MainActor (PetSenses) -> Void) {
+ let observer = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ handler(self)
+ }
+ }
+ observers.append((center, observer))
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetStore.swift b/Sources/BrowKit/Pet/PetStore.swift
new file mode 100644
index 0000000..b13834f
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetStore.swift
@@ -0,0 +1,302 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// Everything `pet.json` holds: the pet's seed, its hunger, what it has seen of each account's periods,
+/// its fed days and good weeks. Not the plate, the behaviour or the pointer: a relaunch starts in the
+/// condition the data says, in idle, with H as saved, and the time Brow was not running is away time.
+///
+/// Every field has a default and is read on its own, the way `BrowSettings` reads `config.json`: a
+/// field this build cannot read costs that field, not the pet, and a field it does not know is
+/// ignored. Nothing in it leaves the Mac; it holds organisation ids (already in `limits.json`),
+/// dates and numbers.
+public struct PetSave: Codable, Sendable, Equatable {
+ /// The file's format. A newer one is read for what this build knows and never written over
+ /// (`PetStore.save`), so going back to an older Brow and forward again loses nothing.
+ public var v: Int
+ public var seed: UInt64
+ public var bornAt: Date
+ public var introDone: Bool
+ public var hunger: Double
+ public var savedAt: Date
+ /// When the last meal started.
+ public var lastMealAt: Date?
+ /// What it held, for the panel's `Ate 3 % · 2 min ago` after a relaunch.
+ public var lastMealPoints: Double?
+ public var trackers: [String: PetPeriods]
+ public var days: [String: Double]
+ public var streak: PetStreak
+ public var goodWeeks: [String]
+ public var lastGoodWeekCelebratedAt: Date?
+ /// The good weeks of the last day, for their panel lines after a relaunch.
+ public var goodWeekLines: [PetGoodWeekLine]
+
+ /// A pet that has just been born: H 30, every tracker empty, so its first readings are baselines.
+ public init(seed: UInt64, bornAt: Date) {
+ v = PetTuning.Store.version
+ self.seed = seed
+ self.bornAt = bornAt
+ introDone = false
+ hunger = PetTuning.Hunger.newPet
+ savedAt = bornAt
+ lastMealAt = nil
+ lastMealPoints = nil
+ trackers = [:]
+ days = [:]
+ streak = PetStreak()
+ goodWeeks = []
+ lastGoodWeekCelebratedAt = nil
+ goodWeekLines = []
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case v, seed, bornAt, introDone, hunger, savedAt, lastMealAt, lastMealPoints, trackers, days, streak,
+ goodWeeks, lastGoodWeekCelebratedAt, goodWeekLines
+ }
+
+ /// One unreadable entry of a list or a map is dropped on its own.
+ private struct Lenient: Decodable {
+ let value: T?
+ init(from decoder: any Decoder) throws { value = try? T(from: decoder) }
+ }
+
+ /// Lenient field by field. A missing seed is the caller's to fill (`PetStore.load` draws one),
+ /// so it decodes as 0 and `hasSeed` says so.
+ public init(from decoder: any Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ func field(_ type: T.Type, _ key: CodingKeys) -> T? {
+ (try? c.decodeIfPresent(type, forKey: key)) ?? nil
+ }
+ v = field(Int.self, .v) ?? PetTuning.Store.version
+ seed = field(UInt64.self, .seed) ?? 0
+ hasSeed = field(UInt64.self, .seed) != nil
+ bornAt = field(Date.self, .bornAt) ?? .distantPast
+ introDone = field(Bool.self, .introDone) ?? false
+ hunger = min(max(field(Double.self, .hunger) ?? PetTuning.Hunger.newPet, PetTuning.Hunger.minimum),
+ PetTuning.Hunger.maximum)
+ savedAt = field(Date.self, .savedAt) ?? .distantPast
+ lastMealAt = field(Date.self, .lastMealAt)
+ lastMealPoints = field(Double.self, .lastMealPoints)
+ trackers = (field([String: Lenient].self, .trackers) ?? [:]).compactMapValues(\.value)
+ days = (field([String: Lenient].self, .days) ?? [:]).compactMapValues(\.value)
+ streak = field(PetStreak.self, .streak) ?? PetStreak()
+ goodWeeks = (field([Lenient].self, .goodWeeks) ?? []).compactMap(\.value)
+ lastGoodWeekCelebratedAt = field(Date.self, .lastGoodWeekCelebratedAt)
+ goodWeekLines = (field([Lenient].self, .goodWeekLines) ?? []).compactMap(\.value)
+ }
+
+ public func encode(to encoder: any Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ try c.encode(v, forKey: .v)
+ try c.encode(seed, forKey: .seed)
+ try c.encode(bornAt, forKey: .bornAt)
+ try c.encode(introDone, forKey: .introDone)
+ try c.encode(hunger, forKey: .hunger)
+ try c.encode(savedAt, forKey: .savedAt)
+ try c.encodeIfPresent(lastMealAt, forKey: .lastMealAt)
+ try c.encodeIfPresent(lastMealPoints, forKey: .lastMealPoints)
+ try c.encode(trackers, forKey: .trackers)
+ try c.encode(days, forKey: .days)
+ try c.encode(streak, forKey: .streak)
+ try c.encode(goodWeeks, forKey: .goodWeeks)
+ try c.encodeIfPresent(lastGoodWeekCelebratedAt, forKey: .lastGoodWeekCelebratedAt)
+ try c.encode(goodWeekLines, forKey: .goodWeekLines)
+ }
+
+ /// False for a file that had no seed: not encoded, and not compared.
+ var hasSeed = true
+
+ public static func == (a: PetSave, b: PetSave) -> Bool {
+ a.v == b.v && a.seed == b.seed && a.bornAt == b.bornAt && a.introDone == b.introDone && a.hunger == b.hunger
+ && a.savedAt == b.savedAt && a.lastMealAt == b.lastMealAt && a.lastMealPoints == b.lastMealPoints
+ && a.trackers == b.trackers && a.days == b.days && a.streak == b.streak && a.goodWeeks == b.goodWeeks
+ && a.lastGoodWeekCelebratedAt == b.lastGoodWeekCelebratedAt && a.goodWeekLines == b.goodWeekLines
+ }
+
+ /// Keeps the file in shape whatever the clock did: no instant after `now`, so a clock that went back
+ /// after a save never makes a negative gap; the last 60 dates and 16 good weeks; the good-week lines
+ /// of the last day.
+ func tidied(now: Date) -> PetSave {
+ var save = self
+ save.bornAt = min(save.bornAt, now)
+ save.savedAt = min(save.savedAt, now)
+ save.lastMealAt = save.lastMealAt.map { min($0, now) }
+ save.lastGoodWeekCelebratedAt = save.lastGoodWeekCelebratedAt.map { min($0, now) }
+ if save.days.count > PetTuning.Store.keptDays {
+ for day in save.days.keys.sorted().prefix(save.days.count - PetTuning.Store.keptDays) { save.days[day] = nil }
+ }
+ if save.goodWeeks.count > PetTuning.Store.keptGoodWeeks {
+ // By the reset each one names, so the newest are kept even from a file that was reordered.
+ let epoch = { (key: String) in Int(key.split(separator: "@").last ?? "") ?? 0 }
+ save.goodWeeks = Array(save.goodWeeks.sorted { epoch($0) < epoch($1) }.suffix(PetTuning.Store.keptGoodWeeks))
+ }
+ save.goodWeekLines = save.goodWeekLines.filter { now.timeIntervalSince($0.at) < PetTuning.Moments.goodWeekLine }
+ return save
+ }
+}
+
+/// `/pet.json`, beside `config.json` and `limits.json`, so the README's uninstall already
+/// removes it. Atomic writes, like `LimitSnapshotStore`; an unreadable file is logged and replaced by a
+/// new pet, and the app starts either way.
+public struct PetStore: Sendable {
+ public static let defaultDirectory = BrowSettingsStore.defaultDirectory
+
+ private let directory: String
+ private var file: String { directory + "/" + PetTuning.Store.fileName }
+
+ public init(directory: String = PetStore.defaultDirectory) {
+ self.directory = directory
+ }
+
+ /// What was on disk.
+ public enum Origin: Sendable, Equatable {
+ /// No file: a first launch for the pet.
+ case new
+ case found
+ /// Not JSON, or not an object: logged at `.error`, and a new pet with a new seed.
+ case unreadable
+ /// Written by a newer Brow: read for what this build knows, and left as it is.
+ case newer(version: Int)
+ }
+
+ /// The pet to start with. `newSeed` is called only for a new pet; the app's draws from
+ /// `SystemRandomNumberGenerator`, the only randomness the pet ever has.
+ public func load(now: Date, newSeed: () -> UInt64 = PetStore.randomSeed) -> (save: PetSave, origin: Origin) {
+ let path = file
+ guard let data = FileManager.default.contents(atPath: path) else {
+ return (PetSave(seed: newSeed(), bornAt: now), .new)
+ }
+ var save: PetSave
+ do {
+ save = try Self.decoder.decode(PetSave.self, from: data)
+ } catch {
+ BrowLog.pet.error("pet.json unreadable at \(path, privacy: .public): \(String(describing: error), privacy: .public); a new pet")
+ return (PetSave(seed: newSeed(), bornAt: now), .unreadable)
+ }
+ if !save.hasSeed {
+ save.seed = newSeed()
+ save.hasSeed = true
+ }
+ if save.bornAt == .distantPast { save.bornAt = now }
+ if save.savedAt == .distantPast { save.savedAt = now }
+ let origin: Origin = save.v > PetTuning.Store.version ? .newer(version: save.v) : .found
+ if case .newer(let version) = origin {
+ BrowLog.pet.error("pet.json is version \(version, privacy: .public), newer than this build's; read, not written")
+ }
+ return (save.tidied(now: now), origin)
+ }
+
+ /// Writes `save` atomically. A file a newer Brow wrote is left alone, and this returns false: the
+ /// pet still runs, it just does not overwrite what the newer build will want back.
+ @discardableResult
+ public func save(_ save: PetSave) throws -> Bool {
+ if let data = FileManager.default.contents(atPath: file),
+ let existing = try? Self.decoder.decode(Version.self, from: data),
+ let version = existing.v, version > PetTuning.Store.version {
+ if Self.newerReport.fire() {
+ BrowLog.pet.error("pet.json was written by a newer Brow (version \(version, privacy: .public)); not saving over it")
+ }
+ return false
+ }
+ try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
+ var tidy = save.tidied(now: save.savedAt)
+ tidy.v = PetTuning.Store.version
+ try Self.encoder.encode(tidy).write(to: URL(fileURLWithPath: file), options: .atomic)
+ return true
+ }
+
+ public static func randomSeed() -> UInt64 {
+ var generator = SystemRandomNumberGenerator()
+ return generator.next()
+ }
+
+ private struct Version: Decodable { let v: Int? }
+
+ private static let newerReport = OneShot()
+
+ private static var decoder: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+
+ private static var encoder: JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
+ return encoder
+ }
+}
+
+/// When to write `pet.json`: at every condition change, at most once a minute after a meal, and every
+/// 10 minutes while running. The controller also writes on `willSleepNotification` and in
+/// `applicationWillTerminate`, whatever this says. Pure, with the monotonic clock passed in.
+public struct PetSaveSchedule: Sendable, Equatable {
+ public private(set) var lastSave: TimeInterval?
+ private var meals = 0
+ private var conditionChanges = 0
+
+ public init() {}
+
+ public func isDue(at mono: TimeInterval, brain: PetBrain) -> Bool {
+ guard let lastSave else { return true }
+ if brain.conditionChanges != conditionChanges { return true }
+ if brain.meals != meals && mono - lastSave >= PetTuning.Store.afterMealAtMost { return true }
+ return mono - lastSave >= PetTuning.Store.every
+ }
+
+ /// When to ask `isDue` again if no event comes first.
+ public func nextCheck(brain: PetBrain) -> TimeInterval? {
+ guard let lastSave else { return nil }
+ return lastSave + (brain.meals != meals ? PetTuning.Store.afterMealAtMost : PetTuning.Store.every)
+ }
+
+ public mutating func saved(at mono: TimeInterval, brain: PetBrain) {
+ lastSave = mono
+ meals = brain.meals
+ conditionChanges = brain.conditionChanges
+ }
+}
+
+// MARK: - The brain from and to pet.json
+
+extension PetBrain {
+ /// A pet as `pet.json` left it. A relaunch is away time: H as saved, the trackers as they were (so
+ /// growth while Brow was not running is a catch-up meal), and the condition the next readings
+ /// give.
+ public init(restoring save: PetSave) {
+ self.init(seed: save.seed)
+ hunger = PetHunger(value: save.hunger)
+ mood = PetHunger.moodOnTheWayUp(hunger.value)
+ unsureMood = mood
+ food = PetFood(trackers: save.trackers)
+ moments.days = save.days
+ moments.streak = save.streak
+ moments.goodWeeks = save.goodWeeks
+ moments.lastGoodWeekCelebratedAt = save.lastGoodWeekCelebratedAt
+ moments.goodWeekLines = save.goodWeekLines
+ introDone = save.introDone
+ bornAt = save.bornAt
+ lastMealWall = save.lastMealAt
+ lastMealPoints = save.lastMealPoints
+ }
+
+ /// What to write to `pet.json` at `time`.
+ public func save(at time: PetTime) -> PetSave {
+ let now = brought(to: time)
+ var save = PetSave(seed: seed, bornAt: bornAt ?? time.wall)
+ save.introDone = introDone
+ save.hunger = now.hunger.value
+ save.savedAt = time.wall
+ save.lastMealAt = now.lastMealWall
+ save.lastMealPoints = now.lastMealPoints
+ save.trackers = now.food.trackers
+ save.days = now.moments.days
+ save.streak = now.moments.streak
+ save.goodWeeks = now.moments.goodWeeks
+ save.lastGoodWeekCelebratedAt = now.moments.lastGoodWeekCelebratedAt
+ save.goodWeekLines = now.moments.goodWeekLines
+ return save.tidied(now: time.wall)
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetText.swift b/Sources/BrowKit/Pet/PetText.swift
new file mode 100644
index 0000000..334245e
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetText.swift
@@ -0,0 +1,232 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// What the words are about at one instant, from the brain (`PetBrain.status(at:)`): the panel header
+/// and VoiceOver say it, the rig never reads it.
+public struct PetStatus: Sendable, Equatable {
+ public enum Situation: Sendable, Equatable {
+ case off
+ case noAccounts
+ case hibernating(wakesAt: Date?)
+ /// The weekly reset passed and it stirs in the den, waiting for fresh numbers.
+ case stirring
+ case asleep(wakesAt: Date?)
+ case drowsy
+ /// No fresh numbers: the mood it had, and when the newest reading was fetched (nil: none yet).
+ case unsure(mood: PetMood, newest: Date?)
+ case eating(points: Double)
+ case eager
+ case awake(mood: PetMood)
+ }
+
+ public var situation: Situation
+ /// What the meter shows, 0 to 100.
+ public var meter: Double
+ /// When the last meal started, and what it held.
+ public var lastMeal: Date?
+ public var lastMealPoints: Double?
+ /// Pace damping `f` (`PetLimits.pace`): at 0.5 or less the detail adds that the week is well fed.
+ public var pace: Double
+ /// The header's third line, newest first; the header shows the first.
+ public var moments: [PetMomentLine]
+
+ public init(situation: Situation, meter: Double, lastMeal: Date? = nil, lastMealPoints: Double? = nil,
+ pace: Double = 1, moments: [PetMomentLine] = []) {
+ self.situation = situation
+ self.meter = meter
+ self.lastMeal = lastMeal
+ self.lastMealPoints = lastMealPoints
+ self.pace = pace
+ self.moments = moments
+ }
+}
+
+/// Every word the pet says, in one pure place like `PanelText`: `now`, the time zone and the calendar
+/// are injected. No line says "you" or judges use: hunger is a mood, a limit used up is a nap, and
+/// nothing is ever lost.
+public enum PetText {
+ /// The panel header's words.
+ public struct Header: Sendable, Equatable {
+ /// 13 pt semibold: `Content`, `Eating`, `Asleep`.
+ public var word: String
+ /// 10 pt, one line: `Last meal 3 h ago`.
+ public var detail: String
+ /// 10 pt, only while a moment is live: `Streak: 7 fed days`.
+ public var moment: String?
+ }
+
+ /// The accessibility label of the pet in the strip and in the header, and the name of the header's
+ /// action, which boops it.
+ public static let label = "Pet"
+ public static let actionName = "Pet"
+
+ /// - Parameter names: the visible accounts' panel names by organisation. A good week names its
+ /// account when there are several.
+ public static func header(_ status: PetStatus, now: Date, timeZone: TimeZone, calendar: Calendar,
+ names: [String: String] = [:]) -> Header {
+ let (word, parts) = words(status, now: now, timeZone: timeZone, calendar: calendar)
+ return Header(word: word, detail: parts.map(\.shown).joined(separator: " · "),
+ moment: status.moments.first.map { moment($0, names: names) })
+ }
+
+ /// The VoiceOver value: the state word, the detail with its units spelled out, the meter.
+ /// `Content. Last meal 12 minutes ago. Hunger 20 percent.`
+ public static func voiceOverValue(_ status: PetStatus, now: Date, timeZone: TimeZone, calendar: Calendar) -> String {
+ let (word, parts) = words(status, now: now, timeZone: timeZone, calendar: calendar)
+ let detail = parts.map(\.spoken).joined(separator: ", ")
+ let hunger = "Hunger \(Int(min(max(status.meter, 0), 100).rounded())) percent"
+ return [word, detail, hunger].filter { !$0.isEmpty }.map { $0 + "." }.joined(separator: " ")
+ }
+
+ // MARK: - The words
+
+ /// One piece of a detail line, as the header shows it and as VoiceOver says it.
+ struct Part: Equatable {
+ var shown: String
+ var spoken: String
+
+ init(_ shown: String, spoken: String? = nil) {
+ self.shown = shown
+ self.spoken = spoken ?? shown
+ }
+ }
+
+ static func words(_ status: PetStatus, now: Date, timeZone: TimeZone, calendar: Calendar) -> (String, [Part]) {
+ switch status.situation {
+ case .off:
+ return ("", [])
+ case .noAccounts:
+ return ("Waiting", [Part("No Claude account to eat from yet")])
+ case .hibernating(let wakesAt):
+ return ("Hibernating", [Part("Weekly limit used up"), wakes(wakesAt, now: now, timeZone: timeZone, calendar: calendar)])
+ case .stirring:
+ return ("Waking up", [Part("The weekly limit has reset")])
+ case .asleep(let wakesAt):
+ return ("Asleep", [Part("5-hour limit used up"), wakes(wakesAt, now: now, timeZone: timeZone, calendar: calendar)])
+ case .drowsy:
+ return ("Waking up", [Part("The 5-hour limit has reset")])
+ case .unsure(let mood, let newest):
+ guard let newest else { return (word(mood), [Part("No numbers yet")]) }
+ let age = duration(now.timeIntervalSince(newest))
+ return (word(mood), [Part("No fresh numbers for \(age.shown)", spoken: "No fresh numbers for \(age.spoken)")])
+ case .eating(let points):
+ let amount = percent(points)
+ return ("Eating", [Part("\(amount.shown) of the 5-hour limit", spoken: "\(amount.spoken) of the 5-hour limit")])
+ case .eager:
+ return ("Waiting for food", [Part("Claude Code is working")])
+ case .awake(let mood):
+ if let meal = status.lastMeal, now.timeIntervalSince(meal) < PetTuning.Words.recentMeal {
+ let amount = percent(status.lastMealPoints ?? 0)
+ let ago = age(meal, now: now)
+ return (word(mood), [Part("Ate \(amount.shown)", spoken: "Ate \(amount.spoken)"), ago])
+ }
+ var parts: [Part]
+ if let meal = status.lastMeal {
+ let ago = age(meal, now: now)
+ parts = [Part("Last meal \(ago.shown)", spoken: "Last meal \(ago.spoken)")]
+ } else {
+ parts = [Part("No meal yet")]
+ }
+ if status.pace <= PetTuning.Words.wellFedPace { parts.append(Part("well fed this week")) }
+ return (word(mood), parts)
+ }
+ }
+
+ static func word(_ mood: PetMood) -> String {
+ switch mood {
+ case .full: return "Full"
+ case .content: return "Content"
+ case .peckish: return "Peckish"
+ case .hungry: return "Hungry"
+ }
+ }
+
+ /// Aggregate points as the readout's percent, rounded: `3 %`, `<1 %` below half a point.
+ static func percent(_ points: Double) -> Part {
+ guard points >= PetTuning.Words.lessThanOne else { return Part("<1 %", spoken: "less than 1 percent") }
+ let whole = Int(points.rounded())
+ return Part("\(whole) %", spoken: "\(whole) percent")
+ }
+
+ /// `Formatting.age`, with its units spelled out for VoiceOver.
+ static func age(_ date: Date, now: Date) -> Part {
+ let shown = Formatting.age(date, now: now)
+ let seconds = max(0, now.timeIntervalSince(date))
+ guard seconds >= 60 else { return Part(shown) }
+ return Part(shown, spoken: spelled(seconds) + " ago")
+ }
+
+ /// How long something has lasted, in `Formatting.age`'s units: `14 min`, `3 h`, `2 d`.
+ static func duration(_ seconds: TimeInterval) -> Part {
+ let s = max(0, seconds)
+ let shown: String
+ if s < 3600 { shown = "\(Int(s / 60)) min" } else if s < 86_400 { shown = "\(Int(s / 3600)) h" } else { shown = "\(Int(s / 86_400)) d" }
+ return Part(shown, spoken: spelled(s))
+ }
+
+ private static func spelled(_ seconds: TimeInterval) -> String {
+ func unit(_ n: Int, _ name: String) -> String { "\(n) \(name)\(n == 1 ? "" : "s")" }
+ if seconds < 3600 { return unit(Int(seconds / 60), "minute") }
+ if seconds < 86_400 { return unit(Int(seconds / 3600), "hour") }
+ return unit(Int(seconds / 86_400), "day")
+ }
+
+ /// `wakes at 18:40` the same day, `wakes 22 Oct 02:00` another, `wakes when it resets` without a
+ /// known reset: the forms `NoticeText` uses, in `en_US_POSIX`.
+ static func wakes(_ date: Date?, now: Date, timeZone: TimeZone, calendar: Calendar) -> Part {
+ guard let date else { return Part("wakes when it resets") }
+ var calendar = calendar
+ calendar.timeZone = timeZone
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = timeZone
+ if calendar.isDate(date, inSameDayAs: now) {
+ formatter.dateFormat = "HH:mm"
+ return Part("wakes at \(formatter.string(from: date))")
+ }
+ formatter.dateFormat = "d MMM HH:mm"
+ return Part("wakes \(formatter.string(from: date))")
+ }
+
+ static func moment(_ line: PetMomentLine, names: [String: String]) -> String {
+ switch line {
+ case .streak(let days):
+ return "Streak: \(days) fed days"
+ case .goodWeek(let account, let used):
+ let percent = Int(used.rounded())
+ // Among several accounts the name comes last, so a long one is what the one-line header
+ // cuts, never the number.
+ if names.count > 1, let name = names[account] { return "Good week: \(percent) % used · \(name)" }
+ return "Good week: \(percent) % of the weekly limit used"
+ }
+ }
+}
+
+extension PetBrain {
+ /// What the panel header and VoiceOver say at `time`.
+ public func status(at time: PetTime) -> PetStatus {
+ let now = brought(to: time)
+ let playing = now.timeline.segments.last { $0.start <= time.mono }?.behaviour
+ let situation: PetStatus.Situation
+ if case .eat(let meal)? = playing {
+ situation = .eating(points: meal.points)
+ } else {
+ switch now.condition {
+ case .off: situation = .off
+ case .noAccounts: situation = .noAccounts
+ case .hibernating: situation = now.den.isWaiting ? .stirring : .hibernating(wakesAt: now.den.wakesAt)
+ case .asleep: situation = now.nap.isWaiting ? .drowsy : .asleep(wakesAt: now.nap.wakesAt)
+ case .unsure:
+ situation = .unsure(mood: now.unsureMood, newest: now.accounts?.compactMap { $0.reading?.fetchedAt }.max())
+ case .awake(let mood):
+ situation = playing == .eager || playing == .perk(.prompt) ? .eager : .awake(mood: mood)
+ }
+ }
+ return PetStatus(situation: situation, meter: min(max(now.meterValue, 0), 100), lastMeal: now.lastMealWall,
+ lastMealPoints: now.lastMealPoints,
+ pace: now.accounts.map { PetLimits.pace($0, now: time.wall, food: now.food) } ?? 1,
+ moments: now.moments.lines(at: time.wall, timeZone: now.timeZone))
+ }
+}
diff --git a/Sources/BrowKit/Pet/PetTuning.swift b/Sources/BrowKit/Pet/PetTuning.swift
new file mode 100644
index 0000000..f827d43
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetTuning.swift
@@ -0,0 +1,662 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// Every number the pet runs on, in one place and grouped by what it tunes. Units: seconds, points on
+/// screen (pt), percent used (0 to 100) and food points, where one point of food is one point of the
+/// left readout. Angles are in degrees.
+///
+/// Brow's own numbers are not repeated here, so they cannot drift apart: the grey-dot age is
+/// `LimitSnapshot.staleAfter`, the strip and pill sizes are `NotchGeometry`'s, the readouts'
+/// padding, dot and fonts are `EarsMetrics`'s and the tier weights are `RateLimitModel`'s. The one
+/// exception is the outline's two springs (`Hop`), which `NotchPanelController` holds as
+/// `Animation` values that cannot be read back as numbers.
+public enum PetTuning {
+
+ // MARK: - Mechanics
+
+ /// What counts as food.
+ public enum Food {
+ /// Two reset instants this close are the same period. Absorbs the endpoint's `…:59.64`
+ /// beside `:00`, as `LimitWarnings.samePeriod` does; two 5-hour periods are never this close.
+ public static let samePeriod: TimeInterval = 600
+ /// A reading whose reset instant lies more than this before its own `fetchedAt` describes a
+ /// window that had already closed when it was fetched, and is ignored.
+ public static let closedReadingSlack: TimeInterval = 60
+ }
+
+ /// The plate: the queue of food the pet has not been seen eating yet.
+ public enum Plate {
+ /// A meal starts once the plate holds this much (and the pet is visible, awake and free).
+ public static let mealMinimum: Double = 0.5
+ /// A plate this full that gets no more food for `flushAfter` is eaten as a nibble. Less than
+ /// this, never followed by more, is crumbs: dropped, with no animation.
+ public static let flushMinimum: Double = 0.1
+ public static let flushAfter: TimeInterval = 120
+ /// From the start of one meal to the start of the next; food arriving meanwhile waits.
+ public static let mealGap: TimeInterval = 40
+ /// The first meal after this long without one is a full meal; any other is a nibble.
+ public static let fullMealAfter: TimeInterval = 600
+ /// Chews by plate size: under 1.5 points one, under 2.5 two, otherwise three, never more.
+ public static let twoChewsFrom: Double = 1.5
+ public static let threeChewsFrom: Double = 2.5
+ public static let maxChews = 3
+ /// A plate this full is a feast: always a full meal, a bigger swallow, a round belly.
+ public static let feastFrom: Double = 8
+ }
+
+ /// The meal's timing, which the rig hits to one frame. A full meal of three chews, such as
+ /// 41 → 44 % after a quiet while, is 1.98 s.
+ public enum Meal {
+ public static let fullAnticipation: TimeInterval = 0.30
+ public static let fullLunge: TimeInterval = 0.12
+ public static let fullHold: TimeInterval = 0.06
+ public static let fullRecoil: TimeInterval = 0.18
+ public static let fullChew: TimeInterval = 0.32
+ public static let fullSwallow: TimeInterval = 0.36
+ public static let nibbleAnticipation: TimeInterval = 0.10
+ public static let nibbleLunge: TimeInterval = 0.10
+ public static let nibbleHold: TimeInterval = 0
+ public static let nibbleRecoil: TimeInterval = 0.14
+ public static let nibbleChew: TimeInterval = 0.28
+ public static let nibbleSwallow: TimeInterval = 0.24
+ /// A feast's swallow is this much longer: 0.76 s instead of 0.36.
+ public static let feastSwallowExtra: TimeInterval = 0.40
+ /// After a full meal, `satisfied` plays this long; after a feast, `satisfiedFeast`; after a
+ /// nibble, only a lick.
+ public static let satisfied: TimeInterval = 3.0
+ public static let satisfiedFeast: TimeInterval = 4.0
+ public static let lick: TimeInterval = 0.6
+ /// `eat` and `celebrate` may cut `satisfied` only after this much of it.
+ public static let satisfiedMinimum: TimeInterval = 1.5
+ /// A feast leaves the belly this round, easing back to 0 over `feastBellyDecay`.
+ public static let feastBelly: Double = 1.0
+ public static let feastBellyDecay: TimeInterval = 20 * 60
+ /// Crumbs from the food source to the mouth: how many, how big, how opaque (of the source
+ /// readout's dot colour) and how long their arc takes, landing on the snap.
+ public static let crumbsMinimum = 2
+ public static let crumbsMaximum = 4
+ public static let crumbSizeMinimum: Double = 1.0
+ public static let crumbSizeMaximum: Double = 1.5
+ public static let crumbOpacity: Double = 0.8
+ public static let crumbFlight: TimeInterval = 0.35
+ }
+
+ /// The hunger meter. H runs from `minimum` to `maximum`; the meter shows it clamped to 0...100.
+ public enum Hunger {
+ public static let minimum: Double = -50
+ public static let maximum: Double = 100
+ public static let newPet: Double = 30
+ /// While the hunger clock runs, at an even weekly pace (f = 1).
+ public static let perHour: Double = 20
+ /// What one food point takes off H: one point is 18 minutes of hunger, and the break-even
+ /// intake is 20 / 6 = 3.3 points per hour of hunger time.
+ public static let perPoint: Double = 6
+ /// Pace damping: f = clamp(1 − ahead / paceStop, 0, 1). Fifteen points ahead of an even pace
+ /// the pet gets hungry at half speed; thirty ahead, not at all.
+ public static let paceStop: Double = 30
+ /// The weekly window's length, for an even week's pace (`PetLimits.evenPace`).
+ public static let week: TimeInterval = 7 * 86_400
+ /// Mood thresholds, judged on H + pendingDrop: Content above 0, Peckish from 35, Hungry from
+ /// 65. A mood is entered at its threshold on the way up and left `moodHysteresis` below it
+ /// on the way down.
+ public static let contentAbove: Double = 0
+ public static let peckishFrom: Double = 35
+ public static let hungryFrom: Double = 65
+ public static let moodHysteresis: Double = 5
+ }
+
+ /// A limit used up. The 5-hour window puts the pet to sleep and the weekly one into hibernation, by
+ /// the same rules.
+ public enum Limits {
+ /// The effective aggregate at which the readout rounds to `100%`: asleep, or hibernating.
+ public static let enter: Double = 99.5
+ /// Stays asleep (or hibernating) while the aggregate is at least this: a jittery API 99
+ /// after a statusline 100 does not wake it.
+ public static let stay: Double = 97
+ /// The shortest stay before an exit that is not a reset. A reset wakes it whenever it comes.
+ public static let minimumStay: TimeInterval = 60
+ /// After the wake instant, how long it waits drowsy for a fresh reading before it gets up.
+ public static let confirmationWait: TimeInterval = 180
+ /// It falls asleep again no sooner than this after a wake (5-hour window only).
+ public static let sleepAfterWake: TimeInterval = 30
+ /// Drowsy: eyes this open, a slow blink every 3 to 5 s, a yawn at once and every 40 s.
+ public static let drowsyEyes: Double = 0.35
+ public static let drowsyBlinkMinimum: TimeInterval = 3
+ public static let drowsyBlinkMaximum: TimeInterval = 5
+ public static let drowsyYawnEvery: TimeInterval = 40
+ }
+
+ /// Claude Code is working: food is coming.
+ public enum Eager {
+ /// A small bounce every 1.6 to 2.2 s.
+ public static let bounceMinimum: TimeInterval = 1.6
+ public static let bounceMaximum: TimeInterval = 2.2
+ /// Eager lasts this long after the latest prompt; each prompt extends it.
+ public static let afterPrompt: TimeInterval = 90
+ /// At most this long from the first perk; then idle until `cooldown` passes with no prompt.
+ public static let maximum: TimeInterval = 600
+ public static let cooldown: TimeInterval = 60
+ /// At most one perk per this long.
+ public static let reperk: TimeInterval = 60
+ /// The shortest `eager` before it may end.
+ public static let minimum: TimeInterval = 2.0
+ /// Asleep, a prompt makes the pet twitch at most once per this long; it does not wake.
+ public static let sleepTwitchEvery: TimeInterval = 60
+ /// A watched `history.jsonl` that was deleted or renamed is opened again after this long.
+ public static let watcherReopen: TimeInterval = 1
+ }
+
+ /// The pointer.
+ public enum Pointer {
+ /// Noticed within this distance of the eyes, which sit `eyeHeight × scale` above the anchor.
+ public static let notice: Double = 160
+ public static let eyeHeight: Double = 15
+ /// Released beyond this distance, after holding the gaze for `releaseHold`.
+ public static let release: Double = 200
+ public static let releaseHold: TimeInterval = 0.6
+ /// The eyes track critically damped at this rate (rad/s)...
+ public static let eyeOmega: Double = 18
+ /// ...and the head follows once the angle to the pointer passes `headFollowDegrees`.
+ public static let headFollowDegrees: Double = 25
+ public static let headOmega: Double = 9
+ public static let headZeta: Double = 0.9
+ /// Asleep, the pointer moving within this distance makes it twitch, at most once per
+ /// `sleepTwitchEvery`.
+ public static let sleepTwitch: Double = 40
+ public static let sleepTwitchEvery: TimeInterval = 20
+ }
+
+ /// Whether anyone is at the Mac.
+ public enum Presence {
+ /// Input idle this long: `settled`, idle motion at `settledAmplitude`, no rare idles.
+ public static let settled: TimeInterval = 120
+ public static let settledAmplitude: Double = 0.5
+ /// Input idle this long: away. The doze starts, the hunger clock stops (counted from this
+ /// long after the last input), and coming back after it is a welcome back.
+ public static let away: TimeInterval = 600
+ /// The presence check runs this often while the display is awake and the session active.
+ public static let checkEvery: TimeInterval = 5
+ public static let checkTolerance: TimeInterval = 1
+ /// Two checks further apart than this mean the Mac slept and the wake notification was missed.
+ public static let missedCheck: TimeInterval = 30
+ }
+
+ /// No fresh numbers.
+ public enum Stale {
+ /// Every visible reading older than this, or none at all: `unsure`, and the hunger clock stops
+ /// (it also needs one visible reading younger than this to run).
+ public static let unsure: TimeInterval = 600
+ /// Unsure, a head tilt every 20 to 40 s.
+ public static let tiltMinimum: TimeInterval = 20
+ public static let tiltMaximum: TimeInterval = 40
+ }
+
+ /// The den behind the camera.
+ public enum Den {
+ /// No visible account: a peek out of the den every 60 to 120 s (on the pill, a look around).
+ public static let peekMinimum: TimeInterval = 60
+ public static let peekMaximum: TimeInterval = 120
+ /// Hibernating beside, at least this share of the body's area is past the notch's edge.
+ public static let hiddenArea: Double = 0.70
+ /// What stays visible breathes: glow 0.15 to 0.25 on a 6 s cycle, a Zzz from the edge every 6 s.
+ public static let glowMinimum: Double = 0.15
+ public static let glowMaximum: Double = 0.25
+ public static let glowCycle: TimeInterval = 6.0
+ public static let zzzEvery: TimeInterval = 6.0
+ /// Below, hibernating leaves a line of glow this thick along the notch's bottom edge: light
+ /// under the door.
+ public static let doorLight: Double = 1
+ /// On the pill there is no den: hibernation curls into a ball with its glow at this share.
+ public static let pillGlow: Double = 0.30
+ }
+
+ /// The Mac asleep, and clocks that jump.
+ public enum Clock {
+ /// Wall and monotonic deltas between two checks that differ by more than this are a jump.
+ public static let jump: TimeInterval = 5
+ /// After a night asleep, what plays at the first visible frame (wake, catch-up meal, one
+ /// moment) never takes much more than this.
+ public static let morningAtMost: TimeInterval = 10
+ }
+
+ /// The pet's file, `pet.json`.
+ public enum Store {
+ public static let fileName = "pet.json"
+ public static let version = 1
+ /// Kept: points eaten for the last 60 local dates, the last 16 good weeks.
+ public static let keptDays = 60
+ public static let keptGoodWeeks = 16
+ /// Written at most this often after a meal, and this often while running.
+ public static let afterMealAtMost: TimeInterval = 60
+ public static let every: TimeInterval = 10 * 60
+ /// The file stays under this size.
+ public static let maximumBytes = 8 * 1024
+ }
+
+ /// Moments. Nothing random, nothing lost.
+ public enum Moments {
+ /// A 5-hour period that closes and frees this many points of the left readout (its high-water
+ /// mark times the account's share) is a fresh window, at most once per `freshWindowEvery`.
+ public static let freshWindowFreed: Double = 20
+ public static let freshWindowEvery: TimeInterval = 30 * 60
+ /// A local day with this many food points eaten is a fed day. A streak survives `restDays`
+ /// unfed days in a row; the next one ends it, silently.
+ public static let fedDay: Double = 10
+ public static let restDays = 2
+ public static let milestones = [3, 7, 14, 30, 60, 100]
+ /// An account's weekly period that closes with its high-water mark at this or more is a good
+ /// week.
+ public static let goodWeek: Double = 70
+ /// The panel's good-week line stays this long, and there is at most one good-week celebration
+ /// per `celebrationEvery`.
+ public static let goodWeekLine: TimeInterval = 86_400
+ public static let celebrationEvery: TimeInterval = 86_400
+ }
+
+ // MARK: - The state machine
+
+ /// How long each behaviour plays when nothing cuts it. `PetBehaviour.duration` reads these; the
+ /// meal's own timing is `Meal`'s.
+ public enum Behaviour {
+ /// Peeks out, looks around, notices the readout, steps out and sits; with no accounts it
+ /// peeks, looks at the `—` and goes back in.
+ public static let intro: TimeInterval = 3.6
+ public static let introNoAccounts: TimeInterval = 2.2
+ /// After a prompt, and the longer one at the readout when fresh numbers end `unsureIdle`.
+ public static let perk: TimeInterval = 0.5
+ public static let freshDataPerk: TimeInterval = 0.8
+ public static let celebrateStreak: TimeInterval = 2.4
+ public static let celebrateGoodWeek: TimeInterval = 2.8
+ public static let freshWindow: TimeInterval = 1.0
+ public static let fallAsleepYawn: TimeInterval = 1.0
+ public static let fallAsleepDroop: TimeInterval = 0.8
+ public static let fallAsleepCurl: TimeInterval = 0.8
+ public static let fallAsleep = fallAsleepYawn + fallAsleepDroop + fallAsleepCurl
+ public static let wakeStir: TimeInterval = 0.6
+ public static let wakeStretch: TimeInterval = 1.6
+ public static let wakeShake: TimeInterval = 0.5
+ public static let wake = wakeStir + wakeStretch + wakeShake
+ public static let rollOver: TimeInterval = 1.0
+ /// Into the den at the weekly limit. With no visible account it plays only the second half,
+ /// a retreat.
+ public static let enterDen: TimeInterval = 3.2
+ public static let retreat = enterDen / 2
+ public static let leaveDenPeek: TimeInterval = 0.8
+ public static let leaveDenOut: TimeInterval = 0.8
+ public static let leaveDenStretch: TimeInterval = 1.6
+ public static let leaveDenShake: TimeInterval = 0.5
+ public static let leaveDen = leaveDenPeek + leaveDenOut + leaveDenStretch + leaveDenShake
+ /// Out of the den without the stretch and the shake: an account became visible again.
+ public static let leaveDenShort = leaveDenPeek + leaveDenOut
+ /// One look out of the den during `denWait`.
+ public static let peek: TimeInterval = 2.2
+ public static let dozeIn: TimeInterval = 2.0
+ public static let welcomeBack: TimeInterval = 1.8
+ /// `showPet` turned off fades the pet out over this; turned on, it fades in over `fadeIn`.
+ public static let fadeOut: TimeInterval = 0.2
+ public static let fadeIn: TimeInterval = 0.3
+ /// The pointer noticed: eyes widen, the head turns.
+ public static let notice: TimeInterval = 0.25
+ }
+
+ /// Boops, in the panel only.
+ public enum Boop {
+ public static let boop: TimeInterval = 0.6
+ /// No new boop until this long after the last one.
+ public static let spacing: TimeInterval = 0.8
+ /// This many boops within `giggleWindow` play a giggle, and boops are then ignored for
+ /// `giggleQuiet`.
+ public static let giggleCount = 5
+ public static let giggleWindow: TimeInterval = 6
+ public static let giggle: TimeInterval = 1.2
+ public static let giggleQuiet: TimeInterval = 10
+ /// Booped asleep, it opens one eye and closes it; hibernating, its glow pulses once.
+ public static let sleepyEye: TimeInterval = 0.9
+ public static let glowPulse: TimeInterval = 0.6
+ /// A click within this of the body's box is a boop.
+ public static let hitSlop: Double = 4
+ }
+
+ // MARK: - Where it lives
+
+ /// The readouts the pet must not cover. Widths in `EarsMetrics.readoutFont` (tabular digits, so
+ /// every reading of a class is one width), as SwiftUI lays them out at 8x: to the eighth of a point.
+ /// On a 2x screen each text's frame rounds up to the half point (`100%` takes 35.5), which moves a
+ /// readout that hugs its right edge, as the pill's right one does, up to 0.375 pt to the left.
+ public enum Readout {
+ /// `—`, `0%`, `41%` and `100%`.
+ public static let dashWidth: Double = 12.625
+ public static let oneDigitWidth: Double = 18.875
+ public static let twoDigitWidth: Double = 27.0
+ public static let threeDigitWidth: Double = 35.125
+ /// The model initial `F` in `EarsMetrics.initialFont`; `EarsMetrics.initialSpacing` follows it.
+ public static let initialWidth: Double = 6.125
+ /// The pet's zone keeps this much black between it and the readout.
+ public static let gap: Double = 4
+ }
+
+ /// The strip's zones. Offsets are from the notch (`frames.notch`) and from the bottom of the room
+ /// the pet stands in, so they hold on any notched Mac.
+ public enum Zone {
+ /// The body's rest envelope at scale 1, centred on the anchor and standing on it. The drawing
+ /// may change the silhouette, not this.
+ public static let restWidth: Double = 24
+ public static let restHeight: Double = 22
+ /// Extremes every pose is clamped to: squash keeps the area (`sx = 1 / sy`), so the width
+ /// reaches `maxWidth` × rest at the deepest squash.
+ public static let minimumSquash: Double = 0.80
+ public static let maxWidth: Double = 1.25
+ public static let maxLeanDegrees: Double = 14
+ /// Every zone stops this far from its room's top and bottom edges (y 1 to 31 beside).
+ public static let edgeInset: Double = 1
+ /// Beside: the anchor sits `besideAnchorFromNotch` left of the notch, the feet
+ /// `besideFeetAboveBottom` above the strip's bottom; scale 1.
+ public static let besideAnchorFromNotch: Double = 20
+ public static let besideFeetAboveBottom: Double = 3.5
+ public static let besideScale: Double = 1.0
+ public static let besideMaxStretch: Double = 1.20
+ /// Below: the zone starts `belowLeftFromNotch` right of the notch's left edge, clear of the
+ /// strip's bottom-left corner at the feet; the anchor sits `belowAnchorFromNotch` right of
+ /// that edge, the feet `belowFeetAboveBottom` above the strip's bottom. The one layout where
+ /// the pet is under 18 pt tall, because the strip is 22 pt tall; stretch is held lower so the
+ /// head stays under the notch.
+ public static let belowLeftFromNotch: Double = 5
+ public static let belowAnchorFromNotch: Double = 20.5
+ public static let belowFeetAboveBottom: Double = 2.5
+ public static let belowScale: Double = 0.80
+ public static let belowMaxStretch: Double = 1.10
+ /// The pill: the anchor at its centre, the feet `pillFeetAboveBottom` above its bottom.
+ public static let pillFeetAboveBottom: Double = 3
+ public static let pillScale: Double = 0.82
+ public static let pillMaxStretch: Double = 1.15
+ /// Asleep in the open, lying at the anchor, the pet is at most this tall; its Zzz rise toward
+ /// `zzzCeiling` below the screen's top edge.
+ public static let asleepMaxHeight: Double = 14
+ public static let zzzCeiling: Double = 2
+ }
+
+ /// The panel header: the first row of `PanelView` while the pet is on.
+ public enum Panel {
+ /// The row: the stage, the words, the meter.
+ public static let rowHeight: Double = 52
+ public static let rowSpacing: Double = 12
+ /// `NotchPanelController.estimatedExpandedHeight` grows by the row plus one stack spacing.
+ public static let addedHeight: Double = rowHeight + 10
+ /// The stage the pet layer draws in, at the panel's leading padding.
+ public static let stageWidth: Double = 64
+ public static let stageHeight: Double = 52
+ /// The anchor in the stage: (46, top + 48) on the 14", scale 2.
+ public static let anchorFromStageLeft: Double = 32
+ public static let anchorFromStageTop: Double = 48
+ public static let scale: Double = 2.0
+ /// Particles may rise into the black above the stage up to this far below the window's top
+ /// edge (y 6 to 40 is clear of the notch, which starts at x 143.5).
+ public static let particleCeiling: Double = 6
+ /// The words: state word 13 pt semibold, detail and moment line 10 pt, 2 pt apart. The spacer
+ /// before the meter may close up: the row's spacing on both sides of it still keeps the words
+ /// 24 pt from the meter, and the words need the room on a panel with no notch, where the widest
+ /// detail, `Weekly limit used up · wakes 30 May 04:44`, takes 209 of their 212 pt.
+ public static let wordSize: Double = 13
+ public static let detailSize: Double = 10
+ public static let textSpacing: Double = 2
+ public static let spacerMinimum: Double = 0
+ /// The meter: a 120 × 6 pt capsule under its 10 pt "Hunger" label, 4 pt apart. The track is
+ /// white at 12 %, the fill the pet's accent at 85 % (never the severity colours), the ticks at
+ /// 35 and 65 white at 30 %, 1 pt wide. A change animates over 0.6 s; no number is shown.
+ public static let meterWidth: Double = 120
+ public static let meterHeight: Double = 6
+ public static let meterSpacing: Double = 4
+ public static let meterTrackOpacity: Double = 0.12
+ public static let meterFillOpacity: Double = 0.85
+ public static let meterTicks: [Double] = [35, 65]
+ public static let tickWidth: Double = 1
+ public static let tickOpacity: Double = 0.30
+ public static let meterAnimation: TimeInterval = 0.6
+ }
+
+ /// The words' thresholds (`PetText`).
+ public enum Words {
+ /// A meal within this long reads `Ate 3 % · 2 min ago`; later, `Last meal 3 h ago`.
+ public static let recentMeal: TimeInterval = 10 * 60
+ /// A meal below this many points reads `<1 %`.
+ public static let lessThanOne: Double = 0.5
+ /// At or below this pace factor f, the detail adds `· well fed this week`.
+ public static let wellFedPace: Double = 0.5
+ }
+
+ /// The hop between the strip and the panel. The flight takes
+ /// T = clamp(`flightBase` + `flightPerPoint` · distance, `flightMinimum`, `flightMaximum`).
+ public enum Hop {
+ /// Expand: a crouch, a takeoff stretch, a ballistic flight, a landing squash, a spring.
+ public static let crouch: TimeInterval = 0.08
+ public static let crouchSquash: Double = 0.88
+ public static let takeoffStretch: Double = 1.06
+ public static let flightBase: TimeInterval = 0.22
+ public static let flightPerPoint: TimeInterval = 0.0006
+ public static let flightMinimum: TimeInterval = 0.26
+ public static let flightMaximum: TimeInterval = 0.40
+ /// Beside, the expand's apex is this far above the strip anchor. Below and on the pill, which
+ /// start at the top of their room, it steps off with no upward speed.
+ public static let expandApexBeside: Double = 3
+ /// Speed stretch along the velocity:
+ /// 1 + min(`speedStretchMaximum`, speed / `speedStretchSpeed`), speed in pt/s.
+ public static let speedStretchMaximum: Double = 0.12
+ public static let speedStretchSpeed: Double = 2500
+ public static let landingSquash: Double = 0.86
+ public static let landing: TimeInterval = 0.08
+ public static let settleOmega: Double = 28
+ public static let settleZeta: Double = 0.45
+ /// In flight the head, at that instant's scale and stretch, never goes above this line (the
+ /// screen's top edge) nor into the notch.
+ public static let topClearance: Double = 0.5
+ /// Collapse: x eases out over `collapseX`, y flies over `collapseY` to an apex 2 pt above the
+ /// strip anchor beside and 1 pt below and on the pill, then a shorter landing squash.
+ public static let collapseX: TimeInterval = 0.16
+ public static let collapseY: TimeInterval = 0.22
+ public static let collapseApexBeside: Double = 2
+ public static let collapseApexOther: Double = 1
+ public static let collapseLanding: TimeInterval = 0.06
+ /// Asleep, drowsy or hibernating there is no hop: a glide along the straight line, eased in and
+ /// out.
+ public static let glide: TimeInterval = 0.34
+ /// The hop overlay lasts 0.26 to 0.80 s.
+ public static let overlayMinimum: TimeInterval = 0.26
+ public static let overlayMaximum: TimeInterval = 0.80
+ /// Reduce Motion: the strip pet fades out while the header pet fades in, overlapping a little.
+ public static let reducedFadeOut: TimeInterval = 0.15
+ public static let reducedFadeIn: TimeInterval = 0.15
+ public static let reducedOverlap: TimeInterval = 0.05
+ /// A layout change (the Ears picker, another display): out at the old anchor, in at the new.
+ public static let relayoutFadeOut: TimeInterval = 0.15
+ public static let relayoutFadeIn: TimeInterval = 0.20
+ /// Readouts hidden, the pet appears only in the panel, fading in with it.
+ public static let panelOnlyFade: TimeInterval = 0.2
+ /// Brow's outline springs (`NotchPanelController.openAnimation` and `closeAnimation`), which
+ /// the hop is timed against. Mirrored, because an `Animation` cannot be read back.
+ public static let openResponse: Double = 0.32
+ public static let openDamping: Double = 0.86
+ public static let closeResponse: Double = 0.26
+ public static let closeDamping: Double = 1.0
+ }
+
+ /// The pet's own canvas, which moves with it; its size follows the scale during the hop.
+ public enum Canvas {
+ public static let width: Double = 64
+ public static let height: Double = 44
+ public static let panelWidth: Double = 128
+ public static let panelHeight: Double = 112
+ }
+
+ // MARK: - The animation runtime
+
+ /// Idle life: layered cycles of periods that never line up, with phases from the seed.
+ public enum Idle {
+ /// Breathing scales y by ±1.5 %; sway leans ±1.2°; weight shift moves ±0.4 pt.
+ public static let breathPeriod: TimeInterval = 3.7
+ public static let breathAmplitude: Double = 0.015
+ public static let swayPeriod: TimeInterval = 7.3
+ public static let swayDegrees: Double = 1.2
+ public static let shiftPeriod: TimeInterval = 11.9
+ public static let shiftAmplitude: Double = 0.4
+ public static let appendagePeriod: TimeInterval = 5.3
+ /// Blinks come at intervals of `blinkBase` + `blinkSpread` · hash, a pet's rate set ±15 % by
+ /// its seed. Each takes 0.14 s (close 0.05, hold 0.02, open 0.07); 12 % are double.
+ public static let blinkBase: TimeInterval = 2.4
+ public static let blinkSpread: TimeInterval = 4.1
+ public static let blinkRateSpread: Double = 0.15
+ public static let blinkClose: TimeInterval = 0.05
+ public static let blinkHold: TimeInterval = 0.02
+ public static let blinkOpen: TimeInterval = 0.07
+ public static let blink = blinkClose + blinkHold + blinkOpen
+ public static let doubleBlinkShare: Double = 0.12
+ /// Glances at intervals of `glanceBase` + `glanceSpread` · hash, to the food source, the den,
+ /// the viewer or down; the eyes lead the head by `eyeLead`.
+ public static let glanceBase: TimeInterval = 4
+ public static let glanceSpread: TimeInterval = 7
+ public static let eyeLead: TimeInterval = 0.06
+ /// A rare idle (scratch, small yawn, look around, a dust mote) at intervals of `rareBase` +
+ /// `rareSpread` · hash; peckish and hungry add a tummy rumble every 40 to 70 s.
+ public static let rareBase: TimeInterval = 45
+ public static let rareSpread: TimeInterval = 75
+ public static let rumbleMinimum: TimeInterval = 40
+ public static let rumbleMaximum: TimeInterval = 70
+ /// Moods shift posture and rates: Full breathes slower and rounder; Hungry leans forward,
+ /// lower, with bigger eyes and three times the glances at the food source; Eager stands taller.
+ public static let fullBreathPeriod: TimeInterval = 4.2
+ public static let hungryLeanDegrees: Double = 3
+ public static let hungryGlances: Double = 3
+ public static let eagerHeight: Double = 1.04
+ /// Moves ease in and out (cubic); pops ease out with this overshoot ("back"). No linear tweens.
+ public static let popOvershoot: Double = 1.2
+ }
+
+ /// Secondary motion. Idle-driven responses are the springs' steady state in closed form; each
+ /// action adds a transient integrated at `step` from its start until `tail` after its end.
+ public enum Secondary {
+ /// Appendages: Glim's brows and arms.
+ public static let appendageOmega: Double = 22
+ public static let appendageZeta: Double = 0.32
+ public static let bellyOmega: Double = 30
+ public static let bellyZeta: Double = 0.4
+ public static let step: TimeInterval = 1.0 / 240
+ public static let tail: TimeInterval = 2
+ }
+
+ /// Particles, each spawned on the timeline with hashed parameters.
+ public enum Particles {
+ public static let maximumAlive = 12
+ /// Asleep, a Zzz (drawn strokes, not text) every 1.8 s, rising 10 pt over 2.6 s with ±2 pt of
+ /// drift, three alive at most.
+ public static let zzzEvery: TimeInterval = 1.8
+ public static let zzzRise: Double = 10
+ public static let zzzLife: TimeInterval = 2.6
+ public static let zzzDrift: Double = 2
+ public static let zzzAlive = 3
+ /// Celebrations: 4 to 6 four-point stars, 1.5 to 3 pt, each twinkling 0.6 s.
+ public static let sparklesMinimum = 4
+ public static let sparklesMaximum = 6
+ public static let sparkleSizeMinimum: Double = 1.5
+ public static let sparkleSizeMaximum: Double = 3
+ public static let sparkleTwinkle: TimeInterval = 0.6
+ }
+
+ /// Light, made of gradients only so that a frame stays cheap: no `.blur`, no `.shadow`, no
+ /// offscreen pass per frame.
+ public enum Light {
+ /// The rim: a 0.75 pt stroke at scale 1, warm white at 35 %.
+ public static let rimWidth: Double = 0.75
+ public static let rimOpacity: Double = 0.35
+ /// The glow: a radial gradient of this radius, the accent at 10 to 16 %, times the mood's
+ /// factor. Where it reaches the readout it is under `glowAtReadout`.
+ public static let glowRadius: Double = 18
+ public static let glowMinimum: Double = 0.10
+ public static let glowMaximum: Double = 0.16
+ public static let glowEager: Double = 1.2
+ public static let glowAsleep: Double = 0.6
+ public static let glowHibernating: Double = 0.3
+ public static let glowAtReadout: Double = 0.03
+ /// A tint of the food source's dot colour on the near rim.
+ public static let dotTint: Double = 0.03
+ /// Increase Contrast: the rim at 60 %, and a 0.5 pt outline at 40 %. Reduce Transparency
+ /// drops the glow and keeps the rim.
+ public static let contrastRimOpacity: Double = 0.60
+ public static let contrastOutlineWidth: Double = 0.5
+ public static let contrastOutlineOpacity: Double = 0.40
+ }
+
+ /// What each frame class may move per frame at its own rate. Anything faster (the snap, the hop,
+ /// the shake) runs in the fast class; blinks are exempt (classic blinks on twos).
+ public enum Speed {
+ /// Ambient motion: at most 0.5 pt per frame, so idle layers stay under 15 pt/s at 30 fps.
+ public static let ambientPerFrame: Double = 0.5
+ /// Actions: at most 1 pt per frame at 60 fps.
+ public static let actionPerFrame: Double = 1.0
+ }
+
+ /// Frame rates per class, and the caps applied in order.
+ public enum Frames {
+ public static let still: Double = 10
+ public static let sleep: Double = 15
+ public static let idle: Double = 30
+ public static let action: Double = 60
+ /// The pointer moved within `Pointer.notice` this recently: at least the action rate.
+ public static let pointerRecent: TimeInterval = 0.5
+ /// On battery, `fast` is held to this.
+ public static let batteryFast: Double = 60
+ /// Low Power Mode: idle 15, sleep 10, action and fast 30.
+ public static let lowPowerIdle: Double = 15
+ public static let lowPowerSleep: Double = 10
+ public static let lowPowerAction: Double = 30
+ /// Reduce Motion: idle 10 (breathing only), action and fast 30.
+ public static let reducedIdle: Double = 10
+ public static let reducedAction: Double = 30
+ /// Input idle 120 to 600 s: at most this.
+ public static let settledAtMost: Double = 10
+ }
+
+ /// Reduce Motion.
+ public enum Reduced {
+ /// No squash or stretch beyond this.
+ public static let squash: Double = 0.02
+ /// A meal is a crossfade to a chewing pose over `mealFade`, one chew per point up to three at
+ /// `chew` each, and a crossfade back.
+ public static let mealFade: TimeInterval = 0.15
+ public static let chew: TimeInterval = 0.4
+ /// Idle is breathing at this share of its amplitude, and blinks.
+ public static let breath: Double = 0.5
+ }
+
+ /// The CPU budget on the reference Mac (release build, AC power), and how it is measured.
+ public enum Budget {
+ /// `PetRig.pose`: mean and p99.
+ public static let poseMean: TimeInterval = 0.05e-3
+ public static let poseP99: TimeInterval = 0.15e-3
+ /// Main thread per frame (TimelineView body and Canvas recording): mean and p99.
+ public static let frameMean: TimeInterval = 0.40e-3
+ public static let frameP99: TimeInterval = 1.0e-3
+ /// Process CPU over 60 s, pet on minus pet off, in percent of one core.
+ public static let idleCPU: Double = 1.5
+ public static let asleepCPU: Double = 1.0
+ public static let hibernatingCPU: Double = 0.5
+ public static let pausedCPU: Double = 0.05
+ /// Share of a working day at 60 fps or more.
+ public static let fastShare: Double = 0.05
+ /// `testPoseEvaluationCost`: this many poses over a scripted timeline this long that visits
+ /// every behaviour; `testCanvasCost`: this many strip-size frames at `renderScale`.
+ public static let poseSamples = 100_000
+ public static let scriptLength: TimeInterval = 10 * 60
+ public static let canvasFrames = 600
+ public static let renderScale: Double = 2
+ /// `-petPerfLog` writes one line this often.
+ public static let logEvery: TimeInterval = 60
+ }
+}
+
+extension PetTuning.Behaviour {
+ /// A streak milestone's second hop is a twirl, the face going round the back: from just before its
+ /// crouch to just after its landing it moves far beyond an action's 1 pt a frame, so this part of
+ /// `celebrate(.streak)` is in the fast class. Times into the celebration.
+ public static let streakTwirl: Range = 0.98..<1.6
+}
diff --git a/Sources/BrowKit/Pet/PetTypes.swift b/Sources/BrowKit/Pet/PetTypes.swift
new file mode 100644
index 0000000..d9ae9b8
--- /dev/null
+++ b/Sources/BrowKit/Pet/PetTypes.swift
@@ -0,0 +1,436 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+// The words the pet's two halves share. The brain (`PetBrain`) hears events and writes a timeline of
+// behaviours; the rig (`PetRig`) reads a timeline at an instant and poses the pet. The timeline is all
+// they share, and nothing in this file decides anything.
+
+// MARK: - Time
+
+/// One instant on both clocks the pet uses. `mono` is `ProcessInfo.systemUptime`: it stops while the
+/// Mac sleeps and never jumps, so hunger and animation run on it. `wall` is for what the server dates
+/// (reset instants) and for calendar days. The controller reads both once per event and hands them in;
+/// nothing inside the brain or the rig asks the system what time it is.
+public struct PetTime: Sendable, Equatable {
+ public var wall: Date
+ public var mono: TimeInterval
+
+ public init(wall: Date, mono: TimeInterval) {
+ self.wall = wall
+ self.mono = mono
+ }
+
+ /// The same instant `seconds` later on both clocks: a deadline worked out from either clock.
+ public func adding(_ seconds: TimeInterval) -> PetTime {
+ PetTime(wall: wall.addingTimeInterval(seconds), mono: mono + seconds)
+ }
+}
+
+// MARK: - What the brain hears
+
+/// Everything the brain is told: by `PetController` in the app, by a `PetScript` in tests. The
+/// controller sends the whole state once at start (`showPet`, `motion`, `timeZone`, `presence`,
+/// `visible`, `readings`) and then each change, every event with its own `PetTime`.
+public enum PetEvent: Sendable, Equatable {
+ /// The visible accounts in panel order, read one main-actor turn after each `LimitsStore` publish.
+ /// Hidden accounts are not in it, so they never feed or wake the pet.
+ case readings([PetAccount])
+ /// A visible account's `history.jsonl`, or one of its alias dirs', was written: a prompt.
+ case prompt
+ /// The person at the Mac, from the 5 s presence check and every display, lock and session
+ /// notification.
+ case presence(PetPresence)
+ /// `NSWorkspace.willSleepNotification`: away at once.
+ case systemSleep
+ /// `NSWorkspace.didWakeNotification`. Its time shows the gap: `wall` moved on, `mono` did not.
+ case systemWake
+ /// `NSSystemClockDidChange`: the wall clock was set.
+ case clockChanged
+ /// The time zone fed days are dated in: at start and on `NSSystemTimeZoneDidChange`. The brain
+ /// keeps no `Calendar.autoupdatingCurrent` of its own, so a script can move the zone.
+ case timeZone(TimeZone)
+ /// Whether anyone can see the pet: the panel window is on screen and not occluded, the display is
+ /// awake, the screen unlocked, the session active, and either the strip shows the pet (readouts on)
+ /// or the panel is open. Meals, moments and transitions wait for it.
+ case visible(Bool)
+ /// Settings › General › Show the pet.
+ case showPet(Bool)
+ /// Reduce Motion and the other display options, at start and on each change. The brain needs them
+ /// because Reduce Motion's meal is timed differently.
+ case motion(PetMotion)
+ /// The pointer moved; `distance` is from the pet's eyes (`PetTuning.Pointer.eyeHeight` × scale above
+ /// the anchor of the zone it is in) to the pointer, in points.
+ case pointer(distance: Double)
+ /// A click on the pet in the panel, or VoiceOver's "Pet" action.
+ case boop
+ /// The instant `PetBrain.nextDeadline(after:)` asked for has come.
+ case tick
+ /// Script-only: sets H, so a test can make the pet hungry without hours of script.
+ case debugHunger(Double)
+ /// Pins the condition for a measurement, `-petForceState idle|asleep|hibernating`, or in a script;
+ /// nil lets the data decide again.
+ case forceState(PetForcedState?)
+}
+
+/// One visible account as the pet sees it, built by the controller from a `LimitsStore` row: `id` is
+/// `organizationUuid` and `weight` is `RateLimitModel.weight(forTier:)` (20, 5, 1; unknown 1). The
+/// model weekly window (Fable, Opus) is left out on purpose: it never feeds the pet or puts it to
+/// sleep, since other models still work when it is used up.
+public struct PetAccount: Sendable, Equatable {
+ public var id: String
+ public var weight: Double
+ /// nil: the account is visible but has no reading yet (the readout's `—`).
+ public var reading: PetReading?
+}
+
+/// One account's last reading: its snapshot's 5-hour and weekly windows.
+public struct PetReading: Sendable, Equatable {
+ /// `LimitSnapshot.fetchedAt`.
+ public var fetchedAt: Date
+ /// nil: the window has not started (0 % used, no reset instant).
+ public var fiveHour: PetWindow?
+ public var sevenDay: PetWindow?
+}
+
+/// One window of one reading.
+public struct PetWindow: Sendable, Equatable {
+ /// `usedPercentage`, 0 to 100.
+ public var used: Double
+ /// `Formatting.resetInstant(resetsAt)`, to the minute as the panel and `LimitWarnings` round it;
+ /// nil for a reading that carries no date (it counts toward the aggregate, never as food).
+ public var resetAt: Date?
+}
+
+/// The person at the Mac. Present means the display awake, the screen unlocked, the session active, and
+/// input within `PetTuning.Presence.away`.
+public struct PetPresence: Sendable, Equatable {
+ /// From `screensDidSleepNotification` until `screensDidWakeNotification`.
+ public var displayAsleep = false
+ /// From `com.apple.screenIsLocked` until `com.apple.screenIsUnlocked`.
+ public var screenLocked = false
+ /// From `sessionDidResignActiveNotification` (fast user switching) until it is active again.
+ public var sessionInactive = false
+ /// Seconds since the last input of any kind:
+ /// `CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: CGEventType(rawValue: ~0)!)`.
+ public var inputIdle: TimeInterval = 0
+}
+
+/// What `-petForceState` can pin for a measurement.
+public enum PetForcedState: String, Sendable, CaseIterable {
+ case idle, asleep, hibernating
+}
+
+// MARK: - Conditions and moods
+
+/// What the hunger meter means to the pet: thresholds 0, 35 and 65 on H + pendingDrop, each entered at
+/// its threshold on the way up and left 5 points below it on the way down.
+public enum PetMood: String, Sendable, Hashable, CaseIterable {
+ /// Rounder, breathing slower.
+ case full
+ case content
+ case peckish
+ /// Hopeful, never sad, because hunger is a nudge and never a reproach: big eyes on the number, a
+ /// lick, a tummy rumble.
+ case hungry
+}
+
+/// What the data says the pet's state is, highest first. It is re-evaluated on every brain update, and
+/// the behaviours follow from it.
+public enum PetCondition: Sendable, Equatable {
+ /// `showPet` is off. No hunger, no frames.
+ case off
+ /// No visible account. In the den.
+ case noAccounts
+ /// The effective weekly aggregate reached 99.5 and has not dropped below 97.
+ case hibernating
+ /// The same for the 5-hour aggregate.
+ case asleep
+ /// No reading yet, or every visible reading is older than 600 s.
+ case unsure
+ /// Otherwise. The hunger clock runs while someone is present.
+ case awake(PetMood)
+}
+
+// MARK: - Behaviours
+
+/// What the pet does, which the rig plays: every behaviour, with what the rig needs to play it.
+/// Durations are `duration`'s; which behaviour follows which is the brain's.
+public enum PetBehaviour: Sendable, Hashable {
+ /// A new pet's first visibility with data loaded: it peeks out of the den, looks around, notices
+ /// the readout, steps out and sits (3.6 s). With no accounts it peeks, looks at the `—` and goes
+ /// back in (2.2 s).
+ case intro(noAccounts: Bool)
+ /// Awake with nothing to do: breathing, glances, blinks, rare idles, the posture of its mood.
+ case idle(PetMood)
+ /// Input idle for 120 to 600 s: the mood's idle at half amplitude, no rare idles.
+ case settled(PetMood)
+ /// Sits up, brows up, eyes on the food source.
+ case perk(Perk)
+ /// The perk's loop while Claude Code works: taller, a small bounce every 1.6 to 2.2 s, eyes on
+ /// the food source. Over the hungry posture too: the most hopeful face it has.
+ case eager
+ /// A meal: anticipation, the bite, the chews, the swallow.
+ case eat(PetMeal)
+ /// After a meal: 3.0 s, 4.0 after a feast; after a nibble, a 0.6 s lick.
+ case satisfied(Satisfaction)
+ /// A click on the pet in the panel: 0.6 s, or the 1.2 s giggle after five boops in 6 s.
+ case boop(giggle: Bool)
+ /// A streak milestone (2.4 s) or a good week (2.8 s).
+ case celebrate(Celebration)
+ /// A 5-hour period closed and freed 20 points or more: it looks at the readout and hops once,
+ /// 1.0 s.
+ case freshWindow
+ /// Stuffed: a yawn, a droop, a curl, 2.6 s.
+ case fallAsleep
+ /// Asleep at the 5-hour limit, lying at the anchor at most 14 pt tall, Zzz rising.
+ case asleep
+ /// The reset instant passed: sitting up, eyes at 0.35, slow blinks and yawns, waiting up to 180 s
+ /// for fresh numbers.
+ case drowsy
+ /// The weekly reset passed: still in the den, it stirs while it waits up to 180 s for fresh
+ /// numbers, its glow brightening and an eye at the edge. The den's own `drowsy`: a case of
+ /// its own, so the rig never has to guess from the segment before it whether the pet is in the
+ /// open or in the den.
+ case stirring
+ /// A stir, a stretch, a shake, 2.7 s.
+ case wake
+ /// A fresh reading in `drowsy` still says full: back to sleep in 1.0 s, no yawn.
+ case rollOver
+ /// Into the den at the weekly limit, 3.2 s; `retreat` is its second half, 1.6 s, when no account
+ /// is visible any more.
+ case enterDen(retreat: Bool)
+ /// In the den at the weekly limit: most of the body past the edge, and what still shows breathes
+ /// with a dim glow. On the pill, curled into a ball.
+ case hibernating
+ /// Out of the den: a peek, out, a stretch, a shake, 3.7 s; without the stretch and the shake,
+ /// 1.6 s, when an account becomes visible again.
+ case leaveDen(stretch: Bool)
+ /// No visible account: in the den, between peeks. On the pill, curled and dim.
+ case denWait
+ /// One 2.2 s look out of the den during `denWait`; on the pill, a look around. A segment of its
+ /// own, so that the frame policy and the rig both see where it starts and ends.
+ case peek
+ /// Every reading older than 600 s, or none yet: it looks at the grey dot (or the `—`) with a head
+ /// tilt every 20 to 40 s, in the last mood it had.
+ case unsureIdle(PetMood)
+ /// Away for 600 s: 2.0 s of settling into a doze.
+ case dozeIn
+ /// Away: nothing is drawn.
+ case doze
+ /// Back after 600 s or more away: a blink and a small stretch, 1.8 s.
+ case welcomeBack
+ /// `showPet` turned off: a 0.2 s fade, then nothing. Whatever segment follows fades in over 0.3 s.
+ case off
+
+ /// What a perk answers: a prompt (0.5 s), or fresh numbers ending `unsureIdle` (0.8 s, at the readout).
+ public enum Perk: Sendable, Hashable {
+ case prompt, freshData
+ }
+
+ /// Which meal `satisfied` follows.
+ public enum Satisfaction: Sendable, Hashable {
+ case meal, feast, lick
+ }
+
+ /// What is being celebrated.
+ public enum Celebration: Sendable, Hashable {
+ /// A fed-day milestone: 3, 7, 14, 30, 60 or 100 days.
+ case streak(days: Int)
+ case goodWeek
+ }
+}
+
+/// One meal as the brain planned it.
+public struct PetMeal: Sendable, Hashable {
+ public enum Size: Sendable, Hashable {
+ /// The first meal after 600 s without one, and every feast.
+ case full
+ /// Any other meal, and every flush.
+ case nibble
+ }
+
+ public var size: Size
+ /// 1 to 3: under 1.5 points one, under 2.5 two, otherwise three. Under Reduce Motion, one per
+ /// point, up to three.
+ public var chews: Int
+ /// The plate held 8 points or more: always full, a longer swallow, a round belly for 20 minutes.
+ public var feast: Bool
+ /// What the plate held, in food points: the panel's "Ate 3 %".
+ public var points: Double
+ /// Planned as Reduce Motion's meal: a crossfade to a chewing pose, the chews, a crossfade back.
+ /// Stamped when it is planned, so a setting changed mid-meal cannot change its length.
+ public var reduceMotion: Bool
+
+ /// From the meal's start to the swallow's: where the meter's drop lands. Under Reduce Motion, the
+ /// start of the crossfade back.
+ public var swallowStart: TimeInterval {
+ typealias M = PetTuning.Meal
+ if reduceMotion { return PetTuning.Reduced.mealFade + Double(chews) * PetTuning.Reduced.chew }
+ switch size {
+ case .full:
+ return M.fullAnticipation + M.fullLunge + M.fullHold + M.fullRecoil + Double(chews) * M.fullChew
+ case .nibble:
+ return M.nibbleAnticipation + M.nibbleLunge + M.nibbleHold + M.nibbleRecoil
+ + Double(chews) * M.nibbleChew
+ }
+ }
+
+ /// The whole meal, anticipation to the end of the swallow: 1.34, 1.66 or 1.98 s full, 0.86, 1.14
+ /// or 1.42 s as a nibble, 2.38 s for a three-chew feast.
+ public var duration: TimeInterval {
+ typealias M = PetTuning.Meal
+ if reduceMotion { return swallowStart + PetTuning.Reduced.mealFade }
+ switch size {
+ case .full: return swallowStart + M.fullSwallow + (feast ? M.feastSwallowExtra : 0)
+ case .nibble: return swallowStart + M.nibbleSwallow
+ }
+ }
+}
+
+/// How often a behaviour needs a frame. `PetFramePolicy` turns a class into a rate and applies the
+/// caps; the speed tests in `PetRigTests` hold each behaviour to its class's rate.
+public enum PetFrameClass: Int, Sendable, Hashable, Comparable, CaseIterable {
+ /// Paused: `doze`, `denWait` between peeks, and every closed gate.
+ case off
+ /// 10 fps: `hibernating`, `settled`.
+ case still
+ /// 15 fps: `asleep`, `drowsy`, `stirring`, `rollOver`.
+ case sleep
+ /// 30 fps: idle in every mood, `eager`, `unsureIdle`, `dozeIn`.
+ case idle
+ /// 60 fps: transitions, `satisfied`, celebrations, pointer tracking, and meals on battery.
+ case action
+ /// The display's own rate, on AC power: `eat`, `boop`, the hop, the wake's shake.
+ case fast
+
+ public static func < (a: PetFrameClass, b: PetFrameClass) -> Bool { a.rawValue < b.rawValue }
+}
+
+extension PetBehaviour {
+ /// How long the behaviour plays when nothing cuts it; nil for the open ones, which last until the
+ /// brain plans what comes next. `drowsy` and `stirring` are open too: the brain ends them within
+ /// `PetTuning.Limits.confirmationWait`.
+ public var duration: TimeInterval? {
+ typealias B = PetTuning.Behaviour
+ switch self {
+ case .intro(let noAccounts): return noAccounts ? B.introNoAccounts : B.intro
+ case .perk(let perk): return perk == .prompt ? B.perk : B.freshDataPerk
+ case .eat(let meal): return meal.duration
+ case .satisfied(.meal): return PetTuning.Meal.satisfied
+ case .satisfied(.feast): return PetTuning.Meal.satisfiedFeast
+ case .satisfied(.lick): return PetTuning.Meal.lick
+ case .boop(let giggle): return giggle ? PetTuning.Boop.giggle : PetTuning.Boop.boop
+ case .celebrate(.streak): return B.celebrateStreak
+ case .celebrate(.goodWeek): return B.celebrateGoodWeek
+ case .freshWindow: return B.freshWindow
+ case .fallAsleep: return B.fallAsleep
+ case .wake: return B.wake
+ case .rollOver: return B.rollOver
+ case .enterDen(let retreat): return retreat ? B.retreat : B.enterDen
+ case .leaveDen(let stretch): return stretch ? B.leaveDen : B.leaveDenShort
+ case .peek: return B.peek
+ case .dozeIn: return B.dozeIn
+ case .welcomeBack: return B.welcomeBack
+ case .idle, .settled, .eager, .asleep, .drowsy, .stirring, .hibernating, .denWait, .unsureIdle, .doze, .off:
+ return nil
+ }
+ }
+
+ /// The frame class `elapsed` seconds into the behaviour. Three behaviours change class part-way: the
+ /// wake's shake and a streak's twirl need the fast rate, and `off` needs frames only for its fade.
+ public func frameClass(elapsed: TimeInterval) -> PetFrameClass {
+ typealias B = PetTuning.Behaviour
+ switch self {
+ case .celebrate(.streak):
+ return B.streakTwirl.contains(elapsed) ? .fast : .action
+ case .idle, .eager, .unsureIdle, .dozeIn:
+ return .idle
+ case .settled, .hibernating:
+ return .still
+ case .asleep, .drowsy, .stirring, .rollOver:
+ return .sleep
+ case .eat, .boop:
+ return .fast
+ case .wake:
+ return elapsed >= B.wakeStir + B.wakeStretch ? .fast : .action
+ case .off:
+ return elapsed < B.fadeOut ? .action : .off
+ case .denWait, .doze:
+ return .off
+ case .intro, .perk, .satisfied, .celebrate, .freshWindow, .fallAsleep, .enterDen, .leaveDen, .peek,
+ .welcomeBack:
+ return .action
+ }
+ }
+}
+
+// MARK: - The timeline
+
+/// One behaviour, from its start until the next segment starts. The brain plans what follows a
+/// behaviour with a `duration` when it plans the behaviour, so the next segment starts at its end at
+/// the latest, and the rig never has to guess what comes after an action.
+public struct PetSegment: Sendable, Hashable {
+ /// `PetTime.mono` seconds in the app; seconds from 0 in a hand-written segment script.
+ public var start: TimeInterval
+ public var behaviour: PetBehaviour
+}
+
+/// A short reaction on top of whatever behaviour is playing. The brain decides whether one happens,
+/// with its conditions and rate limits; the rig decides how it looks.
+public struct PetCue: Sendable, Hashable {
+ public enum Kind: Sendable, Hashable {
+ /// The pointer came within 160 pt: for 0.25 s the eyes widen and the head turns, and from then
+ /// on the eyes follow `PetPointerTrack`.
+ case notice
+ /// The pointer went beyond 200 pt: the gaze holds 0.6 s, then it is back to its own glances.
+ case release
+ /// Asleep, a prompt or the pointer moving within 40 pt: a brow twitches in its sleep.
+ case twitch
+ /// A boop while asleep: one eye opens and closes, 0.9 s.
+ case sleepyEye
+ /// A boop while hibernating: the glow pulses once, 0.6 s.
+ case glowPulse
+ }
+
+ public var t: TimeInterval
+ public var kind: Kind
+}
+
+/// One straight piece of the hunger the meter shows: from `t` until the next key it is
+/// `value + rate · (now − t)`. `value` is H + pendingDrop before the clamp (−50 to 100), so the light
+/// changes at the swallow, not when a reading arrives.
+public struct PetMeterKey: Sendable, Hashable {
+ public var t: TimeInterval
+ public var value: Double
+ /// Points per second: 20 · f / 3600 while the hunger clock runs, 0 while it stands.
+ public var rate: Double
+}
+
+/// What the brain writes and the rig reads. The same timeline at the same instant always gives the
+/// same frame.
+public struct PetTimeline: Sendable, Equatable {
+ /// In start order. A showreel's segment script is a timeline written by hand.
+ public var segments: [PetSegment] = []
+ /// In time order.
+ public var cues: [PetCue] = []
+ /// In time order. Before the first key the meter reads as the first key's value, and with no key at
+ /// all as a new pet's (`PetTuning.Hunger.newPet`).
+ public var meter: [PetMeterKey] = []
+
+ /// How much of the past the brain keeps: a past segment still shapes the pose for
+ /// `PetTuning.Secondary.tail` of follow-through after it ends, and a feast's belly for 20 minutes.
+ public static let memory: TimeInterval = PetTuning.Meal.feastBellyDecay
+}
+
+/// Events with their times, for the brain: the brain tests, and any render that wants the brain to
+/// decide what plays.
+public struct PetScript: Sendable, Equatable {
+ public struct Step: Sendable, Equatable {
+ public var time: PetTime
+ public var event: PetEvent
+ }
+
+ public var steps: [Step] = []
+}
diff --git a/Sources/BrowKit/State/ClaudeCodeActivity.swift b/Sources/BrowKit/State/ClaudeCodeActivity.swift
index 02125e7..ec463ec 100644
--- a/Sources/BrowKit/State/ClaudeCodeActivity.swift
+++ b/Sources/BrowKit/State/ClaudeCodeActivity.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Darwin
import Foundation
diff --git a/Sources/BrowKit/State/ClaudeCodeProcesses.swift b/Sources/BrowKit/State/ClaudeCodeProcesses.swift
index 0567a1d..d28dc3f 100644
--- a/Sources/BrowKit/State/ClaudeCodeProcesses.swift
+++ b/Sources/BrowKit/State/ClaudeCodeProcesses.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Darwin
import Foundation
diff --git a/Sources/BrowKit/State/LimitsStore.swift b/Sources/BrowKit/State/LimitsStore.swift
index 82049d8..9e759a3 100644
--- a/Sources/BrowKit/State/LimitsStore.swift
+++ b/Sources/BrowKit/State/LimitsStore.swift
@@ -1,4 +1,8 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
+import GroveAppKit
import GroveCore
public enum AccountStatus: Sendable, Equatable {
@@ -142,6 +146,10 @@ public final class LimitsStore: ObservableObject {
public private(set) var aggregate: LimitsAggregate
/// Newest `fetchedAt` across visible accounts; nil until any fetch succeeded.
public private(set) var dataAsOf: Date?
+ /// True once an account scan has landed, however late (a scan parked behind a Keychain
+ /// prompt lands after `bootstrap()` has returned). Before it, an empty `rows` means "not
+ /// looked yet", not "no account", and the pet waits rather than going into its den.
+ public private(set) var hasScanned = false
/// Set only when NO visible account succeeded in the last cycle.
@Published public private(set) var footerError: String?
/// A configuration problem that no fetch can fix — today only "`claude` not
@@ -176,6 +184,17 @@ public final class LimitsStore: ObservableObject {
}
}
+ /// Folders Workspaces knows that the scan would not find by itself (anything outside
+ /// `~/.claude` and `~/.claude-accounts`), scanned along with `settings.extraDirs`.
+ /// Not saved: they are Workspaces' own, and come back with it.
+ public var runtimeExtraDirs: [String] = []
+
+ /// Every extra folder a scan walks.
+ var scanExtraDirs: [String] {
+ var seen = Set()
+ return (settings.extraDirs + runtimeExtraDirs).filter { seen.insert(expandTilde($0)).inserted }
+ }
+
/// The path auto-detection found, or nil while it is pending / found nothing.
public var claudeDetected: String? {
if case .found(let path) = claudeDetection { return path }
@@ -350,7 +369,7 @@ public final class LimitsStore: ObservableObject {
/// Keyed on `extraDirs`: a scan started before a dir was typed into Settings never
/// looked at that dir and cannot answer for it.
private func liveScan() -> Task<[DiscoveredAccount], Never> {
- let dirs = settings.extraDirs
+ let dirs = scanExtraDirs
if let live = scanInFlight, live.extraDirs == dirs {
BrowLog.limits.info("joining the account scan already in flight")
return live.task
@@ -593,7 +612,7 @@ public final class LimitsStore: ObservableObject {
deps.directory.invalidate(configDir: account.configDir)
for alias in account.aliasDirs { deps.directory.invalidate(configDir: alias) }
}
- deps.directory.invalidateCandidates(extraDirs: settings.extraDirs)
+ deps.directory.invalidateCandidates(extraDirs: scanExtraDirs)
}
/// One account's token upkeep and usage request, off the main actor. Sampled
@@ -712,9 +731,18 @@ public final class LimitsStore: ObservableObject {
let locked = scanned.contains(where: \.keychainLocked)
accounts = scanned
setKeychainState(locked || (answered.isEmpty && deniable) ? .denied : .ok)
+ // Published even when the scan changes nothing else: an empty answer is an answer too.
+ if !hasScanned {
+ objectWillChange.send()
+ hasScanned = true
+ }
recompute()
}
+ /// Offscreen renders only: the accounts a scan would have found, applied as a
+ /// scan's are, with no Keychain and no disk.
+ func preview(accounts: [DiscoveredAccount]) { applyScan(accounts) }
+
/// The user pressed the key: read this account's items WITH the Keychain dialog
/// allowed — "Always Allow" there is what makes every later cycle silent — then
/// refresh so the grant shows at once. Off the main actor: the dialog is modal
@@ -967,3 +995,29 @@ public final class LimitsStore: ObservableObject {
}
}
}
+
+/// Workspaces shows each account as the notch does: its limits in the account pickers,
+/// and its name and plan everywhere it names the account. The notch's row for the
+/// account that owns any of the folders asked about, hidden ones included.
+extension LimitsStore: AccountLimitsProviding {
+ public func limits(forConfigDirs dirs: [String]) -> AccountLimits? {
+ guard let snapshot = row(forConfigDirs: dirs)?.snapshot else { return nil }
+ return AccountLimits(fiveHour: snapshot.fiveHour?.usedPercentage,
+ weekly: snapshot.sevenDay?.usedPercentage,
+ stale: snapshot.isStale(now: deps.now()))
+ }
+
+ /// The name from Settings › Accounts (else the email or the folder, as the panel has
+ /// it) and the plan as the panel writes it.
+ public func label(forConfigDirs dirs: [String]) -> AccountLabel? {
+ guard let row = row(forConfigDirs: dirs) else { return nil }
+ return AccountLabel(name: row.name, plan: row.account.tier.map(PanelText.tierLabel))
+ }
+
+ private func row(forConfigDirs dirs: [String]) -> AccountRow? {
+ let wanted = Set(dirs.map(WorkspacesAccountJoin.normalized))
+ return allRows.first { row in
+ !wanted.isDisjoint(with: ([row.account.configDir] + row.account.aliasDirs).map(WorkspacesAccountJoin.normalized))
+ }
+ }
+}
diff --git a/Sources/BrowKit/State/RefreshTriggers.swift b/Sources/BrowKit/State/RefreshTriggers.swift
index fc4f866..83af2f1 100644
--- a/Sources/BrowKit/State/RefreshTriggers.swift
+++ b/Sources/BrowKit/State/RefreshTriggers.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import AppKit
import Combine
import Foundation
@@ -46,6 +49,11 @@ public final class RefreshTriggers {
/// it to `TokenKeeper` — an actor that cannot look at the main-actor store.
@Published public private(set) var isOnline = true
+ /// How `start()` reads the network at once, before the monitor's first callback: the
+ /// monitor's current path. A test hands in its own answer — that path is still being
+ /// evaluated at that moment, so reading it again a moment later can answer otherwise.
+ var currentPathIsSatisfied: (NWPathMonitor) -> Bool = { $0.currentPath.status == .satisfied }
+
/// What `start()` has actually put in place. Each flag is one trigger.
var installed: (timer: Bool, ticker: Bool, wake: Bool, path: Bool) {
(timer != nil, ticker != nil, wakeObserver != nil, pathMonitor != nil)
@@ -92,7 +100,7 @@ public final class RefreshTriggers {
// its 5-hour window for a refresh that could never have worked, and counts
// a failure that walks the breaker up. `pathChanged` records before it acts
// and is idempotent, so seeding it costs nothing when the path is fine.
- pathChanged(satisfied: monitor.currentPath.status == .satisfied)
+ pathChanged(satisfied: currentPathIsSatisfied(monitor))
}
// Only on the call that armed the poll: `start()` is now safe to call twice
// and must not spend a cycle for it.
diff --git a/Sources/BrowKit/State/SettingsDrafts.swift b/Sources/BrowKit/State/SettingsDrafts.swift
index 94cdf52..02e907d 100644
--- a/Sources/BrowKit/State/SettingsDrafts.swift
+++ b/Sources/BrowKit/State/SettingsDrafts.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
/// Edits typed into the Settings window that have not been written to
diff --git a/Sources/BrowKit/State/StatuslineSource.swift b/Sources/BrowKit/State/StatuslineSource.swift
index 81fa4a1..2e24c43 100644
--- a/Sources/BrowKit/State/StatuslineSource.swift
+++ b/Sources/BrowKit/State/StatuslineSource.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
diff --git a/Sources/BrowKit/Views/AboutView.swift b/Sources/BrowKit/Views/AboutView.swift
new file mode 100644
index 0000000..4de62fe
--- /dev/null
+++ b/Sources/BrowKit/Views/AboutView.swift
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import AppKit
+import GroveCore
+import SwiftUI
+
+/// Settings › About: what Brow is, its version and licence, and whose work it builds on.
+struct AboutView: View {
+ static let credits: [(name: String, url: String, what: String)] = [
+ ("boring.notch", "https://github.com/TheBoredTeam/boring.notch",
+ "GPL-3.0. The notch outline's constants, and the idea of drawing in a SkyLight space of its own so the panel stays put through Spaces changes."),
+ ("NotchNook", "https://lo.cafe/notchnook", "The same way of keeping a notch panel still across Spaces."),
+ ("cmux", "https://cmux.dev", "The terminal Workspaces opens sessions in, through its socket and AppleScript."),
+ ("Grove", "https://github.com/efim0v/grove",
+ "Brow's companion app until 0.4: its Workspaces engine is now part of Brow, merged with its history."),
+ ]
+
+ var body: some View {
+ Form {
+ Section {
+ HStack(alignment: .center, spacing: 14) {
+ Image(nsImage: NSApp.applicationIconImage ?? NSImage())
+ .resizable().frame(width: 56, height: 56)
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Brow").font(.title2.weight(.semibold))
+ Text("Version \(BrowVersion.current)").foregroundStyle(.secondary)
+ Text("Claude Code usage limits in the notch, and workspaces for parallel sessions.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ LabeledContent("Licence") {
+ Link("GNU GPL v3", destination: URL(string: "https://github.com/efim0v/brow/blob/main/LICENSE")!)
+ }
+ LabeledContent("Source") {
+ Link("github.com/efim0v/brow", destination: URL(string: "https://github.com/efim0v/brow")!)
+ }
+ } footer: {
+ Text("Coming from Grove? Grove is now part of Brow: your projects, accounts and settings come along, and Grove.app can go.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Section("Built with and on") {
+ ForEach(Self.credits, id: \.name) { credit in
+ VStack(alignment: .leading, spacing: 2) {
+ Link(credit.name, destination: URL(string: credit.url)!)
+ Text(credit.what).font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ Section {
+ Text("An independent tool, not affiliated with or endorsed by Anthropic.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ .formStyle(.grouped)
+ }
+}
diff --git a/Sources/BrowKit/Views/AccountBlockView.swift b/Sources/BrowKit/Views/AccountBlockView.swift
index 4172223..124cf96 100644
--- a/Sources/BrowKit/Views/AccountBlockView.swift
+++ b/Sources/BrowKit/Views/AccountBlockView.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import SwiftUI
import GroveCore
diff --git a/Sources/BrowKit/Views/BrowMark.swift b/Sources/BrowKit/Views/BrowMark.swift
new file mode 100644
index 0000000..26a1649
--- /dev/null
+++ b/Sources/BrowKit/Views/BrowMark.swift
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import AppKit
+
+/// Brow's mark — the notch, with its two readout dots — drawn in code, for the
+/// Workspaces menu-bar item. The silhouette is the app icon's (`site/favicon.svg`): a
+/// 50 × 28 shape whose bottom corners are rounded by 11, with dots of radius 5.5 at
+/// 12.5 and 37.5 across, 14 down. As a template image the dots are knocked out, so the
+/// menu bar's own tint draws the shape and the bar shows through the dots.
+public enum BrowMark {
+ /// The silhouette's own size, in the icon's units.
+ static let artwork = CGSize(width: 50, height: 28)
+
+ /// A template image `width` points wide, as tall as the menu bar's other glyphs.
+ public static func templateImage(width: CGFloat = 22) -> NSImage {
+ let scale = width / artwork.width
+ let size = NSSize(width: width, height: max(16, (artwork.height * scale).rounded(.up)))
+ let image = NSImage(size: size, flipped: true) { rect in
+ let height = artwork.height * scale
+ let origin = CGPoint(x: 0, y: (rect.height - height) / 2)
+ path(origin: origin, scale: scale).fill()
+ return true
+ }
+ image.isTemplate = true
+ image.accessibilityDescription = "Brow Workspaces"
+ return image
+ }
+
+ /// The notch with two holes, in a flipped (y down) space, filled even-odd.
+ static func path(origin: CGPoint, scale: CGFloat) -> NSBezierPath {
+ func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: origin.x + x * scale, y: origin.y + y * scale) }
+ let r = 11 * scale
+ let shape = NSBezierPath()
+ shape.move(to: p(0, 0))
+ shape.line(to: p(50, 0))
+ shape.line(to: p(50, 17))
+ shape.appendArc(withCenter: p(39, 17), radius: r, startAngle: 0, endAngle: 90)
+ shape.line(to: p(11, 28))
+ shape.appendArc(withCenter: p(11, 17), radius: r, startAngle: 90, endAngle: 180)
+ shape.close()
+ for cx: CGFloat in [12.5, 37.5] {
+ let d = 11 * scale
+ let center = p(cx, 14)
+ shape.appendOval(in: CGRect(x: center.x - d / 2, y: center.y - d / 2, width: d, height: d))
+ }
+ shape.windingRule = .evenOdd
+ return shape
+ }
+}
diff --git a/Sources/BrowKit/Views/CalendarView.swift b/Sources/BrowKit/Views/CalendarView.swift
index d0802ad..f701420 100644
--- a/Sources/BrowKit/Views/CalendarView.swift
+++ b/Sources/BrowKit/Views/CalendarView.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import SwiftUI
/// The month under the accounts: a dot per weekly reset in the account's colour, a
diff --git a/Sources/BrowKit/Views/EarsMetrics.swift b/Sources/BrowKit/Views/EarsMetrics.swift
new file mode 100644
index 0000000..c56fb3d
--- /dev/null
+++ b/Sources/BrowKit/Views/EarsMetrics.swift
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import SwiftUI
+
+/// How a readout is laid out in its ear, in one place. The pet's zones (`PetLayout`) are worked out from
+/// the same numbers, so the free black the pet lives in cannot drift from what the strip draws. The text
+/// widths each class takes in these fonts are `PetTuning.Readout`'s.
+public enum EarsMetrics {
+ /// Between an ear's outer edge and its dot, in the left ear, or its text, in the right one.
+ public static let padding: CGFloat = 8
+ /// The status dot's diameter.
+ public static let dotSize: CGFloat = 7
+ /// Between the dot and the text.
+ public static let dotSpacing: CGFloat = 5
+ /// Between the model initial (the right ear's `F`) and its percentage.
+ public static let initialSpacing: CGFloat = 3
+ /// The percentage: 12 pt semibold rounded with tabular digits, so every reading of a width class
+ /// is the same width.
+ public static var readoutFont: Font { .system(size: 12, weight: .semibold, design: .rounded).monospacedDigit() }
+ /// The model initial.
+ public static var initialFont: Font { .system(size: 10, weight: .bold, design: .rounded) }
+}
diff --git a/Sources/BrowKit/Views/EarsView.swift b/Sources/BrowKit/Views/EarsView.swift
index ed77a35..4185967 100644
--- a/Sources/BrowKit/Views/EarsView.swift
+++ b/Sources/BrowKit/Views/EarsView.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import SwiftUI
/// The collapsed readouts, drawn on the notch outline itself.
@@ -81,29 +84,32 @@ public struct EarsView: View {
/// `outward`: the readout hugs the outer edge of its wing, which is what keeps the
/// notch-wide middle clear in `beside`. Otherwise it hugs the middle, so the two
/// halves read as the one centred `● 14% 27% ●` row the strip needs.
+ ///
+ /// Every number comes from `EarsMetrics`, which the pet's zones are worked out from:
+ /// the black the pet lives in is exactly what this leaves free.
private func ear(_ r: EarReadout, leading: Bool, outward: Bool) -> some View {
let spacerFirst = outward ? !leading : leading
- return HStack(spacing: 5) {
+ return HStack(spacing: EarsMetrics.dotSpacing) {
if spacerFirst { Spacer(minLength: 0) }
if leading { dot(r) }
- HStack(spacing: 3) {
+ HStack(spacing: EarsMetrics.initialSpacing) {
if let initial = r.modelInitial {
- Text(initial).font(.system(size: 10, weight: .bold, design: .rounded)).foregroundStyle(.secondary)
+ Text(initial).font(EarsMetrics.initialFont).foregroundStyle(.secondary)
}
Text(PanelText.earsText(r, hasData: aggregate.hasData))
- .font(.system(size: 12, weight: .semibold, design: .rounded))
- .monospacedDigit()
+ .font(EarsMetrics.readoutFont)
.foregroundStyle(.white)
}
if !leading { dot(r) }
if !spacerFirst { Spacer(minLength: 0) }
}
- .padding(.horizontal, 8)
+ .padding(.horizontal, EarsMetrics.padding)
}
/// Grey next to the `—`: the severity of a reading we do not have is not `.ok`.
private func dot(_ r: EarReadout) -> some View {
- Circle().fill(aggregate.hasData ? color(for: r) : Color.gray).frame(width: 7, height: 7)
+ Circle().fill(aggregate.hasData ? color(for: r) : Color.gray)
+ .frame(width: EarsMetrics.dotSize, height: EarsMetrics.dotSize)
}
static func color(for r: EarReadout) -> Color {
diff --git a/Sources/BrowKit/Views/Formatting.swift b/Sources/BrowKit/Views/Formatting.swift
index 494ceef..6f3d36a 100644
--- a/Sources/BrowKit/Views/Formatting.swift
+++ b/Sources/BrowKit/Views/Formatting.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
import GroveCore
diff --git a/Sources/BrowKit/Views/PanelText.swift b/Sources/BrowKit/Views/PanelText.swift
index 7fcb3b0..772eaf1 100644
--- a/Sources/BrowKit/Views/PanelText.swift
+++ b/Sources/BrowKit/Views/PanelText.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import Foundation
/// Every string the notch puts on screen, in one pure place. The views below hold
diff --git a/Sources/BrowKit/Views/PanelView.swift b/Sources/BrowKit/Views/PanelView.swift
index 1be68ef..4dff3c2 100644
--- a/Sources/BrowKit/Views/PanelView.swift
+++ b/Sources/BrowKit/Views/PanelView.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import SwiftUI
import Combine
@@ -48,8 +51,14 @@ public struct PanelView: View {
let drawsBackground: Bool
let onSettings: () -> Void
let onRefresh: () -> Void
+ /// The way into Workspaces, and the one that is always there: macOS hides a status
+ /// item that does not fit beside the notch. Left of share, so ⟳ keeps its place at
+ /// the corner (`PanelRefreshGateTests`). nil: no button.
+ let onWorkspaces: (() -> Void)?
/// The calendar's hovered day, driven by the controller's mouse monitor.
@ObservedObject var hover: CalendarHoverTracker
+ /// The pet's words and meter for the header row; nil where the panel is drawn without a pet.
+ let pet: PetPanelModel?
/// The share button's checkmark, like the copy button's in `AccountBlockView`.
@State private var copiedCard = false
@@ -60,19 +69,26 @@ public struct PanelView: View {
public init(store: LimitsStore, clock: PanelClock, topInset: CGFloat, flare: CGFloat,
drawsBackground: Bool = true, hover: CalendarHoverTracker = CalendarHoverTracker(),
- onSettings: @escaping () -> Void, onRefresh: @escaping () -> Void) {
+ pet: PetPanelModel? = nil, onSettings: @escaping () -> Void, onRefresh: @escaping () -> Void,
+ onWorkspaces: (() -> Void)? = nil) {
self.store = store
self.clock = clock
self.topInset = topInset
self.flare = flare
self.drawsBackground = drawsBackground
self.hover = hover
+ self.pet = pet
self.onSettings = onSettings
self.onRefresh = onRefresh
+ self.onWorkspaces = onWorkspaces
}
public var body: some View {
VStack(alignment: .leading, spacing: 10) {
+ // The pet's row, first, while the pet is on. Its own view, so the words re-render it alone.
+ if let pet, store.settings.showPet {
+ PetHeaderRow(model: pet)
+ }
overall
Divider().overlay(Color.white.opacity(0.15))
if store.rows.isEmpty {
@@ -97,6 +113,9 @@ public struct PanelView: View {
bottom: Self.padding, trailing: Self.padding))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
+ .onPreferenceChange(PetFiveHourFrame.self) { [pet] rect in
+ if let rect { MainActor.assumeIsolated { pet?.fiveHourFrameChanged(rect) } }
+ }
.background { if drawsBackground { Color.black } }
// Same outline as the collapsed strip, with the wider bottom radius the spec
// gives the panel; the fill is behind the clip, so the black — inset included —
@@ -119,13 +138,15 @@ public struct PanelView: View {
Spacer()
// Greyed out when the aggregate is stale: a three-day-old 32 % must not
// render as confident green (spec, Staleness).
- stat("5h", store.aggregate.fiveHour)
+ stat("5h", store.aggregate.fiveHour, reportsFrame: pet != nil)
stat("Weekly", store.aggregate.weekly)
if let s = store.aggregate.weeklyScoped { stat(s.model, s.percentage) }
}
}
- private func stat(_ label: String, _ value: Double) -> some View {
+ /// `reportsFrame`: the 5-hour figure is where the pet's food comes from in the panel, so its frame
+ /// goes up as a preference, the way `CalendarView` reports its cells.
+ private func stat(_ label: String, _ value: Double, reportsFrame: Bool = false) -> some View {
let readout = EarReadout(usedPercentage: value, modelInitial: nil,
severity: LimitsAggregate.severity(value), stale: store.aggregate.stale)
// Same rule as the ears, one inch away from them: with no snapshot behind it the
@@ -135,6 +156,11 @@ public struct PanelView: View {
Text(PanelText.earsText(readout, hasData: store.aggregate.hasData))
.font(.system(size: 12, weight: .semibold)).monospacedDigit()
.foregroundStyle(store.aggregate.hasData ? EarsView.color(for: readout) : Color.gray)
+ .background {
+ if reportsFrame {
+ GeometryReader { geo in Color.clear.preference(key: PetFiveHourFrame.self, value: geo.frame(in: .global)) }
+ }
+ }
}
}
@@ -156,6 +182,13 @@ public struct PanelView: View {
.foregroundStyle(store.panelError == nil ? Color.secondary : Color.orange)
}
Spacer()
+ if let onWorkspaces {
+ Button(action: onWorkspaces) {
+ Image(systemName: "square.stack.3d.up")
+ .frame(width: 20, height: 20).contentShape(Rectangle())
+ }
+ .buttonStyle(.plain).help("Workspaces")
+ }
// Left of ⟳ on purpose: `PanelRefreshGateTests` finds ⟳ at a fixed offset
// from the bottom-right corner. The clipboard needs no activation, so this
// works from the non-activating panel; saving a file lives in Settings.
@@ -196,3 +229,66 @@ public struct PanelView: View {
}
}
}
+
+/// The Overall line's "5h" figure, in the hosting view's space (window coordinates): the pet's food
+/// source in the panel.
+struct PetFiveHourFrame: PreferenceKey {
+ static let defaultValue: CGRect? = nil
+ static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) { value = nextValue() ?? value }
+}
+
+/// The pet's row at the top of the panel: the stage the pet layer draws in, the state word, the detail and
+/// the moment line, and the hunger meter with no number. One VoiceOver element for all of it, whose action
+/// boops the pet.
+struct PetHeaderRow: View {
+ @ObservedObject var model: PetPanelModel
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ private typealias P = PetTuning.Panel
+
+ var body: some View {
+ HStack(alignment: .center, spacing: P.rowSpacing) {
+ // The pet layer draws here; the stage only holds the place, and its tooltip is the detail.
+ Color.clear
+ .frame(width: P.stageWidth, height: P.stageHeight)
+ .contentShape(Rectangle())
+ .help(model.header.detail)
+ VStack(alignment: .leading, spacing: P.textSpacing) {
+ Text(model.header.word).font(.system(size: P.wordSize, weight: .semibold)).foregroundStyle(.white)
+ Text(model.header.detail).font(.system(size: P.detailSize)).foregroundStyle(.secondary).lineLimit(1)
+ if let moment = model.header.moment {
+ Text(moment).font(.system(size: P.detailSize)).foregroundStyle(.tertiary).lineLimit(1)
+ }
+ }
+ Spacer(minLength: P.spacerMinimum)
+ VStack(alignment: .trailing, spacing: P.meterSpacing) {
+ Text("Hunger").font(.system(size: P.detailSize)).foregroundStyle(.secondary)
+ meter
+ }
+ }
+ .frame(height: P.rowHeight)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(PetText.label)
+ .accessibilityValue(model.accessibilityValue)
+ .accessibilityAddTraits(.updatesFrequently)
+ .accessibilityAction(named: PetText.actionName) { model.boop() }
+ }
+
+ /// 120 × 6 pt: the track, the fill in the pet's own light (never a severity colour, which would read
+ /// as a limit), the two mood thresholds. Under Reduce Motion it changes without animating.
+ private var meter: some View {
+ let fill = P.meterWidth * min(max(model.meter, 0), 100) / 100
+ return ZStack(alignment: .leading) {
+ Rectangle().fill(Color.white.opacity(P.meterTrackOpacity))
+ Rectangle().fill(PetPalette.standard.colors.glow.color(opacity: P.meterFillOpacity))
+ .frame(width: fill)
+ ForEach(P.meterTicks, id: \.self) { tick in
+ Rectangle().fill(Color.white.opacity(P.tickOpacity))
+ .frame(width: P.tickWidth, height: P.meterHeight)
+ .offset(x: P.meterWidth * tick / 100 - P.tickWidth / 2)
+ }
+ }
+ .frame(width: P.meterWidth, height: P.meterHeight)
+ .clipShape(Capsule())
+ .animation(reduceMotion ? nil : .easeInOut(duration: P.meterAnimation), value: model.meter)
+ }
+}
diff --git a/Sources/BrowKit/Views/SettingsView.swift b/Sources/BrowKit/Views/SettingsView.swift
index f9ddaa0..de97abc 100644
--- a/Sources/BrowKit/Views/SettingsView.swift
+++ b/Sources/BrowKit/Views/SettingsView.swift
@@ -1,21 +1,62 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import GroveAppKit
+import GroveCore
import ServiceManagement
import SwiftUI
+/// The tabs of Brow's one Settings window.
+public enum SettingsPane: String, CaseIterable, Sendable {
+ case general, accounts, workspaces, about
+
+ var title: String {
+ switch self {
+ case .general: return "General"
+ case .accounts: return "Accounts"
+ case .workspaces: return "Workspaces"
+ case .about: return "About"
+ }
+ }
+
+ /// Where the Workspaces panel's links land.
+ public init(_ link: WorkspacesSettingsLink) {
+ switch link {
+ case .workspaces: self = .workspaces
+ case .accounts: self = .accounts
+ }
+ }
+}
+
+/// Which tab the window shows. Owned outside the view tree, so the panel's gear can
+/// turn the window to Workspaces while it is already open.
+@MainActor
+public final class SettingsNavigation: ObservableObject {
+ @Published public var pane: SettingsPane = .general
+ public init() {}
+}
+
public struct SettingsView: View {
@ObservedObject var store: LimitsStore
@ObservedObject var addFlow: AddAccountFlow
+ @ObservedObject var navigation: SettingsNavigation
/// Names typed but not committed yet. Held outside the view tree so closing the
/// window cannot discard them — see `SettingsDrafts`.
let drafts: SettingsDrafts
/// nil where nothing can post (the README screenshots): the permission row is left out.
let notifier: LimitNotifier?
- @State private var launchAtLogin = SMAppService.mainApp.status == .enabled
+ /// nil where Workspaces is not hosted (tests, screenshots of the notch only).
+ let workspaces: WorkspacesHost?
- public init(store: LimitsStore, addFlow: AddAccountFlow, drafts: SettingsDrafts, notifier: LimitNotifier? = nil) {
+ @MainActor
+ public init(store: LimitsStore, addFlow: AddAccountFlow, drafts: SettingsDrafts, notifier: LimitNotifier? = nil,
+ workspaces: WorkspacesHost? = nil, navigation: SettingsNavigation? = nil) {
self.store = store
self.addFlow = addFlow
self.drafts = drafts
self.notifier = notifier
+ self.workspaces = workspaces
+ self.navigation = navigation ?? SettingsNavigation()
}
/// The RAW auto-detection result, never the effective path: its only job is to
@@ -31,147 +72,26 @@ public struct SettingsView: View {
private var claudePath: String { store.settings.claudePath ?? claudeDetected ?? "claude" }
public var body: some View {
- TabView {
- accounts.tabItem { Text("Accounts") }
- general.tabItem { Text("General") }
+ TabView(selection: $navigation.pane) {
+ general.tabItem { Text(SettingsPane.general.title) }.tag(SettingsPane.general)
+ AccountsTab(store: store, addFlow: addFlow, drafts: drafts, workspaces: workspaces, claudePath: claudePath)
+ .tabItem { Text(SettingsPane.accounts.title) }.tag(SettingsPane.accounts)
+ workspacesTab.tabItem { Text(SettingsPane.workspaces.title) }.tag(SettingsPane.workspaces)
+ AboutView().tabItem { Text(SettingsPane.about.title) }.tag(SettingsPane.about)
}
- .frame(width: 560, height: 420)
+ .frame(width: 620, height: 500)
.padding()
}
- /// `allRows`, not `rows`: a hidden account has to stay listed here or its
- /// "Show" toggle could never be turned back on.
- private var accounts: some View {
- Form {
- Section {
- HStack(alignment: .center) {
- VStack(alignment: .leading, spacing: 2) {
- Text("Add an account").font(.headline)
- Text("One click: Terminal opens with Anthropic's sign-in; Brow does the rest.")
- .font(.caption).foregroundStyle(.secondary)
- }
- Spacer()
- // Nothing to pick or name: the folder is chosen for the user
- // (~/.claude-accounts/account-N) and the sign-in is Anthropic's own.
- Button { addFlow.quickAdd(claudePath: claudePath) } label: {
- Label("Sign in…", systemImage: "person.badge.plus")
- }
- .buttonStyle(.borderedProminent)
- // For a config folder that already exists somewhere else.
- Button("Existing folder…") {
- if let dir = addFlow.chooseFolder() { addFlow.begin(dir: dir) }
- }
- }
- if let dir = addFlow.pendingDir {
- let command = AddAccountFlow.launchCommand(dir: dir)
- HStack {
- Text(command).font(.system(.caption, design: .monospaced))
- .lineLimit(1).truncationMode(.middle).textSelection(.enabled)
- Spacer()
- Button { AddAccountFlow.copyToPasteboard(command) } label: { Label("Copy", systemImage: "doc.on.doc") }
- }
- }
- if let s = addFlow.status { Text(s).font(.caption) }
- }
- ForEach(store.allRows) { row in
- Section {
- // Committed on Return / focus loss / window close, not on every
- // keystroke: each write of `store.settings` is a synchronous atomic
- // JSON write plus a full recompute plus a panel re-render with a
- // 0.18 s animation.
- AccountNameField(accountID: row.id, initial: store.settings.accounts[row.id]?.name ?? "",
- drafts: drafts, store: store)
- LabeledContent("Email", value: row.account.email ?? "—")
- LabeledContent("Tier", value: PanelText.tierLabel(row.account.tier))
- LabeledContent("Folder") {
- Text(row.account.configDir + (row.account.aliasDirs.isEmpty ? "" : " (+\(row.account.aliasDirs.count) alias)"))
- .font(.caption).textSelection(.enabled)
- }
- LabeledContent("Token", value: row.tokenStatus)
- // The command that runs Claude Code as this account, in plain sight:
- // paste it into any terminal. The copy button sits right on it.
- LabeledContent("Launch") {
- let command = AddAccountFlow.launchCommand(dir: row.account.configDir)
- HStack(spacing: 6) {
- Text(command)
- .font(.system(.caption, design: .monospaced))
- .lineLimit(1).truncationMode(.middle)
- .textSelection(.enabled)
- Button { AddAccountFlow.copyToPasteboard(command) } label: {
- Image(systemName: "doc.on.doc")
- }
- .buttonStyle(.borderless)
- .help("Copy the launch command")
- }
- }
- HStack {
- Toggle("Show", isOn: Binding(
- get: { !store.settings.isHidden(row.id) },
- set: { store.settings.accounts[row.id, default: AccountOverride(name: nil, hidden: false)].hidden = !$0 }))
- Spacer()
- if row.account.keychainLocked {
- Button {
- Task { await store.grantKeychainAccess(row.account) }
- } label: { Label("Grant Keychain access", systemImage: "key.fill") }
- .help("macOS asks once; choose “Always Allow”. Needed again only after a fresh `claude auth login` recreates the item.")
- }
- Button {
- AddAccountFlow.copyToPasteboard(AddAccountFlow.launchCommand(dir: row.account.configDir))
- } label: { Label("Copy launch command", systemImage: "doc.on.doc") }
- Button {
- AddAccountFlow.login(dir: row.account.configDir, claudePath: claudePath)
- } label: { Label("Sign in again", systemImage: "person.badge.key") }
- .help("Opens Terminal with `claude auth login` for this account; the browser window is this account's own profile.")
- }
- HStack {
- Text("Browser profile: a complete Chrome profile of its own, named after the account — every site's sessions, cookies and saved passwords stay in it between launches.")
- .font(.caption).foregroundStyle(.secondary)
- Spacer()
- Button {
- AddAccountFlow.openBrowserProfile(dir: row.account.configDir)
- } label: { Label("Open browser profile", systemImage: "globe") }
- .help("Open this account's own Chrome profile on claude.ai. Sign in there once (Apple, Google, email — and any other site you like); it all stays, and every later `claude auth login` is one click.")
- }
- }
- }
- Section {
- Toggle("Allow `claude -p` fallback", isOn: $store.settings.allowPromptFallback)
- Text("Used only when `claude doctor` fails to refresh a token. Spends a small amount of that account's limit and starts its 5-hour window.")
+ @ViewBuilder private var workspacesTab: some View {
+ if let workspaces {
+ WorkspacesSettingsTab(host: workspaces)
+ } else {
+ Form {
+ Text(WorkspacesSettingsTab.explanation(activation: .off, groveSetup: false))
.font(.caption).foregroundStyle(.secondary)
}
- }
- .formStyle(.grouped)
- }
-
- /// A name field whose edits stay local until the user is done with them — but
- /// every keystroke is recorded in `drafts`, which outlives this view. Closing the
- /// window destroys the SwiftUI tree and this `@State` with it, and the focus-loss
- /// change is not guaranteed to be delivered first, so a rename typed and then
- /// "finished" with Cmd-W used to be silently discarded. `drafts` is what
- /// `windowWillClose` flushes.
- private struct AccountNameField: View {
- let accountID: String
- let initial: String
- let drafts: SettingsDrafts
- let store: LimitsStore
- @State private var text: String
- @FocusState private var focused: Bool
-
- init(accountID: String, initial: String, drafts: SettingsDrafts, store: LimitsStore) {
- self.accountID = accountID
- self.initial = initial
- self.drafts = drafts
- self.store = store
- _text = State(initialValue: initial)
- }
-
- var body: some View {
- TextField("Name", text: $text)
- .focused($focused)
- .onChange(of: text) { _, value in drafts.record(value, for: accountID) }
- .onSubmit { drafts.flush(into: store) }
- .onChange(of: focused) { _, isFocused in if !isFocused { drafts.flush(into: store) } }
- .onChange(of: initial) { _, value in if !focused { text = value } }
+ .formStyle(.grouped)
}
}
@@ -227,11 +147,12 @@ public struct SettingsView: View {
private var general: some View {
Form {
- Toggle("Launch at login", isOn: $launchAtLogin)
- .onChange(of: launchAtLogin) { _, on in
- do { if on { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() } }
- catch { BrowLog.panel.error("launch at login: \(error.localizedDescription, privacy: .public)") }
- }
+ LaunchAtLoginRow()
+ Section {
+ Toggle("Show Workspaces in the menu bar", isOn: $store.settings.workspaces.showInMenuBar)
+ Text("While Workspaces is on (Settings › Workspaces). Off, it still opens from the button in the notch panel and when you open Brow again.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
NotificationSettings(settings: $store.settings, notifier: notifier)
Section {
Toggle("Show the reset calendar in the panel", isOn: $store.settings.showCalendar)
@@ -239,8 +160,13 @@ public struct SettingsView: View {
.font(.caption).foregroundStyle(.secondary)
}
Section {
+ Toggle("Show Brow in the notch", isOn: $store.settings.showNotch)
+ Text("Off: Brow draws nothing at the top of the screen and no panel opens there. Limit warnings still arrive, Workspaces still opens from its menu-bar item, and opening Brow again from Finder or Spotlight shows Workspaces or this window.")
+ .font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
Toggle("Show readouts next to the notch", isOn: $store.settings.showEars)
- Text("Off: nothing is drawn until the pointer reaches the notch itself; the panel then opens with its animation.")
+ .disabled(!store.settings.showNotch)
+ Text("Off: nothing is drawn until the pointer reaches the notch itself — on a display without one, the top centre of the screen — and the panel then opens with its animation.")
.font(.caption).foregroundStyle(.secondary)
// The store writes config.json and publishes on every change, and the
// panel re-reads `earsPlacement` on every render — so the strip reshapes
@@ -250,9 +176,16 @@ public struct SettingsView: View {
Text("Below the notch").tag(EarsPlacement.below)
}
.pickerStyle(.segmented)
- .disabled(!store.settings.showEars)
+ .disabled(!store.settings.showNotch || !store.settings.showEars)
Text("Where the two readouts sit on a built-in display with a notch. An external display always shows the pill.")
.font(.caption).foregroundStyle(.secondary)
+ // On by default: it draws only in Brow's own black, covers no number, needs no
+ // permission and interrupts nothing. It lives in the notch, so with Brow out of the
+ // notch there is no pet to show.
+ Toggle("Show the pet", isOn: $store.settings.showPet)
+ .disabled(!store.settings.showNotch)
+ Text("A small creature beside the notch that eats what your accounts use: it takes a bite when the 5-hour number goes up, sleeps when a limit is used up and wakes at the reset. It only gets hungry while you are at the Mac, and follows Reduce Motion. Nothing leaves this Mac.")
+ .font(.caption).foregroundStyle(.secondary)
}
UsageImageSection(store: store)
Section("One browser profile per account") {
@@ -286,9 +219,403 @@ public struct SettingsView: View {
case .notFound:
Text("Not found. Ran: \(ClaudePathResolver.detectionCommand)").font(.caption).foregroundStyle(.orange)
}
+ Text("Sign-ins, Open in Terminal, token refresh and the sessions Workspaces starts all run this claude.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ .formStyle(.grouped)
+ }
+}
+
+/// Settings › Accounts: one card per account Brow found, and — while Workspaces runs —
+/// the card's Workspaces section, for the Workspaces account in the same config folder.
+struct AccountsTab: View {
+ @ObservedObject var store: LimitsStore
+ @ObservedObject var addFlow: AddAccountFlow
+ let drafts: SettingsDrafts
+ let workspaces: WorkspacesHost?
+ let claudePath: String
+
+ /// `allRows`, not `rows`: a hidden account has to stay listed here or its
+ /// "Show" toggle could never be turned back on.
+ var body: some View {
+ Form {
+ Section {
+ HStack(alignment: .center) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Add an account").font(.headline)
+ Text("One click: Terminal opens with Anthropic's sign-in; Brow does the rest.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ // Nothing to pick or name: the folder is chosen for the user
+ // (~/.claude-accounts/account-N) and the sign-in is Anthropic's own.
+ Button { addFlow.quickAdd(claudePath: claudePath) } label: {
+ Label("Sign in…", systemImage: "person.badge.plus")
+ }
+ .buttonStyle(.borderedProminent)
+ // For a config folder that already exists somewhere else.
+ Button("Existing folder…") {
+ if let dir = addFlow.chooseFolder() { addFlow.begin(dir: dir) }
+ }
+ }
+ if let dir = addFlow.pendingDir {
+ let command = AddAccountFlow.launchCommand(dir: dir)
+ HStack {
+ Text(command).font(.system(.caption, design: .monospaced))
+ .lineLimit(1).truncationMode(.middle).textSelection(.enabled)
+ Spacer()
+ Button { AddAccountFlow.copyToPasteboard(command) } label: { Label("Copy", systemImage: "doc.on.doc") }
+ }
+ }
+ if let s = addFlow.status { Text(s).font(.caption) }
+ }
+ ForEach(store.allRows) { row in
+ Section {
+ card(row)
+ if let workspaces { WorkspacesCardSection(host: workspaces, row: row) }
+ } footer: {
+ // What Capture the status line does, once, under the last card.
+ if let workspaces, row.id == store.allRows.last?.id { CaptureFootnote(host: workspaces) }
+ }
+ }
+ if let workspaces { WorkspacesOtherAccounts(host: workspaces, cards: store.allRows.map(\.account)) }
+ Section {
+ Toggle("Allow `claude -p` fallback", isOn: $store.settings.allowPromptFallback)
+ Text("Used only when `claude doctor` fails to refresh a token. Spends a small amount of that account's limit and starts its 5-hour window.")
+ .font(.caption).foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
+ // A status line Workspaces captured stays captured while it is off: read which.
+ .onAppear { workspaces?.reloadDormantAccounts() }
+ }
+
+ @ViewBuilder private func card(_ row: AccountRow) -> some View {
+ // Committed on Return / focus loss / window close, not on every keystroke: each
+ // write of `store.settings` is a synchronous atomic JSON write plus a full
+ // recompute plus a panel re-render with a 0.18 s animation.
+ AccountNameField(accountID: row.id, initial: store.settings.accounts[row.id]?.name ?? "",
+ drafts: drafts, store: store)
+ LabeledContent("Email", value: row.account.email ?? "—")
+ LabeledContent("Tier", value: PanelText.tierLabel(row.account.tier))
+ LabeledContent("Folder") {
+ Text(row.account.configDir + (row.account.aliasDirs.isEmpty ? "" : " (+\(row.account.aliasDirs.count) alias)"))
+ .font(.caption).textSelection(.enabled)
+ }
+ LabeledContent("Token", value: row.tokenStatus)
+ // The command that runs Claude Code as this account, in plain sight: paste it
+ // into any terminal. The copy button sits right on it.
+ LabeledContent("Launch") {
+ let command = AddAccountFlow.launchCommand(dir: row.account.configDir, home: addFlow.home)
+ HStack(spacing: 6) {
+ Text(command)
+ .font(.system(.caption, design: .monospaced))
+ .lineLimit(1).truncationMode(.middle)
+ .textSelection(.enabled)
+ Button { AddAccountFlow.copyToPasteboard(command) } label: {
+ Image(systemName: "doc.on.doc")
+ }
+ .buttonStyle(.borderless)
+ .help("Copy the launch command")
+ }
+ }
+ HStack {
+ Toggle("Show", isOn: Binding(
+ get: { !store.settings.isHidden(row.id) },
+ set: { store.settings.accounts[row.id, default: AccountOverride(name: nil, hidden: false)].hidden = !$0 }))
+ Spacer()
+ if row.account.keychainLocked {
+ Button {
+ Task { await store.grantKeychainAccess(row.account) }
+ } label: { Label("Grant Keychain access", systemImage: "key.fill") }
+ .help("macOS asks once; choose “Always Allow”. Needed again only after a fresh `claude auth login` recreates the item.")
+ }
+ Button {
+ AddAccountFlow.copyToPasteboard(AddAccountFlow.launchCommand(dir: row.account.configDir, home: addFlow.home))
+ } label: { Label("Copy launch command", systemImage: "doc.on.doc") }
+ Button {
+ AddAccountFlow.login(dir: row.account.configDir, claudePath: claudePath)
+ } label: { Label("Sign in again", systemImage: "person.badge.key") }
+ .help("Opens Terminal with `claude auth login` for this account; the browser window is this account's own profile.")
+ }
+ HStack {
+ Text("Browser profile: a complete Chrome profile of its own, named after the account — every site's sessions, cookies and saved passwords stay in it between launches.")
+ .font(.caption).foregroundStyle(.secondary)
+ Spacer()
+ Button {
+ AddAccountFlow.openBrowserProfile(dir: row.account.configDir)
+ } label: { Label("Open browser profile", systemImage: "globe") }
+ .help("Open this account's own Chrome profile on claude.ai. Sign in there once (Apple, Google, email — and any other site you like); it all stays, and every later `claude auth login` is one click.")
+ }
+ }
+}
+
+/// The Workspaces part of one account card: all of it while Workspaces runs; while it
+/// does not, the switch that gives back a status line it captured, which goes on
+/// running Brow's wrapper until that switch is turned off.
+private struct WorkspacesCardSection: View {
+ @ObservedObject var host: WorkspacesHost
+ let row: AccountRow
+
+ var body: some View {
+ if host.activation == .on, let state = host.state {
+ WorkspacesCardJoin(state: state, row: row)
+ } else if let account = WorkspacesAccountJoin.match(row.account, in: host.dormantCaptures) {
+ Text("Workspaces").font(.subheadline.weight(.semibold)).padding(.top, 4)
+ DormantCaptureSwitch(host: host, account: account)
+ }
+ }
+}
+
+/// The Workspaces accounts no card above shows (`WorkspacesAccountJoin.unmatched`), each
+/// in a section of its own: every one while Workspaces runs, the ones whose status line
+/// it captured while it does not.
+private struct WorkspacesOtherAccounts: View {
+ @ObservedObject var host: WorkspacesHost
+ let cards: [DiscoveredAccount]
+
+ var body: some View {
+ if host.activation == .on, let state = host.state {
+ WorkspacesOtherAccountList(state: state, cards: cards)
+ } else {
+ ForEach(WorkspacesAccountJoin.unmatched(host.dormantCaptures, cards: cards), id: \.name) { account in
+ let gone = WorkspacesAccountJoin.isGone(account)
+ Section {
+ LabeledContent("Folder") {
+ Text(account.configDir).font(.caption).textSelection(.enabled)
+ }
+ if gone {
+ GoneAccountRow(disabled: host.dormantChangeBlock != nil) { host.removeAccount(account) }
+ if let error = host.changeError(for: account) { FailureNote(text: error) }
+ } else {
+ DormantCaptureSwitch(host: host, account: account)
+ }
+ } header: {
+ Text("Workspaces account “\(account.name)”")
+ } footer: {
+ if gone { GoneAccountRow.footer }
+ }
+ }
+ }
+ }
+}
+
+/// Capture the status line, for an account Workspaces captured while Workspaces is off
+/// or paused: on, and turning it off gives the account its own command back. Not while
+/// Grove.app, or another copy of Brow running Workspaces, could write the same files.
+struct DormantCaptureSwitch: View {
+ @ObservedObject var host: WorkspacesHost
+ let account: AccountConfig
+
+ var body: some View {
+ let waitsFor = host.dormantChangeBlock
+ // Why it is still on is said once, under the account cards (`CaptureFootnote`).
+ Toggle("Capture the status line", isOn: Binding(
+ get: { true },
+ set: { on in if !on { host.releaseStatusLine(account) } }))
+ .disabled(waitsFor != nil)
+ .help(Self.explanation(activation: host.activation, waitsFor: waitsFor))
+ if let error = host.changeError(for: account) {
+ FailureNote(text: error)
+ }
+ }
+
+ /// Why a status line Workspaces captured is still on while Workspaces is not, and how
+ /// to turn it off. `waitsFor`: what writes the same files meanwhile, if anything
+ /// (`WorkspacesHost.dormantChangeBlock`).
+ static func explanation(activation: WorkspacesActivation, waitsFor: WorkspacesPause?) -> String {
+ switch (activation, waitsFor) {
+ case (_, .anotherBrow?):
+ return "Workspaces runs in another copy of Brow, and the status lines it captured still go through Brow's wrapper. Turn capture off there, or quit that copy to turn it off here."
+ case (.paused, _):
+ return "Workspaces is paused while Grove runs, and the status lines it captured still go through Brow's wrapper. Quit Grove to turn Capture the status line off here."
+ case (_, .groveRunning?):
+ return offText + " Grove is running and edits the same files: quit Grove to turn it off here."
+ default:
+ return offText + " Turn it off to put your command back."
+ }
+ }
+
+ private static let offText = "Workspaces is off, but the status line of each account with Capture the status line on still goes through Brow's wrapper, which records each session for the notch and then runs your own command."
+}
+
+/// What Capture the status line does, said once under the account cards instead of in
+/// every card: how it works while Workspaces runs, why it is still on while it does not.
+struct CaptureFootnote: View {
+ @ObservedObject var host: WorkspacesHost
+
+ static let capturing = "Capture the status line points the account's statusLine in settings.json at Brow's wrapper, which records each session's model, context use and limits, then runs your own status-line command. Turning it off puts your command back."
+
+ var body: some View {
+ if let text = Self.text(activation: host.activation, waitsFor: host.dormantChangeBlock,
+ hasAccounts: !(host.state?.config.accounts.isEmpty ?? true),
+ hasDormantCaptures: !host.dormantCaptures.isEmpty) {
+ Text(text)
+ .font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ /// nil when no switch is shown above it. `waitsFor`: as `DormantCaptureSwitch` has it.
+ static func text(activation: WorkspacesActivation, waitsFor: WorkspacesPause?,
+ hasAccounts: Bool, hasDormantCaptures: Bool) -> String? {
+ switch activation {
+ case .on: return hasAccounts ? capturing : nil
+ case .paused, .off:
+ return hasDormantCaptures ? DormantCaptureSwitch.explanation(activation: activation, waitsFor: waitsFor) : nil
+ }
+ }
+}
+
+private struct WorkspacesOtherAccountList: View {
+ @ObservedObject var state: AppState
+ let cards: [DiscoveredAccount]
+ /// What the last Remove could not do.
+ @State private var failure: String?
+
+ var body: some View {
+ ForEach(WorkspacesAccountJoin.unmatched(state.config.accounts, cards: cards), id: \.name) { account in
+ let gone = WorkspacesAccountJoin.isGone(account)
+ Section {
+ LabeledContent("Folder") {
+ Text(account.configDir).font(.caption).textSelection(.enabled)
+ }
+ if gone {
+ GoneAccountRow(disabled: false) {
+ failure = state.attempt { state.removeAccount(name: account.name) }
+ }
+ if let failure { FailureNote(text: failure) }
+ } else {
+ WorkspacesAccountSection(state: state, account: account)
+ }
+ } header: {
+ Text("Workspaces account “\(account.name)”")
+ } footer: {
+ if gone {
+ GoneAccountRow.footer
+ } else {
+ Text("Brow shows no limits for this account: its folder has no sign-in Brow can read, or it is a second folder of an account above.")
+ .font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ }
+}
+
+/// A Workspaces account whose folder is gone. Nothing about it can change any more —
+/// its status line, sessions and settings went with the folder, and nothing re-creates
+/// it — but whether Workspaces still lists it, in its pickers and here.
+struct GoneAccountRow: View {
+ let disabled: Bool
+ let remove: () -> Void
+
+ var body: some View {
+ LabeledContent {
+ Button("Remove from Workspaces", action: remove)
+ .disabled(disabled)
+ .help("Takes the account out of Workspaces' list only: nothing on disk changes")
+ } label: {
+ Label("Its folder is gone", systemImage: "folder.badge.questionmark")
+ }
+ }
+
+ static var footer: some View {
+ Text(explanation)
+ .font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ static let explanation = "Its folder was deleted or is on a disk that isn't connected, so Brow shows no limits for it and nothing can launch in it. Remove from Workspaces takes it out of the account pickers and of Settings; a project that launched on it by default uses the first account instead."
+}
+
+private struct WorkspacesCardJoin: View {
+ @ObservedObject var state: AppState
+ let row: AccountRow
+
+ var body: some View {
+ if let account = WorkspacesAccountJoin.match(row.account, in: state.config.accounts) {
+ Text("Workspaces").font(.subheadline.weight(.semibold)).padding(.top, 4)
+ WorkspacesAccountSection(state: state, account: account)
+ }
+ }
+}
+
+/// A name field whose edits stay local until the user is done with them — but every
+/// keystroke is recorded in `drafts`, which outlives this view. Closing the window
+/// destroys the SwiftUI tree and this `@State` with it, and the focus-loss change is not
+/// guaranteed to be delivered first, so a rename typed and then "finished" with Cmd-W
+/// used to be silently discarded. `drafts` is what `windowWillClose` flushes.
+struct AccountNameField: View {
+ let accountID: String
+ let initial: String
+ let drafts: SettingsDrafts
+ let store: LimitsStore
+ @State private var text: String
+ @FocusState private var focused: Bool
+
+ init(accountID: String, initial: String, drafts: SettingsDrafts, store: LimitsStore) {
+ self.accountID = accountID
+ self.initial = initial
+ self.drafts = drafts
+ self.store = store
+ _text = State(initialValue: initial)
+ }
+
+ var body: some View {
+ TextField("Name", text: $text)
+ .focused($focused)
+ .onChange(of: text) { _, value in drafts.record(value, for: accountID) }
+ .onSubmit { drafts.flush(into: store) }
+ .onChange(of: focused) { _, isFocused in if !isFocused { drafts.flush(into: store) } }
+ .onChange(of: initial) { _, value in if !focused { text = value } }
+ }
+}
+
+/// Launch at login, as macOS has it — read back after every change, so the toggle can
+/// never show on while it is off (brow#8): a register that fails, or one waiting for the
+/// user's approval in System Settings, leaves it off and says why.
+struct LaunchAtLoginRow: View {
+ @State private var status = SMAppService.mainApp.status
+ @State private var failure: String?
+
+ var body: some View {
+ Toggle("Launch at login", isOn: Binding(get: { LaunchAtLogin.isOn(status) }, set: change))
+ if let note = LaunchAtLogin.note(status: status, failure: failure) {
+ HStack {
+ Text(note).font(.caption).foregroundStyle(.orange)
+ Spacer()
+ if status == .requiresApproval {
+ Button("Open Login Items") { SMAppService.openSystemSettingsLoginItems() }
+ }
+ }
+ }
+ }
+
+ private func change(_ on: Bool) {
+ do {
+ if on { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() }
+ failure = nil
+ } catch {
+ failure = error.localizedDescription
+ BrowLog.panel.error("launch at login: \(error.localizedDescription, privacy: .public)")
+ }
+ status = SMAppService.mainApp.status
+ }
+}
+
+/// The launch-at-login toggle's rules, apart from the view.
+enum LaunchAtLogin {
+ /// On only when macOS says it is: waiting for approval is not on.
+ static func isOn(_ status: SMAppService.Status) -> Bool { status == .enabled }
+
+ static func note(status: SMAppService.Status, failure: String?) -> String? {
+ if status == .requiresApproval {
+ return "Waiting for your approval in System Settings › General › Login Items."
+ }
+ if let failure { return "macOS did not change it: \(failure)" }
+ return nil
}
}
diff --git a/Sources/BrowKit/Views/WeekCard.swift b/Sources/BrowKit/Views/WeekCard.swift
index b422bc1..30dce2c 100644
--- a/Sources/BrowKit/Views/WeekCard.swift
+++ b/Sources/BrowKit/Views/WeekCard.swift
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
import SwiftUI
import GroveCore
@@ -119,14 +122,14 @@ extension CardShape {
}
/// The card itself: the expanded notch hanging from the top of a tile in the app
-/// icon's grey. Explicit colours throughout — `ImageRenderer` draws without the
+/// icon's cream. Explicit colours throughout — `ImageRenderer` draws without the
/// panel's darkAqua appearance, so `.secondary` would come out dark on black.
struct WeekCardView: View {
let model: WeekCardModel
let shape: CardShape
/// The app icon's tile.
- private static let tile = Color(red: 0x7A / 255, green: 0x7B / 255, blue: 0x80 / 255)
+ private static let tile = Color(red: 0xF2 / 255, green: 0xDF / 255, blue: 0xB8 / 255)
private static let margin: CGFloat = 20
/// How much larger than the panel's own sizes the content is drawn, largest
/// first; the first that fits the black panel wins. A chat or a feed shows the
diff --git a/Sources/BrowKit/Views/WorkspacesSettingsView.swift b/Sources/BrowKit/Views/WorkspacesSettingsView.swift
new file mode 100644
index 0000000..0f5df83
--- /dev/null
+++ b/Sources/BrowKit/Views/WorkspacesSettingsView.swift
@@ -0,0 +1,347 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import AppKit
+import GroveAppKit
+import GroveCore
+import SwiftUI
+
+/// Settings › Workspaces: on or off, and — while it runs — the settings that are not
+/// any one project's: the projects themselves, the folder new workspaces go in, the
+/// transcript safety net and the panel's material. A project's own settings stay in
+/// the panel, next to the project, where its scan and branch lists are.
+struct WorkspacesSettingsTab: View {
+ @ObservedObject var host: WorkspacesHost
+
+ var body: some View {
+ Form {
+ Section {
+ Toggle("Workspaces", isOn: Binding(get: { host.activation.isWanted },
+ set: { host.setEnabled($0) }))
+ status
+ } footer: {
+ Text(Self.explanation(activation: host.activation, groveSetup: host.isGroveSetup))
+ .font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ if host.activation == .on, let state = host.state {
+ WorkspacesEngineSettings(state: state, host: host)
+ } else if !host.dormantCaptures.isEmpty {
+ capturedStatusLines
+ }
+ }
+ .formStyle(.grouped)
+ .onAppear { host.reloadDormantAccounts() }
+ }
+
+ /// What Workspaces is and does, in the words that are true for this Mac: a setup
+ /// Grove made carries on as Grove had it, a new one starts with nothing turned on.
+ static func explanation(activation: WorkspacesActivation, groveSetup: Bool) -> String {
+ var text = "Workspaces gives each feature of a multi-repo project its own folder of git worktrees and runs Claude Code sessions in them, from Brow's menu-bar item."
+ if activation == .off {
+ text += " Off, none of it runs, with one exception: an account whose status line it captured keeps going through Brow's wrapper until you turn capture off for it under Accounts."
+ }
+ let it = activation == .off ? "Turned on, it" : "It"
+ text += groveSetup
+ ? " \(it) runs the projects, accounts and settings you had in Grove, from Grove's folder, ~/Library/Application Support/Grove: status lines Grove captured stay captured, and the transcript safety net keeps the setting Grove gave it."
+ : " \(it) keeps its projects and settings in ~/Library/Application Support/Grove and edits no Claude Code file until you ask: capturing an account's status line is turned on per account under Accounts, and the transcript safety net starts off for a new setup."
+ return text
+ }
+
+ /// While Workspaces is off or paused, the status lines it captured: they keep running
+ /// Brow's wrapper until given back.
+ @ViewBuilder private var capturedStatusLines: some View {
+ let waitsFor = host.dormantChangeBlock
+ Section {
+ LabeledContent {
+ Button("Give them back") { host.releaseAllStatusLines() }
+ .disabled(waitsFor != nil)
+ } label: {
+ Text(host.dormantCaptures.map(host.displayName(for:)).joined(separator: ", "))
+ }
+ ForEach(host.changeErrors(for: host.dormantCaptures), id: \.self) { error in
+ FailureNote(text: error)
+ }
+ } header: {
+ Text("Status lines still captured")
+ } footer: {
+ Text(waitsFor.map { WorkspacesHost.pausedChangeNote($0) }
+ ?? "Each of these accounts' status line goes through Brow's wrapper, which records each session for the notch, then runs your own command. Give them back to put your own commands in place.")
+ .font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ @ViewBuilder private var status: some View {
+ switch host.activation {
+ case .on:
+ Label("On — Brow's menu-bar item opens it, and so does the button in the notch panel.",
+ systemImage: "checkmark.circle.fill")
+ .font(.caption).foregroundStyle(.secondary)
+ case .paused where host.pause == .anotherBrow:
+ HStack {
+ Label("Paused: another copy of Brow is running Workspaces. It starts here when that copy quits.",
+ systemImage: "pause.circle.fill")
+ .font(.caption).foregroundStyle(.orange)
+ Spacer()
+ Button("Try Again") { host.refresh() }
+ }
+ case .paused:
+ HStack {
+ Label("Paused: Grove is still running. Workspaces starts when Grove quits.",
+ systemImage: "pause.circle.fill")
+ .font(.caption).foregroundStyle(.orange)
+ Spacer()
+ Button("Quit Grove") { host.quitGrove() }
+ }
+ case .off:
+ EmptyView()
+ }
+ }
+}
+
+/// The engine's own settings; only while it runs, never while Grove.app might be
+/// writing the same file.
+struct WorkspacesEngineSettings: View {
+ @ObservedObject var state: AppState
+ let host: WorkspacesHost
+ @State private var material = WindowSubstrateStyle.current
+ /// What the last change made here could not do.
+ @State private var failure: String?
+
+ var body: some View {
+ if let failure {
+ Section { FailureNote(text: failure) }
+ }
+ Section("Projects") {
+ if state.config.projects.isEmpty {
+ Text("No projects yet. A project is a folder that holds your repositories.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ ForEach(state.config.projects) { project in
+ HStack {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(project.name)
+ Text(project.path).font(.caption.monospaced()).foregroundStyle(.secondary)
+ .lineLimit(1).truncationMode(.middle)
+ }
+ Spacer()
+ Button("Remove") { run { state.removeProject(id: project.id) } }
+ .help("Takes the project out of Workspaces only: no repository, worktree or branch is touched")
+ }
+ }
+ Button("Add project…", action: addProject)
+ }
+ Section {
+ // The label in the form's own font; only the value, a path, is monospaced.
+ LabeledContent("Workspace folder") {
+ TextField("Workspace folder", text: Binding(get: { state.config.workspacesRootTemplate },
+ set: { template in run { state.setWorkspacesRootTemplate(template) } }))
+ .labelsHidden()
+ .font(.system(.body, design: .monospaced))
+ .multilineTextAlignment(.trailing)
+ }
+ } header: {
+ Text("Workspace folders")
+ } footer: {
+ Text("Where a project's workspaces are made. {project} is the project's name; a project's own setting wins.")
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Section {
+ Toggle("Keep a backup of every session transcript", isOn: mirrorBinding(\.enabled))
+ Stepper("Keep for at most \(state.config.transcriptMirror.maxDays) days",
+ value: mirrorBinding(\.maxDays), in: 1...365)
+ Stepper("Use at most \(state.config.transcriptMirror.maxMB) MB",
+ value: mirrorBinding(\.maxMB), in: 10...10_000, step: 10)
+ retentionRows
+ if state.config.transcriptMirror.enabled {
+ RestorableTranscriptRows(state: state)
+ }
+ } header: {
+ Text("Transcript safety net")
+ } footer: {
+ Text(Self.safetyNetExplanation)
+ .font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .task {
+ if state.config.transcriptMirror.enabled { await state.reconcileTranscripts() }
+ else { await state.refreshTranscriptRetention() }
+ }
+ Section("Panel") {
+ Picker("Material", selection: Binding(get: { material }, set: { style in
+ material = style
+ host.setPanelMaterial(style)
+ })) {
+ ForEach(WindowSubstrateStyle.allCases, id: \.self) { Text($0.label).tag($0) }
+ }
+ .pickerStyle(.segmented)
+ }
+ }
+
+ static let safetyNetExplanation = "A hard link to each transcript, so one deleted by accident can be put back. Nothing comes back by itself: a transcript that vanishes is listed here, and Restore puts it back. The safety net keeps no transcript longer than Claude Code keeps it (cleanupPeriodDays: the shortest that your organization's managed settings, the account's settings or any project's settings set), and offers none back within a day of that. A period passed to claude with \u{2011}\u{2011}settings is not visible to it."
+
+ @ViewBuilder private var retentionRows: some View {
+ ForEach(state.config.accounts, id: \.name) { account in
+ if let retention = state.transcriptRetention[account.name] {
+ LabeledContent(state.displayName(for: account)) {
+ Text("Claude Code keeps \(retention.summary)")
+ .font(.caption)
+ .foregroundStyle(retention.isCertain ? Color.secondary : Color.orange)
+ }
+ }
+ }
+ }
+
+ private func mirrorBinding(_ keyPath: WritableKeyPath) -> Binding {
+ Binding(get: { state.config.transcriptMirror[keyPath: keyPath] },
+ set: { value in
+ var settings = state.config.transcriptMirror
+ settings[keyPath: keyPath] = value
+ run { state.setTranscriptMirror(settings) }
+ })
+ }
+
+ private func run(_ action: () -> Void) { failure = state.attempt(action) }
+
+ private func addProject() {
+ let panel = NSOpenPanel()
+ panel.canChooseFiles = false
+ panel.canChooseDirectories = true
+ panel.allowsMultipleSelection = false
+ panel.prompt = "Add Project"
+ panel.message = "Pick the folder that contains your repositories."
+ if panel.runModal() == .OK, let url = panel.url {
+ run { state.addProject(at: url.path) }
+ }
+ }
+}
+
+/// What a change made in Settings could not do, under the control that made it: the
+/// engine reports a failure in the panel's banner, which is not where the user is looking.
+struct FailureNote: View {
+ let text: String
+
+ var body: some View {
+ Label(text, systemImage: "exclamationmark.triangle.fill")
+ .font(.caption).foregroundStyle(.orange)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+}
+
+/// An account card's Workspaces section, for the Workspaces account that shares the
+/// card's config folder: sharing sessions with the other accounts, capturing the status
+/// line, and the account's default model and effort.
+struct WorkspacesAccountSection: View {
+ @ObservedObject var state: AppState
+ let account: AccountConfig
+ /// What the last change made here could not do: sharing that could not link, a
+ /// settings.json the capture switch could not edit, a setting not saved.
+ @State private var failure: String?
+
+ private var current: AccountConfig {
+ state.config.accounts.first { $0.name == account.name } ?? account
+ }
+
+ var body: some View {
+ let account = current
+ LabeledContent("Share sessions") {
+ if expandTilde(account.configDir) == state.folders.canonicalDir {
+ Text("The default account: the others share into it").font(.caption).foregroundStyle(.secondary)
+ } else if account.sharedStore {
+ Label("Shared with the default account", systemImage: "link").font(.caption)
+ } else {
+ Button("Share sessions") { run { state.linkAccount(account) } }
+ .help("Links this account's session stores into ~/.claude, so Resume as… works in both directions")
+ }
+ }
+ // What it does is said once, under the account cards (`CaptureFootnote`).
+ Toggle("Capture the status line", isOn: Binding(
+ get: { current.monitoring },
+ set: { on in run { if on { state.installMonitoring(current) } else { state.disableMonitoring(current) } } }))
+ .help(CaptureFootnote.capturing)
+ LabeledContent("Default model") {
+ ModelPicker(model: Binding(get: { current.defaultModel },
+ set: { model in run { state.setAccountModel(name: account.name, model: model) } }))
+ }
+ LabeledContent("Default effort") {
+ Picker("", selection: Binding(get: { current.defaultEffort },
+ set: { effort in run { state.setAccountEffort(name: account.name, effort: effort) } })) {
+ Text("(default)").tag(String?.none)
+ ForEach(ClaudeService.effortLevels, id: \.self) { Text($0).tag(String?.some($0)) }
+ }
+ .labelsHidden().fixedSize()
+ }
+ if let failure { FailureNote(text: failure) }
+ }
+
+ private func run(_ action: () -> Void) { failure = state.attempt(action) }
+}
+
+/// The transcripts the safety net can put back, newest first, each with Restore: gone from
+/// Claude Code's folders, inside every retention that applies. Nothing comes back without
+/// the button — Claude Code may have removed one on purpose, under a period Brow can't see.
+struct RestorableTranscriptRows: View {
+ @ObservedObject var state: AppState
+ @State private var working = false
+ @State private var note: String?
+
+ /// How many are listed one by one; Restore all covers the rest.
+ static let shown = 6
+
+ var body: some View {
+ let all = state.restorableTranscripts
+ if !all.isEmpty {
+ LabeledContent {
+ if all.count > 1 {
+ Button("Restore all \(all.count)") { restore(all) }.disabled(working)
+ }
+ } label: {
+ Text(all.count == 1 ? "1 transcript is gone from Claude Code's folders"
+ : "\(all.count) transcripts are gone from Claude Code's folders")
+ }
+ ForEach(all.prefix(Self.shown)) { item in
+ HStack(alignment: .firstTextBaseline) {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(Self.place(item))
+ .font(.caption.monospaced()).lineLimit(1).truncationMode(.middle)
+ Text(Self.detail(item, now: Date(), name: state.displayName(forAccountNamed:)))
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button("Restore") { restore([item]) }.disabled(working)
+ }
+ }
+ if all.count > Self.shown {
+ Text("and \(all.count - Self.shown) more").font(.caption).foregroundStyle(.secondary)
+ }
+ if let note { Text(note).font(.caption).foregroundStyle(.secondary) }
+ }
+ }
+
+ private func restore(_ chosen: [RestorableTranscript]) {
+ working = true
+ Task { @MainActor in
+ let restored = await state.restoreTranscripts(chosen)
+ note = Self.outcome(asked: chosen.count, restored: restored)
+ working = false
+ }
+ }
+
+ /// Where the session ran, `~/Code/shop`; its id when the transcript names no folder.
+ static func place(_ item: RestorableTranscript) -> String {
+ item.cwd.map { ($0 as NSString).abbreviatingWithTildeInPath } ?? item.entry.sessionId
+ }
+
+ /// "Work · last active 3 days ago", the account named by `name` as the notch names it.
+ static func detail(_ item: RestorableTranscript, now: Date, name: (String) -> String = { $0 }) -> String {
+ let days = Int(max(0, now.timeIntervalSince(item.entry.lastActivity)) / 86_400)
+ let age = days == 0 ? "today" : days == 1 ? "yesterday" : "\(days) days ago"
+ return [item.accountName.map(name), "last active \(age)"].compactMap { $0 }.joined(separator: " · ")
+ }
+
+ static func outcome(asked: Int, restored: Int) -> String {
+ if restored == asked { return restored == 1 ? "Put back." : "\(restored) put back." }
+ return "\(restored) of \(asked) put back: the others are no longer inside Claude Code's retention, or no longer missing."
+ }
+}
diff --git a/Sources/GroveAppKit/DesignSystem.swift b/Sources/GroveAppKit/DesignSystem.swift
new file mode 100644
index 0000000..c61bea0
--- /dev/null
+++ b/Sources/GroveAppKit/DesignSystem.swift
@@ -0,0 +1,138 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import SwiftUI
+import AppKit
+
+/// The app's accent palette — ONE source of truth for every accent / status /
+/// capacity color. Semantics:
+/// - `primary` (blue): the MAIN accent. Buttons, card icons, all charts' main
+/// color, growth / additions, "on track" / plenty capacity, the running status.
+/// - `negative` (pink): the antonym to growth — deletions, decline, critical /
+/// near-limit capacity.
+/// - `mid` (yellow): the waiting session status and tight / mid capacity.
+/// - `neutral` (gray): no-data / closed / idle.
+///
+/// Hex values are parsed via `Color(hex:)` (ProjectAccent.swift) so the palette
+/// stays declared with its source-of-truth hex strings.
+enum Palette {
+ static let primary = Color(hex: "#3478F6")! // blue 52,120,246
+ static let negative = Color(hex: "#E45C9C")! // pink 228,92,156
+ static let mid = Color(hex: "#F6C844")! // yellow 246,200,68
+ static let neutral = Color.gray // no-data / closed
+
+ /// A continuous "heat" ramp across the brand palette for activity/intensity
+ /// (0…1): calm = blue (primary), mid = yellow, hot = pink (negative). Derived
+ /// from the palette colors themselves (no hardcoded channels) so charts pick up
+ /// any future palette change. Used by the multi-colour daily-usage bars.
+ ///
+ /// A plain sRGB blend between two saturated anchors dips through a washed-out
+ /// middle (interpolating channels desaturates the mix), so the bars read faded.
+ /// We keep the SAME gradation (low→blue, mid→yellow, high→pink) and the SAME
+ /// palette anchors, then re-saturate the blended colour in HSB (boost S ~1.35x,
+ /// nudge brightness up) so every step stays vivid instead of grey-leaning.
+ static func heat(_ t: Double) -> Color {
+ let u = min(max(t, 0), 1)
+ let blended = u < 0.5 ? primary.blended(to: mid, u / 0.5)
+ : mid.blended(to: negative, (u - 0.5) / 0.5)
+ return blended.saturated(by: 1.35, minBrightness: 0.92)
+ }
+}
+
+extension Color {
+ /// Linear RGB interpolation toward `other` by `t` (0…1), via sRGB components.
+ /// Lets brand ramps be built FROM the Palette instead of copying its channels,
+ /// keeping one source of truth (memory: prefer robust over copy-and-sync).
+ func blended(to other: Color, _ t: Double) -> Color {
+ let a = NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self)
+ let b = NSColor(other).usingColorSpace(.sRGB) ?? NSColor(other)
+ let u = CGFloat(min(max(t, 0), 1))
+ return Color(.sRGB,
+ red: Double(a.redComponent + (b.redComponent - a.redComponent) * u),
+ green: Double(a.greenComponent + (b.greenComponent - a.greenComponent) * u),
+ blue: Double(a.blueComponent + (b.blueComponent - a.blueComponent) * u),
+ opacity: Double(a.alphaComponent + (b.alphaComponent - a.alphaComponent) * u))
+ }
+
+ /// Push a colour toward its pure hue in HSB: multiply saturation by `factor`
+ /// (clamped to 1) and raise brightness to at least `minBrightness`. Used to
+ /// re-vivify the daily-usage heat ramp, whose sRGB blend would otherwise pass
+ /// through a desaturated middle. Hue is preserved, so the gradation is intact.
+ func saturated(by factor: CGFloat, minBrightness: CGFloat = 0) -> Color {
+ let c = NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self)
+ var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, alpha: CGFloat = 0
+ c.getHue(&h, saturation: &s, brightness: &b, alpha: &alpha)
+ let boostedS = min(max(s * factor, 0), 1)
+ let boostedB = max(b, minBrightness)
+ return Color(nsColor: NSColor(hue: h, saturation: boostedS, brightness: boostedB, alpha: alpha))
+ }
+}
+
+/// Apple 26 corner system: one radius per chrome level, every corner drawn
+/// with the continuous (squircle) style, and nesting kept CONCENTRIC — an
+/// element inset by `d` inside a rounded parent wants radius `parent - d`,
+/// never an ad-hoc literal. Corner-adjacent nested elements use
+/// ConcentricRectangle (resolved against the `.containerShape` GlassCard
+/// declares); `nested(parent:inset:)` supplies the floor for elements too far
+/// from any container corner to resolve, and the radius for plain
+/// RoundedRectangle fallbacks.
+enum DesignRadius {
+ // Standard, restrained corners. Earlier passes over-rounded the chrome
+ // (26→16 still read too round and pushed text into the curve); these are
+ // conventional macOS radii — a window-like panel, lightly rounded cards,
+ // barely-rounded fields — keeping the concentric ordering panel > card > field.
+
+ /// Full-screen panel/window corners (continuous "squircle"). The window is a
+ /// transparent Liquid Glass surface, so it carries a generous Apple-26 radius.
+ static let panel: CGFloat = 17
+ /// Cards/sections (GlassCard chrome) — the gray content substrates. ~20%
+ /// rounder than the previous 10.5 for a more pronounced iOS-26 squircle.
+ static let card: CGFloat = 12.6
+ /// Text fields, picker chips, log wells.
+ static let field: CGFloat = 10.2
+
+ /// Concentric radius for an element inset inside a rounded parent,
+ /// floored at 4 so tight insets never collapse to sharp corners (and stay
+ /// below `card` so a nested element never out-rounds its parent).
+ static func nested(parent: CGFloat, inset: CGFloat) -> CGFloat {
+ max(4, parent - inset)
+ }
+}
+
+/// Capsule chrome for selection chips (tab strips, view toggles, the graph
+/// repo selector): a plain translucent capsule fill in BOTH live and snapshot
+/// mode. SwiftUI `.glassEffect`/`GlassEffectContainer` crash the window-server
+/// (EXC_BAD_ACCESS in GlassEffectContextResolvedData.updateValue) when nested
+/// inside our AppKit NSGlassEffectView window substrate, AND ImageRenderer
+/// draws .glassEffect views fully invisible offscreen — so the safe
+/// translucent fill (previously the snapshot-only fallback) is now the single
+/// render path everywhere.
+struct SelectionCapsule: ViewModifier {
+ let isOn: Bool
+ /// Faint fill kept under unselected chips (GraphScreen's repo selector
+ /// shows every repo as a chip, so idle chips stay barely visible).
+ var idleOpacity: Double = 0
+
+ func body(content: Content) -> some View {
+ content.background(.white.opacity(isOn ? 0.18 : idleOpacity), in: .capsule)
+ }
+}
+
+extension View {
+ /// Selected state = translucent capsule fill; unselected = `idleOpacity`
+ /// fill. Same single material in live and snapshot mode (see SelectionCapsule
+ /// for why the SwiftUI glass path is gone).
+ func selectionCapsule(isOn: Bool, idleOpacity: Double = 0) -> some View {
+ modifier(SelectionCapsule(isOn: isOn, idleOpacity: idleOpacity))
+ }
+}
+
+/// Pill chrome for the header search field: a plain translucent capsule fill in
+/// BOTH live and snapshot mode. SwiftUI `.glassEffect` crashes nested inside the
+/// AppKit NSGlassEffectView window substrate (and renders invisible offscreen),
+/// so the safe translucent fill (previously snapshot-only) is the single path.
+struct SearchFieldChrome: ViewModifier {
+ func body(content: Content) -> some View {
+ content.background(.white.opacity(0.07), in: Capsule())
+ }
+}
diff --git a/Sources/GroveAppKit/GroveLog.swift b/Sources/GroveAppKit/GroveLog.swift
new file mode 100644
index 0000000..a040828
--- /dev/null
+++ b/Sources/GroveAppKit/GroveLog.swift
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import os
+
+/// Central os.Logger handles for Workspaces, under Brow's one subsystem. Stream with:
+/// log stream --predicate 'subsystem == "dev.artemefimov.brow" AND category IN {"workspaces", "perf"}'
+/// or open Console.app and filter on the subsystem.
+public enum GroveLog {
+ public static let workspaces = Logger(subsystem: "dev.artemefimov.brow", category: "workspaces")
+ /// Open/scan/usage timings (item 2 perf work).
+ public static let perf = Logger(subsystem: "dev.artemefimov.brow", category: "perf")
+}
diff --git a/Sources/GroveAppKit/Presentation/AccountUsage.swift b/Sources/GroveAppKit/Presentation/AccountUsage.swift
new file mode 100644
index 0000000..643afc8
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/AccountUsage.swift
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+// Pure aggregation for the Accounts tab (spec §6.3). No SwiftUI, no I/O.
+
+/// One drill-down row: where an account is active (workspace/worktree -> project).
+public struct AccountUsageEntry: Equatable, Identifiable {
+ public var id: String { project + "/" + location }
+ public let project: String
+ /// Workspace name, or the loose worktree's leaf directory name.
+ public let location: String
+ public let liveCount: Int
+ public let sessionCount: Int
+
+ public init(project: String, location: String, liveCount: Int, sessionCount: Int) {
+ self.project = project
+ self.location = location
+ self.liveCount = liveCount
+ self.sessionCount = sessionCount
+ }
+}
+
+public struct AccountUsage: Equatable {
+ /// Live Claude processes of this account across all loaded snapshots.
+ public let liveCount: Int
+ /// Scanned session transcripts of this account across all loaded snapshots.
+ public let sessionCount: Int
+ /// Per-location breakdown, most active first (live desc, sessions desc,
+ /// then location name) so the order is deterministic regardless of the
+ /// snapshots-dictionary iteration order.
+ public let entries: [AccountUsageEntry]
+
+ public init(liveCount: Int, sessionCount: Int, entries: [AccountUsageEntry]) {
+ self.liveCount = liveCount
+ self.sessionCount = sessionCount
+ self.entries = entries
+ }
+}
+
+/// Aggregates an account's activity over EVERY loaded project snapshot
+/// (workspaces AND loose worktrees), matching by LiveProcess.accountName /
+/// ClaudeSession.accountName. Locations with zero activity are omitted.
+public func accountUsage(account: AccountConfig,
+ snapshots: [ProjectSnapshot]) -> AccountUsage {
+ var entries: [AccountUsageEntry] = []
+ for snapshot in snapshots {
+ for workspace in snapshot.workspaces {
+ let live = workspace.liveProcesses.filter { $0.accountName == account.name }.count
+ let sessions = workspace.sessions.filter { $0.accountName == account.name }.count
+ if live > 0 || sessions > 0 {
+ entries.append(AccountUsageEntry(project: snapshot.project.name,
+ location: workspace.name,
+ liveCount: live, sessionCount: sessions))
+ }
+ }
+ for loose in snapshot.loose {
+ let live = loose.liveProcesses.filter { $0.accountName == account.name }.count
+ let sessions = loose.sessions.filter { $0.accountName == account.name }.count
+ if live > 0 || sessions > 0 {
+ entries.append(AccountUsageEntry(
+ project: snapshot.project.name,
+ location: (loose.entry.path as NSString).lastPathComponent,
+ liveCount: live, sessionCount: sessions))
+ }
+ }
+ }
+ entries.sort { lhs, rhs in
+ if lhs.liveCount != rhs.liveCount { return lhs.liveCount > rhs.liveCount }
+ if lhs.sessionCount != rhs.sessionCount { return lhs.sessionCount > rhs.sessionCount }
+ if lhs.location != rhs.location { return lhs.location < rhs.location }
+ return lhs.project < rhs.project
+ }
+ return AccountUsage(liveCount: entries.reduce(0) { $0 + $1.liveCount },
+ sessionCount: entries.reduce(0) { $0 + $1.sessionCount },
+ entries: entries)
+}
diff --git a/Sources/GroveAppKit/Presentation/CodeStatsPresentation.swift b/Sources/GroveAppKit/Presentation/CodeStatsPresentation.swift
new file mode 100644
index 0000000..3148af7
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/CodeStatsPresentation.swift
@@ -0,0 +1,925 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import SwiftUI
+import GroveCore
+
+// Pure presentation logic for the code-stats screen (Stage 4). No SwiftUI, no I/O —
+// every model below is built from an already-scanned `CodeStats` / `CodeStatsPoint`
+// history (and a `DirNode` skeleton for the exclusion tree) and is fully
+// unit-testable, mirroring DashboardPresentation. The stats screen renders these.
+
+// MARK: - Language bars (one row per language)
+
+/// Which `LanguageStats` field drives the Languages card — the bar length, the
+/// sort order, and the share. The Languages card header selector writes this; the
+/// default is `.code` (the historical behavior). `rawValue` is the human label.
+public enum LanguageMetric: String, CaseIterable, Identifiable, Sendable {
+ case code = "Code"
+ case files = "Files"
+ case total = "Lines" // code + comment + blank
+ case comment = "Comment"
+ case blank = "Blank"
+ public var id: String { rawValue }
+ /// The chosen field's value for one language.
+ public func value(_ l: LanguageStats) -> Int {
+ switch self {
+ case .code: return l.code
+ case .files: return l.files
+ case .total: return l.total
+ case .comment: return l.comment
+ case .blank: return l.blank
+ }
+ }
+}
+
+/// Which cumulative quantity the "over time" growth chart plots. Only these three are
+/// derivable from git history (line counts grouped by file LANGUAGE): `lines` = every
+/// line (netLines), `code`/`data` = lines in code-language / data-language files.
+/// Comment/Blank/Files can't be reconstructed over time (they need file CONTENT at each
+/// historical commit) — they live only in the current Totals/Languages snapshot.
+public enum GrowthMetric: String, CaseIterable, Identifiable, Sendable {
+ case lines = "Lines"
+ case code = "Code"
+ case data = "Data"
+ public var id: String { rawValue }
+}
+
+/// One language's row in the breakdown. `fraction` is this language's metric
+/// value relative to the BUSIEST language's metric value (0…1), so the view draws
+/// bars on a shared axis. `share` is this language's metric value over the SUM of
+/// the metric across all languages (0…1). The formatted strings keep number
+/// formatting out of the view; `metricText`/`shareText` reflect the chosen metric.
+public struct LanguageBar: Equatable, Sendable, Identifiable {
+ public var id: String { language }
+ public let language: String
+ public let code: Int
+ public let comment: Int
+ public let blank: Int
+ public let files: Int
+ public let total: Int // code + comment + blank
+ public let fraction: Double // 0…1 of the max metric across languages
+ public let metricValue: Int // the chosen metric's raw value
+ public let share: Double // 0…1 of the summed metric across languages
+ public let codeText: String // "12.5k"
+ public let commentText: String // "1.2k"
+ public let blankText: String // "840"
+ public let filesText: String // "384"
+ public let metricText: String // the chosen metric's compact value, e.g. "12.5k"
+ public let shareText: String // "42%"
+
+ /// Backwards-compatible initializer: the metric is `code`, so `metricValue`/
+ /// `metricText` mirror code and `total` is derived from the parts. `share` is 0
+ /// (callers that care pass it via the full init below). Kept so the zero-arg
+ /// `languageBars(_:)` default and existing tests compile unchanged.
+ public init(language: String, code: Int, comment: Int, blank: Int, files: Int, fraction: Double) {
+ self.init(language: language, code: code, comment: comment, blank: blank,
+ files: files, total: code + comment + blank, fraction: fraction,
+ metricValue: code, share: 0)
+ }
+
+ /// Full initializer carrying the chosen metric's value + share so the hover
+ /// overlay and bar trailing text can read them directly.
+ public init(language: String, code: Int, comment: Int, blank: Int, files: Int,
+ total: Int, fraction: Double, metricValue: Int, share: Double) {
+ self.language = language
+ self.code = code
+ self.comment = comment
+ self.blank = blank
+ self.files = files
+ self.total = total
+ self.fraction = fraction
+ self.metricValue = metricValue
+ self.share = share
+ self.codeText = formatCompactTokens(code)
+ self.commentText = formatCompactTokens(comment)
+ self.blankText = formatCompactTokens(blank)
+ self.filesText = formatCompactTokens(files)
+ self.metricText = formatCompactTokens(metricValue)
+ self.shareText = "\(Int((share * 100).rounded()))%"
+ }
+}
+
+/// Build one bar per language, driven by `metric` (default `.code`). Bars are
+/// sorted DESC by the chosen metric (ties broken by language name for a stable,
+/// deterministic order); `fraction` is each language's metric value over the
+/// busiest language's, and `share` is its metric value over the summed metric. The
+/// default `.code` over `CodeStats.byLanguage` (already DESC-by-code) preserves the
+/// historical order and per-row text.
+public func languageBars(_ stats: CodeStats, metric: LanguageMetric = .code) -> [LanguageBar] {
+ let langs = stats.byLanguage
+ let maxValue = langs.map { metric.value($0) }.max() ?? 0
+ let sumValue = langs.reduce(0) { $0 + metric.value($1) }
+ return langs
+ .sorted { a, b in
+ let va = metric.value(a), vb = metric.value(b)
+ return va != vb ? va > vb : a.language < b.language
+ }
+ .map { l in
+ let v = metric.value(l)
+ return LanguageBar(
+ language: l.language, code: l.code, comment: l.comment, blank: l.blank,
+ files: l.files, total: l.total,
+ fraction: maxValue > 0 ? Double(v) / Double(maxValue) : 0,
+ metricValue: v,
+ share: sumValue > 0 ? Double(v) / Double(sumValue) : 0)
+ }
+}
+
+// MARK: - Headline totals
+
+/// Thousands-grouped integer: 12481 -> "12,481". Deterministic (POSIX locale).
+func groupedThousands(_ n: Int) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.usesGroupingSeparator = true
+ formatter.groupingSeparator = ","
+ formatter.groupingSize = 3
+ return formatter.string(from: NSNumber(value: n)) ?? "\(n)"
+}
+
+// MARK: - Per-file directory tree (folders + files with summed LOC)
+
+/// One row in the stats-settings directory+FILE tree. Folders carry a SUMMED
+/// `lines` (every descendant file) and a `fileCount`; files carry their own line
+/// total, a `language`, and an `isDataProse` tint flag (and no toggle). `id` is the
+/// `relativePath`, which for a folder is the project-root-relative directory and for
+/// a file is the full project-relative file path. `depth` indents the row;
+/// `excludedByAncestor` distinguishes an inherited exclusion (disabled toggle) from a
+/// directly-set one.
+public struct FileTreeRow: Equatable, Sendable, Identifiable {
+ public var id: String { relativePath }
+ public let name: String
+ public let depth: Int
+ public let relativePath: String
+ public let lines: Int // summed for folders, single file for files
+ public let fileCount: Int // descendant files for folders, 1 for files
+ public let isFolder: Bool
+ public let isExcluded: Bool
+ public let excludedByAncestor: Bool
+ public let language: String? // nil for folders
+ public let isDataProse: Bool // false for folders
+
+ public init(name: String, depth: Int, relativePath: String, lines: Int, fileCount: Int,
+ isFolder: Bool, isExcluded: Bool, excludedByAncestor: Bool,
+ language: String? = nil, isDataProse: Bool = false) {
+ self.name = name
+ self.depth = depth
+ self.relativePath = relativePath
+ self.lines = lines
+ self.fileCount = fileCount
+ self.isFolder = isFolder
+ self.isExcluded = isExcluded
+ self.excludedByAncestor = excludedByAncestor
+ self.language = language
+ self.isDataProse = isDataProse
+ }
+}
+
+/// A nested tree node for the SwiftUI `OutlineGroup`/`DisclosureGroup` rendering.
+/// A folder has non-nil `children` (folders first, then files, each group sorted by
+/// name); a file has `children == nil`. Carries the same display fields as
+/// `FileTreeRow`. Built by `buildFileTreeNodes`; the flat `buildFileTree` is the
+/// depth-first flattening of the same structure (used by unit tests).
+public struct FileTreeNode: Equatable, Sendable, Identifiable {
+ public var id: String { relativePath }
+ public let name: String
+ public let relativePath: String
+ public let lines: Int
+ public let fileCount: Int
+ public let isFolder: Bool
+ public let isExcluded: Bool
+ public let excludedByAncestor: Bool
+ public let language: String?
+ public let isDataProse: Bool
+ public let children: [FileTreeNode]?
+
+ public init(name: String, relativePath: String, lines: Int, fileCount: Int,
+ isFolder: Bool, isExcluded: Bool, excludedByAncestor: Bool,
+ language: String? = nil, isDataProse: Bool = false,
+ children: [FileTreeNode]? = nil) {
+ self.name = name
+ self.relativePath = relativePath
+ self.lines = lines
+ self.fileCount = fileCount
+ self.isFolder = isFolder
+ self.isExcluded = isExcluded
+ self.excludedByAncestor = excludedByAncestor
+ self.language = language
+ self.isDataProse = isDataProse
+ self.children = children
+ }
+}
+
+/// Intermediate mutable folder used while assembling the tree from a flat file list.
+private final class _FolderBuilder {
+ let path: String // project-relative dir ("" for the synthetic root)
+ var subfolders: [String: _FolderBuilder] = [:] // child dir name -> builder
+ var files: [StatFileEntry] = [] // files directly in this dir
+ init(path: String) { self.path = path }
+}
+
+/// Group a flat `[StatFileEntry]` into a folder tree, then flatten DEPTH-FIRST into
+/// display rows: each folder row is followed by its SUBFOLDERS (sorted by name) and
+/// then its files (sorted by name) recursively — folders-first then files at every
+/// level, so the list reads as an indented tree and matches `buildFileTreeNodes` /
+/// the spec. Folder `lines`/`fileCount` are SUMMED over every descendant file. A
+/// folder is `isExcluded` when it (or any ancestor) is in `ignoredFolders`; its
+/// files/subfolders inherit that with `excludedByAncestor`. PURE: no I/O.
+public func buildFileTree(files: [StatFileEntry], ignoredFolders: Set) -> [FileTreeRow] {
+ let root = _assembleFolderTree(files: files)
+ var rows: [FileTreeRow] = []
+ func walk(_ folder: _FolderBuilder, depth: Int, ancestorExcluded: Bool) {
+ let selfExcluded = !folder.path.isEmpty && ignoredFolders.contains(folder.path)
+ let excluded = ancestorExcluded || selfExcluded
+ // Emit the folder row (the synthetic root is never emitted).
+ if !folder.path.isEmpty {
+ let (lines, count) = _folderTotals(folder)
+ rows.append(FileTreeRow(
+ name: _leafName(folder.path), depth: depth, relativePath: folder.path,
+ lines: lines, fileCount: count, isFolder: true,
+ isExcluded: excluded, excludedByAncestor: ancestorExcluded))
+ }
+ let childDepth = folder.path.isEmpty ? 0 : depth + 1
+ // Subfolders first (sorted), then files (sorted) — folders-first, matching
+ // `buildFileTreeNodes` and the spec.
+ for sub in folder.subfolders.values.sorted(by: { _leafName($0.path) < _leafName($1.path) }) {
+ walk(sub, depth: childDepth, ancestorExcluded: excluded)
+ }
+ for file in folder.files.sorted(by: { _leafName($0.path) < _leafName($1.path) }) {
+ rows.append(FileTreeRow(
+ name: _leafName(file.path), depth: childDepth, relativePath: file.path,
+ lines: file.lines, fileCount: 1, isFolder: false,
+ isExcluded: excluded, excludedByAncestor: excluded,
+ language: file.language, isDataProse: file.isDataProse))
+ }
+ }
+ walk(root, depth: 0, ancestorExcluded: false)
+ return rows
+}
+
+/// The nested-node form of `buildFileTree`, for `OutlineGroup`/`DisclosureGroup`
+/// (lazy disclosure). Children are folders-first then files, each sorted by name.
+/// PURE: no I/O.
+public func buildFileTreeNodes(files: [StatFileEntry], ignoredFolders: Set) -> [FileTreeNode] {
+ let root = _assembleFolderTree(files: files)
+ func build(_ folder: _FolderBuilder, ancestorExcluded: Bool) -> FileTreeNode {
+ let selfExcluded = !folder.path.isEmpty && ignoredFolders.contains(folder.path)
+ let excluded = ancestorExcluded || selfExcluded
+ var children: [FileTreeNode] = []
+ // Subfolders first (sorted), then files (sorted).
+ for sub in folder.subfolders.values.sorted(by: { _leafName($0.path) < _leafName($1.path) }) {
+ children.append(build(sub, ancestorExcluded: excluded))
+ }
+ for file in folder.files.sorted(by: { _leafName($0.path) < _leafName($1.path) }) {
+ children.append(FileTreeNode(
+ name: _leafName(file.path), relativePath: file.path, lines: file.lines,
+ fileCount: 1, isFolder: false, isExcluded: excluded,
+ excludedByAncestor: excluded, language: file.language,
+ isDataProse: file.isDataProse, children: nil))
+ }
+ let (lines, count) = _folderTotals(folder)
+ return FileTreeNode(
+ name: _leafName(folder.path), relativePath: folder.path, lines: lines,
+ fileCount: count, isFolder: true, isExcluded: excluded,
+ excludedByAncestor: ancestorExcluded, children: children)
+ }
+ // The synthetic root is not emitted; return its children as the top-level nodes.
+ let rootNode = build(root, ancestorExcluded: false)
+ return rootNode.children ?? []
+}
+
+/// Assemble the synthetic-root folder tree from a flat file list, creating every
+/// intermediate folder along each file's path.
+private func _assembleFolderTree(files: [StatFileEntry]) -> _FolderBuilder {
+ let root = _FolderBuilder(path: "")
+ for file in files {
+ let comps = file.path.split(separator: "/").map(String.init)
+ guard !comps.isEmpty else { continue }
+ var folder = root
+ var prefix = ""
+ // Walk/create each intermediate folder (all components except the last = file).
+ for comp in comps.dropLast() {
+ prefix = prefix.isEmpty ? comp : prefix + "/" + comp
+ if let existing = folder.subfolders[comp] {
+ folder = existing
+ } else {
+ let made = _FolderBuilder(path: prefix)
+ folder.subfolders[comp] = made
+ folder = made
+ }
+ }
+ folder.files.append(file)
+ }
+ return root
+}
+
+/// Summed (lines, fileCount) over a folder and all its descendants.
+private func _folderTotals(_ folder: _FolderBuilder) -> (lines: Int, fileCount: Int) {
+ var lines = folder.files.reduce(0) { $0 + $1.lines }
+ var count = folder.files.count
+ for sub in folder.subfolders.values {
+ let (l, c) = _folderTotals(sub)
+ lines += l; count += c
+ }
+ return (lines, count)
+}
+
+/// Last `/`-separated component of a path ("" → "").
+private func _leafName(_ path: String) -> String {
+ path.split(separator: "/").last.map(String.init) ?? path
+}
+
+// MARK: - Code vs Data/Prose split (two headline numbers)
+
+/// The Totals card's two headline numbers: "Code" lines (non-data languages) and
+/// "Data/Prose" lines (Markdown/JSON/YAML/TOML), plus their file counts. Line counts
+/// use each language's `total` so the headline matches the language-table rows.
+public struct DataProseTotals: Equatable, Sendable {
+ public let codeLines: Int
+ public let dataProseLines: Int
+ public let codeFiles: Int
+ public let dataProseFiles: Int
+ public let codeLinesText: String // grouped thousands, e.g. "281,989"
+ public let dataProseLinesText: String
+ public let codeFilesText: String
+ public let dataProseFilesText: String
+
+ public init(codeLines: Int, dataProseLines: Int, codeFiles: Int, dataProseFiles: Int) {
+ self.codeLines = codeLines
+ self.dataProseLines = dataProseLines
+ self.codeFiles = codeFiles
+ self.dataProseFiles = dataProseFiles
+ self.codeLinesText = groupedThousands(codeLines)
+ self.dataProseLinesText = groupedThousands(dataProseLines)
+ self.codeFilesText = groupedThousands(codeFiles)
+ self.dataProseFilesText = groupedThousands(dataProseFiles)
+ }
+}
+
+/// Partition a scan's `byLanguage` into Code vs Data/Prose using
+/// `CodeStatsEngine.isDataProse`. "Lines" sums each language's `total`.
+public func dataProseBreakdown(_ stats: CodeStats) -> DataProseTotals {
+ var codeLines = 0, dataLines = 0, codeFiles = 0, dataFiles = 0
+ for l in stats.byLanguage {
+ if CodeStatsEngine.isDataProse(l.language) {
+ dataLines += l.total; dataFiles += l.files
+ } else {
+ codeLines += l.total; codeFiles += l.files
+ }
+ }
+ return DataProseTotals(codeLines: codeLines, dataProseLines: dataLines,
+ codeFiles: codeFiles, dataProseFiles: dataFiles)
+}
+
+// MARK: - Period selection (window recompute, no re-scan)
+
+/// The Totals card's period segmented-control model (7d / 30d / 90d / 180d / 360d).
+/// "All" was removed — the user only wants bounded net-delta windows; the bars' visible
+/// viewport still defaults to ~180 days independently of this selection.
+public enum StatsPeriod: String, CaseIterable, Identifiable, Sendable {
+ case d7 = "7d"
+ case d30 = "30d"
+ case d90 = "90d"
+ case d180 = "180d"
+ case d360 = "360d"
+ public var id: String { rawValue }
+ /// Window length in days. Always bounded now (no unbounded "All" case).
+ public var days: Int {
+ switch self {
+ case .d7: return 7
+ case .d30: return 30
+ case .d90: return 90
+ case .d180: return 180
+ case .d360: return 360
+ }
+ }
+ /// Window start instant: `now − days·86400`.
+ public func start(now: Date) -> Date {
+ now.addingTimeInterval(-Double(days) * 24 * 3600)
+ }
+}
+
+// MARK: - Delta-triangle formatting (shared by totals + repo cards)
+
+/// A formatted ▲/▼ delta. `direction` drives the tint (.up→primary, .down→negative,
+/// .flat→neutral); `label` is the display string ("▲ +1,240" / "▼ −50" / "±0").
+public struct DeltaTriangle: Equatable, Sendable {
+ public enum Direction: Sendable { case up, down, flat }
+ public let direction: Direction
+ public let label: String
+ public init(direction: Direction, label: String) {
+ self.direction = direction
+ self.label = label
+ }
+}
+
+/// Build a `DeltaTriangle` from a net line count. Positive → ▲ "+N"; negative → ▼ with
+/// a U+2212 MINUS and the absolute value; zero → "±0". Counts are grouped-thousands.
+public func deltaTriangle(net: Int) -> DeltaTriangle {
+ if net > 0 {
+ return DeltaTriangle(direction: .up, label: "▲ +\(groupedThousands(net))")
+ } else if net < 0 {
+ return DeltaTriangle(direction: .down, label: "▼ \u{2212}\(groupedThousands(abs(net)))")
+ } else {
+ return DeltaTriangle(direction: .flat, label: "±0")
+ }
+}
+
+/// A COMPACT `DeltaTriangle` for the Totals hero: the magnitude is rounded to whole thousands
+/// with a "K" suffix once it reaches 1,000 (the user doesn't need exact churn in the delta —
+/// "+11K" reads at a glance), and shown exactly below 1,000. Sign + direction are unchanged:
+/// ▲ "+11K" / ▼ "−2K" / exact "+850" / "±0". Rounding is to NEAREST thousand (11,800 → 12K).
+public func compactDeltaTriangle(net: Int) -> DeltaTriangle {
+ let mag = abs(net)
+ let num: String = mag >= 1000
+ ? "\(Int((Double(mag) / 1000).rounded()))K"
+ : "\(mag)"
+ if net > 0 {
+ return DeltaTriangle(direction: .up, label: "▲ +\(num)")
+ } else if net < 0 {
+ return DeltaTriangle(direction: .down, label: "▼ \u{2212}\(num)")
+ } else {
+ return DeltaTriangle(direction: .flat, label: "±0")
+ }
+}
+
+// MARK: - Net-lines delta (cumulative-state difference, churn-free)
+
+/// Carry-forward value of a cumulative series AS OF an instant `t`: the value of the
+/// LAST point whose date is ≤ `t`, or 0 before the first point. The series must be a
+/// cumulative/running total (e.g. `CodeStatsPoint.totalLines` or `RepoHistoryPoint.netLines`),
+/// so this reads the codebase SIZE at `t` — not per-day activity. Points may be in any
+/// order; this finds the max-dated point ≤ `t`.
+private func cumulativeValue(_ series: [T], asOf t: Date,
+ date: (T) -> Date, value: (T) -> Int) -> Int {
+ var result = 0
+ var best: Date? = nil
+ for p in series {
+ let d = date(p)
+ guard d <= t else { continue }
+ if best == nil || d > best! { best = d; result = value(p) }
+ }
+ return result
+}
+
+/// Net codebase growth over a period = (cumulative total AS OF now) − (cumulative total
+/// AS OF the period start), read from an aggregate cumulative history
+/// (`CodeStatsPoint.totalLines` is the carried-forward project size). This is a STATE
+/// DIFFERENCE: a line churned 5× counts ONCE (telescoping), so it's the honest net
+/// "did the codebase grow or shrink" number — not gross added/removed churn.
+public func netLinesDelta(_ history: [CodeStatsPoint], period: StatsPeriod, now: Date) -> Int {
+ let nowValue = cumulativeValue(history, asOf: now, date: { $0.date }, value: { $0.totalLines })
+ let startValue = cumulativeValue(history, asOf: period.start(now: now),
+ date: { $0.date }, value: { $0.totalLines })
+ return nowValue - startValue
+}
+
+/// Per-repo net growth over a period from that repo's cumulative `netLines` history
+/// (state difference, churn-free). Mirrors the aggregate `netLinesDelta`.
+public func netLinesDelta(repo history: [RepoHistoryPoint], period: StatsPeriod, now: Date) -> Int {
+ let nowValue = cumulativeValue(history, asOf: now, date: { $0.date }, value: { $0.netLines })
+ let startValue = cumulativeValue(history, asOf: period.start(now: now),
+ date: { $0.date }, value: { $0.netLines })
+ return nowValue - startValue
+}
+
+/// Per-category net growth (code vs data/prose) over a period, reconstructed from the
+/// classified PER-DAY churn carried on each point. The classified fields are per-day
+/// (not cumulative), so we telescope them into a cumulative classified state and take
+/// the state difference across the window — equivalent to summing the per-day net
+/// (codeAdded − codeRemoved) over `[periodStart, now]`. This counts a churned line's NET
+/// effect once per day it changed; over the whole window it telescopes to the net code /
+/// data growth. The window is INCLUSIVE on both ends (`[periodStart, now]`), matching
+/// `GitStatsService.delta`/`languageSplitDelta`. Returns `(code, dataProse)` net line
+/// counts (may be negative).
+public func netLinesDeltaByCategory(_ history: [CodeStatsPoint], period: StatsPeriod, now: Date)
+ -> (code: Int, dataProse: Int) {
+ let start = period.start(now: now)
+ var codeNet = 0, dataNet = 0
+ for point in history where point.date >= start && point.date <= now {
+ codeNet += point.codeAdded - point.codeRemoved
+ dataNet += point.dataAdded - point.dataRemoved
+ }
+ return (code: codeNet, dataProse: dataNet)
+}
+
+// MARK: - Repo scope (filter every stats block to one repo, or all)
+
+/// The shared controls strip's repository selector model: "All repos" (`name == nil`)
+/// plus one entry per repo, sorted by name (matching the per-repo blocks + legend order).
+/// `id` is the selection token written by the menu (`name`, with the synthetic "" for All
+/// so SwiftUI's tag is non-optional-friendly). The view reads `name` to drive the scope.
+public struct RepoScopeOption: Equatable, Sendable, Identifiable {
+ public var id: String { name ?? "" }
+ public let name: String? // nil == "All repos"
+ public let label: String // "All repos" / the repo name
+ public init(name: String?, label: String) {
+ self.name = name
+ self.label = label
+ }
+}
+
+/// Build the repo-selector options: "All repos" first, then each repo by name (sorted,
+/// matching `repoCells`/the legend). PURE + deterministic.
+public func repoScopeOptions(_ repos: [RepoStats]) -> [RepoScopeOption] {
+ var options = [RepoScopeOption(name: nil, label: "All repos")]
+ for name in repos.map(\.repoName).sorted() {
+ options.append(RepoScopeOption(name: name, label: name))
+ }
+ return options
+}
+
+/// The aggregate `CodeStats` (Totals headline + Languages bars + line kinds) SCOPED to
+/// the selected repo: `nil` returns the project aggregate unchanged; a repo name returns
+/// that repo's own `stats` (its `byLanguage`, totals, file count). An unknown name falls
+/// back to the aggregate so a stale selection can't blank the screen. PURE.
+public func filterAggregateByRepo(_ aggregate: CodeStats, repos: [RepoStats],
+ repoName: String?) -> CodeStats {
+ guard let repoName, let repo = repos.first(where: { $0.repoName == repoName })
+ else { return aggregate }
+ return repo.stats
+}
+
+/// The aggregate per-day history (drives the Totals net-delta triangles) SCOPED to the
+/// selected repo: `nil` returns the project aggregate history unchanged; a repo name
+/// projects that repo's `[RepoHistoryPoint]` into the `[CodeStatsPoint]` shape the delta
+/// helpers consume — `netLines` (cumulative) → `totalLines`, and the per-day classified
+/// churn fields carry over verbatim, so `netLinesDelta` / `netLinesDeltaByCategory` read
+/// the repo's own growth. The non-delta fields (`code`/`comment`/`blank`/`totalFiles`)
+/// aren't carried per-day on a repo's history, so they're 0 here — the deltas are the
+/// only consumer of this scoped series. PURE.
+public func filterHistoryByRepo(_ aggregateHistory: [CodeStatsPoint], repos: [RepoStats],
+ repoName: String?) -> [CodeStatsPoint] {
+ guard let repoName, let repo = repos.first(where: { $0.repoName == repoName })
+ else { return aggregateHistory }
+ return repo.history.map { p in
+ CodeStatsPoint(date: p.date, totalLines: max(p.netLines, 0),
+ code: 0, comment: 0, blank: 0, totalFiles: 0,
+ dayAdded: p.dayAdded, dayRemoved: p.dayRemoved,
+ codeAdded: p.codeAdded, codeRemoved: p.codeRemoved,
+ dataAdded: p.dataAdded, dataRemoved: p.dataRemoved)
+ }
+}
+
+/// The per-repo list (per-repo blocks + stacked growth chart + legend) SCOPED to the
+/// selected repo: `nil` returns every repo unchanged; a repo name returns just that one
+/// (an unknown name returns all, never an empty screen). PURE.
+public func filterRepoStats(_ repos: [RepoStats], repoName: String?) -> [RepoStats] {
+ guard let repoName else { return repos }
+ let scoped = repos.filter { $0.repoName == repoName }
+ return scoped.isEmpty ? repos : scoped
+}
+
+// MARK: - Stacked cumulative bars (codebase size over time, stacked by repo)
+
+/// One repo's slice of a single day's stacked bar: the repo's carried-forward cumulative
+/// line count (`netLines` as of that day, clamped ≥ 0) on that calendar day.
+public struct StackedSegment: Equatable, Sendable {
+ public let repoName: String
+ public let lines: Int
+ public init(repoName: String, lines: Int) {
+ self.repoName = repoName
+ self.lines = lines
+ }
+}
+
+/// One calendar day's stacked bar: the TOTAL codebase size across all repos that day,
+/// split into one segment per repo. `segments` are sorted BIGGEST-FIRST (the view stacks
+/// them bottom-up, so the biggest repo sits at the bottom; ties broken by repoName for
+/// determinism), and repos with 0 lines that day are dropped. `total` is the sum — the
+/// bar's height. The series GROWS left→right as the cumulative per-repo `netLines` rise.
+public struct StackedDayBar: Equatable, Sendable, Identifiable {
+ public var id: Date { date }
+ public let date: Date // start-of-day (GMT)
+ public let segments: [StackedSegment] // biggest-first (bottom-up), 0-line repos dropped
+ public let total: Int // sum of segments
+
+ public init(date: Date, segments: [StackedSegment]) {
+ self.date = date
+ self.segments = segments
+ self.total = segments.reduce(0) { $0 + $1.lines }
+ }
+}
+
+/// The hard cap on how many days of stacked bars we ever build: 1 YEAR. Older history is
+/// kept intact in the engine; only the drawn window is bounded (matching the churn cap).
+public let stackedBarMaxDaysBack = 365
+
+/// How many of the most-recent stacked days fill the chart's DEFAULT viewport (~6 months).
+/// The full series spans `stackedBarMaxDaysBack` so the strip is scrollable; it opens
+/// scrolled to today showing this many days. Independent of the Totals period.
+public let stackedDefaultVisibleDays = 180
+
+/// Build a DENSE, day-filled stacked cumulative series: for EVERY calendar day from
+/// `daysBack` days ago through `now`'s start-of-day, each repo contributes its
+/// carry-forward cumulative `netLines` (the last point with date ≤ that day; 0 before the
+/// repo's first commit, clamped ≥ 0). The per-day segments are sorted biggest-first
+/// (biggest repo drawn at the bottom), 0-line repos dropped, and `total` is the day's
+/// codebase size. `daysBack` is clamped to `[0, stackedBarMaxDaysBack]`. PURE: no I/O.
+///
+/// This is the "how many lines REALLY existed on a given day" chart — cumulative codebase
+/// size stacked by repo, which grows over time — NOT per-day churn.
+public func stackedRepoSeries(_ repos: [RepoStats], daysBack: Int,
+ metric: GrowthMetric = .lines, now: Date) -> [StackedDayBar] {
+ let cal = GitStatsService.gmtCalendar
+ let capped = min(max(daysBack, 0), stackedBarMaxDaysBack)
+ let windowStart = cal.date(byAdding: .day, value: -capped, to: now) ?? .distantPast
+
+ // Pre-sort each repo's history oldest-first once so the per-day carry-forward is a
+ // single forward walk (a moving pointer) rather than an O(history) scan per day. The
+ // per-point cumulative value for the chosen metric is precomputed here: `lines` is
+ // already cumulative (netLines); `code`/`data` are prefix sums of the per-day classified
+ // deltas (codeAdded-codeRemoved / dataAdded-dataRemoved), so lines == code + data.
+ struct RepoSeries { let name: String; let dates: [Date]; let cum: [Int] }
+ let prepared: [RepoSeries] = repos.map { repo in
+ let pts = repo.history.sorted { $0.date < $1.date }
+ var cum = [Int](); cum.reserveCapacity(pts.count)
+ var running = 0
+ for p in pts {
+ switch metric {
+ case .lines: running = p.netLines
+ case .code: running += p.codeAdded - p.codeRemoved
+ case .data: running += p.dataAdded - p.dataRemoved
+ }
+ cum.append(running)
+ }
+ return RepoSeries(name: repo.repoName, dates: pts.map(\.date), cum: cum)
+ }
+
+ var bars: [StackedDayBar] = []
+ var current = cal.startOfDay(for: windowStart)
+ let end = cal.startOfDay(for: now)
+ guard current <= end else { return [] }
+
+ // One advancing index per repo: the last point with date ≤ current day.
+ var cursors = [Int](repeating: -1, count: prepared.count)
+
+ while current <= end {
+ var segments: [StackedSegment] = []
+ for (i, series) in prepared.enumerated() {
+ // Advance this repo's cursor to the last point whose date ≤ current.
+ var idx = cursors[i]
+ while idx + 1 < series.dates.count && series.dates[idx + 1] <= current {
+ idx += 1
+ }
+ cursors[i] = idx
+ let lines = idx >= 0 ? max(series.cum[idx], 0) : 0
+ if lines > 0 {
+ segments.append(StackedSegment(repoName: series.name, lines: lines))
+ }
+ }
+ // Biggest-first (drawn bottom-up); ties broken by name for determinism.
+ segments.sort { $0.lines != $1.lines ? $0.lines > $1.lines : $0.repoName < $1.repoName }
+ bars.append(StackedDayBar(date: current, segments: segments))
+ guard let next = cal.date(byAdding: .day, value: 1, to: current) else { break }
+ current = next
+ }
+ return bars
+}
+
+/// The peak total (codebase size) across a stacked series (1 floored), the denominator
+/// the view normalizes every bar's height against so the tallest day fills the plot.
+public func stackedPeak(_ bars: [StackedDayBar]) -> Int {
+ max(bars.map(\.total).max() ?? 0, 1)
+}
+
+/// The pixel height of one repo's segment against a resolved plot `height` and a shared
+/// `peak` denominator (from `stackedPeak`): `lines / peak * height`, floored at 1px when
+/// `lines > 0` so a tiny-but-present repo stays visible; 0 when the repo has no lines.
+public func stackedSegmentHeight(lines: Int, peak: Int, height: CGFloat) -> CGFloat {
+ guard lines > 0 else { return 0 }
+ let denom = CGFloat(max(peak, 1))
+ return max(CGFloat(lines) / denom * height, 1)
+}
+
+// MARK: - Stacked chart axes (month X-labels + nice Y-ticks)
+
+/// One X-axis month label for the stacked strip: the short month name and the pixel
+/// `x` offset of the day-column where that month begins.
+public struct MonthLabel: Equatable, Sendable, Identifiable {
+ public var id: CGFloat { x }
+ public let label: String // "Jun" (or "Jun '26" across a year boundary)
+ public let x: CGFloat // dayIndex · slotWidth from the strip's leading edge
+ public init(label: String, x: CGFloat) {
+ self.label = label
+ self.x = x
+ }
+}
+
+/// One label per MONTH BOUNDARY in a day-filled stacked series, positioned under the
+/// bars. A boundary is the first day of each distinct (GMT year, month); index 0 always
+/// emits so the leading edge is labeled. Deduped by construction (one label per month).
+/// `x = dayIndex · slotWidth`. The label is "MMM"; if the series spans more than one
+/// calendar year it disambiguates with "MMM ''yy" so repeated months read distinctly.
+/// PURE + deterministic (POSIX locale, GMT) so snapshots and tests are stable.
+public func monthLabelPositions(_ bars: [StackedDayBar], slotWidth: CGFloat) -> [MonthLabel] {
+ guard !bars.isEmpty else { return [] }
+ let cal = GitStatsService.gmtCalendar
+ // Span >1 calendar year → include the year so "Jun '25" vs "Jun '26" don't collide.
+ let years = Set(bars.map { cal.component(.year, from: $0.date) })
+ let f = DateFormatter()
+ f.locale = Locale(identifier: "en_US_POSIX")
+ f.timeZone = TimeZone(identifier: "GMT")
+ f.dateFormat = years.count > 1 ? "MMM ''yy" : "MMM"
+
+ var labels: [MonthLabel] = []
+ var previousKey: DateComponents? = nil
+ for (i, bar) in bars.enumerated() {
+ let key = cal.dateComponents([.year, .month], from: bar.date)
+ if key != previousKey {
+ labels.append(MonthLabel(label: f.string(from: bar.date), x: CGFloat(i) * slotWidth))
+ previousKey = key
+ }
+ }
+ return labels
+}
+
+/// 3–4 "nice" round Y-axis ticks covering `[0, peak]`: `[0, step, 2·step, …]` where
+/// `step` is a 1/2/5 × 10ⁿ number and the LAST tick is the smallest multiple of `step`
+/// that is ≥ `peak` (so the tallest bar fits under the top gridline). `count` is the
+/// target number of intervals (default 4 → up to ~5 ticks). `peak ≤ 0` → `[0]`. Returns
+/// strictly-increasing tick VALUES; the view formats them (e.g. via `formatCompactTokens`).
+public func niceTicks(peak: Int, count: Int = 4) -> [Int] {
+ guard peak > 0 else { return [0] }
+ let intervals = max(count, 1)
+ let rawStep = Double(peak) / Double(intervals)
+ // Round the raw step UP to the nearest 1/2/5 × 10ⁿ "nice" number.
+ let magnitude = pow(10.0, floor(log10(rawStep)))
+ let normalized = rawStep / magnitude // in [1, 10)
+ let niceNormalized: Double
+ if normalized <= 1 { niceNormalized = 1 }
+ else if normalized <= 2 { niceNormalized = 2 }
+ else if normalized <= 5 { niceNormalized = 5 }
+ else { niceNormalized = 10 }
+ let step = Int((niceNormalized * magnitude).rounded())
+ guard step > 0 else { return [0, peak] }
+ var ticks = [0]
+ var value = step
+ while value < peak {
+ ticks.append(value)
+ value += step
+ }
+ ticks.append(value) // smallest multiple ≥ peak (the top gridline ≥ the tallest bar)
+ return ticks
+}
+
+/// The per-day tooltip for a tapped stacked bar: a short GMT date plus that day's total
+/// codebase size ("Jun 14 · 48,790 lines"). A day before any repo had code reads
+/// "· no code yet". Pure + deterministic (POSIX, GMT) so it's unit-testable.
+public func stackedDayReadout(_ bar: StackedDayBar) -> String {
+ let date = shortDayDateText(bar.date)
+ guard bar.total > 0 else { return "\(date) · no code yet" }
+ let lines = groupedThousands(bar.total)
+ return "\(date) · \(lines) lines"
+}
+
+/// "Jun 14" style short date for a bar's GMT start-of-day. Deterministic
+/// (POSIX locale, GMT) so snapshots and tests are stable.
+public func shortDayDateText(_ date: Date) -> String {
+ let f = DateFormatter()
+ f.locale = Locale(identifier: "en_US_POSIX")
+ f.timeZone = TimeZone(identifier: "GMT")
+ f.dateFormat = "MMM d"
+ return f.string(from: date)
+}
+
+// MARK: - Per-repo color ramp (distinct, readable on dark)
+
+/// A distinct color for the repo at `index` of `count` repos in the stacked chart's
+/// legend + bars, spread across the brand `Palette.heat` ramp (blue → yellow → pink) so
+/// adjacent repos are visibly different and every color stays readable on the dark cards.
+/// A single repo gets `Palette.primary` (no spread). The mapping is pure and stable for a
+/// given (index, count), so a repo keeps its color across days and matches its per-repo
+/// block when callers feed a stable ordering (e.g. sorted-by-name, as `repoCells` does).
+func repoColor(index: Int, count: Int) -> Color {
+ guard count > 1 else { return Palette.primary }
+ let clamped = min(max(index, 0), count - 1)
+ return Palette.heat(Double(clamped) / Double(count - 1))
+}
+
+// MARK: - Per-repo display blocks
+
+/// One per-repo block on the screen: name, default branch (read-only for now), total
+/// LOC, and a NET period delta (cumulative-state difference, churn-free) with a formatted
+/// ▲/▼ triangle. `net` is how much this repo's codebase grew (or shrank) over the period —
+/// a line churned 5× counts once.
+public struct RepoCard: Equatable, Sendable, Identifiable {
+ public var id: String { repoPath }
+ public let repoName: String
+ public let repoPath: String // absolute repo path; keys the branch switcher
+ public let defaultBranch: String // EFFECTIVE branch (resolved or overridden)
+ public let totalLines: Int
+ public let totalLinesText: String
+ public let net: Int // net line growth/shrink over the period
+ public let triangle: DeltaTriangle
+ public init(repoName: String, repoPath: String, defaultBranch: String, totalLines: Int,
+ net: Int) {
+ self.repoName = repoName
+ self.repoPath = repoPath
+ self.defaultBranch = defaultBranch
+ self.totalLines = totalLines
+ self.totalLinesText = groupedThousands(totalLines)
+ self.net = net
+ self.triangle = deltaTriangle(net: net)
+ }
+}
+
+/// Build the per-repo blocks, sorted by repo name. Each block's NET delta is recomputed
+/// for `period` from that repo's cumulative `netLines` history (no re-scan, churn-free
+/// state difference), so switching the period updates every repo in lockstep with the
+/// Totals card.
+public func repoCells(_ repos: [RepoStats], period: StatsPeriod, now: Date) -> [RepoCard] {
+ repos.map { repo in
+ let net = netLinesDelta(repo: repo.history, period: period, now: now)
+ return RepoCard(repoName: repo.repoName, repoPath: repo.repoPath,
+ defaultBranch: repo.defaultBranch,
+ totalLines: repo.stats.totalLines, net: net)
+ }
+ .sorted { $0.repoName < $1.repoName }
+}
+
+// MARK: - Memo caches for expensive in-body aggregations
+
+/// Memoizes `stackedRepoSeries` (the 365-day × N-repo cumulative chart). Reference type so
+/// writing it inside a SwiftUI `body` does NOT itself trigger a re-render. Keyed on
+/// `(projectID, scope, scannedAt, metric)` — the only four inputs that should change the
+/// series. `now` is threaded as a stable anchor captured once per scan (not `Date.now` per
+/// render tick), so the key is stable across hover ticks.
+final class SeriesCache {
+ private var projectID: UUID?
+ private var scope: Set = []
+ private var scannedAt: Date?
+ private var metric: GrowthMetric?
+ private var value: [StackedDayBar]?
+
+ /// Return the cached series if all key fields match; otherwise call `compute`, store, and
+ /// return the result. `compute` receives the repos + anchor `now` and should call
+ /// `stackedRepoSeries` (injected so tests can count invocations without a full scan).
+ func bars(projectID: UUID, scope: Set, scannedAt: Date,
+ metric: GrowthMetric, now: Date, repos: [RepoStats],
+ compute: ([RepoStats], Date) -> [StackedDayBar]) -> [StackedDayBar] {
+ if self.projectID == projectID, self.scope == scope,
+ self.scannedAt == scannedAt, self.metric == metric,
+ let cached = value {
+ return cached
+ }
+ let result = compute(repos, now)
+ self.projectID = projectID
+ self.scope = scope
+ self.scannedAt = scannedAt
+ self.metric = metric
+ self.value = result
+ return result
+ }
+}
+
+/// Memoizes `repoCells` (the per-repo card list). Reference type for the same reason as
+/// `SeriesCache`. Keyed on `(projectID, scope, scannedAt, period)` — period changes drive
+/// a delta recompute, scannedAt/scope changes reflect a new scan or changed repo selection.
+final class RepoCellsCache {
+ private var projectID: UUID?
+ private var scope: Set = []
+ private var scannedAt: Date?
+ private var period: StatsPeriod?
+ private var value: [RepoCard]?
+
+ /// Return the cached cells if all key fields match; otherwise call `compute`, store, and
+ /// return the result. `compute` receives repos + anchor `now`.
+ func cells(projectID: UUID, scope: Set, scannedAt: Date,
+ period: StatsPeriod, now: Date, repos: [RepoStats],
+ compute: ([RepoStats], Date) -> [RepoCard]) -> [RepoCard] {
+ if self.projectID == projectID, self.scope == scope,
+ self.scannedAt == scannedAt, self.period == period,
+ let cached = value {
+ return cached
+ }
+ let result = compute(repos, now)
+ self.projectID = projectID
+ self.scope = scope
+ self.scannedAt = scannedAt
+ self.period = period
+ self.value = result
+ return result
+ }
+}
+
+// MARK: - Compact number formatting
+
+/// 1_234_567 -> "1.2M", 12_300 -> "12.3k", 999 -> "999". The language bars and the
+/// growth chart's axis ticks read through this.
+public func formatCompactTokens(_ n: Int) -> String {
+ let value = Double(n)
+ func trim(_ v: Double) -> String {
+ let s = String(format: "%.1f", v)
+ return s.hasSuffix(".0") ? String(s.dropLast(2)) : s
+ }
+ switch abs(value) {
+ case 1_000_000_000...: return trim(value / 1_000_000_000) + "B"
+ case 1_000_000...: return trim(value / 1_000_000) + "M"
+ case 1_000...: return trim(value / 1_000) + "k"
+ default: return "\(n)"
+ }
+}
diff --git a/Sources/GroveAppKit/Presentation/CreateForm.swift b/Sources/GroveAppKit/Presentation/CreateForm.swift
new file mode 100644
index 0000000..eb47b4b
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/CreateForm.swift
@@ -0,0 +1,106 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+/// Prefill for CreateWorkspaceScreen, carried in AppState.createPrefill and
+/// consumed by the .createWorkspace route. Producers: WorkspacesScreen
+/// ("+ Workspace" -> empty, "+ child workspace" -> forkFrom set) and
+/// GraphScreen ("create workspace from branch" -> branch = existing branch
+/// name, base = that branch as a display hint). Identifiable: every request
+/// carries a fresh id, so re-entering the route starts with clean @State.
+public struct CreatePrefill: Identifiable {
+ public let id = UUID()
+ public var name: String
+ /// nil -> the branch field live-previews from the project's branchTemplate.
+ public var branch: String?
+ public var forkFrom: FeatureWorkspace?
+ /// Display-only base start point (graph prefill: the picked branch).
+ /// The actual start point is resolved by WorkspaceService at create time.
+ public var base: String?
+
+ public init(name: String = "", branch: String? = nil,
+ forkFrom: FeatureWorkspace? = nil, base: String? = nil) {
+ self.name = name
+ self.branch = branch
+ self.forkFrom = forkFrom
+ self.base = base
+ }
+}
+
+/// nil = valid. Mirrors GroveCore's creation-time rule (^[A-Za-z0-9._-]+$)
+/// so the user gets feedback before pressing Create, not after.
+public func workspaceNameIssue(_ name: String) -> String? {
+ if name.isEmpty { return "Enter a workspace name." }
+ if name.range(of: "^[A-Za-z0-9._-]+$", options: .regularExpression) == nil {
+ return "Only letters, digits, '.', '_' and '-' are allowed."
+ }
+ return nil
+}
+
+/// "{name}" substitution; the user can still override the result in the form.
+public func branchPreview(template: String, name: String) -> String {
+ template.replacingOccurrences(of: "{name}", with: name)
+}
+
+/// Best-effort DISPLAY of the start point creation will use for `repo`.
+/// The authoritative resolution happens inside WorkspaceService.createWorkspace;
+/// this mirrors it from scan data: fork-from workspace's branch in this repo
+/// (falling through to the repo base when the parent does not include the
+/// repo, spec §2) > explicit base hint > project baseBranchOverrides >
+/// base branch seen by the scan > "main".
+public func startPointCaption(repo: RepoInfo,
+ forkFrom: FeatureWorkspace?,
+ base: String?,
+ snapshot: ProjectSnapshot) -> String {
+ if let forkFrom {
+ if let branch = forkFrom.repos.first(where: { $0.repo.path == repo.path })?.entry.branch {
+ return branch
+ }
+ // Parent lacks this repo -> fork from the repo's base.
+ } else if let base {
+ return base
+ }
+ if let override = snapshot.project.baseBranchOverrides[repo.dirName] {
+ return override
+ }
+ for workspace in snapshot.workspaces {
+ if let meta = workspace.repos.first(where: { $0.repo.path == repo.path })?.meta {
+ return meta.baseBranch
+ }
+ }
+ for loose in snapshot.loose where loose.repo.path == repo.path {
+ if let meta = loose.meta { return meta.baseBranch }
+ }
+ return "main"
+}
+
+/// Options for a repo's start-point picker: the repo's local branch list with
+/// the resolved default prepended when it is not already listed (the default
+/// can be a parent's feature branch or a not-yet-fetched override). An
+/// empty/not-yet-loaded branch list degrades to just the default.
+public func startPointOptions(default resolved: String, branches: [String]) -> [String] {
+ branches.contains(resolved) ? branches : [resolved] + branches
+}
+
+/// Start-point overrides to pass to createWorkspace, keyed by repo dirName
+/// (the key WorkspaceService expects): only selections that DIFFER from the
+/// repo's resolved default (startPointCaption) count — picking the default is
+/// not an override, so creation keeps its normal resolution for those repos.
+public func resolvedStartPointOverrides(repos: [RepoInfo],
+ selections: [String: String],
+ forkFrom: FeatureWorkspace?,
+ base: String?,
+ snapshot: ProjectSnapshot) -> [String: String] {
+ var overrides: [String: String] = [:]
+ for repo in repos {
+ guard let selected = selections[repo.path] else { continue }
+ let resolved = startPointCaption(repo: repo, forkFrom: forkFrom,
+ base: base, snapshot: snapshot)
+ if selected != resolved {
+ overrides[repo.dirName] = selected
+ }
+ }
+ return overrides
+}
diff --git a/Sources/GroveAppKit/Presentation/FlatOrder.swift b/Sources/GroveAppKit/Presentation/FlatOrder.swift
new file mode 100644
index 0000000..79c312a
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/FlatOrder.swift
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+/// Most recent activity of a workspace: the newest of its session-activity
+/// dates and per-repo last-commit dates. nil = no data at all.
+public func recencyDate(_ workspace: FeatureWorkspace) -> Date? {
+ let sessionDates = workspace.sessions.map(\.lastActivity)
+ let commitDates = workspace.repos.compactMap { $0.meta?.lastCommitDate }
+ return (sessionDates + commitDates).max()
+}
+
+/// Flat-list ordering (spec §6.1 "≡ список"): newest activity first, rows
+/// without any date sink to the bottom, ties break alphabetically by name
+/// so the output is stable.
+public func flattenByRecency(_ rows: [WorkspaceTreeRow]) -> [WorkspaceTreeRow] {
+ rows.sorted { lhs, rhs in
+ let l = recencyDate(lhs.workspace) ?? .distantPast
+ let r = recencyDate(rhs.workspace) ?? .distantPast
+ if l != r { return l > r }
+ return lhs.name < rhs.name
+ }
+}
diff --git a/Sources/GroveAppKit/Presentation/GraphPresentation.swift b/Sources/GroveAppKit/Presentation/GraphPresentation.swift
new file mode 100644
index 0000000..7d524d5
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/GraphPresentation.swift
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+// Pure presentation logic for the Graph tab (spec §6.2). No SwiftUI, no I/O.
+
+// MARK: - Ref chips
+
+/// Origin of a `%D` ref, in display priority order.
+public enum RefKind: Equatable {
+ case head // "HEAD -> branch" (or detached "HEAD")
+ case local // plain local branch name
+ case remote // "origin/..." (v1 marks the conventional remote prefix only)
+ case tag // "tag: name"
+}
+
+/// One capsule chip on a commit row. `branch` is the name usable for branch
+/// actions (create workspace / open Claude): the checked-out branch for HEAD
+/// chips, the name without "origin/" for remote chips, nil for tags and a
+/// detached HEAD.
+public struct RefChip: Equatable, Identifiable {
+ public var id: String { rawRef }
+ /// Verbatim ref as emitted by git (`%D` component).
+ public let rawRef: String
+ public let kind: RefKind
+ /// Display text ("dev", "origin/dev", "v0.4.0").
+ public let name: String
+ public let branch: String?
+
+ public init(rawRef: String, kind: RefKind, name: String, branch: String?) {
+ self.rawRef = rawRef
+ self.kind = kind
+ self.name = name
+ self.branch = branch
+ }
+}
+
+/// Classifies CommitNode.refs into chips, preserving git's order.
+/// "HEAD -> x" -> head(x); bare "HEAD" -> detached head (no branch);
+/// "tag: t" -> tag(t); "origin/x" -> remote (branch = x); else local.
+public func refChips(_ refs: [String]) -> [RefChip] {
+ refs.map { ref in
+ if ref == "HEAD" {
+ return RefChip(rawRef: ref, kind: .head, name: "HEAD", branch: nil)
+ }
+ if ref.hasPrefix("HEAD -> ") {
+ let branch = String(ref.dropFirst("HEAD -> ".count))
+ return RefChip(rawRef: ref, kind: .head, name: branch, branch: branch)
+ }
+ if ref.hasPrefix("tag: ") {
+ return RefChip(rawRef: ref, kind: .tag,
+ name: String(ref.dropFirst("tag: ".count)), branch: nil)
+ }
+ if ref.hasPrefix("origin/") {
+ return RefChip(rawRef: ref, kind: .remote, name: ref,
+ branch: String(ref.dropFirst("origin/".count)))
+ }
+ return RefChip(rawRef: ref, kind: .local, name: ref, branch: ref)
+ }
+}
+
+// MARK: - Branch -> workspace-name prefill
+
+/// Default workspace name when creating from a branch: the leaf path component
+/// ("feat/media-upload" -> "media-upload"), with every character outside
+/// GroveCore's ^[A-Za-z0-9._-]+$ name rule replaced by "-". Empty leaves
+/// (e.g. branch "/") fall back to "workspace".
+public func sanitizedWorkspaceName(fromBranch branch: String) -> String {
+ let leaf = branch.split(separator: "/").last.map(String.init) ?? ""
+ let sanitized = String(leaf.map { char -> Character in
+ if let scalar = char.unicodeScalars.first, char.unicodeScalars.count == 1 {
+ let v = scalar.value
+ let allowed = (v >= 0x30 && v <= 0x39) || (v >= 0x41 && v <= 0x5A)
+ || (v >= 0x61 && v <= 0x7A) || char == "." || char == "_" || char == "-"
+ if allowed { return char }
+ }
+ return "-"
+ })
+ return sanitized.isEmpty ? "workspace" : sanitized
+}
+
+// MARK: - Graph repo auto-selection
+
+/// Repo the Graph tab should auto-load: the snapshot's first repo when the
+/// current selection is nil or STALE (not among the snapshot's repos — e.g.
+/// left over from a previously selected project, v1.2.1 fix 1); nil when the
+/// selection is still valid or there is nothing to select (no reload needed).
+public func graphAutoSelectRepo(current: String?, repos: [RepoInfo]) -> RepoInfo? {
+ if let current, repos.contains(where: { $0.path == current }) { return nil }
+ return repos.first
+}
+
+// MARK: - Branch -> existing worktree lookup
+
+/// Where a branch is already checked out, for "Open Claude in worktree of this
+/// branch" (spec §6.2). cwd is the workspace UMBRELLA (sessions live there)
+/// or the loose worktree path itself.
+public struct WorktreeLocation: Equatable {
+ public let cwd: String
+ public let title: String
+
+ public init(cwd: String, title: String) {
+ self.cwd = cwd
+ self.title = title
+ }
+}
+
+/// First match wins: workspaces in snapshot order (any repo state on the
+/// branch), then loose worktrees. nil = branch not checked out anywhere.
+public func worktreeLocation(forBranch branch: String,
+ in snapshot: ProjectSnapshot) -> WorktreeLocation? {
+ for workspace in snapshot.workspaces {
+ if workspace.repos.contains(where: { $0.entry.branch == branch }) {
+ return WorktreeLocation(cwd: workspace.umbrellaPath, title: workspace.name)
+ }
+ }
+ for loose in snapshot.loose where loose.entry.branch == branch {
+ return WorktreeLocation(cwd: loose.entry.path,
+ title: (loose.entry.path as NSString).lastPathComponent)
+ }
+ return nil
+}
diff --git a/Sources/GroveAppKit/Presentation/ProjectSessionRows.swift b/Sources/GroveAppKit/Presentation/ProjectSessionRows.swift
new file mode 100644
index 0000000..337ecf3
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/ProjectSessionRows.swift
@@ -0,0 +1,82 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+// Pure presentation for the Projects tab's per-project session previews (items
+// 4/7). Joins the cheap recent-session index with live processes + the cmux hook
+// map to produce a user-facing status (running / waiting / closed) and a one-tap
+// terminal target (Go to the cmux workspace, or Resume).
+
+/// One session preview block under a project card.
+public struct ProjectSessionRow: Sendable, Equatable, Identifiable {
+ /// User-facing status trio (item 7): "выполняется / ожидает / закрыта".
+ public enum Status: String, Sendable, Equatable {
+ case running // a live process actively generating (Claude status "busy")
+ case waiting // a live process that finished its turn, awaiting your next
+ // prompt (Claude status "idle"/"shell") — where you jump in
+ case closed // no live process (resumable)
+ }
+
+ public var id: String { cwd + "\u{0}" + sessionId }
+ public let sessionId: String
+ public let title: String
+ public let cwd: String
+ public let location: String // leaf directory of the cwd
+ public let accountName: String
+ public let lastActivity: Date
+ public let status: Status
+ /// cmux workspace hosting the session, if any — present -> "Go", nil -> "Resume".
+ public let cmuxWorkspaceId: String?
+
+ public var canGo: Bool { cmuxWorkspaceId != nil }
+
+ public init(sessionId: String, title: String, cwd: String, location: String,
+ accountName: String, lastActivity: Date, status: Status,
+ cmuxWorkspaceId: String?) {
+ self.sessionId = sessionId
+ self.title = title
+ self.cwd = cwd
+ self.location = location
+ self.accountName = accountName
+ self.lastActivity = lastActivity
+ self.status = status
+ self.cmuxWorkspaceId = cmuxWorkspaceId
+ }
+}
+
+/// Joins recent `sessions` with `live` processes (by sessionId) and the cmux hook
+/// `cmuxMap` to status + a Go/Resume target. `closed` = no live process.
+public func buildProjectSessionRows(sessions: [ClaudeSession],
+ live: [LiveProcess],
+ cmuxMap: [String: String]) -> [ProjectSessionRow] {
+ return sessions.map { s in
+ // Shared id-or-cwd join: by sessionId, else a fresh (empty-id) process carried
+ // by the same canonicalized cwd — so a recent session sitting at a directory
+ // where a bare `claude` is running reads as LIVE, not "closed" (which would let
+ // a tap spawn a transcript-corrupting duplicate). The canonicalization
+ // collapses /var↔/private/var spellings a trailing-slash key would miss.
+ let process = live.liveProcess(forSessionId: s.id, cwd: s.cwd)
+ let status: ProjectSessionRow.Status
+ // Claude Code's live status is "busy" (mid-turn) / "idle" (finished, waiting
+ // for input) / "shell". Only "busy" is genuinely RUNNING; "idle"/"shell"
+ // both map to .idle and mean the session is waiting for your next prompt —
+ // the actionable "where do I jump in" state. nil = no live process = closed.
+ switch process.map({ SessionLiveStatus(rawStatus: $0.status) }) {
+ case .some(.busy): status = .running
+ case .some(.waiting), .some(.idle): status = .waiting
+ case nil: status = .closed
+ }
+ let title = (s.title?.isEmpty == false) ? s.title! : String(s.id.prefix(8))
+ return ProjectSessionRow(
+ sessionId: s.id,
+ title: title,
+ cwd: s.cwd,
+ location: (s.cwd as NSString).lastPathComponent,
+ accountName: s.accountName,
+ lastActivity: s.lastActivity,
+ status: status,
+ cmuxWorkspaceId: cmuxMap[s.id])
+ }
+}
diff --git a/Sources/GroveAppKit/Presentation/SessionRows.swift b/Sources/GroveAppKit/Presentation/SessionRows.swift
new file mode 100644
index 0000000..73d5fb9
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/SessionRows.swift
@@ -0,0 +1,329 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+// Pure aggregation for the Claude Sessions tab (spec §3/§4). No SwiftUI, no I/O.
+// Joins a project snapshot's sessions (workspaces[] + loose[]) with their live
+// processes by sessionId, derives a location and a primary action, and sorts
+// for display. SessionsScreen renders the result as a snapshot-safe table.
+
+/// Live status bucket of a session, or nil when the session has no live process
+/// (then the row is "resumable"). busy/waiting/idle drive the colored dot and
+/// the sort order; any unrecognised live status falls into `.idle`.
+public enum SessionLiveStatus: String, Equatable, Sendable {
+ case busy
+ case waiting
+ case idle
+
+ /// Maps a LiveProcess.status string to a bucket. "shell" and anything
+ /// unknown bucket as `.idle` (the calmest live colour).
+ init(rawStatus: String) {
+ switch rawStatus {
+ case "busy": self = .busy
+ case "waiting": self = .waiting
+ default: self = .idle // "idle", "shell", anything else
+ }
+ }
+
+ /// Sort rank: busy < waiting < idle so a plain ascending sort puts the
+ /// loudest status first.
+ var sortRank: Int {
+ switch self {
+ case .busy: return 0
+ case .waiting: return 1
+ case .idle: return 2
+ }
+ }
+}
+
+/// Primary action for a session row.
+/// - go: jump to the running cmux workspace hosting it.
+/// - resume: relaunch Claude with --resume (in a fresh workspace).
+public enum SessionRowAction: Equatable, Sendable {
+ case go
+ case resume
+}
+
+/// One row of the Claude Sessions table.
+///
+/// `id` is a composite of cwd + sessionId, NOT the raw session id: with a shared
+/// store the SAME sessionId at one cwd is listed under EVERY linked account, so
+/// the scanner flatMaps the same `(cwd, sessionId)` once per account. Collapsing
+/// on (cwd, sessionId) makes a shared session ONE row whose `accounts` set carries
+/// every account it is reachable under, keeping SwiftUI's ForEach well-defined.
+public struct SessionRow: Identifiable, Equatable, Sendable {
+ /// Stable, unique per (cwd, sessionId). See type doc.
+ public let id: String
+ /// The raw Claude session id (NOT unique across accounts).
+ public let sessionId: String
+ public let title: String // session title, or sessionId prefix(8)
+ public let location: String // workspace name / loose worktree leaf
+ public let accountName: String
+ /// Every account this session is reachable under (a shared session is listed
+ /// under every linked account). Sorted for determinism; always contains
+ /// `accountName`. Single-account sessions hold exactly `[accountName]`.
+ public let accounts: [String]
+ public let cwd: String
+ /// nil = no live process (resumable). Otherwise the live status bucket.
+ public let liveStatus: SessionLiveStatus?
+ /// Live process start time (for the runtime column); nil when not live or unknown.
+ public let startedAt: Date?
+ public let lastActivity: Date
+ /// Git branch the session was opened on (from the transcript's first user
+ /// record); nil when unknown. Shown next to the location.
+ public let gitBranch: String?
+ /// Session creation moment (first transcript timestamp) — "created Nd ago".
+ public let createdAt: Date?
+ /// Human-prompt count — "N turns".
+ public let turnCount: Int
+ /// Last model the session ran on — shown short (e.g. "opus-4-8").
+ public let model: String?
+ public let action: SessionRowAction
+ /// For a `.go` row, the cmux workspace id to jump to (resolved from the hook
+ /// map or a cmux workspace already sitting in the session's cwd). nil for
+ /// `.resume` rows. Lets the UI selectWorkspace even on the cwd-only match
+ /// path, instead of relaunching --resume in a fresh workspace.
+ public let cmuxWorkspaceId: String?
+
+ /// Stable Identifiable id for a collapsed session: (cwd, sessionId). A shared
+ /// session at one cwd is ONE row no matter how many accounts list it. The NUL
+ /// joiner can't appear in either field, so the pair maps injectively.
+ public static func rowID(cwd: String, session: String) -> String {
+ cwd + "\u{0}" + session
+ }
+
+ public init(sessionId: String, title: String, location: String, accountName: String,
+ accounts: [String] = [],
+ cwd: String, liveStatus: SessionLiveStatus?, startedAt: Date?,
+ lastActivity: Date, action: SessionRowAction, cmuxWorkspaceId: String? = nil,
+ gitBranch: String? = nil, createdAt: Date? = nil,
+ turnCount: Int = 0, model: String? = nil) {
+ self.id = SessionRow.rowID(cwd: cwd, session: sessionId)
+ self.sessionId = sessionId
+ self.title = title
+ self.location = location
+ self.accountName = accountName
+ self.accounts = accounts.isEmpty ? [accountName] : accounts
+ self.cwd = cwd
+ self.liveStatus = liveStatus
+ self.startedAt = startedAt
+ self.lastActivity = lastActivity
+ self.gitBranch = gitBranch
+ self.createdAt = createdAt
+ self.turnCount = turnCount
+ self.model = model
+ self.action = action
+ self.cmuxWorkspaceId = cmuxWorkspaceId
+ }
+
+ public var isLive: Bool { liveStatus != nil }
+}
+
+/// Distinct account names of a session group, in first-seen order then sorted
+/// for a stable chip layout. Always non-empty.
+private func orderedUniqueAccounts(_ sessions: [ClaudeSession]) -> [String] {
+ var seen: Set = []
+ var result: [String] = []
+ for s in sessions where seen.insert(s.accountName).inserted { result.append(s.accountName) }
+ return result.sorted()
+}
+
+/// Builds the Claude Sessions table for one project snapshot.
+///
+/// Every session in `snapshot.workspaces[].sessions` and
+/// `snapshot.loose[].sessions` becomes a row, joined to its live process by
+/// sessionId within the SAME container. Action is `.go` when the session is
+/// live AND it is either mapped in `cmuxMap` (the hook registry) OR some cmux
+/// workspace in its container already lists the session's cwd; a `.go` row
+/// carries that workspace's id so the UI can selectWorkspace directly. Otherwise
+/// `.resume`. Rows sort live-first (busy, waiting, idle), then resumable by
+/// lastActivity descending; ties break on title then id for determinism.
+///
+/// Collapse: a row is keyed by (cwd, sessionId). With a shared store the SAME
+/// sessionId at one cwd is listed under EVERY linked account, and the scanner
+/// flatMaps every account, so the same (cwd, sessionId) occurs once per account
+/// AND possibly in more than one container. Those occurrences collapse GLOBALLY
+/// into ONE row whose `accounts` set carries every account it is reachable under;
+/// the primary `accountName` is the live owner if any occurrence is live, else
+/// first-seen.
+public func buildSessionRows(snapshot: ProjectSnapshot,
+ cmuxMap: [String: String]) -> [SessionRow] {
+ var rows: [SessionRow] = []
+
+ /// One flattened occurrence of a session in some container, carrying the
+ /// container's live processes and location label so global grouping keeps them.
+ struct Occurrence {
+ let session: ClaudeSession
+ let live: [LiveProcess]
+ let cmux: [CmuxWorkspace]
+ let location: String
+ }
+
+ // 1) Flatten EVERY occurrence across all containers (workspaces + loose) first.
+ var occurrences: [Occurrence] = []
+ func collect(sessions: [ClaudeSession], live: [LiveProcess],
+ cmux: [CmuxWorkspace], location: String) {
+ for session in sessions {
+ occurrences.append(Occurrence(session: session, live: live, cmux: cmux, location: location))
+ }
+ }
+
+ for workspace in snapshot.workspaces {
+ collect(sessions: workspace.sessions, live: workspace.liveProcesses,
+ cmux: workspace.cmuxWorkspaces, location: workspace.name)
+ }
+ for loose in snapshot.loose {
+ collect(sessions: loose.sessions, live: loose.liveProcesses,
+ cmux: loose.cmuxWorkspaces,
+ location: (loose.entry.path as NSString).lastPathComponent)
+ }
+
+ // 2) Group GLOBALLY by (cwd, sessionId), preserving first-seen order. The account
+ // of EVERY occurrence is merged into the row's set — no container is dropped.
+ var order: [String] = []
+ var grouped: [String: [Occurrence]] = [:]
+ for occ in occurrences {
+ let key = SessionRow.rowID(cwd: occ.session.cwd, session: occ.session.id)
+ if grouped[key] == nil { order.append(key) }
+ grouped[key, default: []].append(occ)
+ }
+
+ // 3) One row per key. Primary account = the live owner if any occurrence is live,
+ // else first-seen. accounts = the merged set across ALL occurrences/containers.
+ for key in order {
+ let group = grouped[key]!
+ let firstOcc = group[0]
+ let first = firstOcc.session
+ // First live process for this session across any occurrence in the group —
+ // by sessionId OR (for a fresh, empty-id process) the canonicalized cwd, so a
+ // bare `claude` running at the session's directory reads as live here too.
+ let liveOcc = group.first { occ in occ.live.liveProcess(forSessionId: first.id, cwd: first.cwd) != nil }
+ let process = liveOcc?.live.liveProcess(forSessionId: first.id, cwd: first.cwd)
+ // Primary account: the live owner if its account is among the group, else first-seen.
+ let liveOwner = process.flatMap { p in
+ group.map(\.session).first { $0.accountName == p.accountName }
+ }
+ let primary = liveOwner ?? first
+ let accounts = orderedUniqueAccounts(group.map(\.session))
+ // Location/cmux come from the occurrence that owns the live process if any,
+ // else the first occurrence (stable).
+ let owningOcc = liveOcc ?? firstOcc
+ var cmuxByCwd: [String: String] = [:]
+ for ws in owningOcc.cmux where cmuxByCwd[ws.currentDirectory] == nil {
+ cmuxByCwd[ws.currentDirectory] = ws.id
+ }
+ let liveStatus = process.map { SessionLiveStatus(rawStatus: $0.status) }
+ let workspaceId = cmuxMap[first.id] ?? cmuxByCwd[first.cwd]
+ let isGo = process != nil && workspaceId != nil
+ let action: SessionRowAction = isGo ? .go : .resume
+ let title = first.title.flatMap { $0.isEmpty ? nil : $0 }
+ ?? String(first.id.prefix(8))
+ rows.append(SessionRow(
+ sessionId: first.id,
+ title: title,
+ location: owningOcc.location,
+ accountName: primary.accountName,
+ accounts: accounts,
+ cwd: first.cwd,
+ liveStatus: liveStatus,
+ startedAt: process?.startedAt,
+ lastActivity: first.lastActivity,
+ action: action,
+ cmuxWorkspaceId: isGo ? workspaceId : nil,
+ gitBranch: first.gitBranch,
+ createdAt: first.createdAt,
+ turnCount: first.turnCount,
+ model: first.model))
+ }
+
+ rows.sort { lhs, rhs in
+ switch (lhs.liveStatus, rhs.liveStatus) {
+ case let (l?, r?): // both live: by status bucket
+ if l.sortRank != r.sortRank { return l.sortRank < r.sortRank }
+ case (.some, .none): return true // live before resumable
+ case (.none, .some): return false
+ case (.none, .none): // both resumable: newest first
+ if lhs.lastActivity != rhs.lastActivity {
+ return lhs.lastActivity > rhs.lastActivity
+ }
+ }
+ if lhs.title != rhs.title { return lhs.title < rhs.title }
+ if lhs.sessionId != rhs.sessionId { return lhs.sessionId < rhs.sessionId }
+ return lhs.accountName < rhs.accountName
+ }
+ return rows
+}
+
+// MARK: - External session rows (Part A)
+
+/// Returns `SessionRow`s for sessions discovered externally (via `refreshSessionIndex`'s
+/// disk-wide scan) that are NOT already represented in `snapshotRows` — i.e., whose
+/// `(cwd, sessionId)` composite key does not already appear in the current project
+/// snapshot. This prevents listing the same session twice when it happens to be inside
+/// a scanned workspace.
+///
+/// The returned rows carry the full `cwd` and `accountName` so Task 4 ("Share / adopt"
+/// affordance) can use them directly. All rows have `.resume` action (external sessions
+/// have no live-process information at this stage).
+///
+/// Input order is preserved (the caller supplies sessions newest-first from
+/// `allRecentSessions`). Location label is the leaf directory of each session's `cwd`.
+public func buildExternalSessionRows(snapshotRows: [SessionRow],
+ externalSessions: [ClaudeSession]) -> [SessionRow] {
+ let shownKeys = Set(snapshotRows.map { SessionRow.rowID(cwd: $0.cwd, session: $0.sessionId) })
+ var result: [SessionRow] = []
+ for s in externalSessions {
+ let key = SessionRow.rowID(cwd: s.cwd, session: s.id)
+ guard !shownKeys.contains(key) else { continue }
+ let location = (s.cwd as NSString).lastPathComponent
+ let title = s.title.flatMap { $0.isEmpty ? nil : $0 } ?? String(s.id.prefix(8))
+ result.append(SessionRow(
+ sessionId: s.id,
+ title: title,
+ location: location,
+ accountName: s.accountName,
+ accounts: [s.accountName],
+ cwd: s.cwd,
+ liveStatus: nil,
+ startedAt: nil,
+ lastActivity: s.lastActivity,
+ action: .resume,
+ cmuxWorkspaceId: nil,
+ gitBranch: s.gitBranch,
+ createdAt: s.createdAt,
+ turnCount: s.turnCount,
+ model: s.model))
+ }
+ return result
+}
+
+// MARK: - Other session rows (Part B)
+
+/// Converts `otherSessions` (sessions matching no configured project) into `SessionRow`s
+/// for display in the "Other sessions" bucket. Input order is preserved (newest-first from
+/// the caller). Location label is the leaf directory of each session's `cwd`. All rows
+/// have `.resume` action.
+public func buildOtherSessionRows(sessions: [ClaudeSession]) -> [SessionRow] {
+ sessions.map { s in
+ let location = (s.cwd as NSString).lastPathComponent
+ let title = s.title.flatMap { $0.isEmpty ? nil : $0 } ?? String(s.id.prefix(8))
+ return SessionRow(
+ sessionId: s.id,
+ title: title,
+ location: location,
+ accountName: s.accountName,
+ accounts: [s.accountName],
+ cwd: s.cwd,
+ liveStatus: nil,
+ startedAt: nil,
+ lastActivity: s.lastActivity,
+ action: .resume,
+ cmuxWorkspaceId: nil,
+ gitBranch: s.gitBranch,
+ createdAt: s.createdAt,
+ turnCount: s.turnCount,
+ model: s.model)
+ }
+}
diff --git a/Sources/GroveAppKit/Presentation/SettingsForm.swift b/Sources/GroveAppKit/Presentation/SettingsForm.swift
new file mode 100644
index 0000000..2b337a8
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/SettingsForm.swift
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+/// One row of the settings base-branch-overrides editor. repoPath is nil when
+/// the row comes from an override key the scan does not know (no snapshot yet,
+/// or a stale/excluded dir name) — those rows degrade to a text field because
+/// there is no repo to list branches for.
+public struct OverrideEditorRow: Equatable, Identifiable {
+ public var id: String { dirName }
+ public let dirName: String
+ public let repoPath: String?
+
+ public init(dirName: String, repoPath: String?) {
+ self.dirName = dirName
+ self.repoPath = repoPath
+ }
+}
+
+/// Rows for the per-project base-branch-overrides editor, sorted by dirName:
+/// every repo of the scan snapshot, plus any override keys without a matching
+/// repo (kept visible so stale entries can still be removed). With no
+/// snapshot at all, the dict keys are the only rows.
+public func overrideEditorRows(snapshot: ProjectSnapshot?,
+ overrides: [String: String]) -> [OverrideEditorRow] {
+ let repos = snapshot?.repos ?? []
+ let known = Set(repos.map(\.dirName))
+ let repoRows = repos.map { OverrideEditorRow(dirName: $0.dirName, repoPath: $0.path) }
+ let orphanRows = overrides.keys
+ .filter { !known.contains($0) }
+ .map { OverrideEditorRow(dirName: $0, repoPath: nil) }
+ return (repoRows + orphanRows).sorted { $0.dirName < $1.dirName }
+}
diff --git a/Sources/GroveAppKit/Presentation/TranscriptBadge.swift b/Sources/GroveAppKit/Presentation/TranscriptBadge.swift
new file mode 100644
index 0000000..12a0aec
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/TranscriptBadge.swift
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+public enum TranscriptBadge: Equatable {
+ case mirrored, restorable, unmirrored
+ public static func state(liveExists: Bool, mirrored: Bool) -> TranscriptBadge {
+ switch (liveExists, mirrored) {
+ case (true, true): return .mirrored
+ case (false, true): return .restorable
+ default: return .unmirrored
+ }
+ }
+ public var label: String {
+ switch self {
+ case .mirrored: return "mirrored"
+ case .restorable: return "mirror-only"
+ case .unmirrored: return "unmirrored"
+ }
+ }
+}
diff --git a/Sources/GroveAppKit/Presentation/TreeModel.swift b/Sources/GroveAppKit/Presentation/TreeModel.swift
new file mode 100644
index 0000000..62e096c
--- /dev/null
+++ b/Sources/GroveAppKit/Presentation/TreeModel.swift
@@ -0,0 +1,252 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+// Pure presentation logic for the Workspaces tree. No SwiftUI, no I/O:
+// everything here takes core snapshot values plus an injected `now` and is
+// fully unit-tested in GroveAppKitTests.
+
+// MARK: - Age buckets (spec §6.1 traffic light: 🟢 < 7d, 🟠 < 21d, 🔴 older)
+
+public enum AgeBucket: Equatable {
+ case fresh // age < 7 days
+ case aging // 7 days <= age < 21 days
+ case stale // age >= 21 days
+ case unknown // no fork-date data in any repo
+}
+
+// MARK: - Claude activity (LiveProcess.status -> badge category)
+
+public enum ClaudeActivity: Equatable {
+ case busy
+ case waiting
+ case idle
+
+ /// "busy" -> .busy, "waiting" -> .waiting, anything else ("idle", "shell",
+ /// unknown future values) -> .idle.
+ public init(status: String) {
+ switch status {
+ case "busy": self = .busy
+ case "waiting": self = .waiting
+ default: self = .idle
+ }
+ }
+}
+
+// MARK: - Badges
+
+public struct WorkspaceBadges: Equatable {
+ /// Whole days since the workspace's worktrees were CREATED on disk (max over
+ /// repos' `entry.createdAt` birthtime, measured against `now`); nil when no
+ /// repo carries a creation time. This is the worktree's own age — NOT the age
+ /// of the commit it forked from, so a fresh fork off a stale base reads "0d".
+ public let ageDays: Int?
+ public let ageBucket: AgeBucket
+ /// Sum of repos' meta.dirtyCount (repos without meta contribute 0).
+ public let dirtyTotal: Int
+ /// Live Claude processes with status "busy".
+ public let busyCount: Int
+ /// Live Claude processes with status "waiting".
+ public let waitingCount: Int
+ /// Sessions with NO matching live process (matched by session id).
+ public let resumableCount: Int
+
+ public init(ageDays: Int?, ageBucket: AgeBucket, dirtyTotal: Int,
+ busyCount: Int, waitingCount: Int, resumableCount: Int) {
+ self.ageDays = ageDays
+ self.ageBucket = ageBucket
+ self.dirtyTotal = dirtyTotal
+ self.busyCount = busyCount
+ self.waitingCount = waitingCount
+ self.resumableCount = resumableCount
+ }
+}
+
+public func badges(for ws: FeatureWorkspace, now: Date) -> WorkspaceBadges {
+ // Age = how long the worktree has existed on disk (birthtime), not how old
+ // the commit it forked from is. The max across repos is the oldest worktree.
+ let createdAges: [Int] = ws.repos.compactMap { state in
+ guard let createdAt = state.entry.createdAt else { return nil }
+ return max(0, Int(now.timeIntervalSince(createdAt) / 86_400))
+ }
+ let ageDays = createdAges.max()
+
+ let ageBucket: AgeBucket
+ switch ageDays {
+ case .none: ageBucket = .unknown
+ case .some(let days) where days < 7: ageBucket = .fresh
+ case .some(let days) where days < 21: ageBucket = .aging
+ case .some: ageBucket = .stale
+ }
+
+ let dirtyTotal = ws.repos.reduce(0) { $0 + ($1.meta?.dirtyCount ?? 0) }
+
+ // Dedup by process identity (sessionId, or pid for fresh empty-id ones) so the
+ // same process can't be counted twice if it surfaces in more than one container.
+ var busyCount = 0
+ var waitingCount = 0
+ var seenLive = Set()
+ for process in ws.liveProcesses {
+ let key = process.sessionId.isEmpty ? "pid:\(process.pid)" : process.sessionId
+ guard seenLive.insert(key).inserted else { continue }
+ switch ClaudeActivity(status: process.status) {
+ case .busy: busyCount += 1
+ case .waiting: waitingCount += 1
+ case .idle: break
+ }
+ }
+
+ // A session is RESUMABLE only if it has no live process — matched by id OR (for a
+ // fresh, empty-id process) by its canonicalized cwd. The shared id-or-cwd join
+ // keeps the /var↔/private/var spellings aligned, so a live fresh session is never
+ // miscounted as closed (a trailing-slash-only normalize missed that).
+ let resumableCount = ws.sessions.filter {
+ ws.liveProcesses.liveProcess(forSessionId: $0.id, cwd: $0.cwd) == nil
+ }.count
+
+ return WorkspaceBadges(ageDays: ageDays, ageBucket: ageBucket, dirtyTotal: dirtyTotal,
+ busyCount: busyCount, waitingCount: waitingCount,
+ resumableCount: resumableCount)
+}
+
+// MARK: - Tree rows
+
+public struct WorkspaceTreeRow: Identifiable, Equatable {
+ public var id: String { name }
+ public let name: String
+ public let workspace: FeatureWorkspace
+ /// 0 = root (forked straight from base branches).
+ public let depth: Int
+ /// One flag per ancestor level (count == depth): true when that ancestor
+ /// lineage keeps a continuing "│" lane below this row (i.e. the ancestor
+ /// at that level is NOT the last among its siblings).
+ public let ancestorContinues: [Bool]
+ /// True when this row is the last among its own siblings.
+ public let isLast: Bool
+ public let badges: WorkspaceBadges
+
+ public init(name: String, workspace: FeatureWorkspace, depth: Int,
+ ancestorContinues: [Bool], isLast: Bool, badges: WorkspaceBadges) {
+ self.name = name
+ self.workspace = workspace
+ self.depth = depth
+ self.ancestorContinues = ancestorContinues
+ self.isLast = isLast
+ self.badges = badges
+ }
+}
+
+/// Flattens the snapshot's workspaces into DFS pre-order rows.
+///
+/// Roots are workspaces whose parentName is nil, unknown (not in the snapshot),
+/// self-referential, or part of a parent cycle. Cycle handling: a workspace
+/// whose parent chain leads back to itself sits ON a cycle — its parent edge is
+/// cut, so every cycle member becomes a root, while workspaces hanging BELOW
+/// the cycle keep their edge and stay attached. Roots and children are sorted
+/// by name; every workspace appears exactly once.
+public func buildWorkspaceTree(_ snapshot: ProjectSnapshot, now: Date) -> [WorkspaceTreeRow] {
+ let byName = Dictionary(snapshot.workspaces.map { ($0.name, $0) },
+ uniquingKeysWith: { first, _ in first })
+
+ // Resolved parent edges: the parent must exist and differ from the child.
+ var parent: [String: String] = [:]
+ for ws in byName.values {
+ if let p = ws.parentName, p != ws.name, byName[p] != nil {
+ parent[ws.name] = p
+ }
+ }
+
+ // Cut the edge of every node that is ON a cycle (walking its ancestor chain
+ // returns to the node itself). The step bound makes the walk total even on
+ // arbitrary inconsistent input.
+ var onCycle: Set = []
+ for name in byName.keys {
+ var current = parent[name]
+ var steps = 0
+ while let p = current, steps <= byName.count {
+ if p == name {
+ onCycle.insert(name)
+ break
+ }
+ current = parent[p]
+ steps += 1
+ }
+ }
+ for name in onCycle {
+ parent.removeValue(forKey: name)
+ }
+
+ var children: [String: [String]] = [:]
+ for (child, p) in parent {
+ children[p, default: []].append(child)
+ }
+ for key in children.keys {
+ children[key]?.sort()
+ }
+ let roots = byName.keys.filter { parent[$0] == nil }.sorted()
+
+ var rows: [WorkspaceTreeRow] = []
+ func visit(_ name: String, depth: Int, ancestorContinues: [Bool], isLast: Bool) {
+ guard let ws = byName[name] else { return }
+ rows.append(WorkspaceTreeRow(name: name, workspace: ws, depth: depth,
+ ancestorContinues: ancestorContinues, isLast: isLast,
+ badges: badges(for: ws, now: now)))
+ let kids = children[name] ?? []
+ for (index, kid) in kids.enumerated() {
+ visit(kid, depth: depth + 1,
+ ancestorContinues: ancestorContinues + [!isLast],
+ isLast: index == kids.count - 1)
+ }
+ }
+ for (index, root) in roots.enumerated() {
+ visit(root, depth: 0, ancestorContinues: [], isLast: index == roots.count - 1)
+ }
+ return rows
+}
+
+// MARK: - Relative age ("5m", "3h", "2d", "5w")
+
+/// Coarse relative age for session/commit rows. Buckets: minutes below one
+/// hour, hours below one day, days below one week, whole weeks after that.
+/// `date` in the future clamps to "0m".
+public func relativeAge(_ date: Date, now: Date) -> String {
+ let seconds = max(0, now.timeIntervalSince(date))
+ let minutes = Int(seconds / 60)
+ if minutes < 60 { return "\(minutes)m" }
+ let hours = minutes / 60
+ if hours < 24 { return "\(hours)h" }
+ let days = hours / 24
+ if days < 7 { return "\(days)d" }
+ return "\(days / 7)w"
+}
+
+// MARK: - Search filter
+
+/// Case-insensitive substring filter over workspace names and per-repo branch
+/// names. A kept row's ancestors are always kept too (so the tree stays
+/// connected); descendants of a match are kept only if they match themselves.
+/// An empty / whitespace-only query returns the rows unchanged.
+public func filterTree(_ rows: [WorkspaceTreeRow], query: String) -> [WorkspaceTreeRow] {
+ let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !needle.isEmpty else { return rows }
+
+ func matches(_ row: WorkspaceTreeRow) -> Bool {
+ if row.name.lowercased().contains(needle) { return true }
+ return row.workspace.repos.contains { state in
+ state.entry.branch?.lowercased().contains(needle) == true
+ }
+ }
+
+ // Rows are DFS pre-order, so a stack of row indices keyed by depth is
+ // exactly the ancestor chain of the current row.
+ var keep = Set()
+ var stack: [Int] = []
+ for (index, row) in rows.enumerated() {
+ while stack.count > row.depth { stack.removeLast() }
+ stack.append(index)
+ if matches(row) { keep.formUnion(stack) }
+ }
+ return rows.enumerated().filter { keep.contains($0.offset) }.map { $0.element }
+}
diff --git a/Sources/GroveAppKit/Snapshot/SnapshotMode.swift b/Sources/GroveAppKit/Snapshot/SnapshotMode.swift
new file mode 100644
index 0000000..05faf3e
--- /dev/null
+++ b/Sources/GroveAppKit/Snapshot/SnapshotMode.swift
@@ -0,0 +1,731 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import AppKit
+import SwiftUI
+import GroveCore
+
+/// Pre-expanded workspace names for the "workspaces-expanded" snapshot scene.
+/// The placeholder RootView ignores it; WorkspacesScreen (Task 19) consumes it
+/// to render expanded WorkspaceRowCards without interaction.
+struct SnapshotExpandedWorkspacesKey: EnvironmentKey {
+ static let defaultValue: Set = []
+}
+
+extension EnvironmentValues {
+ var snapshotExpandedWorkspaces: Set {
+ get { self[SnapshotExpandedWorkspacesKey.self] }
+ set { self[SnapshotExpandedWorkspacesKey.self] = newValue }
+ }
+}
+
+/// Headless UI harness: `BrowApp --snapshot ` renders the Workspaces panel
+/// with a synthetic fixture state into PNGs and exits without ever starting
+/// NSApplication. Rich fixture (4 workspaces incl. one stacked child, all four
+/// age buckets, busy/waiting/resumable mixes, 2 loose worktrees); every scene
+/// renders RootView with the scene's ROUTE set (the panel is a state machine
+/// of full-screen views) at that route's adaptive panel size.
+public enum SnapshotMode {
+ enum SnapshotError: Error, CustomStringConvertible {
+ case renderFailed(String)
+ case encodeFailed(String)
+
+ var description: String {
+ switch self {
+ case .renderFailed(let name): return "ImageRenderer produced no image for \(name)"
+ case .encodeFailed(let name): return "PNG encoding failed for \(name)"
+ }
+ }
+ }
+
+ enum SnapshotScene: String, CaseIterable {
+ case projects = "projects"
+ case rootWorkspaces = "root-workspaces"
+ case workspacesExpanded = "workspaces-expanded"
+ case createSheet = "create-sheet"
+ case graph = "graph"
+ case stats = "stats"
+ case sessions = "sessions"
+ case accounts = "accounts"
+ case settings = "settings"
+ case statsSettings = "stats-settings"
+ case errorBanner = "error-banner"
+
+ var fileName: String { rawValue + ".png" }
+
+ /// Canvas size = the route's adaptive panel frame (RootView).
+ /// error-banner adds vertical allowance on top of the projects frame
+ /// because RootView stacks the banner ABOVE the routed screen.
+ var size: CGSize {
+ switch self {
+ case .projects: return CGSize(width: 460, height: 520)
+ case .rootWorkspaces, .workspacesExpanded, .graph, .stats, .sessions:
+ return CGSize(width: 600, height: 540)
+ case .createSheet: return CGSize(width: 540, height: 560)
+ case .accounts: return CGSize(width: 560, height: 480)
+ case .settings, .statsSettings: return CGSize(width: 560, height: 560)
+ case .errorBanner: return CGSize(width: 460, height: 584)
+ }
+ }
+ }
+
+ /// True (and never actually returns: exit() inside) when "--snapshot "
+ /// is present; false when absent so main.swift starts the real app.
+ @MainActor
+ public static func runIfRequested() -> Bool {
+ guard let outDir = parseSnapshotDir(from: CommandLine.arguments) else { return false }
+ do {
+ let count = try renderAll(into: URL(fileURLWithPath: outDir, isDirectory: true))
+ print("snapshot: \(count) files")
+ exit(0)
+ } catch {
+ FileHandle.standardError.write(Data("snapshot failed: \(error)\n".utf8))
+ exit(1)
+ }
+ }
+
+ /// Pure: the value following "--snapshot", nil when the flag is absent or last.
+ static func parseSnapshotDir(from arguments: [String]) -> String? {
+ guard let index = arguments.firstIndex(of: "--snapshot"),
+ arguments.indices.contains(index + 1)
+ else { return nil }
+ return arguments[index + 1]
+ }
+
+ // MARK: - Fixture
+
+ /// Synthetic AppState: no disk scanning, no git, no real config file.
+ /// The ConfigStore points at a non-existent temp path, so load() yields
+ /// defaults and nothing is ever written. Public so tests reuse the fixture.
+ @MainActor
+ public static func fixtureState() -> AppState {
+ let url = URL(fileURLWithPath: NSTemporaryDirectory())
+ .appendingPathComponent("grove-snapshot-\(UUID().uuidString)/config.json")
+ let state = AppState(configStore: ConfigStore(url: url))
+
+ let project = ProjectConfig(
+ id: UUID(uuidString: "B0000000-0000-0000-0000-000000000001")!,
+ name: "acme.shop",
+ path: "/Users/demo/Desktop/acme.shop",
+ workspacesRoot: "/Users/demo/Workspaces/acme.shop",
+ baseBranchOverrides: ["acme-server-config-a": "docker"],
+ defaultAccount: "work", // settings.png shows the account picker non-empty
+ // One pre-excluded folder so stats-settings shows the excluded (dimmed,
+ // disabled-descendant) state in the directory+file tree.
+ statsIgnoredFolders: ["Snapshot"]
+ )
+ state.config = GroveConfig(
+ version: 1,
+ workspacesRootTemplate: "~/Workspaces/{project}",
+ projects: [project],
+ accounts: [
+ AccountConfig(name: "default", configDir: "~/.claude"),
+ AccountConfig(name: "work", configDir: "~/.claude-accounts/work", sharedStore: true),
+ ]
+ )
+ let now = Date()
+ state.snapshots = [project.id: fixtureSnapshot(project: project, now: now)]
+ state.selectedProjectID = project.id
+ // What loadBranches would find — offscreen renders must not run git.
+ // Each list contains the repo's resolved default start point, so the
+ // create-sheet/settings pickers show real selections in the PNGs.
+ state.branchesByRepo = [
+ project.path + "/acme_client":
+ ["dev", "feat/folders-followup", "feat/media-pipeline",
+ "feat/media-upload", "main"],
+ project.path + "/acme_server":
+ ["feat/media-pipeline", "feat/media-upload", "master"],
+ project.path + "/acme-server-config-a":
+ ["docker", "feat/live-tier-redis", "main"],
+ ]
+ state.graphRepoPath = project.path + "/acme_client"
+ state.graphNodes = fixtureGraphNodes(now: now)
+
+ // Statusline captures: deterministic per-session model/context/effort for
+ // the Accounts scene's session cards.
+ state.snapshotsByAccount = fixtureSnapshotsByAccount(now: now)
+
+ // Code-stats fixture (Stage 5): a canned CodeStats + a short history so the
+ // Stats tab renders the totals header, language bars/table, and the growth
+ // chart's MANUAL (non-Charts) fallback offscreen. The `.task` scan never runs
+ // in snapshot mode (routes are set directly), so these must be pre-seeded.
+ state.codeStats = [project.id: fixtureCodeStats(now: now)]
+ state.codeStatsHistory = [project.id: fixtureCodeStatsHistory(now: now)]
+ state.repoStats = [project.id: fixtureRepoStats(now: now)]
+ // Per-file list (Stage 5 final): the stats-settings page builds its
+ // directory+file tree purely from this, so the tree renders offscreen.
+ // One folder (Snapshot) is pre-excluded so the disabled/dimmed state shows.
+ state.statsFiles = [project.id: fixtureStatFiles()]
+
+ // Projects-tab session previews (item 4): a running session mapped to a
+ // cmux workspace (Go) and a waiting one (Resume).
+ let wsRoot = "/Users/demo/Workspaces/acme.shop"
+ state.recentSessionsByProject = [project.id: [
+ ProjectSessionRow(sessionId: "s-mp-1", title: "media pipeline retries",
+ cwd: wsRoot + "/media-pipeline", location: "media-pipeline",
+ accountName: "default", lastActivity: now.addingTimeInterval(-900),
+ status: .running, cmuxWorkspaceId: "ws-101"),
+ ProjectSessionRow(sessionId: "s-mu-1", title: "upload endpoint",
+ cwd: wsRoot + "/media-upload", location: "media-upload",
+ accountName: "work", lastActivity: now.addingTimeInterval(-120),
+ status: .waiting, cmuxWorkspaceId: nil),
+ ]]
+ return state
+ }
+
+ // MARK: - Usage fixtures (Task 12)
+
+ /// ISO8601 (`.withInternetDateTime`) string for a reset instant relative to
+ /// the fixture `now`, so `RateLimitModel.window` parses a stable countdown.
+ private static let isoReset: ISO8601DateFormatter = {
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime]
+ return f
+ }()
+
+ /// Most-recent statusline captures per account: the Accounts scene's session
+ /// cards read model / context % / effort from these.
+ static func fixtureSnapshotsByAccount(now: Date) -> [String: [UsageSnapshot]] {
+ func reset(_ seconds: TimeInterval) -> String {
+ isoReset.string(from: now.addingTimeInterval(seconds))
+ }
+ let defaultSnap = UsageSnapshot(
+ accountName: "default", sessionId: "s-mp-1",
+ capturedAt: now.addingTimeInterval(-900),
+ cwd: "/Users/demo/Workspaces/acme.shop/media-pipeline",
+ modelId: "claude-opus-4-8", modelDisplayName: "Opus 4.8",
+ effort: "high", contextUsedPercentage: 42,
+ totalInputTokens: 184_300, totalCostUSD: 6.42,
+ fiveHour: CapturedWindow(usedPercentage: 30, resetsAt: reset(2 * 3_600)),
+ sevenDay: CapturedWindow(usedPercentage: 45, resetsAt: reset(3 * 86_400)),
+ sevenDaySonnet: CapturedWindow(usedPercentage: 22, resetsAt: reset(3 * 86_400)))
+ // Two COMPLETED 5h sessions (reset in the past) so the session-trend
+ // (avg/prev) renders: peaks 53% then 40% → avg 46%, prev 53%.
+ func histSnap(_ id: String, used: Double, resetAgo: TimeInterval) -> UsageSnapshot {
+ UsageSnapshot(accountName: "default", sessionId: id, capturedAt: now.addingTimeInterval(-resetAgo),
+ cwd: nil, modelId: nil, modelDisplayName: nil, effort: nil,
+ contextUsedPercentage: nil, totalInputTokens: nil, totalCostUSD: nil,
+ fiveHour: CapturedWindow(usedPercentage: used, resetsAt: reset(-resetAgo)),
+ sevenDay: nil)
+ }
+ let hist = [histSnap("s-h1", used: 53, resetAgo: 5 * 3_600),
+ histSnap("s-h2", used: 40, resetAgo: 10 * 3_600)]
+ let workSnap = UsageSnapshot(
+ accountName: "work", sessionId: "s-mu-1",
+ capturedAt: now.addingTimeInterval(-120),
+ cwd: "/Users/demo/Workspaces/acme.shop/media-upload",
+ modelId: "claude-sonnet-4-6", modelDisplayName: "Sonnet 4.6",
+ effort: "medium", contextUsedPercentage: 18,
+ totalInputTokens: 92_100, totalCostUSD: 1.87,
+ fiveHour: CapturedWindow(usedPercentage: 70, resetsAt: reset(1 * 3_600)),
+ sevenDay: CapturedWindow(usedPercentage: 60, resetsAt: reset(4 * 86_400)),
+ sevenDaySonnet: CapturedWindow(usedPercentage: 35, resetsAt: reset(4 * 86_400)))
+ return ["default": [defaultSnap] + hist, "work": [workSnap]]
+ }
+
+ /// Ages are relative to `now` (real clock at render time) because the
+ /// views compute badges against Date() — fixed dates would drift.
+ ///
+ /// Coverage matrix (badge variety per contract):
+ /// media-pipeline root, 12d aging, dirty 12, 1 busy, 0 resumable, cmux-mapped
+ /// media-upload CHILD of media-pipeline, 2d fresh, dirty 3, 1 waiting, 1 resumable
+ /// folders-followup root, 24d stale, dirty 0, no live, 2 resumable
+ /// live-tier-redis root, meta degraded -> unknown age, nothing else
+ /// loose: group-chats (+9/-3, 1 resumable) and legacy-auth (meta nil)
+ static func fixtureSnapshot(project: ProjectConfig, now: Date) -> ProjectSnapshot {
+ let root = "/Users/demo/Workspaces/acme.shop"
+ let client = RepoInfo(path: project.path + "/acme_client", dirName: "acme_client")
+ let server = RepoInfo(path: project.path + "/acme_server", dirName: "acme_server")
+ let config = RepoInfo(path: project.path + "/acme-server-config-a",
+ dirName: "acme-server-config-a")
+
+ func day(_ n: Double) -> Date { now.addingTimeInterval(-n * 86_400) }
+
+ // media-pipeline: root workspace, 2 repos, busy Claude, cmux workspace.
+ let mpUmbrella = root + "/media-pipeline"
+ let mediaPipeline = FeatureWorkspace(
+ name: "media-pipeline",
+ umbrellaPath: mpUmbrella,
+ repos: [
+ WorkspaceRepoState(
+ repo: client,
+ entry: WorktreeEntry(path: mpUmbrella + "/acme_client",
+ branch: "feat/media-pipeline",
+ head: "aaaa111", isMain: false,
+ createdAt: day(12)),
+ meta: WorktreeMeta(baseBranch: "dev", forkPoint: "ffff000",
+ forkDate: day(12), ahead: 14, behind: 2, dirtyCount: 8,
+ lastCommitDate: day(0.04),
+ lastCommitSubject: "wire upload progress events"),
+ scanError: nil),
+ WorkspaceRepoState(
+ repo: server,
+ entry: WorktreeEntry(path: mpUmbrella + "/acme_server",
+ branch: "feat/media-pipeline",
+ head: "aaaa222", isMain: false,
+ createdAt: day(12)),
+ meta: WorktreeMeta(baseBranch: "master", forkPoint: "ffff001",
+ forkDate: day(12), ahead: 5, behind: 0, dirtyCount: 4,
+ lastCommitDate: day(0.2),
+ lastCommitSubject: "media service: chunked uploads"),
+ scanError: nil),
+ ],
+ parentName: nil,
+ sessions: [
+ ClaudeSession(id: "s-mp-1", cwd: mpUmbrella,
+ title: "Implement media pipeline",
+ lastActivity: now.addingTimeInterval(-900),
+ accountName: "default",
+ gitBranch: "feat/media-pipeline"),
+ ],
+ liveProcesses: [
+ LiveProcess(pid: 4242, sessionId: "s-mp-1", cwd: mpUmbrella,
+ status: "busy", accountName: "default",
+ startedAt: now.addingTimeInterval(-2_700)), // 45m runtime
+ ],
+ cmuxWorkspaces: [
+ CmuxWorkspace(id: "ws-101", title: "media-pipeline",
+ currentDirectory: mpUmbrella),
+ ]
+ )
+
+ // media-upload: STACKED on media-pipeline (meta relative to the parent
+ // branch, mirroring WorkspaceService.scan), waiting Claude + 1 resumable.
+ let muUmbrella = root + "/media-upload"
+ let mediaUpload = FeatureWorkspace(
+ name: "media-upload",
+ umbrellaPath: muUmbrella,
+ repos: [
+ WorkspaceRepoState(
+ repo: client,
+ entry: WorktreeEntry(path: muUmbrella + "/acme_client",
+ branch: "feat/media-upload",
+ head: "bbbb111", isMain: false,
+ createdAt: day(2)),
+ meta: WorktreeMeta(baseBranch: "feat/media-pipeline", forkPoint: "aaaa111",
+ forkDate: day(2), ahead: 3, behind: 0, dirtyCount: 3,
+ lastCommitDate: day(0.1),
+ lastCommitSubject: "upload retry with backoff"),
+ scanError: nil),
+ WorkspaceRepoState(
+ repo: server,
+ entry: WorktreeEntry(path: muUmbrella + "/acme_server",
+ branch: "feat/media-upload",
+ head: "bbbb222", isMain: false,
+ createdAt: day(2)),
+ meta: WorktreeMeta(baseBranch: "feat/media-pipeline", forkPoint: "aaaa222",
+ forkDate: day(2), ahead: 1, behind: 0, dirtyCount: 0,
+ lastCommitDate: day(1.5),
+ lastCommitSubject: "accept multipart on /media"),
+ scanError: nil),
+ ],
+ parentName: "media-pipeline",
+ sessions: [
+ ClaudeSession(id: "s-mu-1", cwd: muUmbrella,
+ title: "Multipart upload endpoint",
+ lastActivity: now.addingTimeInterval(-120),
+ accountName: "work",
+ gitBranch: "feat/media-upload"),
+ ClaudeSession(id: "s-mu-2", cwd: muUmbrella,
+ title: "Client retry UX",
+ lastActivity: day(1),
+ accountName: "default",
+ gitBranch: "feat/media-upload"),
+ ],
+ liveProcesses: [
+ LiveProcess(pid: 4343, sessionId: "s-mu-1", cwd: muUmbrella,
+ status: "waiting", accountName: "work",
+ startedAt: now.addingTimeInterval(-360)), // 6m runtime
+ ],
+ cmuxWorkspaces: []
+ )
+
+ // folders-followup: stale root, clean tree, two resumable sessions.
+ let ffUmbrella = root + "/folders-followup"
+ let foldersFollowup = FeatureWorkspace(
+ name: "folders-followup",
+ umbrellaPath: ffUmbrella,
+ repos: [
+ WorkspaceRepoState(
+ repo: client,
+ entry: WorktreeEntry(path: ffUmbrella + "/acme_client",
+ branch: "feat/folders-followup",
+ head: "cccc111", isMain: false,
+ createdAt: day(24)),
+ meta: WorktreeMeta(baseBranch: "dev", forkPoint: "ffff002",
+ forkDate: day(24), ahead: 9, behind: 6, dirtyCount: 0,
+ lastCommitDate: day(20),
+ lastCommitSubject: "folder rename flow"),
+ scanError: nil),
+ ],
+ parentName: nil,
+ sessions: [
+ ClaudeSession(id: "s-ff-1", cwd: ffUmbrella,
+ title: "Folder sharing follow-ups",
+ lastActivity: day(20),
+ accountName: "default",
+ gitBranch: "feat/folders-followup"),
+ ClaudeSession(id: "s-ff-2", cwd: ffUmbrella,
+ title: "Migration dry-run",
+ lastActivity: day(21),
+ accountName: "work",
+ gitBranch: "feat/folders-followup"),
+ ],
+ liveProcesses: [],
+ cmuxWorkspaces: []
+ )
+
+ // live-tier-redis: degraded scan (meta nil -> unknown age), nothing live.
+ let ltUmbrella = root + "/live-tier-redis"
+ let liveTierRedis = FeatureWorkspace(
+ name: "live-tier-redis",
+ umbrellaPath: ltUmbrella,
+ repos: [
+ WorkspaceRepoState(
+ repo: config,
+ entry: WorktreeEntry(path: ltUmbrella + "/acme-server-config-a",
+ branch: "feat/live-tier-redis",
+ head: "dddd111", isMain: false),
+ meta: nil,
+ scanError: "git meta timed out after 10s"),
+ ],
+ parentName: nil,
+ sessions: [],
+ liveProcesses: [],
+ cmuxWorkspaces: []
+ )
+
+ // Two loose worktrees (outside the workspaces root, spec §2).
+ let gcPath = client.path + "/.worktrees/group-chats"
+ let groupChats = LooseWorktree(
+ repo: client,
+ entry: WorktreeEntry(path: gcPath, branch: "feature/group-chats",
+ head: "eeee111", isMain: false),
+ meta: WorktreeMeta(baseBranch: "dev", forkPoint: "ffff003",
+ forkDate: day(30), ahead: 9, behind: 3, dirtyCount: 0,
+ lastCommitDate: day(9),
+ lastCommitSubject: "group chat read receipts"),
+ sessions: [
+ ClaudeSession(id: "s-gc-1", cwd: gcPath,
+ title: "Group chats",
+ lastActivity: day(9),
+ accountName: "default",
+ gitBranch: "feature/group-chats"),
+ ],
+ liveProcesses: [],
+ cmuxWorkspaces: []
+ )
+ let legacyAuth = LooseWorktree(
+ repo: server,
+ entry: WorktreeEntry(path: server.path + "/.worktrees/legacy-auth",
+ branch: "legacy-auth",
+ head: "eeee222", isMain: false),
+ meta: nil,
+ sessions: [],
+ liveProcesses: [],
+ cmuxWorkspaces: []
+ )
+
+ // Workspaces sorted by name, like WorkspaceService.scan emits them.
+ return ProjectSnapshot(
+ project: project,
+ repos: [config, client, server].sorted { $0.path < $1.path },
+ workspaces: [foldersFollowup, liveTierRedis, mediaPipeline, mediaUpload],
+ loose: [groupChats, legacyAuth],
+ errors: []
+ )
+ }
+
+ // MARK: - Graph fixture (consumed by GraphScreen in Task 21)
+
+ /// Hand-laid-out commit topology: trunk on lane 0, a merged feature branch
+ /// on lane 1, an old side branch on lane 2. The lane numbers are exactly
+ /// what GroveCore.layoutLanes produces for this parent structure
+ /// (testFixtureGraphLanesMatchLayoutLanes keeps that honest).
+ static func fixtureGraphNodes(now: Date) -> [CommitNode] {
+ func at(hoursAgo: Double) -> Date { now.addingTimeInterval(-hoursAgo * 3_600) }
+ return [
+ CommitNode(hash: "a1", parents: ["a2", "b1"], author: "artem", date: at(hoursAgo: 2),
+ refs: ["HEAD -> dev", "origin/dev"], subject: "Merge media upload pipeline", lane: 0),
+ CommitNode(hash: "b1", parents: ["b2"], author: "claude", date: at(hoursAgo: 5),
+ refs: ["feat/media-upload"], subject: "Add chunked upload retry", lane: 1),
+ CommitNode(hash: "a2", parents: ["a3"], author: "artem", date: at(hoursAgo: 8),
+ refs: [], subject: "Fix session token refresh", lane: 0),
+ CommitNode(hash: "b2", parents: ["a3"], author: "claude", date: at(hoursAgo: 26),
+ refs: [], subject: "Wire upload progress events", lane: 1),
+ CommitNode(hash: "a3", parents: ["a4"], author: "artem", date: at(hoursAgo: 50),
+ refs: ["tag: v0.4.0"], subject: "Release 0.4.0", lane: 0),
+ CommitNode(hash: "c1", parents: ["a4"], author: "artem", date: at(hoursAgo: 70),
+ refs: ["feat/folders-followup"], subject: "Folder share ACL checks", lane: 1),
+ CommitNode(hash: "d1", parents: ["a4"], author: "claude", date: at(hoursAgo: 200),
+ refs: ["feat/live-tier-redis"], subject: "Redis live tier experiment", lane: 2),
+ CommitNode(hash: "a4", parents: [], author: "artem", date: at(hoursAgo: 240),
+ refs: ["main"], subject: "Initial import", lane: 0),
+ ]
+ }
+
+ // MARK: - Code-stats fixture (consumed by CodeStatsScreen in Stage 5)
+
+ /// A canned multi-language tally so the Stats tab renders bars + the per-language
+ /// table + the totals header. `byLanguage` is sorted DESC by code, like the real
+ /// scanner; totals sum the languages so the code/data percentage is consistent.
+ static func fixtureCodeStats(now: Date) -> CodeStats {
+ let langs = [
+ LanguageStats(language: "Swift", files: 142, code: 18_420, comment: 3_180, blank: 2_640, total: 24_240),
+ LanguageStats(language: "TypeScript/JavaScript", files: 96, code: 11_900, comment: 1_540, blank: 1_810, total: 15_250),
+ LanguageStats(language: "Python", files: 38, code: 4_310, comment: 920, blank: 760, total: 5_990),
+ LanguageStats(language: "Shell", files: 14, code: 820, comment: 210, blank: 160, total: 1_190),
+ LanguageStats(language: "Markdown", files: 22, code: 1_640, comment: 0, blank: 480, total: 2_120),
+ ]
+ let code = langs.reduce(0) { $0 + $1.code }
+ let comment = langs.reduce(0) { $0 + $1.comment }
+ let blank = langs.reduce(0) { $0 + $1.blank }
+ let files = langs.reduce(0) { $0 + $1.files }
+ return CodeStats(totalFiles: files, totalLines: code + comment + blank,
+ code: code, comment: comment, blank: blank,
+ byLanguage: langs, scannedAt: now, skippedBinary: 9)
+ }
+
+ /// A short rising line history (oldest first) so the growth chart's manual bar
+ /// fallback draws a real rising series offscreen. Each step also carries that day's
+ /// added/removed so the Totals delta-triangle and the bar-selection readout have
+ /// real values in snapshots.
+ static func fixtureCodeStatsHistory(now: Date) -> [CodeStatsPoint] {
+ func day(_ n: Double) -> Date { now.addingTimeInterval(-n * 86_400) }
+ let totals = [38_200, 39_100, 41_500, 44_900, 46_300, 48_790]
+ var previous = 0
+ return totals.enumerated().map { i, total in
+ // Derive per-day added/removed from the cumulative step (a little churn so
+ // "removed" is non-zero): the net matches the cumulative delta.
+ let net = i == 0 ? total : total - previous
+ previous = total
+ let removed = i == 0 ? 0 : max(net / 4, 0)
+ let added = net + removed
+ // ~80% of each day's churn is code, ~20% data/prose (markdown), so the
+ // honest Code/Data triangles have real, distinct values in snapshots.
+ let codeAdded = Int(Double(added) * 0.8)
+ let codeRemoved = Int(Double(removed) * 0.8)
+ return CodeStatsPoint(date: day(Double(totals.count - 1 - i) * 6),
+ totalLines: total,
+ code: Int(Double(total) * 0.79), comment: Int(Double(total) * 0.10),
+ blank: Int(Double(total) * 0.11),
+ totalFiles: 280 + i * 6,
+ dayAdded: added, dayRemoved: removed,
+ codeAdded: codeAdded, codeRemoved: codeRemoved,
+ dataAdded: added - codeAdded, dataRemoved: removed - codeRemoved)
+ }
+ }
+
+ /// Per-repo breakdown for the Stats tab's per-repo blocks: two repos with their own
+ /// branch, LOC, and a per-day history (oldest first) so each block's period delta
+ /// recomputes client-side like the real scan output.
+ static func fixtureRepoStats(now: Date) -> [RepoStats] {
+ func day(_ n: Double) -> Date { now.addingTimeInterval(-n * 86_400) }
+ // `dataFraction` of each day's churn is data/prose; the rest is code. media-pipeline
+ // is code-heavy (0), media-upload is mixed (some markdown/json churn).
+ func history(_ steps: [(net: Int, added: Int, removed: Int)], dataFraction: Double) -> [RepoHistoryPoint] {
+ var cumulative = 0
+ return steps.enumerated().map { i, s in
+ cumulative += s.net
+ let dataAdded = Int(Double(s.added) * dataFraction)
+ let dataRemoved = Int(Double(s.removed) * dataFraction)
+ return RepoHistoryPoint(date: day(Double(steps.count - 1 - i) * 6),
+ netLines: cumulative,
+ dayAdded: s.added, dayRemoved: s.removed,
+ codeAdded: s.added - dataAdded, codeRemoved: s.removed - dataRemoved,
+ dataAdded: dataAdded, dataRemoved: dataRemoved)
+ }
+ }
+ func stats(files: Int, lines: Int, langs: [LanguageStats]) -> CodeStats {
+ CodeStats(totalFiles: files, totalLines: lines, code: Int(Double(lines) * 0.82),
+ comment: Int(Double(lines) * 0.09), blank: Int(Double(lines) * 0.09),
+ byLanguage: langs, scannedAt: now, skippedBinary: 0)
+ }
+ func lang(_ name: String, files: Int, code: Int, comment: Int = 0, blank: Int = 0) -> LanguageStats {
+ LanguageStats(language: name, files: files, code: code, comment: comment, blank: blank,
+ total: code + comment + blank)
+ }
+ return [
+ RepoStats(repoPath: "/Users/demo/Workspaces/acme.shop/media-pipeline",
+ repoName: "media-pipeline",
+ defaultBranch: "main",
+ // A per-repo language split so a snapshot scoped to this repo (via the
+ // shared repo selector) shows real Languages bars, not an empty card.
+ stats: stats(files: 184, lines: 31_400, langs: [
+ lang("Swift", files: 120, code: 18_400, comment: 2_100, blank: 2_400),
+ lang("Python", files: 44, code: 4_600, comment: 600, blank: 700),
+ lang("Markdown", files: 20, code: 0, comment: 0, blank: 100),
+ ]),
+ history: history([(0, 0, 0), (640, 700, 60), (1_180, 1_300, 120),
+ (1_540, 1_720, 180), (820, 990, 170)], dataFraction: 0),
+ delta: RepoDelta(added: 4_010, removed: 530, filesChanged: 22)),
+ RepoStats(repoPath: "/Users/demo/Workspaces/acme.shop/media-upload",
+ repoName: "media-upload",
+ defaultBranch: "develop",
+ stats: stats(files: 96, lines: 17_390, langs: [
+ lang("TypeScript/JavaScript", files: 60, code: 9_800, comment: 900, blank: 1_300),
+ lang("JSON", files: 18, code: 2_200, comment: 0, blank: 0),
+ lang("CSS", files: 18, code: 1_900, comment: 100, blank: 290),
+ ]),
+ history: history([(0, 0, 0), (310, 360, 50), (-120, 40, 160),
+ (540, 620, 80), (290, 330, 40)], dataFraction: 0.25),
+ delta: RepoDelta(added: 1_350, removed: 330, filesChanged: 11)),
+ ]
+ }
+
+ /// A canned project-relative per-file list so the stats-settings page renders its
+ /// directory+file tree offscreen: a couple of nested source folders (so folder
+ /// rows show summed LOC + counts), a data/prose file (Markdown → neutral tint),
+ /// and one pre-excluded folder (see `fixtureState` setting `statsIgnoredFolders`).
+ /// The excluded folder's file carries `isExcluded` exactly as a live scan emits it,
+ /// so the Snapshot folder stays in the tree with its (enabled) re-include toggle.
+ static func fixtureStatFiles() -> [StatFileEntry] {
+ [
+ StatFileEntry(path: "Sources/GroveCore/Services/GitStatsService.swift",
+ lines: 520, language: "Swift", isDataProse: false),
+ StatFileEntry(path: "Sources/GroveCore/Services/GitService.swift",
+ lines: 310, language: "Swift", isDataProse: false),
+ StatFileEntry(path: "Sources/GroveAppKit/Views/RootView.swift",
+ lines: 153, language: "Swift", isDataProse: false),
+ StatFileEntry(path: "Sources/GroveAppKit/Views/StatsSettingsScreen.swift",
+ lines: 210, language: "Swift", isDataProse: false),
+ // Pre-excluded (its folder is in statsIgnoredFolders), so it carries
+ // isExcluded — exactly what a live scan emits for an excluded folder,
+ // keeping the Snapshot folder + its re-include toggle in the tree.
+ StatFileEntry(path: "Snapshot/SnapshotMode.swift",
+ lines: 780, language: "Swift", isDataProse: false,
+ isExcluded: true),
+ StatFileEntry(path: "README.md", lines: 48, language: "Markdown", isDataProse: true),
+ ]
+ }
+
+ // MARK: - Identity fixture (consumed by AccountsScreen in Task 22)
+
+ /// Deterministic identities for accounts.png: "default" is logged in,
+ /// every other account renders the not-logged-in row.
+ static func fixtureIdentity(_ account: AccountConfig) -> AccountIdentity? {
+ guard account.name == "default" else { return nil }
+ return AccountIdentity(email: "artem@example.com",
+ organization: "Personal",
+ tier: "max_20x",
+ organizationRateLimitTier: "default_claude_max_20x")
+ }
+
+ // MARK: - Rendering
+
+ @MainActor
+ static func renderAll(into outDir: URL) throws -> Int {
+ try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
+ for scene in SnapshotScene.allCases {
+ try writePNG(view(for: scene), size: scene.size,
+ to: outDir.appendingPathComponent(scene.fileName))
+ }
+ return SnapshotScene.allCases.count
+ }
+
+ /// Fresh fixture per scene with the scene's ROUTE applied. The route is
+ /// set directly (NOT via open()) so no scan/refresh Task ever starts for
+ /// the synthetic project path. SnapshotModeTests asserts the route per
+ /// scene through this seam.
+ @MainActor
+ static func configuredState(for scene: SnapshotScene) -> AppState {
+ let state = fixtureState()
+ // The fixture always has exactly one project selected.
+ let projectID = state.selectedProjectID!
+ switch scene {
+ case .projects:
+ state.route = .projects
+ case .rootWorkspaces, .workspacesExpanded:
+ state.route = .project(projectID)
+ case .createSheet:
+ state.createPrefill = CreatePrefill(name: "checkout-flow")
+ state.route = .createWorkspace(projectID)
+ case .graph:
+ state.selectedTab = .graph
+ state.route = .project(projectID)
+ case .stats:
+ // The fixture pre-seeds codeStats + codeStatsHistory, so the Stats tab
+ // renders the totals header, language bars/table, and the growth chart's
+ // manual (non-Charts) fallback offscreen.
+ state.selectedTab = .stats
+ state.route = .project(projectID)
+ case .sessions:
+ state.selectedTab = .sessions
+ state.route = .project(projectID)
+ case .accounts:
+ state.route = .accounts
+ case .settings:
+ state.route = .projectSettings(projectID)
+ case .statsSettings:
+ // The fixture pre-seeds statsFiles + one excluded folder, so the
+ // directory+file tree (summed folder LOC, file tints, the dimmed
+ // excluded folder) renders offscreen from injected data.
+ state.route = .statsSettings(projectID)
+ case .errorBanner:
+ // Pins the RootView error banner styling (DesignRadius.field,
+ // material strip). The message mentions cmux so the "Launch cmux"
+ // affordance renders too.
+ state.route = .projects
+ state.actionError = "cmux unavailable: socket control mode blocks external clients"
+ }
+ return state
+ }
+
+ /// Renders the heavier screens once offscreen so the FIRST real navigation
+ /// isn't paying SwiftUI's cold body-build + layout cost (the "first run is
+ /// slow, then snappy" effect). Uses isolated fixture state (never ~/.claude)
+ /// with isSnapshotRender, and discards the rendered image.
+ @MainActor
+ static func prewarm() {
+ for scene in [SnapshotScene.rootWorkspaces, .settings] {
+ let content = view(for: scene)
+ .environment(\.isSnapshotRender, true)
+ .environment(\.colorScheme, .dark)
+ let renderer = ImageRenderer(content: content)
+ renderer.scale = 1
+ _ = renderer.cgImage
+ }
+ }
+
+ @MainActor
+ static func view(for scene: SnapshotScene) -> AnyView {
+ let root = RootView(state: configuredState(for: scene))
+ if scene == .workspacesExpanded {
+ return AnyView(root.environment(\.snapshotExpandedWorkspaces, ["media-upload"]))
+ }
+ return AnyView(root)
+ }
+
+ /// Offscreen render at the scene's logical size, scale 2.
+ /// CAVEAT: ImageRenderer has no window/backdrop, so .glassEffect-modified
+ /// views render INVISIBLE offscreen — \.isSnapshotRender makes GlassCard
+ /// fall back to a plain translucent card. Snapshots verify LAYOUT and
+ /// CONTENT, never glass blur. The dark gradient stands in for the missing
+ /// desktop/panel material.
+ @MainActor
+ static func writePNG(_ content: Content, size: CGSize, to url: URL) throws {
+ let wrapped = ZStack {
+ LinearGradient(colors: [Color(red: 0.10, green: 0.11, blue: 0.14),
+ Color(red: 0.16, green: 0.13, blue: 0.20)],
+ startPoint: .top, endPoint: .bottom)
+ content
+ }
+ .frame(width: size.width, height: size.height)
+ .environment(\.colorScheme, .dark)
+ .environment(\.isSnapshotRender, true)
+ // Identity from the fixture, NEVER from ~/.claude.json: offscreen
+ // renders must not read the user's real Claude config.
+ .environment(\.claudeIdentityProvider, { SnapshotMode.fixtureIdentity($0) })
+
+ let renderer = ImageRenderer(content: wrapped)
+ renderer.scale = 2
+ guard let cgImage = renderer.cgImage else {
+ throw SnapshotError.renderFailed(url.lastPathComponent)
+ }
+ let rep = NSBitmapImageRep(cgImage: cgImage)
+ guard let data = rep.representation(using: .png, properties: [:]) else {
+ throw SnapshotError.encodeFailed(url.lastPathComponent)
+ }
+ try data.write(to: url)
+ }
+}
diff --git a/Sources/GroveAppKit/State/AccountLimits.swift b/Sources/GroveAppKit/State/AccountLimits.swift
new file mode 100644
index 0000000..6fb2170
--- /dev/null
+++ b/Sources/GroveAppKit/State/AccountLimits.swift
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import GroveCore
+
+/// One account's use of its 5-hour and weekly limits as the host app last read them —
+/// Brow's notch — for the account pickers: "which account has room" next to "move the
+/// work there". The engine itself never fetches a limit.
+public struct AccountLimits: Equatable, Sendable {
+ /// Percent used, 0…100; nil when the window is not known.
+ public var fiveHour: Double?
+ public var weekly: Double?
+ /// The reading is older than the host calls fresh.
+ public var stale: Bool
+
+ public init(fiveHour: Double?, weekly: Double?, stale: Bool) {
+ self.fiveHour = fiveHour
+ self.weekly = weekly
+ self.stale = stale
+ }
+
+ /// "5h 42% · week 61%", with "· old" when the reading is stale; nil when neither
+ /// window is known.
+ public var label: String? {
+ let parts = [fiveHour.map { "5h \(Self.percent($0))" }, weekly.map { "week \(Self.percent($0))" }]
+ .compactMap { $0 }
+ guard !parts.isEmpty else { return nil }
+ return parts.joined(separator: " · ") + (stale ? " · old" : "")
+ }
+
+ static func percent(_ used: Double) -> String { "\(Int(min(max(used, 0), 100).rounded()))%" }
+}
+
+/// One account as the host app shows it — Brow's notch and Settings › Accounts — so
+/// Workspaces calls it the same: the name the user gave it there (else its email) and
+/// its plan as the notch writes it ("Max 20x"), not Grove's own account name and the
+/// raw tier id.
+public struct AccountLabel: Equatable, Sendable {
+ public var name: String
+ /// nil when the host does not know the plan.
+ public var plan: String?
+
+ public init(name: String, plan: String?) {
+ self.name = name
+ self.plan = plan
+ }
+}
+
+/// Where the account pickers get those numbers, and the names: the app hosting the
+/// engine. Both are joined to an account by its config folders.
+@MainActor
+public protocol AccountLimitsProviding: AnyObject {
+ /// The limits of the account that owns any of `dirs` (its config folder or an alias).
+ func limits(forConfigDirs dirs: [String]) -> AccountLimits?
+ /// What the host calls the account that owns any of `dirs`; nil when it does not know
+ /// the account. A provider without names leaves Grove's own.
+ func label(forConfigDirs dirs: [String]) -> AccountLabel?
+}
+
+public extension AccountLimitsProviding {
+ func label(forConfigDirs dirs: [String]) -> AccountLabel? { nil }
+
+ /// What `account`, one of Grove's `accounts`, is called: the host's name for it, else
+ /// Grove's own. Two of Grove's accounts can get one name from the host — two folders of
+ /// an organisation the notch shows as one account — and would then read alike in every
+ /// picker. Only the first keeps the host's name, as Settings › Accounts gives the
+ /// notch's card the first; the others keep Grove's own names, which tell them apart.
+ func displayName(for account: AccountConfig, among accounts: [AccountConfig]) -> String {
+ func hostName(_ account: AccountConfig) -> String? {
+ label(forConfigDirs: account.allConfigDirs.map(expandTilde))?.name
+ }
+ guard let name = hostName(account) else { return account.name }
+ let first = accounts.first { hostName($0) == name }
+ return first == nil || first?.name == account.name ? name : account.name
+ }
+}
diff --git a/Sources/GroveAppKit/State/AppState.swift b/Sources/GroveAppKit/State/AppState.swift
new file mode 100644
index 0000000..6126ab1
--- /dev/null
+++ b/Sources/GroveAppKit/State/AppState.swift
@@ -0,0 +1,1763 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+import SwiftUI
+import AppKit
+import GroveCore
+
+/// Capsule tab strip inside the project scope (Workspaces | Graph | Claude).
+/// Accounts left the strip when it became its own Route (.accounts).
+public enum MainTab: String, CaseIterable {
+ case workspaces
+ case graph
+ case stats
+ case sessions
+
+ /// Tab-strip label. `sessions` reads "Claude" (the strip is
+ /// Workspaces | Graph | Stats | Claude).
+ public var label: String {
+ switch self {
+ case .workspaces: return "Workspaces"
+ case .graph: return "Graph"
+ case .stats: return "Stats"
+ case .sessions: return "Claude"
+ }
+ }
+}
+
+/// Single observable source of truth for the app. Owns the config (loaded via
+/// ConfigStore), per-project scan snapshots, selection, and every user action.
+/// Action methods never throw into views: failures land in `actionError`.
+@MainActor
+public final class AppState: ObservableObject {
+ @Published public var config: GroveConfig
+ @Published public var configIssue: String?
+ @Published public var snapshots: [UUID: ProjectSnapshot] = [:]
+ /// Per-account statusline captures (model, context %, effort, cost per session),
+ /// filled by refreshUsage on the scan tick. Empty until the first refresh.
+ @Published public var snapshotsByAccount: [String: [UsageSnapshot]] = [:]
+ /// Per-account identity (email/org/tier), resolved OFF the main actor in
+ /// refreshUsage. AccountsScreen reads this instead of a synchronous .claude.json
+ /// read per card per render. Empty until the first refresh (the screen falls back
+ /// to its injected provider then — which is also the snapshot seam).
+ var identityByAccount: [String: AccountIdentity] = [:]
+ /// Per-project recent Claude sessions (item 4): the Projects tab's previews.
+ /// Filled by refreshSessionIndex (cheap, off-main — no git scan).
+ @Published public var recentSessionsByProject: [UUID: [ProjectSessionRow]] = [:]
+ /// Disk-wide recent sessions (Phase 2 / Task 2) attributed to a configured
+ /// project by cwd prefix match. Keyed by ProjectConfig.id, newest-first.
+ /// Populated by refreshSessionIndex alongside recentSessionsByProject.
+ @Published public var externalSessionsByProject: [UUID: [ClaudeSession]] = [:]
+ /// Disk-wide recent sessions whose cwd matches NO configured project.
+ /// Newest-first. Populated by refreshSessionIndex.
+ @Published public var otherSessions: [ClaudeSession] = []
+ /// When non-nil, the launch sheet is presented to configure a Resume/New launch
+ /// (open-target, account, model, effort) before it runs.
+ @Published public var launchRequest: LaunchRequest?
+ @Published public var selectedProjectID: UUID?
+ @Published public var selectedTab: MainTab = .workspaces
+ /// The panel's current full-screen state. Mutate via open()/goBack() so
+ /// the transition direction and per-route side effects stay consistent.
+ @Published public var route: Route = .projects
+ /// Pending prefill for the createWorkspace route; producers set it right
+ /// before open(.createWorkspace(id)), goBack() consumes it.
+ @Published public var createPrefill: CreatePrefill?
+ /// Direction of the LAST route change (push = forward, pop = backward),
+ /// derived from route depth. Drives RootView's transition edges. Not
+ /// @Published: it always changes together with `route`.
+ public private(set) var routeIsForward = true
+ /// The scan spawned by the last open(.project(id)); tests await it.
+ internal var refreshTask: Task?
+ @Published public var searchQuery: String = ""
+ /// True only while the menu-bar panel is actually open. The controller sets it
+ /// on show/hide; RootView's 15s refresh loop is keyed on it so the loop never
+ /// runs while the panel is hidden (or in a headless render/test) — which would
+ /// otherwise spin forever parsing real transcripts (the menu-bar panel hides
+ /// via orderOut, which does NOT deallocate the view or cancel its .task).
+ @Published public var isPanelOpen: Bool = false
+ @Published public var isScanning: Bool = false
+ @Published public var actionError: String?
+ @Published public var graphRepoPath: String?
+ @Published public var graphNodes: [CommitNode] = []
+ /// True when the last graph page came back full — drives the "Load more" row.
+ @Published public var graphCanLoadMore: Bool = false
+ /// The commit whose file changes are expanded inline, and the lazily-loaded
+ /// list (nil while loading). Tapping the same commit collapses it.
+ @Published public var expandedCommit: String?
+ @Published public var expandedCommitFiles: [CommitFileChange]?
+ /// Local branch names per repo (key = repo.path), filled by loadBranches.
+ /// Branch pickers fall back to the resolved default while a repo is absent.
+ @Published public var branchesByRepo: [String: [String]] = [:]
+ /// Stats-tab per-repo branch override (key = repo.path → chosen branch). Repo paths
+ /// are unique across projects, so a flat dict is fine. Empty ⇒ each repo uses its
+ /// auto-detected default. Fed into GitStatsService.scan as `branchOverrides`; an
+ /// override that no longer names a real branch is silently ignored by the engine.
+ @Published public var selectedStatsBranchByRepo: [String: String] = [:]
+
+ /// Per-project code-stats snapshot (Stage 4), filled lazily by refreshCodeStats
+ /// from the stats screen's .task (NOT the global refresh). Empty until scanned.
+ @Published public var codeStats: [UUID: CodeStats] = [:]
+ /// Per-project code-stats history (the "lines over time" series). Now GIT-derived:
+ /// `refreshCodeStats` OVERWRITES it each scan with the per-day cumulative net-lines
+ /// series summed across the project's repos (no longer an append-only snapshot log).
+ @Published public var codeStatsHistory: [UUID: [CodeStatsPoint]] = [:]
+ /// Per-project per-repo breakdown (current LOC + history + delta per repo), produced
+ /// alongside the aggregate by GitStatsService. Published for a LATER per-repo UI pass;
+ /// the current screen reads only `codeStats`/`codeStatsHistory`.
+ @Published public var repoStats: [UUID: [RepoStats]] = [:]
+ /// Per-project per-file list (project-root-relative path + classified line total +
+ /// language), produced by the same scan that fills `codeStats`. Feeds the stats
+ /// settings page's directory+file tree; honors the folder/.ignorestats exclusions.
+ @Published public var statsFiles: [UUID: [StatFileEntry]] = [:]
+ /// True only while a code-stats scan is in flight (drives the screen's spinner).
+ @Published public var isStatsScanning: Bool = false
+
+ /// How long Claude Code keeps each account's transcripts (`ClaudeRetention`), by
+ /// account name — the strictest of the account's folders. The safety net never keeps
+ /// or restores anything older; Settings shows it. Filled by `reconcileTranscripts`.
+ @Published public var transcriptRetention: [String: ClaudeRetention.Resolution] = [:]
+ /// Transcripts gone from Claude Code's folders that the safety net could put back:
+ /// inside every retention that applies to them. Nothing comes back by itself — a
+ /// period passed to `claude` with `--settings` can't be seen from here — so Settings
+ /// lists them, newest first, each with Restore. Filled by `reconcileTranscripts`;
+ /// empty while the safety net is off.
+ @Published public var restorableTranscripts: [RestorableTranscript] = []
+
+ private let configStore: ConfigStore
+
+ /// Persistent service instance. Its mtime parse-cache MUST survive across
+ /// scans/refreshes — a fresh `ClaudeService()` per tick re-parsed every transcript
+ /// from scratch on the 15s loop (item 2 perf bug).
+ private let claude = ClaudeService()
+
+ /// Git-as-source-of-truth code-stats service (per-repo, honors .gitignore via git's
+ /// own engine, excludes worktrees). Stateless value type; the per-project/per-repo
+ /// file cache below survives across refreshes so steady-state scans only re-read
+ /// changed files. Replaces the old filesystem-walk scanner for the stats numbers.
+ private let gitStats = GitStatsService()
+ /// Retained ONLY for `statsDirectoryTree` (the folder-exclusion picker still walks
+ /// the directory skeleton via this; its `scan` is no longer used for the numbers).
+ private let statsScanner = CodeStatsScanner()
+ /// Per-project git-stats file cache, keyed by project UUID then repo path. Mutated
+ /// only on the main actor (the detached scan takes a COPY of the relevant project's
+ /// cache and returns the updated one — same pattern as refreshUsage's off-main work).
+ private var gitStatsCacheByProject: [UUID: [String: RepoFileCache]] = [:]
+ /// Per-project scan serialization. Two scans of the SAME project must never run
+ /// concurrently: both would start from the same cache snapshot and the slower one
+ /// would overwrite the faster's cache on completion, silently dropping mtime
+ /// entries (so the next scan needlessly re-reads those files). A project in
+ /// `statsScanInFlight` has a scan running; a refresh requested meanwhile records
+ /// `statsRescanPending` and is honored once the in-flight scan settles.
+ private var statsScanInFlight: Set = []
+ private var statsRescanPending: Set = []
+ /// In-flight debounce task for `setStatsBranch` — cancelled and rescheduled on each
+ /// rapid branch pick so N quick switches coalesce to one rescan.
+ private var statsBranchDebounce: Task?
+
+ /// Debounce interval (seconds) for branch-switch rescans. Default 0.25s in production;
+ /// tests set this to 0 so the debounce fires synchronously and the coalesce assertion
+ /// is deterministic without artificial sleeps.
+ internal var statsBranchDebounceInterval: TimeInterval = 0.25
+
+ /// Test seam: when set, every cmux interaction uses this service instead of
+ /// a real `CmuxService()` (which would resolve and invoke the real cmux
+ /// binary). Internal so GroveAppKitTests can inject via @testable import.
+ internal var cmuxOverride: CmuxService?
+ /// Test seam for CmuxService.claudeSessionWorkspaceMap(hookFile:); nil
+ /// means the real ~/.cmuxterm/claude-hook-sessions.json.
+ internal var cmuxHookFile: String?
+ /// Graph page size (spec §6.2: 300). Internal so tests can page through a
+ /// tiny fixture repo instead of building 300+ commits.
+ internal var graphPageSize = 300
+
+ /// Every folder the engine reads or writes outside a project: Grove's config, the
+ /// status-line wrappers, the code statistics, the accounts root, the canonical
+ /// (default) account and the home folder that holds its `.claude.json`. Injected
+ /// at construction. A config file that is not the real one gets folders BESIDE it
+ /// (`WorkspacesFolders.beside`), so a test or an offscreen render can never write
+ /// into the user's own `~/.claude` or `~/Library/Application Support/Grove`.
+ /// Tests may change single fields afterwards.
+ public internal(set) var folders: WorkspacesFolders
+
+ /// Test seam: the pid-liveness predicate used by the concurrency guard. nil
+ /// means the real check (pid alive AND its command mentions "claude"). Tests
+ /// set { _ in true } so a fixture sessions/.json counts as live.
+ internal var liveProcessValidatorOverride: ((Int32) -> Bool)?
+
+ /// Test seam: injects a fixed live-process list into adoptSession's liveness
+ /// guard, bypassing both file-record reads AND the process-table scan. Required
+ /// for testing the table-only live path (cwd=="", sessionId set) without a real
+ /// process. Resume as… (sessionLiveUnderOtherAccount) uses it for its id-or-cwd
+ /// check too; its account attribution still reads the file records. nil means
+ /// the real allLiveProcesses call.
+ internal var allLiveProcessesOverride: [LiveProcess]?
+
+ /// Whether the engine installs the status-line wrapper into an account's
+ /// `settings.json` without being asked: at launch for every account not yet
+ /// monitored, and as a side effect of adding, linking or migrating into an account.
+ /// Grove.app did. Off unless a host turns it on, and Brow never does: capture is
+ /// turned on per account in Settings, or kept where Grove already had it on — so a
+ /// user's Claude Code files are edited only when they asked for it, whichever app
+ /// hosts the engine.
+ public var capturesByDefault = false
+
+ /// Asked before the engine writes Grove's `config.json` or the transcript safety net;
+ /// false refuses the write. A host that knows of another engine writing the same
+ /// files — Grove.app, still running — says no until that has stopped.
+ public var mayWrite: (() -> Bool)?
+
+ /// The host keeps the panel's material in its own settings: the in-panel picker
+ /// hands a change to it here (and the host sets `WindowSubstrateStyle.current`).
+ public var onPanelMaterialChange: ((WindowSubstrateStyle) -> Void)?
+
+ /// Opens the host's Settings window where the panel cannot hold the settings:
+ /// Workspaces' own (the footer's gear) or an account's (the Accounts screen).
+ public var openSettings: ((WorkspacesSettingsLink) -> Void)?
+
+ /// Each account's 5-hour and weekly use, from the host (Brow's limits store), for
+ /// the account pickers. nil: no numbers, just names.
+ public weak var limitsProvider: AccountLimitsProviding?
+
+ /// What the host last read for `account`, by its config folders.
+ public func limits(for account: AccountConfig) -> AccountLimits? {
+ limitsProvider?.limits(forConfigDirs: account.allConfigDirs.map(expandTilde))
+ }
+
+ /// What the account is called wherever Workspaces shows it — pickers, session rows,
+ /// the Accounts screen: the host's name for it, the one the notch shows, else the name
+ /// in Grove's config (its email, as the engine names accounts). The config name stays
+ /// the account's key; only what is shown follows the host. A second account the host
+ /// names the same keeps its config name (`AccountLimitsProviding.displayName`).
+ public func displayName(for account: AccountConfig) -> String {
+ limitsProvider?.displayName(for: account, among: config.accounts) ?? account.name
+ }
+
+ /// The same for a session's or a row's account, which they carry by its config name.
+ public func displayName(forAccountNamed name: String) -> String {
+ config.accounts.first { $0.name == name }.map(displayName(for:)) ?? name
+ }
+
+ /// The account's plan as the host shows it ("Max 20x"); nil when it does not know.
+ public func plan(for account: AccountConfig) -> String? {
+ limitsProvider?.label(forConfigDirs: account.allConfigDirs.map(expandTilde))?.plan
+ }
+
+ /// An account as the pickers list it: "Resume as Work — 5h 42% · week 61%". The
+ /// numbers are what Brow says about room; the menu is where the work moves.
+ public func menuTitle(_ verb: String, _ account: AccountConfig) -> String {
+ let name = displayName(for: account)
+ let base = verb.isEmpty ? name : "\(verb) \(name)"
+ guard let label = limits(for: account)?.label else { return base }
+ return "\(base) — \(label)"
+ }
+
+ /// Test observability: incremented each time `runCodeStatsScan` begins a scan. nil
+ /// in production (never read); tests set it to 0 before calling `setStatsBranch`
+ /// and assert it equals 1 after the debounce fires to verify coalescing.
+ internal var statsRescanStartedCount: Int?
+
+ /// `folders` defaults to folders beside the config file — see `folders`.
+ public init(configStore: ConfigStore, folders: WorkspacesFolders? = nil) {
+ self.configStore = configStore
+ self.folders = folders ?? .beside(configFile: configStore.url)
+ let loaded = configStore.load()
+ self.config = loaded.config
+ self.configIssue = loaded.issue
+ self.selectedProjectID = loaded.config.projects.first?.id
+ }
+
+ /// The real folders: ~/Library/Application Support/Grove/config.json and Claude
+ /// Code's folders in the user's home.
+ public convenience init(folders: WorkspacesFolders = .live()) {
+ self.init(configStore: ConfigStore(url: folders.configFile), folders: folders)
+ }
+
+ // MARK: - Navigation (panel state machine)
+
+ /// Navigates the panel. Pushes vs pops are classified by route depth
+ /// (deeper-or-equal = forward). Opening a project selects it and kicks
+ /// off a scan so the workspace tree is fresh by the time it settles.
+ public func open(_ target: Route) {
+ routeIsForward = target.depth >= route.depth
+ if case .project(let id) = target {
+ if id != selectedProjectID { resetGraph() }
+ selectedProjectID = id
+ refreshTask = Task { await self.refresh() }
+ }
+ // INSTANT route change — no withAnimation. Animating the route swap made
+ // Apple's Liquid Glass framework (DesignLibrary / MaterialProviderBox) recurse
+ // to a stack-overflow SIGSEGV while it re-resolved the glass layers of the
+ // animated SwiftUI content INSIDE the window's NSGlassEffectView (introduced by
+ // the one-window merge). A non-animated swap renders once and never re-enters
+ // that resolution mid-interpolation.
+ route = target
+ }
+
+ /// Pops along Route.backRoute. Leaving createWorkspace consumes the
+ /// pending prefill so a later visit never reuses stale form state.
+ public func goBack() {
+ if case .createWorkspace = route { createPrefill = nil }
+ open(route.backRoute)
+ }
+
+ // MARK: - Selection
+
+ public var selectedProject: ProjectConfig? {
+ config.projects.first { $0.id == selectedProjectID }
+ }
+
+ public var selectedSnapshot: ProjectSnapshot? {
+ selectedProjectID.flatMap { snapshots[$0] }
+ }
+
+ /// Account a single-click "New Claude" launches on (spec §6.1): the
+ /// selected project's defaultAccount when it names a configured account,
+ /// else the first account. The account MENUS always list all accounts.
+ public var defaultLaunchAccount: AccountConfig? {
+ if let name = selectedProject?.defaultAccount,
+ let account = config.accounts.first(where: { $0.name == name }) {
+ return account
+ }
+ return config.accounts.first
+ }
+
+ /// Runs one of the actions below for a window of the host's own — Brow's Settings —
+ /// and returns what went wrong, if anything. The failure is shown next to the control
+ /// that caused it, where the user is looking; the panel's banner, which they are not,
+ /// is left as it was.
+ @discardableResult
+ public func attempt(_ action: () -> Void) -> String? {
+ let shown = actionError
+ actionError = nil
+ action()
+ let failure = actionError
+ actionError = shown
+ return failure
+ }
+
+ // MARK: - Persistence
+
+ private func persist() {
+ guard mayPersist() else { return }
+ do {
+ try configStore.save(config)
+ } catch {
+ actionError = String(describing: error)
+ }
+ }
+
+ /// Whether the config may be saved now (`mayWrite`); when not, says why in
+ /// `actionError` (`reporting`). Asked first by an action that edits another file
+ /// before it saves the config — an account's settings.json, its session folders — so
+ /// a save refused at the end never leaves half the change made.
+ private func mayPersist(reporting: Bool = true) -> Bool {
+ guard let mayWrite, !mayWrite() else { return true }
+ if reporting {
+ actionError = "Not saved: Grove.app is running and writes the same settings. Quit Grove, then make the change again."
+ }
+ return false
+ }
+
+ // MARK: - Config CRUD
+
+ /// Adds a project named after the path's leaf directory, saves and selects it.
+ public func addProject(at path: String) {
+ let expanded = expandTilde(path)
+ let name = (expanded as NSString).lastPathComponent
+ let project = ProjectConfig(name: name, path: expanded)
+ config.projects.append(project)
+ selectedProjectID = project.id
+ persist()
+ }
+
+ public func removeProject(id: UUID) {
+ config.projects.removeAll { $0.id == id }
+ snapshots.removeValue(forKey: id)
+ // Drop all per-project code-stats state and delete its history file so a
+ // re-added project at the same path starts clean (the UUID differs anyway).
+ // Clear any stats-branch overrides for this project's repos before dropping
+ // repoStats (the only place that maps the project → its repo paths).
+ for repo in repoStats[id] ?? [] {
+ selectedStatsBranchByRepo.removeValue(forKey: repo.repoPath)
+ }
+ codeStats.removeValue(forKey: id)
+ codeStatsHistory.removeValue(forKey: id)
+ repoStats.removeValue(forKey: id)
+ gitStatsCacheByProject.removeValue(forKey: id)
+ statsStore.delete(projectID: id)
+ if selectedProjectID == id {
+ selectedProjectID = config.projects.first?.id
+ resetGraph()
+ }
+ persist()
+ }
+
+ /// Replaces the project with the same id; unknown ids are ignored.
+ public func updateProject(_ p: ProjectConfig) {
+ guard let index = config.projects.firstIndex(where: { $0.id == p.id }) else { return }
+ config.projects[index] = p
+ persist()
+ }
+
+ /// Global settings edit (GlobalSettingsScreen); persists like every other config mutation.
+ public func setWorkspacesRootTemplate(_ template: String) {
+ config.workspacesRootTemplate = template
+ persist()
+ }
+
+ /// Transcript safety-net settings edit (GlobalSettingsScreen); persists immediately.
+ public func setTranscriptMirror(_ settings: TranscriptMirrorSettings) {
+ config.transcriptMirror = settings
+ persist()
+ }
+
+ /// New account convention (spec §6.3): configDir = ~/.claude-accounts/
+ /// (`folders.accountsRoot`). Phase 5A: also creates the
+ /// dir and auto-installs the grove statusline there so the account captures usage
+ /// from its first session (best-effort — account creation still succeeds if the
+ /// install fails).
+ public func addAccount(name: String) {
+ guard !config.accounts.contains(where: { $0.name == name }) else {
+ actionError = "account '\(name)' already exists"
+ return
+ }
+ let account = AccountConfig(name: name, configDir: folders.storedAccountDir(name))
+ config.accounts.append(account)
+ persist()
+ if capturesByDefault { enableMonitoring(account, reportErrors: false) }
+ }
+
+ /// Names every account by the login Claude Code knows it by — the email in its
+ /// `.claude.json` — once identities have been read (`refreshUsage`). Runs at
+ /// launch; a rename moves the project default-account references with it.
+ /// Returns whether anything changed, so the caller can re-read captures under
+ /// the new keys.
+ @discardableResult
+ public func reconcileAccountNames() -> Bool {
+ let emails = identityByAccount.compactMapValues(\.email)
+ // Same login in two folders is one account: the folder Claude Code used most
+ // recently launches it, the other is read for its sessions.
+ var activity: [String: Date] = [:]
+ for account in config.accounts {
+ activity[account.name] = (try? FileManager.default.attributesOfItem(atPath: claudeJSONPath(for: account)))?[.modificationDate] as? Date
+ }
+ let merge = AccountNaming.merged(accounts: config.accounts, emailByName: emails, activityByName: activity)
+ var next = config
+ next.accounts = merge.accounts
+ next = next.renamingAccounts(merge.renames)
+ let renames = AccountNaming.renames(accounts: next.accounts, emailByName: emails)
+ next = next.renamingAccounts(renames)
+ guard next != config else { return false }
+ config = next
+ persist()
+ for (old, new) in merge.renames.merging(renames, uniquingKeysWith: { $1 }).sorted(by: { $0.key < $1.key }) {
+ GroveLog.perf.info("account \(old, privacy: .public) → \(new, privacy: .public)")
+ }
+ return true
+ }
+
+ /// Adds every folder under the accounts root (`~/.claude-accounts`) that is
+ /// signed in and unknown to Grove — the folders Brow's one-click sign-in
+ /// creates — named by its email. Returns whether anything was added.
+ @discardableResult
+ public func discoverAccountDirs() -> Bool {
+ let root = folders.accountsRoot
+ let fm = FileManager.default
+ let known = Set(config.accounts.flatMap(\.allConfigDirs).map(expandTilde))
+ // A folder signed in to a login Grove already lists joins that account as an alias.
+ var emailOfAccount: [String: String] = [:]
+ for account in config.accounts {
+ if let email = ClaudeService.identity(claudeJSONPath: claudeJSONPath(for: account))?.email {
+ emailOfAccount[account.name] = email
+ }
+ }
+ var added = false
+ for folder in ((try? fm.contentsOfDirectory(atPath: root)) ?? []).sorted() where !folder.hasPrefix(".") {
+ let path = root + "/" + folder
+ var isDir: ObjCBool = false
+ guard !known.contains(path), fm.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue,
+ let email = ClaudeService.identity(claudeJSONPath: path + "/.claude.json")?.email
+ else { continue }
+ let stored = folders.storedAccountDir(folder)
+ if let i = config.accounts.firstIndex(where: { emailOfAccount[$0.name] == email }) {
+ config.accounts[i].aliasDirs.append(stored)
+ } else {
+ let name = AccountNaming.uniqueName(email: email, folder: folder, taken: Set(config.accounts.map(\.name)))
+ config.accounts.append(AccountConfig(name: name, configDir: stored))
+ emailOfAccount[name] = email
+ }
+ added = true
+ }
+ if added { persist() }
+ return added
+ }
+
+ /// Brow's per-account browser router: `claude auth login` from a session the engine
+ /// launched then opens that account's own browser profile. The host sets the one in
+ /// its own bundle; without a host, the installed Brow.app's. nil = no router.
+ public var browserRouter: String? = AppState.locateBrowserRouter()
+
+ /// The `claude` sessions launch with: the host's Path to claude (Settings › General,
+ /// or what it detected). nil, or a closure answering nil, leaves it to the engine's
+ /// own guesses (`ClaudeService.claudeExecutable()`).
+ public var claudePath: (() -> String?)?
+
+ static func locateBrowserRouter() -> String? {
+ let candidates = [NSWorkspace.shared.urlForApplication(withBundleIdentifier: "dev.artemefimov.brow")?.path,
+ "/Applications/Brow.app"].compactMap { $0 }
+ for app in candidates {
+ let router = app + "/Contents/Resources/brow-browser"
+ if FileManager.default.isExecutableFile(atPath: router) { return router }
+ }
+ return nil
+ }
+
+ /// Removes the account from Workspaces' config only — the directory is untouched —
+ /// and from the projects that launched on it by default, which fall back to the first
+ /// account. What Settings offers for an account whose folder is gone.
+ public func removeAccount(name: String) {
+ config.accounts.removeAll { $0.name == name }
+ for i in config.projects.indices where config.projects[i].defaultAccount == name {
+ config.projects[i].defaultAccount = nil
+ }
+ persist()
+ }
+}
+
+// MARK: - Services & scanning
+
+extension AppState {
+ private func cmux() -> CmuxService {
+ cmuxOverride ?? CmuxService()
+ }
+
+ /// Built fresh from the CURRENT config so edits (accounts, overrides, hooks)
+ /// take effect on the next scan/creation without restarting. Reuses the
+ /// persistent `claude` so its transcript parse-cache survives across scans.
+ public var workspaceService: WorkspaceService {
+ WorkspaceService(git: GitService(), claude: claude, cmux: cmux(), config: config)
+ }
+
+ /// Scans the selected project; no-op when nothing is selected. scan() itself
+ /// never throws (per-repo/cmux failures degrade into snapshot.errors).
+ ///
+ /// Usage refresh (account-wide, independent of selection) runs CONCURRENTLY
+ /// with the scan via `async let`, and its heavy file I/O happens off the main
+ /// actor (see refreshUsage), so neither freezes the panel (item 2). Both are
+ /// awaited before returning so tests and the 15s loop stay deterministic.
+ /// Local only: captures, sessions and worktrees. Nothing here touches the
+ /// network — per-account limits are Brow's.
+ public func refresh() async {
+ let started = Date()
+ async let usage: Void = refreshUsage(now: started)
+ async let sessions: Void = refreshSessionIndex()
+ // Scan the selected project every tick (its detail view needs fresh data);
+ // scan the OTHERS once, when they have no snapshot yet, so EVERY project card
+ // shows its "N repos · M ws" count — not only the one that's been opened.
+ if !config.projects.isEmpty {
+ isScanning = true
+ for project in config.projects
+ where project.id == selectedProjectID || snapshots[project.id] == nil {
+ snapshots[project.id] = await workspaceService.scan(project: project)
+ }
+ isScanning = false
+ GroveLog.perf.info("scan \(self.config.projects.count, privacy: .public) projects: \(Int(Date().timeIntervalSince(started) * 1000))ms")
+ }
+ await usage
+ await sessions
+ await reconcileTranscripts()
+ }
+
+ /// Cheap per-project recent-session previews for the Projects tab (item 4).
+ /// Independent of the heavy git scan: it only reads recent transcripts + live
+ /// processes + the cmux hook map, all OFF the main actor. This is what makes
+ /// the primary flow (open → pick a session → go to its terminal) instant.
+ ///
+ /// Also populates `externalSessionsByProject` and `otherSessions` (Phase 2 /
+ /// Task 2): a disk-wide scan of ALL recent sessions attributed to projects by
+ /// cwd prefix. The recency cap reuses `config.transcriptMirror.maxDays` (90 by
+ /// default). allRecentSessions + attribution both run inside the detached task;
+ /// the finished dictionaries are assigned back on the main actor.
+ public func refreshSessionIndex() async {
+ let accounts = config.accounts
+ let claude = self.claude
+ let cmuxMap = cmux().claudeSessionWorkspaceMap(hookFile: cmuxHookFile)
+ let jobs: [(id: UUID, roots: [String])] = config.projects.map { p in
+ let wsRoot = expandTilde(p.workspacesRoot
+ ?? config.workspacesRootTemplate.replacingOccurrences(of: "{project}", with: p.name))
+ return (id: p.id, roots: [expandTilde(p.path), wsRoot].filter { !$0.isEmpty })
+ }
+ let sinceDays = config.transcriptMirror.maxDays
+ let now = Date()
+
+ // Precompute canonical roots for every project ONCE on the main actor
+ // (reads config.projects; each root is a single resolvingSymlinksInPath syscall).
+ // The per-session attribution loop below needs these but runs off-main — we
+ // pass a value snapshot so the closure never touches main-actor state. (I1 fix)
+ let projectRoots: [(id: UUID, roots: [String])] = config.projects.map { p in
+ let roots = [expandTilde(p.path), expandTilde(p.workspacesRoot ?? "")]
+ .filter { !$0.isEmpty }
+ .map { canonicalPath($0) }
+ return (id: p.id, roots: roots)
+ }
+
+ let (sessionRows, byProject, unmatched) = await Task.detached(priority: .utility) {
+ () -> ([UUID: [ProjectSessionRow]], [UUID: [ClaudeSession]], [ClaudeSession]) in
+ // Single source of truth (file records ∪ process table) — same data the
+ // project scan / Claude tab use, so no tab can disagree on liveness.
+ let live = claude.allLiveProcesses(accounts: accounts)
+ var out: [UUID: [ProjectSessionRow]] = [:]
+ for job in jobs {
+ let sessions = claude.recentSessions(underRoots: job.roots, accounts: accounts, limit: 2)
+ out[job.id] = buildProjectSessionRows(sessions: sessions, live: live, cmuxMap: cmuxMap)
+ }
+ // Disk-wide scan for attribution indices (Phase 2 / Task 2).
+ // 500 is a GLOBAL budget across all projects (newest-first over the
+ // ~90d transcriptMirror.maxDays window), so on a very active machine a
+ // rarely-used project's recent sessions can fall below the cut.
+ let all = claude.allRecentSessions(accounts: accounts, limit: 500,
+ sinceDays: sinceDays, now: now)
+
+ // Attribute sessions to projects off the main actor using the precomputed
+ // canonical roots snapshot — avoids resolvingSymlinksInPath syscalls (up to
+ // 500 sessions × N projects) on the main thread. (I1 fix)
+ var sessionsByProject: [UUID: [ClaudeSession]] = [:]
+ var otherSessions: [ClaudeSession] = []
+ for session in all {
+ let sessionCanon = canonicalPath(session.cwd)
+ if let match = projectRoots.first(where: { proj in
+ proj.roots.contains { sessionCanon == $0 || sessionCanon.hasPrefix($0 + "/") }
+ }) {
+ sessionsByProject[match.id, default: []].append(session)
+ } else {
+ otherSessions.append(session)
+ }
+ }
+ return (out, sessionsByProject, otherSessions)
+ }.value
+ recentSessionsByProject = sessionRows
+ externalSessionsByProject = byProject
+ otherSessions = unmatched
+ }
+
+ /// Refreshes branchesByRepo for `repos`, concurrently (one git call per
+ /// repo). Existing entries for these repos are REPLACED — a deleted branch
+ /// disappears from the pickers on the next load — while entries for other
+ /// repos are left alone. localBranches never throws ([] on failure), so
+ /// non-repos degrade to an empty list and the pickers fall back to the
+ /// resolved default.
+ public func loadBranches(for repos: [RepoInfo]) async {
+ let fresh = await withTaskGroup(of: (String, [String]).self) { group in
+ for repo in repos {
+ group.addTask { [path = repo.path] in
+ (path, await GitService().localBranches(repoPath: path))
+ }
+ }
+ var collected: [String: [String]] = [:]
+ for await (path, branches) in group {
+ collected[path] = branches
+ }
+ return collected
+ }
+ branchesByRepo.merge(fresh) { _, new in new }
+ }
+}
+
+// MARK: - Graph
+
+extension AppState {
+ /// Drops the loaded graph page. Called whenever the selected project
+ /// changes (open(.project) with a different id, removeProject of the
+ /// selected one): graphNodes/graphRepoPath belong to ONE project, and
+ /// keeping them across a switch rendered project A's commits under
+ /// project B's repo strip (v1.2.1 fix 1). GraphScreen auto-selects the
+ /// new project's first repo via graphAutoSelectRepo on its next .task.
+ private func resetGraph() {
+ graphNodes = []
+ graphRepoPath = nil
+ graphCanLoadMore = false
+ expandedCommit = nil
+ expandedCommitFiles = nil
+ }
+
+ /// Toggles the inline file-change list for commit `sha`. Loads the files lazily
+ /// (`git show --numstat`) on first expand; a second tap collapses it.
+ public func expandCommit(_ sha: String) async {
+ if expandedCommit == sha { expandedCommit = nil; expandedCommitFiles = nil; return }
+ guard let repoPath = graphRepoPath else { return }
+ expandedCommit = sha
+ expandedCommitFiles = nil // spinner until loaded
+ let files = try? await GitService().fileChanges(repoPath: repoPath, sha: sha)
+ // Ignore a stale result if the user expanded a different commit meanwhile.
+ if expandedCommit == sha { expandedCommitFiles = files ?? [] }
+ }
+
+ public func loadGraph(repoPath: String) async {
+ graphRepoPath = repoPath
+ do {
+ graphNodes = try await GitService().commitGraph(repoPath: repoPath, limit: graphPageSize)
+ graphCanLoadMore = graphNodes.count == graphPageSize
+ } catch {
+ graphNodes = []
+ graphCanLoadMore = false
+ actionError = String(describing: error)
+ }
+ }
+
+ /// Appends the next `git log --all` page (spec §6.2 lazy paging). No-op
+ /// when the previous page was short. Commits created between page loads
+ /// shift skip-based ordering, so a page can repeat already-loaded hashes —
+ /// those are dropped (graphNodes hashes must stay unique: they are ForEach
+ /// identities and GraphLanesCanvas row keys). KNOWN v1 LIMITATION:
+ /// commitGraph lays lanes out per page, so lane numbers (and colors)
+ /// restart at each page boundary; links inside a page stay correct.
+ public func loadMoreGraph() async {
+ guard let repoPath = graphRepoPath, graphCanLoadMore else { return }
+ do {
+ let more = try await GitService().commitGraph(repoPath: repoPath,
+ limit: graphPageSize,
+ skip: graphNodes.count)
+ let seen = Set(graphNodes.map(\.hash))
+ graphNodes += more.filter { !seen.contains($0.hash) }
+ graphCanLoadMore = more.count == graphPageSize
+ } catch {
+ actionError = String(describing: error)
+ }
+ }
+}
+
+// MARK: - Claude / cmux actions
+
+extension AppState {
+ /// Launches Claude (optionally resuming a session) in a NEW cmux workspace
+ /// at `cwd`, focused. cmux is started first when not running.
+ public func launchClaude(cwd: String, title: String, account: AccountConfig,
+ resume sessionId: String?,
+ model: String? = nil, effort: String? = nil,
+ skipPermissions: Bool? = nil) async {
+ let service = cmux()
+ // --dangerously-skip-permissions: an explicit per-launch choice (from the
+ // config sheet) wins; otherwise resolve the project default by `cwd`, here in
+ // the single launch chokepoint so EVERY caller honors the setting.
+ ClaudeService.ensureOnboarded(configDir: account.configDir, home: folders.home)
+ let command = ClaudeService.launchCommand(account: account, resume: sessionId,
+ model: model, effort: effort,
+ skipPermissions: skipPermissions ?? effectiveSkipPermissions(cwd: cwd),
+ browser: browserRouter, claude: claudePath?())
+ do {
+ try await service.ensureRunning()
+ try await service.newWorkspace(name: title, cwd: cwd, command: command, focus: true)
+ } catch {
+ actionError = String(describing: error)
+ }
+ }
+
+ /// An account's default model and effort (Settings › Accounts); a project's own
+ /// default wins over them at launch.
+ public func setAccountModel(name: String, model: String?) {
+ guard let i = config.accounts.firstIndex(where: { $0.name == name }) else { return }
+ config.accounts[i].defaultModel = (model?.isEmpty == true) ? nil : model
+ persist()
+ }
+ public func setAccountEffort(name: String, effort: String?) {
+ guard let i = config.accounts.firstIndex(where: { $0.name == name }) else { return }
+ config.accounts[i].defaultEffort = (effort?.isEmpty == true) ? nil : effort
+ persist()
+ }
+
+ public func setProjectModel(projectID: UUID, model: String?) {
+ guard let i = config.projects.firstIndex(where: { $0.id == projectID }) else { return }
+ config.projects[i].defaultModel = (model?.isEmpty == true) ? nil : model
+ persist()
+ }
+ public func setProjectEffort(projectID: UUID, effort: String?) {
+ guard let i = config.projects.firstIndex(where: { $0.id == projectID }) else { return }
+ config.projects[i].defaultEffort = (effort?.isEmpty == true) ? nil : effort
+ persist()
+ }
+ public func setProjectSkipPermissions(projectID: UUID, _ on: Bool) {
+ guard let i = config.projects.firstIndex(where: { $0.id == projectID }) else { return }
+ config.projects[i].dangerouslySkipPermissions = on
+ persist()
+ }
+
+ /// Relaunches a session under `account` with an EXPLICIT model/effort (a session
+ /// card's "Relaunch with model X"). Cross-account still link-on-demands via
+ /// resumeSession's path; here we go straight to launch with the override.
+ public func relaunchSession(_ session: ClaudeSession, as account: AccountConfig,
+ model: String?, effort: String?) async {
+ let title = session.title ?? (session.cwd as NSString).lastPathComponent
+ await launchClaude(cwd: session.cwd, title: title, account: account,
+ resume: session.id, model: model, effort: effort)
+ }
+
+ /// The project that owns `cwd` (its path or workspacesRoot is a prefix), if any.
+ /// Used so a session's launch picks up the right project defaults.
+ private func project(forCwd cwd: String) -> ProjectConfig? {
+ let canon = canonicalPath(cwd)
+ return config.projects.first { p in
+ let roots = [expandTilde(p.path), expandTilde(p.workspacesRoot ?? "")]
+ .filter { !$0.isEmpty }.map(canonicalPath)
+ return roots.contains { canon == $0 || canon.hasPrefix($0 + "/") }
+ }
+ }
+
+ /// Public resolver for the session cards: the project owning a session's cwd,
+ /// so the model/effort pickers write that project's default (`setProjectModel`).
+ public func owningProject(forCwd cwd: String) -> ProjectConfig? {
+ project(forCwd: cwd)
+ }
+
+ /// Effective default model for a launch: project.defaultModel beats
+ /// account.defaultModel beats nil (spec §C.6). cwd resolves the project.
+ func effectiveModel(cwd: String, account: AccountConfig) -> String? {
+ project(forCwd: cwd)?.defaultModel ?? account.defaultModel
+ }
+
+ func effectiveEffort(cwd: String, account: AccountConfig) -> String? {
+ project(forCwd: cwd)?.defaultEffort ?? account.defaultEffort
+ }
+
+ /// Whether to launch with `--dangerously-skip-permissions` for `cwd`: the owning
+ /// project's per-project toggle (false when no project owns the cwd, e.g. a login
+ /// shell at $HOME). Project-scoped only — there is no account/global form.
+ func effectiveSkipPermissions(cwd: String) -> Bool {
+ project(forCwd: cwd)?.dangerouslySkipPermissions ?? false
+ }
+
+ /// Degraded-mode affordance (spec §7): the error banner's "Launch cmux"
+ /// button. ensureRunning performs `open -b com.cmuxterm.app` and waits for
+ /// a ping answer; success clears the banner, failure replaces it.
+ public func launchCmuxApp() async {
+ do {
+ try await cmux().ensureRunning()
+ actionError = nil
+ } catch {
+ actionError = String(describing: error)
+ }
+ }
+
+ /// Projects-tab session block tap (item 4): Go to the live cmux workspace
+ /// hosting the session if known, else Resume it under its account in a fresh
+ /// workspace. The fast routing path — no snapshot lookup needed.
+ public func openSession(_ row: ProjectSessionRow) async {
+ // CLOSED → open the launch sheet (target / account / model / effort) so the
+ // user confirms HOW to resume before a new process is spawned.
+ guard row.status != .closed else {
+ beginResume(row)
+ return
+ }
+ // LIVE → redirect to the running process; NEVER spawn a duplicate. Try each
+ // gate in turn and only fall through on FAILURE — a stale/closed workspace
+ // from one path must not dead-end before the others are tried.
+ let service = cmux()
+ // 1) cmux by workspace id (row, or re-resolved from the hook registry).
+ if let target = row.cmuxWorkspaceId
+ ?? service.claudeSessionWorkspaceMap(hookFile: cmuxHookFile)[row.sessionId],
+ (try? await service.selectWorkspace(target)) != nil {
+ return
+ }
+ // 2) cmux by the session's directory (the registry only tracks the ACTIVE
+ // session per workspace, so most sessions aren't in it).
+ if let ws = await service.workspaceForCwd(row.cwd),
+ (try? await service.selectWorkspace(ws.id)) != nil {
+ return
+ }
+ // 3) Apple's Terminal.app, by matching the process's controlling tty.
+ let claude = self.claude
+ let sessionId = row.sessionId
+ let focused = await Task.detached(priority: .userInitiated) { () -> Bool in
+ guard let tty = claude.ttyForSession(sessionId) else { return false }
+ return TerminalFocus.focusTerminalApp(tty: tty)
+ }.value
+ if !focused {
+ actionError = "“\(row.location)” is running, but in a terminal Brow can't focus "
+ + "(not cmux or Terminal.app). Switch to it in your terminal."
+ }
+ }
+
+ /// Seeds the launch/config sheet for `cwd`/`sessionId` with the owning project's
+ /// defaults (model / effort / skip-permissions), letting the user override any
+ /// of them — plus the account and target — before the launch runs. `account` is
+ /// recorded as BOTH the chosen and the origin account, so a later account change
+ /// in the sheet is detected as a cross-account resume.
+ private func seedLaunch(sessionId: String?, cwd: String, title: String, account: String) {
+ let owner = project(forCwd: cwd)
+ let acct = config.accounts.first { $0.name == account }
+ launchRequest = LaunchRequest(
+ sessionId: sessionId, cwd: cwd, title: title,
+ account: account, originAccount: account,
+ model: owner?.defaultModel ?? acct?.defaultModel,
+ effort: owner?.defaultEffort ?? acct?.defaultEffort,
+ skipPermissions: effectiveSkipPermissions(cwd: cwd),
+ target: .cmux)
+ }
+
+ /// Presents the launch sheet pre-filled to RESUME `row`'s session.
+ public func beginResume(_ row: ProjectSessionRow) {
+ seedLaunch(sessionId: row.sessionId, cwd: row.cwd, title: row.location, account: row.accountName)
+ }
+
+ /// Presents the launch sheet for a fresh ("New Claude") session in `cwd`.
+ public func beginNew(cwd: String, title: String, account: AccountConfig) {
+ seedLaunch(sessionId: nil, cwd: cwd, title: title, account: account.name)
+ }
+
+ /// Opens the per-session config/relaunch sheet for a Claude-tab row (closed →
+ /// resume with new model / effort / account / skip-permissions).
+ public func beginConfigure(_ row: SessionRow) {
+ seedLaunch(sessionId: row.sessionId, cwd: row.cwd, title: row.title, account: row.accountName)
+ }
+
+ /// Opens the per-session config/relaunch sheet for a Workspaces-tab session.
+ public func beginConfigure(session: ClaudeSession) {
+ let title = session.title ?? (session.cwd as NSString).lastPathComponent
+ seedLaunch(sessionId: session.id, cwd: session.cwd, title: title, account: session.accountName)
+ }
+
+ /// Runs the configured launch (Resume or New) at the chosen target. Closes the
+ /// sheet first so it can't be double-submitted.
+ public func confirmLaunch(_ request: LaunchRequest) async {
+ launchRequest = nil
+ let account = config.accounts.first { $0.name == request.account }
+ ?? config.accounts.first ?? AccountConfig(name: "default", configDir: "~/.claude")
+ // Resuming under a DIFFERENT account than the session belongs to needs the
+ // canonical-store linking first (so the new account's claude sees the
+ // transcript); abort the launch if that move is blocked.
+ if let sessionId = request.sessionId, !sessionId.isEmpty {
+ guard prepareResumeAccount(sessionId: sessionId, cwd: request.cwd,
+ fromAccount: request.originAccount, to: account) else { return }
+ }
+ switch request.target {
+ case .cmux:
+ await launchClaude(cwd: request.cwd, title: request.title, account: account,
+ resume: request.sessionId, model: request.model, effort: request.effort,
+ skipPermissions: request.skipPermissions)
+ case .terminal:
+ ClaudeService.ensureOnboarded(configDir: account.configDir, home: folders.home)
+ let command = ClaudeService.launchCommand(account: account, resume: request.sessionId,
+ model: request.model, effort: request.effort,
+ skipPermissions: request.skipPermissions,
+ browser: browserRouter, claude: claudePath?())
+ let cwd = request.cwd
+ let ok = await Task.detached(priority: .userInitiated) {
+ TerminalFocus.launchInTerminal(command: command, cwd: cwd)
+ }.value
+ if !ok { actionError = "Couldn't open Terminal.app for the session." }
+ }
+ }
+
+ public func goToCmux(_ ws: CmuxWorkspace) async {
+ do {
+ try await cmux().selectWorkspace(ws.id)
+ } catch {
+ actionError = String(describing: error)
+ }
+ }
+
+ /// Jumps to the cmux workspace hosting the session. The Sessions table
+ /// resolves the target workspace id when it builds the row (`cmuxWorkspaceId`
+ /// — from the hook registry OR a cmux workspace already sitting in the
+ /// session's cwd, which the spec's "Go" requires) and passes it as
+ /// `workspaceId`. When nil, we re-resolve through the hook registry; only on
+ /// a genuine miss do we relaunch Claude with --resume in a fresh workspace.
+ public func goToSession(_ s: ClaudeSession, fallbackCwd: String, fallbackTitle: String,
+ account: AccountConfig, workspaceId: String? = nil) async {
+ let service = cmux()
+ if let id = workspaceId ?? service.claudeSessionWorkspaceMap(hookFile: cmuxHookFile)[s.id] {
+ do {
+ try await service.selectWorkspace(id)
+ } catch {
+ actionError = String(describing: error)
+ }
+ return
+ }
+ await launchClaude(cwd: fallbackCwd, title: fallbackTitle, account: account, resume: s.id)
+ }
+
+ /// The canonical store directory: the default account's `~/.claude`
+ /// (`folders.canonicalDir`). Linking roots here; it is never symlinked. Internal so
+ /// views in the same module (SessionsScreen, OtherSessionsScreen) can evaluate
+ /// canShareAcrossAccounts without recomputing the path.
+ var canonicalDir: String { folders.canonicalDir }
+
+ /// The account whose expanded configDir IS the canonical dir, if configured.
+ /// Sharing is impossible without it (nothing to root the symlinks at).
+ private var canonicalAccount: AccountConfig? {
+ config.accounts.first { expandTilde($0.configDir) == canonicalDir }
+ }
+
+ // MARK: - Shared store
+
+ /// Links `account` into the canonical store (wholesale dirs only; per-workspace
+ /// projects symlinks are created lazily on resume). Marks it sharedStore=true and
+ /// persists. No-op for the canonical account. Failures land in actionError.
+ public func linkAccount(_ account: AccountConfig) {
+ guard canonicalAccount != nil else {
+ actionError = "Can't link without a canonical account: add an account whose "
+ + "config dir is ~/.claude (the default account)."
+ return
+ }
+ guard expandTilde(account.configDir) != canonicalDir else { return } // canonical: nothing to link
+ // Nothing linked that the config could not then record (`mayPersist`).
+ guard mayPersist() else { return }
+ for dir in account.allConfigDirs.map(expandTilde) where dir != canonicalDir {
+ // The account's CLAUDE_CONFIG_DIR must exist before its wholesale dirs can
+ // be symlinked into canonical (createSymbolicLink needs a real parent).
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ do {
+ _ = try SharedSessionStore().ensureLinked(accountDir: dir, canonicalDir: canonicalDir)
+ } catch {
+ actionError = "Couldn't link “\(displayName(for: account))”: \(error.localizedDescription)"
+ return
+ }
+ }
+ if let index = config.accounts.firstIndex(where: { $0.name == account.name }),
+ !config.accounts[index].sharedStore {
+ config.accounts[index].sharedStore = true
+ persist()
+ }
+ // Phase 5A: capture usage by default for every linked account (when the engine
+ // captures by default at all).
+ if capturesByDefault { enableMonitoring(account, reportErrors: false) }
+ }
+
+ // MARK: - Transcript mirror
+
+ /// Hardlink-mirrors every account's transcripts, off the main actor, and lists the
+ /// vanished ones it could put back (`restorableTranscripts`) — it puts none back by
+ /// itself. No-ops when the feature is disabled or when no canonical account is
+ /// configured. Snapshot-render safety comes from the caller not invoking this
+ /// during `ImageRenderer` passes — the same structural guarantee `verifySharedStore`
+ /// relies on.
+ ///
+ /// Every account's folder carries Claude Code's own retention for it into the
+ /// reconcile (grove#6): the mirror keeps nothing older and offers nothing back that
+ /// Claude Code's sweep may have removed. The periods are read even with the safety net
+ /// off, for Settings.
+ public func reconcileTranscripts() async {
+ await reconcileTranscripts(restore: .none)
+ }
+
+ /// Puts back the transcripts the user chose in Settings, if they still qualify (gone,
+ /// and inside every retention that applies); the rest of the pass is an ordinary
+ /// reconcile. Returns how many came back.
+ @discardableResult
+ public func restoreTranscripts(_ chosen: [RestorableTranscript]) async -> Int {
+ guard !chosen.isEmpty else { return 0 }
+ let report = await reconcileTranscripts(restore: .only(Set(chosen.map(\.entry.mirrorPath))))
+ let restored = report?.restored.count ?? 0
+ if restored > 0 { await refreshSessionIndex() }
+ return restored
+ }
+
+ @discardableResult
+ private func reconcileTranscripts(restore: RestoreMode) async -> ReconcileReport? {
+ if let mayWrite, !mayWrite() { return nil }
+ let retention = folders.claudeRetention
+ let dirsByAccount = accountDirs()
+ let enabled = config.transcriptMirror.enabled && canonicalAccount != nil
+ let canonical = canonicalDir
+ let settings = config.transcriptMirror
+ let names = Dictionary(dirsByAccount.flatMap { account in account.dirs.map { (accountKey($0), account.name) } },
+ uniquingKeysWith: { first, _ in first })
+ let result = await Task.detached(priority: .utility) {
+ () -> (byAccount: [String: ClaudeRetention.Resolution], report: ReconcileReport?,
+ restorable: [RestorableTranscript]) in
+ let (byAccount, mirrorAccounts) = Self.resolveRetention(dirsByAccount, retention, canonicalDir: canonical)
+ guard enabled else { return (byAccount, nil, []) }
+ // An entry whose account left the config has no period of its own that can be
+ // read, so the shortest that could apply: the managed one, Claude Code's
+ // default, or any configured account's (it may have shared the workspace).
+ let orphan = ([retention.resolveManaged().days, ClaudeRetention.defaultDays]
+ + byAccount.values.map(\.days)).min() ?? ClaudeRetention.defaultDays
+ let policy = RetentionPolicy(maxDays: settings.maxDays,
+ maxBytes: Int64(settings.maxMB) * 1_000_000,
+ orphanRetentionDays: orphan)
+ let report = TranscriptMirror().reconcile(accounts: mirrorAccounts, canonicalDir: canonical,
+ policy: policy, restore: restore)
+ let restorable = report.restorable.map {
+ RestorableTranscript(entry: $0, accountName: names[$0.accountKey],
+ cwd: TranscriptCwds.cwd(ofTranscriptAt: $0.mirrorPath))
+ }
+ return (byAccount, report, restorable)
+ }.value
+ if transcriptRetention != result.byAccount { transcriptRetention = result.byAccount }
+ if restorableTranscripts != result.restorable { restorableTranscripts = result.restorable }
+ if let first = result.report?.issues.first { actionError = "Transcript mirror: \(first)" }
+ return result.report
+ }
+
+ /// Reads every account's retention for Settings, touching nothing else.
+ public func refreshTranscriptRetention() async {
+ let retention = folders.claudeRetention
+ let dirsByAccount = accountDirs()
+ let canonical = canonicalDir
+ let byAccount = await Task.detached(priority: .utility) {
+ Self.resolveRetention(dirsByAccount, retention, canonicalDir: canonical).byAccount
+ }.value
+ if transcriptRetention != byAccount { transcriptRetention = byAccount }
+ }
+
+ private func accountDirs() -> [(name: String, dirs: [String])] {
+ config.accounts.map { (name: $0.name, dirs: $0.allConfigDirs.map(expandTilde)) }
+ }
+
+ /// File reads only; called off the main actor.
+ nonisolated private static func resolveRetention(_ dirsByAccount: [(name: String, dirs: [String])],
+ _ retention: ClaudeRetention, canonicalDir: String)
+ -> (byAccount: [String: ClaudeRetention.Resolution], mirrorAccounts: [MirrorAccount]) {
+ var byAccount: [String: ClaudeRetention.Resolution] = [:]
+ var mirrorAccounts: [MirrorAccount] = []
+ for (name, dirs) in dirsByAccount {
+ let resolved = dirs.map { (dir: $0, retention: resolveFolder($0, retention, canonicalDir: canonicalDir)) }
+ byAccount[name] = ClaudeRetention.strictest(resolved.map(\.retention))
+ mirrorAccounts += resolved.map { MirrorAccount(configDir: $0.dir, retention: $0.retention) }
+ }
+ return (byAccount, mirrorAccounts)
+ }
+
+ /// One config folder's period, the project settings of every folder its sessions ran
+ /// in included — those its transcripts record, and those of the transcripts only the
+ /// safety net still holds. File reads only; called off the main actor.
+ nonisolated static func resolveFolder(_ dir: String, _ retention: ClaudeRetention,
+ canonicalDir: String) -> ClaudeRetention.Resolution {
+ let mirrored = TranscriptMirror.mirrorRoot(canonicalDir: canonicalDir) + "/" + accountKey(dir)
+ return retention.resolve(configDir: dir, projectCwds: TranscriptCwds.all(configDir: dir, extraFolders: [mirrored]))
+ }
+
+ // MARK: - Session migration (Phase 4A4)
+
+ /// Full cross-account session migration: copies the session's transcript,
+ /// aux files, tasks, settings keys, and plugins into the target account's
+ /// configDir — non-destructively (never overwrites). No-op when source and
+ /// target share the same account name. Works while the target account is in use.
+ public func migrateSession(cwd: String, sessionId: String,
+ from sourceAccount: AccountConfig,
+ to targetAccount: AccountConfig) async {
+ guard sourceAccount.name != targetAccount.name else { return }
+
+ let fromConfigDir = expandTilde(sourceAccount.configDir)
+ let toConfigDir = expandTilde(targetAccount.configDir)
+
+ // HOT migration: sessions running under the TARGET account are fine. The
+ // target's hot config files (.claude.json, settings.json, plugin registries)
+ // are merged through HotJSONFile — Claude's own `.lock`, a
+ // compare-before-swap, and no write at all when nothing changes — so a
+ // concurrent claude process neither loses an update nor causes one.
+ let fromHomeJSON = claudeJSONPath(for: sourceAccount)
+ let toHomeJSON = claudeJSONPath(for: targetAccount)
+ let mirrorRoot = TranscriptMirror.mirrorRoot(canonicalDir: canonicalDir)
+ let fromKey = accountKey(fromConfigDir)
+
+ // Ensure the target's standard directories exist (like linkAccount does).
+ let fm = FileManager.default
+ try? fm.createDirectory(atPath: toConfigDir, withIntermediateDirectories: true)
+ try? fm.createDirectory(atPath: toConfigDir + "/projects", withIntermediateDirectories: true)
+ try? fm.createDirectory(atPath: toConfigDir + "/plugins", withIntermediateDirectories: true)
+
+ let retention = folders.claudeRetention
+ let canonical = canonicalDir
+ let allDirs = config.accounts.flatMap(\.allConfigDirs).map(expandTilde)
+ let report = await Task.detached(priority: .utility) { () -> MigrateReport in
+ // The safety net's copy stands in for a vanished transcript only inside the
+ // window the safety net itself would offer it back in (grove#6): the shortest
+ // period of every account that reaches the session's workspace folder — a
+ // shared one is swept by each of them — project settings included.
+ let reaching = TranscriptMirror.foldersSharing(encodedCwd: ClaudeService.mangle(cwd),
+ with: fromConfigDir, among: allDirs)
+ let period = ClaudeRetention.strictest(reaching.map { Self.resolveFolder($0, retention, canonicalDir: canonical) })
+ let maxAge = (Double(period.days) - TranscriptMirror.restoreMarginDays) * 86_400
+ return SessionMigration.migrateSession(
+ sessionId: sessionId, cwd: cwd,
+ fromConfigDir: fromConfigDir, toConfigDir: toConfigDir,
+ fromHomeJSON: fromHomeJSON, toHomeJSON: toHomeJSON,
+ mirrorRoot: period.isCertain ? mirrorRoot : nil, fromAccountKey: fromKey,
+ mirrorMaxAge: maxAge)
+ }.value
+
+ if let first = report.issues.first { actionError = "Migrate: \(first)" }
+ // Phase 5A: capture usage by default for the target account now that it holds
+ // a real session. Use the persisted entry so the gate reflects prior state.
+ if capturesByDefault, let target = config.accounts.first(where: { $0.name == targetAccount.name }),
+ !target.monitoring {
+ enableMonitoring(target, reportErrors: false)
+ }
+ await refreshSessionIndex()
+ }
+
+ /// Permanent delete of a session's transcript (mirror + live sides), then
+ /// refreshes the session index. Failures land in actionError.
+ public func purgeTranscript(id: String) {
+ guard canonicalAccount != nil else { return }
+ let accts = config.accounts.flatMap(\.allConfigDirs).map {
+ MirrorAccount(key: accountKey($0), configDir: expandTilde($0))
+ }
+ do {
+ try TranscriptMirror().purge(sessionId: id, accounts: accts, canonicalDir: canonicalDir)
+ } catch {
+ actionError = "Couldn't purge transcript: \(error.localizedDescription)"
+ }
+ Task { await refreshSessionIndex() }
+ }
+
+ /// Runs SharedSessionStore.verify across accounts MARKED sharedStore and
+ /// surfaces the first issue through actionError (the existing banner). No
+ /// issues → clears nothing (so it never stomps an unrelated error). Snapshot
+ /// renders skip it (no real ~/.claude access).
+ public func verifySharedStore() {
+ let marked = config.accounts.filter(\.sharedStore).flatMap(\.allConfigDirs).map(expandTilde).filter { $0 != canonicalDir }
+ guard !marked.isEmpty, canonicalAccount != nil else { return }
+ let issues = SharedSessionStore().verify(accountDirs: marked, canonicalDir: canonicalDir)
+ if let first = issues.first {
+ actionError = "Shared store: \(first)"
+ }
+ }
+
+ /// Links `account` into the canonical store unless it IS the canonical account
+ /// (never linked to itself). Marks it sharedStore=true and persists. Returns
+ /// nil on success, or an error message describing the failure (caller aborts).
+ private func ensureLinkedForResume(_ account: AccountConfig, mangledCwd: String) -> String? {
+ guard expandTilde(account.configDir) != canonicalDir else { return nil } // canonical: skip
+ let store = SharedSessionStore()
+ let dir = expandTilde(account.configDir)
+ // The account's CLAUDE_CONFIG_DIR must exist before its wholesale dirs can
+ // be symlinked into canonical (createSymbolicLink needs a real parent).
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ do {
+ _ = try store.ensureLinked(accountDir: dir, canonicalDir: canonicalDir)
+ _ = try store.ensureWorkspaceLinked(accountDir: dir, canonicalDir: canonicalDir,
+ mangledCwd: mangledCwd)
+ } catch {
+ return "Couldn't link “\(displayName(for: account))” to the shared store: \(error.localizedDescription)"
+ }
+ if let index = config.accounts.firstIndex(where: { $0.name == account.name }),
+ !config.accounts[index].sharedStore {
+ config.accounts[index].sharedStore = true
+ persist()
+ }
+ return nil
+ }
+
+ // MARK: - Session adoption (Phase 2 / Task 4)
+
+ /// Predicate: true when `account` is NOT the canonical/default account, meaning
+ /// its sessions are not yet shared and can benefit from adoption. Pure, static,
+ /// unit-testable. `canonicalDir` is the expanded canonical store path.
+ public static func canShareAcrossAccounts(account: AccountConfig, canonicalDir: String) -> Bool {
+ expandTilde(account.configDir) != canonicalDir
+ }
+
+ /// Brings a session discovered under `account` into the canonical shared store so
+ /// its transcript is reachable from every linked account.
+ ///
+ /// Safety contract:
+ /// - No-op when `account` IS the canonical account (already shared).
+ /// - Aborts with `actionError` when the session is currently live under ANY account
+ /// (ensureWorkspaceLinked moves the transcript file; moving an open file causes
+ /// data loss for the running process).
+ /// - On success, refreshes the session index so the UI reflects the new state.
+ ///
+ /// `sessionId` is required to catch sessions that are live via
+ /// `claude --resume ` / `--session-id ` — these appear in the process
+ /// table with `cwd == ""`, so cwd-only matching misses them (C1 fix).
+ public func adoptSession(cwd: String, sessionId: String, account: AccountConfig) async {
+ // No-op for the canonical/default account — already the root of sharing.
+ guard Self.canShareAcrossAccounts(account: account, canonicalDir: canonicalDir) else { return }
+
+ // SAFETY: refuse if the session is live anywhere. ensureWorkspaceLinked moves
+ // the transcript file; an open file descriptor on the moved file means future
+ // writes from the live claude process go to an unlinked inode (data loss).
+ // ClaudeService.isSessionLive checks BOTH sessionId (table-only --resume path,
+ // cwd=="") AND cwd (fresh sessions) so neither liveness signal is missed.
+ let live: [LiveProcess]
+ if let override = allLiveProcessesOverride {
+ live = override
+ } else {
+ let service = liveProcessValidatorOverride
+ .map { ClaudeService().withProcessValidator($0) } ?? ClaudeService()
+ live = service.allLiveProcesses(accounts: config.accounts)
+ }
+ let mangled = ClaudeService.mangle(cwd)
+ if ClaudeService.isSessionLive(among: live, cwd: cwd, sessionId: sessionId) {
+ actionError = "This session is running — close it, then Share."
+ return
+ }
+
+ if let error = ensureLinkedForResume(account, mangledCwd: mangled) {
+ actionError = error
+ return
+ }
+
+ await refreshSessionIndex()
+ }
+
+ /// The name of an account (other than `launchAccount`) under which the session
+ /// (`sessionId`, or a fresh claude in `cwd`) has a live process, or nil. Used to
+ /// BLOCK a cross-account launch that would have two processes writing one
+ /// transcript (the one real corruption case).
+ private func sessionLiveUnderOtherAccount(_ sessionId: String, cwd: String,
+ launchAccount: AccountConfig) -> String? {
+ let service = liveProcessValidatorOverride
+ .map { ClaudeService().withProcessValidator($0) } ?? ClaudeService()
+ // File records attribute an account — prefer the precise name.
+ for account in config.accounts where account.name != launchAccount.name {
+ if service.liveProcesses(account: account).contains(where: { $0.sessionId == sessionId }) {
+ return displayName(for: account)
+ }
+ }
+ // The session may be live via the process TABLE (which the often-empty file
+ // records miss, and which can't attribute an account): by id when it was
+ // resumed, by cwd when it's a fresh `claude` (no --resume), which the table
+ // lists with its folder but no session id. Same id-or-cwd check as Share
+ // (adoptSession). If it's running anywhere and NOT under the launch account's
+ // own records, still refuse — a second `--resume` would have two processes
+ // writing one transcript, and linking would move it from under the running one.
+ let live = allLiveProcessesOverride ?? service.allLiveProcesses(accounts: config.accounts)
+ let liveAnywhere = ClaudeService.isSessionLive(among: live, cwd: cwd, sessionId: sessionId)
+ let liveUnderLaunch = service.liveProcesses(account: launchAccount).contains { $0.sessionId == sessionId }
+ if liveAnywhere && !liveUnderLaunch { return "a running session" }
+ return nil
+ }
+
+ /// Resumes `session` under `account`. Same-account: links nothing, just
+ /// launches `--resume` in the session's cwd. Cross-account (D7, no copy): links
+ /// BOTH the owning and the target account into the canonical store, so the
+ /// transcript physically lives in canonical and is visible to the target, then
+ /// launches `CLAUDE_CONFIG_DIR= claude --resume `. Aborts with an
+ /// actionError (and launches nothing) when: there is no canonical/default
+ /// account to root the share, the owning account is gone from config, linking
+ /// fails, or the same session is live under a DIFFERENT account (concurrency
+ /// guard, added in Task 4).
+ public func resumeSession(_ session: ClaudeSession, as account: AccountConfig) async {
+ guard prepareResumeAccount(sessionId: session.id, cwd: session.cwd,
+ fromAccount: session.accountName, to: account) else { return }
+ let title = session.title ?? (session.cwd as NSString).lastPathComponent
+ await launchClaude(cwd: session.cwd, title: title, account: account, resume: session.id,
+ model: effectiveModel(cwd: session.cwd, account: account),
+ effort: effectiveEffort(cwd: session.cwd, account: account))
+ }
+
+ /// Prepares a resume that may target a DIFFERENT account than the session
+ /// belongs to: when `account` differs from `fromAccount`, both are linked into
+ /// the canonical store so the transcript is visible under the new account.
+ /// Returns false (and sets `actionError`) when the move is blocked; true when
+ /// same-account (a no-op) or successfully linked. Shared by `resumeSession` and
+ /// the config-sheet `confirmLaunch` path.
+ private func prepareResumeAccount(sessionId: String, cwd: String,
+ fromAccount: String, to account: AccountConfig) -> Bool {
+ guard account.name != fromAccount else { return true }
+ if let other = sessionLiveUnderOtherAccount(sessionId, cwd: cwd, launchAccount: account) {
+ actionError = "This session is live under “\(other)” — close it first, "
+ + "then resume as \(displayName(for: account))."
+ return false
+ }
+ guard canonicalAccount != nil else {
+ actionError = "Can't share sessions without a canonical account: add an "
+ + "account whose config dir is ~/.claude (the default account)."
+ return false
+ }
+ guard let owner = config.accounts.first(where: { $0.name == fromAccount }) else {
+ actionError = "Can't resume as \(displayName(for: account)): the owning account "
+ + "“\(fromAccount)” is no longer configured."
+ return false
+ }
+ let mangled = ClaudeService.mangle(cwd)
+ // Link the owner first (so the transcript migrates into canonical),
+ // then the target (so the symlinked store makes it visible).
+ if let error = ensureLinkedForResume(owner, mangledCwd: mangled) {
+ actionError = error
+ return false
+ }
+ if let error = ensureLinkedForResume(account, mangledCwd: mangled) {
+ actionError = error
+ return false
+ }
+ return true
+ }
+}
+
+// MARK: - Workspace creation
+
+extension AppState {
+ /// nil = no project selected. A report with a non-nil failure also sets
+ /// actionError; the create screen additionally shows report.logLines.
+ /// startPointOverrides (keyed by repo dirName) beat both the resolved base
+ /// branch and any fork-from parent branch — see WorkspaceService.
+ public func createWorkspace(name: String, branch: String, repos: [RepoInfo],
+ forkFrom: FeatureWorkspace?,
+ startPointOverrides: [String: String] = [:]) async -> CreationReport? {
+ guard let project = selectedProject else { return nil }
+ let report = await workspaceService.createWorkspace(project: project, name: name,
+ branch: branch, repos: repos,
+ forkFrom: forkFrom,
+ startPointOverrides: startPointOverrides)
+ if let failure = report.failure {
+ actionError = failure
+ }
+ return report
+ }
+
+ /// Removes ONLY the artifacts of a failed creation run (spec §5.4).
+ public func rollback(_ artifacts: [CreatedArtifact]) async -> [String] {
+ await workspaceService.rollback(artifacts)
+ }
+}
+
+// MARK: - cmux shell fallback (spec §6.1)
+
+extension AppState {
+ /// Creates a NEW shell-only cmux workspace at `cwd` and focuses it — the
+ /// cmux-button fallback when a workspace has no cmux workspace yet.
+ /// `command: nil` means cmux starts its default shell.
+ public func openCmuxShell(cwd: String, title: String) async {
+ let service = cmux()
+ do {
+ try await service.ensureRunning()
+ try await service.newWorkspace(name: title, cwd: cwd, command: nil, focus: true)
+ } catch {
+ actionError = String(describing: error)
+ }
+ }
+}
+
+// MARK: - Usage monitoring
+
+extension AppState {
+ private var statuslineScriptDir: String { folders.bin }
+
+ /// Reads every account's statusline captures and identity (called on the scan
+ /// tick). `now` injected; defaults to Date() ONLY at the production call site.
+ ///
+ /// The file reads + JSON parsing run OFF the main actor (`Task.detached`) so a
+ /// cold read never freezes the panel on open. Results are assigned back on the
+ /// main actor. Per-account limits used to be fetched here too; that is Brow's now.
+ public func refreshUsage(now: Date) async {
+ let jobs: [(name: String, dir: String, claudeJSON: String)] = config.accounts.map {
+ (name: $0.name, dir: expandTilde($0.configDir), claudeJSON: claudeJSONPath(for: $0))
+ }
+ let started = Date()
+ let result = await Task.detached(priority: .utility) {
+ () -> (snaps: [String: [UsageSnapshot]], ids: [String: AccountIdentity]) in
+ let reader = UsageReader()
+ var snaps: [String: [UsageSnapshot]] = [:]
+ var ids: [String: AccountIdentity] = [:]
+ for job in jobs {
+ snaps[job.name] = reader.read(configDir: job.dir, accountName: job.name)
+ // .claude.json read off-main here (was a per-render main-thread read via
+ // AccountsScreen's identity provider).
+ if let id = ClaudeService.identity(claudeJSONPath: job.claudeJSON) {
+ ids[job.name] = id
+ }
+ }
+ return (snaps, ids)
+ }.value
+ snapshotsByAccount = result.snaps
+ identityByAccount = result.ids
+ GroveLog.perf.info("capture refresh (\(jobs.count) accts): \(Int(Date().timeIntervalSince(started) * 1000))ms")
+ }
+
+ private func claudeJSONPath(for account: AccountConfig) -> String {
+ folders.claudeJSONPath(configDir: account.configDir)
+ }
+
+ /// Opens the account's config dir in Finder (`open `). Snapshot-safe
+ /// callers gate this off; production reveals the real dir.
+ public func openConfigDir(_ account: AccountConfig) {
+ NSWorkspace.shared.open(URL(fileURLWithPath: expandTilde(account.configDir)))
+ }
+
+ /// Installs the grove statusline wrapper for `account`, saving its prior
+ /// command into AccountConfig.savedStatusline and marking monitoring=true.
+ /// Failures land in actionError; nothing is mutated on failure. This is the
+ /// user-facing toggle; the auto-install sites (addAccount/linkAccount/
+ /// migrateSession) share the same core via `enableMonitoring(_:)`.
+ public func installMonitoring(_ account: AccountConfig) {
+ enableMonitoring(account, reportErrors: true)
+ }
+
+ /// Shared statusline auto-install: ensures the account's configDir exists, ships
+ /// the (idempotent, original-preserving) grove wrapper, repoints its settings.json,
+ /// and records monitoring=true + savedStatusline. Best-effort — the whole point of
+ /// auto-installing at account-setup time (Phase 5A) is capture-by-default, so a
+ /// failure must never abort the surrounding action (addAccount/linkAccount/
+ /// migrateSession). `reportErrors` surfaces failures in `actionError` only for the
+ /// explicit user toggle; the auto sites stay silent.
+ ///
+ /// Re-runs are safe: `StatuslineInstaller.install` recovers the true original from
+ /// the previously-baked wrapper, so calling this on an already-monitored account
+ /// preserves — never clobbers — the saved original.
+ private func enableMonitoring(_ account: AccountConfig, reportErrors: Bool) {
+ // No wrapper installed that the config could not then record.
+ guard mayPersist(reporting: reportErrors) else { return }
+ let dir = expandTilde(account.configDir)
+ // The wrapper writes /settings.json; the dir must exist first
+ // (addAccount's ~/.claude-accounts/ is brand-new and empty).
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ let installer = StatuslineInstaller(scriptDir: statuslineScriptDir)
+ let saved: String?
+ do { saved = try installer.install(configDir: dir) }
+ catch {
+ if reportErrors {
+ actionError = "Couldn't enable monitoring for “\(displayName(for: account))”: \(error.localizedDescription)"
+ }
+ return
+ }
+ // Every folder of the login captures: an alias dir's sessions are the
+ // account's sessions too (best-effort; nothing to save for an alias).
+ for alias in account.aliasDirs.map(expandTilde) {
+ _ = try? installer.install(configDir: alias)
+ }
+ guard let i = config.accounts.firstIndex(where: { $0.name == account.name }) else { return }
+ config.accounts[i].monitoring = true
+ config.accounts[i].monitoringDisabledByUser = false
+ config.accounts[i].savedStatusline = saved
+ persist()
+ }
+
+ /// Restores the saved original statusline command and clears monitoring.
+ /// Sets `monitoringDisabledByUser=true` so reconcileMonitoring knows not to
+ /// silently re-enable this account on the next launch. Every other folder of the
+ /// login gets back the command its own wrapper recorded (best-effort: a folder that is
+ /// gone, or no longer runs the wrapper, is left alone).
+ public func disableMonitoring(_ account: AccountConfig) {
+ // No command put back while the config, which could not then be saved, still
+ // says the account is captured.
+ guard mayPersist() else { return }
+ let installer = StatuslineInstaller(scriptDir: statuslineScriptDir)
+ let dir = expandTilde(account.configDir)
+ do { try installer.uninstall(configDir: dir, savedStatusline: account.savedStatusline) }
+ catch { actionError = "Couldn't disable monitoring for “\(displayName(for: account))”: \(error.localizedDescription)"; return }
+ for alias in account.aliasDirs.map(expandTilde) {
+ _ = try? installer.restoreBakedOriginal(configDir: alias)
+ }
+ guard let i = config.accounts.firstIndex(where: { $0.name == account.name }) else { return }
+ config.accounts[i].monitoring = false
+ config.accounts[i].monitoringDisabledByUser = true
+ config.accounts[i].savedStatusline = nil
+ persist()
+ }
+
+ /// Launch reconcile (Phase 5A): auto-installs the grove statusline wrapper for
+ /// every configured account not yet monitored AND not explicitly disabled by the
+ /// user — so accounts created before 5A (or an account whose monitoring was never
+ /// enabled, e.g. icloud) start capturing usage. Accounts where the user explicitly
+ /// ran `disableMonitoring` are skipped (monitoringDisabledByUser=true), so the
+ /// user's intent is respected across launches. Best-effort per account: an account
+ /// whose install fails (a settings.json that doesn't parse, a lock that isn't
+ /// released) is logged and stays unmonitored, so the next launch tries again. An
+ /// account whose folder no longer exists is skipped, never re-created.
+ /// File I/O runs off-main to keep launch responsive.
+ public func reconcileMonitoring() {
+ // Only an engine that captures by default installs anything unasked.
+ guard capturesByDefault else { return }
+ // Gather accounts needing install on the main actor, then do file I/O off-main.
+ let toInstall = config.accounts.filter { !$0.monitoring && !$0.monitoringDisabledByUser }
+ guard !toInstall.isEmpty else { return }
+ let scriptDir = statuslineScriptDir
+ Task.detached(priority: .utility) { [weak self] in
+ // Run install file I/O per account off the main thread.
+ var installed: [(name: String, savedStatusline: String?)] = []
+ for account in toInstall {
+ let dir = expandTilde(account.configDir)
+ // A folder that is gone stays gone (grove#12): creating it here left a lone
+ // settings.json that both apps' folder discovery then adopted as an account.
+ // The next launch tries again, should the folder come back.
+ var isDir: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: dir, isDirectory: &isDir), isDir.boolValue else {
+ GroveLog.workspaces.info("statusline not installed for \(account.name, privacy: .public): its folder is gone")
+ continue
+ }
+ let installer = StatuslineInstaller(scriptDir: scriptDir)
+ // `install` returns the ORIGINAL statusline command, nil when the
+ // account had none — `if let saved = try? …` read that nil as a failed
+ // install, so a fresh account got its wrapper on every launch and its
+ // `monitoring` flag never turned true.
+ do {
+ let saved = try installer.install(configDir: dir)
+ installed.append((name: account.name, savedStatusline: saved))
+ } catch {
+ GroveLog.workspaces.error("statusline not installed for \(account.name, privacy: .public), retrying next launch: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+ // Hop back to main actor to persist config changes. A `let` copy: the
+ // closure runs on another executor and must not capture the `var`.
+ let finished = installed
+ await MainActor.run { [weak self] in
+ guard let self else { return }
+ for result in finished {
+ guard let i = self.config.accounts.firstIndex(where: { $0.name == result.name }) else { continue }
+ self.config.accounts[i].monitoring = true
+ self.config.accounts[i].savedStatusline = result.savedStatusline
+ }
+ if !finished.isEmpty { self.persist() }
+ }
+ }
+ }
+}
+
+// MARK: - Code stats
+
+extension AppState {
+ /// The directory holding per-project code-stats history files: `folders.stats`
+ /// (~/Library/Application Support/Grove/stats in the app).
+ private var statsStoreDir: URL { URL(fileURLWithPath: folders.stats) }
+
+ /// Built fresh from the resolved dir (a value type that just holds the URL). The
+ /// scanner + cache that must persist across ticks live on `self`, not here.
+ private var statsStore: CodeStatsStore { CodeStatsStore(dir: statsStoreDir) }
+
+ /// Scans the project's code per-GIT-REPO (GitStatsService), updates the aggregate
+ /// `codeStats`, the per-repo `repoStats` breakdown, and overwrites
+ /// `codeStatsHistory` with the git-derived per-day series. Built like refreshUsage:
+ /// the project's path, scan depth, excluded repos, and that project's git-stats
+ /// cache are captured OFF the main actor in a `.utility` Task.detached; the git
+ /// subprocesses + file reads happen there so they never freeze the panel. Results
+ /// (and the updated cache) are assigned back on the main actor.
+ /// No-op for an unknown id. `isStatsScanning` brackets the whole operation.
+ public func refreshCodeStats(projectID: UUID, now: Date = Date()) async {
+ // Serialize per project: if a scan for this project is already running, record
+ // that another is wanted and return — the in-flight scan re-runs once when it
+ // finishes (see the tail below). This prevents two scans racing on the shared
+ // mtime cache, while still honoring a refresh requested mid-scan (e.g. after a
+ // folder-exclusion toggle clears the cache).
+ guard !statsScanInFlight.contains(projectID) else {
+ statsRescanPending.insert(projectID)
+ return
+ }
+ await runCodeStatsScan(projectID: projectID, now: now)
+ }
+
+ private func runCodeStatsScan(projectID: UUID, now: Date) async {
+ guard let project = config.projects.first(where: { $0.id == projectID }) else { return }
+ let path = project.path
+ let depth = project.scanDepth
+ let excluded = Set(project.excludedRepos)
+ let excludedFolders = Set(project.statsIgnoredFolders)
+ let cache = gitStatsCacheByProject[projectID] ?? [:]
+ let service = gitStats
+ let branchOverrides = selectedStatsBranchByRepo
+
+ if statsRescanStartedCount != nil { statsRescanStartedCount! += 1 }
+ statsScanInFlight.insert(projectID)
+ statsRescanPending.remove(projectID)
+ isStatsScanning = true
+ let result = await Task.detached(priority: .utility) {
+ () -> (stats: ProjectGitStats, cache: [String: RepoFileCache]) in
+ var local = cache
+ let stats = await service.scan(projectPath: path, scanDepth: depth,
+ excludedRepos: excluded, excludedFolders: excludedFolders,
+ branchOverrides: branchOverrides,
+ now: now, cache: &local)
+ return (stats, local)
+ }.value
+ statsScanInFlight.remove(projectID)
+ isStatsScanning = !statsScanInFlight.isEmpty
+ // A concurrent removeProject (or another refresh) may have run while detached;
+ // only commit if the project still exists.
+ guard config.projects.contains(where: { $0.id == projectID }) else {
+ statsRescanPending.remove(projectID)
+ return
+ }
+ // Aggregate feeds the existing screen; the per-repo breakdown is new.
+ codeStats[projectID] = result.stats.aggregate
+ repoStats[projectID] = result.stats.repos
+ statsFiles[projectID] = result.stats.files
+ gitStatsCacheByProject[projectID] = result.cache
+
+ // History now comes from GIT and is authoritative — OVERWRITE the stored series
+ // each scan (not append/coalesce). The store stays as the cross-launch cache;
+ // the screen reads `codeStatsHistory[id]` unchanged.
+ let history = CodeStatsHistory(points: result.stats.aggregateHistory)
+ try? statsStore.save(projectID: projectID, history: history)
+ codeStatsHistory[projectID] = result.stats.aggregateHistory
+
+ // Populate the branch-switcher menus: the scan already discovered every repo,
+ // so reuse its [RepoStats] to load each repo's local branches into
+ // branchesByRepo (the same source the Graph's branch pickers use).
+ let reposForBranches = result.stats.repos.map {
+ RepoInfo(path: $0.repoPath, dirName: $0.repoName)
+ }
+ await loadBranches(for: reposForBranches)
+
+ // Honor a refresh that arrived while this scan was running (its cache may now
+ // be stale — e.g. a repo-exclusion toggle), serialized strictly after us.
+ if statsRescanPending.remove(projectID) != nil {
+ await runCodeStatsScan(projectID: projectID, now: Date())
+ }
+ }
+
+ /// Stats-tab branch switcher: record the chosen branch for `repoPath` and rescan
+ /// the project so its history/delta reflect the new branch (current LOC is taken
+ /// from the working tree and is branch-independent, so it stays put). The dict
+ /// write is OPTIMISTIC (immediate) so the chip label updates at once; the rescan
+ /// is DEBOUNCED — rapid picks cancel the pending task and schedule a new one so
+ /// N consecutive switches coalesce to one scan. Honors the per-project scan
+ /// serialization via refreshCodeStats.
+ public func setStatsBranch(projectID: UUID, repoPath: String, branch: String) {
+ // Optimistic: the chip shows the new label immediately (no wait for rescan).
+ selectedStatsBranchByRepo[repoPath] = branch
+ // Debounce: cancel any pending rescan and schedule a fresh one after the interval.
+ statsBranchDebounce?.cancel()
+ let interval = statsBranchDebounceInterval
+ statsBranchDebounce = Task {
+ if interval > 0 {
+ try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
+ }
+ guard !Task.isCancelled else { return }
+ await refreshCodeStats(projectID: projectID)
+ }
+ }
+
+ /// Excludes (or re-includes) a project-root-relative folder from code-stats
+ /// scans: mutates `ProjectConfig.statsIgnoredFolders`, persists, and clears that
+ /// project's stats cache so the next refresh re-tallies without the stale
+ /// contributions of a folder whose exclusion just changed. No-op for an unknown
+ /// id, or when the requested state already holds.
+ public func setStatsFolderExcluded(projectID: UUID, relativePath: String, excluded: Bool) {
+ guard let i = config.projects.firstIndex(where: { $0.id == projectID }) else { return }
+ var folders = config.projects[i].statsIgnoredFolders
+ let alreadyExcluded = folders.contains(relativePath)
+ if excluded {
+ guard !alreadyExcluded else { return }
+ folders.append(relativePath)
+ } else {
+ guard alreadyExcluded else { return }
+ folders.removeAll { $0 == relativePath }
+ }
+ config.projects[i].statsIgnoredFolders = folders
+ gitStatsCacheByProject[projectID] = [:]
+ persist()
+ // Trigger an immediate rescan so the toggle actually changes the numbers + the
+ // file tree. `refreshCodeStats` serializes per project (queues a clean rescan if
+ // one is already in flight), so rapid toggling never races on the shared cache.
+ Task { await refreshCodeStats(projectID: projectID) }
+ }
+
+ /// I/O-light directory skeleton for the exclusion picker (off the main actor —
+ /// it walks dirs but reads no files).
+ public func statsDirectoryTree(projectID: UUID) async -> DirNode? {
+ guard let project = config.projects.first(where: { $0.id == projectID }) else { return nil }
+ let path = project.path
+ let scanner = statsScanner
+ return await Task.detached(priority: .utility) { scanner.directoryTree(projectPath: path) }.value
+ }
+}
+
+/// A transcript the safety net can put back, as Settings lists it.
+public struct RestorableTranscript: Identifiable, Equatable, Sendable {
+ public let entry: VanishedTranscript
+ /// The Workspaces account it belongs to; nil when its folder is no account's.
+ public let accountName: String?
+ /// The folder the session ran in, from the transcript; nil when it records none.
+ public let cwd: String?
+
+ public init(entry: VanishedTranscript, accountName: String?, cwd: String?) {
+ self.entry = entry
+ self.accountName = accountName
+ self.cwd = cwd
+ }
+
+ public var id: String { entry.id }
+}
diff --git a/Sources/GroveAppKit/State/LaunchRequest.swift b/Sources/GroveAppKit/State/LaunchRequest.swift
new file mode 100644
index 0000000..2a7acf8
--- /dev/null
+++ b/Sources/GroveAppKit/State/LaunchRequest.swift
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// Where a (re)launch opens.
+public enum LaunchTarget: String, CaseIterable, Sendable, Equatable {
+ case cmux
+ case terminal
+ var label: String { self == .cmux ? "cmux" : "Terminal" }
+}
+
+/// A pending Resume/New launch, configured in the launch sheet before it runs.
+/// `sessionId == nil` is a fresh session; otherwise it's `--resume `.
+public struct LaunchRequest: Identifiable, Equatable, Sendable {
+ public let id = UUID()
+ public var sessionId: String?
+ public var cwd: String
+ public var title: String
+ public var account: String // CHOSEN account name (user can change in the sheet)
+ /// The account the session currently BELONGS to (== `account` for a new
+ /// session). When a resume targets a different `account`, the cross-account
+ /// store-linking runs against this origin so the transcript stays visible.
+ public var originAccount: String
+ public var model: String? // nil = (default)
+ public var effort: String? // nil = (default)
+ /// Per-launch `--dangerously-skip-permissions`. Seeded from the project default
+ /// but overridable in the sheet, so a single relaunch can opt in/out.
+ public var skipPermissions: Bool
+ public var target: LaunchTarget
+
+ public init(sessionId: String?, cwd: String, title: String, account: String,
+ originAccount: String? = nil, model: String? = nil, effort: String? = nil,
+ skipPermissions: Bool = false, target: LaunchTarget = .cmux) {
+ self.sessionId = sessionId
+ self.cwd = cwd
+ self.title = title
+ self.account = account
+ self.originAccount = originAccount ?? account
+ self.model = model
+ self.effort = effort
+ self.skipPermissions = skipPermissions
+ self.target = target
+ }
+
+ public var isResume: Bool { sessionId != nil }
+}
diff --git a/Sources/GroveAppKit/State/Route.swift b/Sources/GroveAppKit/State/Route.swift
new file mode 100644
index 0000000..5c9a344
--- /dev/null
+++ b/Sources/GroveAppKit/State/Route.swift
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import Foundation
+
+/// The panel's navigation state machine: at any moment the panel shows ONE
+/// scope (general -> specific), never an overlay. RootView switches over this
+/// and animates transitions; AppState.open/goBack mutate it.
+public enum Route: Equatable {
+ case projects
+ /// Sessions by account. The account settings themselves are in the host's
+ /// Settings window (`AppState.openSettings`).
+ case accounts
+ case project(UUID)
+ case projectSettings(UUID)
+ case createWorkspace(UUID)
+ /// The Stats-tab settings page (directory+file exclusion tree). Reached from a
+ /// gear affordance on the Stats tab; backs out to its project (the Stats tab).
+ case statsSettings(UUID)
+ /// Sessions discovered disk-wide that don't match any configured project.
+ /// Shown as a sibling entry in the projects navigation (footer or cards area).
+ case otherSessions
+
+ /// Scope depth, used to classify a route change as push (deeper or equal)
+ /// or pop (shallower) for the transition direction.
+ public var depth: Int {
+ switch self {
+ case .projects: return 0
+ case .project, .accounts, .otherSessions: return 1
+ case .projectSettings, .createWorkspace, .statsSettings: return 2
+ }
+ }
+
+ /// Explicit back-map: createWorkspace/projectSettings/statsSettings -> their
+ /// project; project/accounts/otherSessions -> projects; projects -> itself (root).
+ public var backRoute: Route {
+ switch self {
+ case .projects: return .projects
+ case .project, .accounts, .otherSessions: return .projects
+ case .projectSettings(let id), .createWorkspace(let id), .statsSettings(let id):
+ return .project(id)
+ }
+ }
+}
+
+/// What the panel can ask the host's Settings window to show.
+public enum WorkspacesSettingsLink: Sendable, Equatable {
+ /// Workspaces' own settings: projects, folders, the safety net, the material.
+ case workspaces
+ /// The accounts, with their sharing, capture and default model.
+ case accounts
+}
diff --git a/Sources/GroveAppKit/Views/AccountsScreen.swift b/Sources/GroveAppKit/Views/AccountsScreen.swift
new file mode 100644
index 0000000..e38878a
--- /dev/null
+++ b/Sources/GroveAppKit/Views/AccountsScreen.swift
@@ -0,0 +1,427 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import SwiftUI
+import AppKit
+import GroveCore
+
+/// How AccountsScreen resolves an account's identity. Default: the real
+/// ClaudeService (reads ~/.claude.json / /.claude.json). SnapshotMode
+/// overrides this with SnapshotMode.fixtureIdentity so an offscreen render
+/// NEVER touches the user's real Claude config.
+struct ClaudeIdentityProviderKey: EnvironmentKey {
+ static let defaultValue: (AccountConfig) -> AccountIdentity? = { account in
+ ClaudeService().identity(account: account)
+ }
+}
+
+extension EnvironmentValues {
+ var claudeIdentityProvider: (AccountConfig) -> AccountIdentity? {
+ get { self[ClaudeIdentityProviderKey.self] }
+ set { self[ClaudeIdentityProviderKey.self] = newValue }
+ }
+}
+
+/// Accounts screen (route .accounts): sessions by account. Back header,
+/// identity rows (email · organization · tier via the injectable identity provider),
+/// live/recent-session usage aggregated across ALL loaded snapshots with a workspace
+/// -> project drill-down, and each account's session cards. Adding an account,
+/// sharing its sessions, capturing its status line and its default model live in the
+/// host's Settings window (`openSettings(.accounts)`); this screen shows their state.
+struct AccountsScreen: View {
+ @ObservedObject var state: AppState
+ @Environment(\.isSnapshotRender) private var isSnapshotRender
+ @Environment(\.claudeIdentityProvider) private var identityProvider
+
+ @State private var expandedAccounts: Set = []
+
+ var body: some View {
+ VStack(spacing: 6) {
+ header
+ cardList
+ }
+ .onAppear {
+ guard !isSnapshotRender else { return }
+ state.verifySharedStore()
+ }
+ }
+
+ private var header: some View {
+ ScopeHeader(title: "Accounts",
+ onBack: { state.goBack() })
+ }
+
+ /// The account settings are a window away.
+ @ViewBuilder private var settingsLink: some View {
+ if isSnapshotRender {
+ Text("Account settings…").font(.caption).foregroundStyle(Palette.primary)
+ } else if let open = state.openSettings {
+ Button("Account settings…") { open(.accounts) }
+ .buttonStyle(.plain).font(.caption).foregroundStyle(Palette.primary)
+ .help("Sign in, sharing, status-line capture and default models: Settings › Accounts")
+ }
+ }
+
+ @ViewBuilder private var cardList: some View {
+ let content = VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Spacer()
+ settingsLink
+ }
+ ForEach(state.config.accounts, id: \.name) { account in
+ accountCard(account)
+ }
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12)
+
+ // ScrollView content is not rendered offscreen -> plain stack in snapshots.
+ if isSnapshotRender {
+ content
+ } else {
+ ScrollView { content }
+ }
+ }
+
+ // MARK: - Account card
+
+ private func accountCard(_ account: AccountConfig) -> some View {
+ let usage = accountUsage(account: account, snapshots: Array(state.snapshots.values))
+ let isExpanded = expandedAccounts.contains(account.name) || isSnapshotRender
+ return VStack(alignment: .leading, spacing: 8) {
+ // Identity on its OWN full-width row so the long email · org · tier
+ // no longer wraps into (and collides with) the controls.
+ HStack(alignment: .center, spacing: 10) {
+ Image(systemName: "person.crop.circle")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(state.displayName(for: account)).font(.callout.weight(.semibold))
+ identityLine(account)
+ }
+ Spacer(minLength: 8)
+ }
+ // Status + controls on a separate, aligned row.
+ HStack(spacing: 12) {
+ usageSummary(usage, account: account)
+ Spacer(minLength: 0)
+ sharedStoreControl(account)
+ monitorControl(account)
+ rootDirControl(account)
+ }
+ if isExpanded && !usage.entries.isEmpty {
+ Divider()
+ drillDown(usage)
+ }
+ if isExpanded {
+ sessionCards(account)
+ }
+ Text(account.configDir)
+ .font(.caption2.monospaced())
+ .foregroundStyle(.tertiary)
+ ForEach(account.aliasDirs, id: \.self) { alias in
+ Text("+ \(alias)")
+ .font(.caption2.monospaced())
+ .foregroundStyle(.tertiary)
+ .help("Also signed in as this account; its sessions are listed here")
+ }
+ }
+ .padding(10)
+ .glassCard()
+ }
+
+ /// Reveals the account's config dir in Finder. Snapshot-safe: a Button is
+ /// AppKit-backed and draws offscreen, so render a plain Image in snapshot mode.
+ @ViewBuilder
+ private func rootDirControl(_ account: AccountConfig) -> some View {
+ if isSnapshotRender {
+ Image(systemName: "folder").foregroundStyle(.secondary)
+ } else {
+ Button { state.openConfigDir(account) } label: { Image(systemName: "folder") }
+ .controlSize(.small)
+ .help("Reveal \(account.configDir) in Finder")
+ }
+ }
+
+ /// Status-line capture, as a label: it is turned on and off in Settings › Accounts.
+ @ViewBuilder
+ private func monitorControl(_ account: AccountConfig) -> some View {
+ if account.monitoring {
+ Label("capturing", systemImage: "dot.radiowaves.left.and.right")
+ .font(.caption2).foregroundStyle(Palette.primary).labelStyle(.titleAndIcon)
+ .help("Brow's status-line wrapper records this account's sessions")
+ }
+ }
+
+ private func identityLine(_ account: AccountConfig) -> some View {
+ Group {
+ // Prefer the OFF-MAIN cache (state.identityByAccount, populated by
+ // refreshUsage) so the body never reads .claude.json on the main thread.
+ // Fall back to the injected provider only before the first refresh — which
+ // is also the snapshot seam (SnapshotMode injects fixtureIdentity; offscreen
+ // renders never populate the cache, so they always use the fixture).
+ if let identity = state.identityByAccount[account.name] ?? identityProvider(account) {
+ // The plan as the host writes it ("Max 20x"), as the notch and Settings
+ // do. Without a host, FIX I2: the CANONICAL organizationRateLimitTier (the
+ // namespace RateLimitModel.tierWeights keys on), else the legacy `tier`.
+ let tier = state.plan(for: account) ?? identity.organizationRateLimitTier ?? identity.tier
+ Text([identity.email, identity.organization, tier]
+ .compactMap { $0 }
+ .joined(separator: " · "))
+ .foregroundStyle(.secondary)
+ } else {
+ Text("not logged in")
+ .foregroundStyle(Palette.mid)
+ }
+ }
+ .font(.caption)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ @ViewBuilder
+ private func usageSummary(_ usage: AccountUsage, account: AccountConfig) -> some View {
+ HStack(spacing: 8) {
+ if usage.liveCount > 0 {
+ Label("\(usage.liveCount) live", systemImage: "circle.fill")
+ .foregroundStyle(Palette.primary)
+ }
+ Label("\(usage.sessionCount) sessions", systemImage: "text.bubble")
+ .foregroundStyle(.secondary)
+ if let limits = state.limits(for: account), let label = limits.label {
+ Label(label, systemImage: "gauge.with.dots.needle.50percent")
+ .foregroundStyle(limits.stale ? AnyShapeStyle(.tertiary) : AnyShapeStyle(.secondary))
+ .help("This account's 5-hour and weekly use, as the notch last read it")
+ }
+ }
+ .font(.caption)
+ if !usage.entries.isEmpty {
+ Button {
+ if expandedAccounts.contains(account.name) {
+ expandedAccounts.remove(account.name)
+ } else {
+ expandedAccounts.insert(account.name)
+ }
+ } label: {
+ Image(systemName: expandedAccounts.contains(account.name)
+ ? "chevron.down" : "chevron.right")
+ .font(.caption2)
+ }
+ .buttonStyle(.plain)
+ .help("Where this account is active")
+ }
+ }
+
+ /// Sharing, as a label: an account is shared from Settings › Accounts.
+ @ViewBuilder
+ private func sharedStoreControl(_ account: AccountConfig) -> some View {
+ let isCanonical = expandTilde(account.configDir) == state.canonicalDir
+ if isCanonical {
+ Label("default", systemImage: "star.fill")
+ .font(.caption2).foregroundStyle(.secondary).labelStyle(.titleAndIcon)
+ .help("The default account (~/.claude): the others share their sessions into it")
+ } else if account.sharedStore {
+ Label("shared", systemImage: "link")
+ .font(.caption2).foregroundStyle(Palette.primary).labelStyle(.titleAndIcon)
+ }
+ }
+
+ /// workspace -> project rows (spec §6.3 "раскрытие — где именно").
+ private func drillDown(_ usage: AccountUsage) -> some View {
+ VStack(alignment: .leading, spacing: 3) {
+ ForEach(usage.entries) { entry in
+ HStack(spacing: 6) {
+ Image(systemName: "arrow.turn.down.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ Text(entry.location)
+ .font(.caption.weight(.medium))
+ Text("· \(entry.project)")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ Spacer()
+ if entry.liveCount > 0 {
+ Text("\(entry.liveCount) live")
+ .font(.caption2)
+ .foregroundStyle(Palette.primary)
+ }
+ Text("\(entry.sessionCount) session\(entry.sessionCount == 1 ? "" : "s")")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+
+ // MARK: - Per-session cards (spec §C.5/C.6)
+
+ /// One session's resolved view-model: the ClaudeSession (for Resume/Relaunch),
+ /// the latest statusline capture (context %, effort, captured model) and whether
+ /// a process is live.
+ private struct SessionCardModel: Identifiable {
+ let session: ClaudeSession
+ let capture: UsageSnapshot?
+ let isLive: Bool
+ var id: String { session.id }
+ }
+
+ /// All of this account's sessions across every loaded snapshot, deduped by
+ /// sessionId (most-recent activity first), each joined to its latest capture
+ /// and live-process status.
+ private func sessionCardModels(_ account: AccountConfig) -> [SessionCardModel] {
+ var byId: [String: ClaudeSession] = [:]
+ var liveIds: Set = []
+ for snapshot in state.snapshots.values {
+ for workspace in snapshot.workspaces {
+ for s in workspace.sessions where s.accountName == account.name { byId[s.id] = s }
+ for p in workspace.liveProcesses where p.accountName == account.name { liveIds.insert(p.sessionId) }
+ }
+ for loose in snapshot.loose {
+ for s in loose.sessions where s.accountName == account.name { byId[s.id] = s }
+ for p in loose.liveProcesses where p.accountName == account.name { liveIds.insert(p.sessionId) }
+ }
+ }
+ // Latest capture per sessionId (snapshotsByAccount may hold several over time).
+ var latestCapture: [String: UsageSnapshot] = [:]
+ for snap in state.snapshotsByAccount[account.name] ?? [] {
+ let existing = latestCapture[snap.sessionId]?.capturedAt ?? .distantPast
+ if (snap.capturedAt ?? .distantPast) >= existing { latestCapture[snap.sessionId] = snap }
+ }
+ return byId.values
+ .map { SessionCardModel(session: $0, capture: latestCapture[$0.id], isLive: liveIds.contains($0.id)) }
+ .sorted { $0.session.lastActivity > $1.session.lastActivity }
+ }
+
+ @ViewBuilder
+ private func sessionCards(_ account: AccountConfig) -> some View {
+ let models = sessionCardModels(account)
+ if !models.isEmpty {
+ Divider()
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Sessions")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ ForEach(models) { model in
+ sessionCard(model, account: account)
+ }
+ Text("A running process can't be re-modeled — relaunch applies the new model.")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+
+ private func sessionCard(_ model: SessionCardModel, account: AccountConfig) -> some View {
+ let session = model.session
+ let project = state.owningProject(forCwd: session.cwd)
+ let displayModel = model.capture?.modelDisplayName ?? model.capture?.modelId ?? model.session.model
+ return VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 6) {
+ if model.isLive {
+ Label("live", systemImage: "circle.fill")
+ .font(.caption2).foregroundStyle(Palette.primary).labelStyle(.titleAndIcon)
+ }
+ Text(session.title ?? (session.cwd as NSString).lastPathComponent)
+ .font(.caption.weight(.medium))
+ Spacer()
+ Text(relativeActivity(model))
+ .font(.caption2).foregroundStyle(.tertiary)
+ }
+ sessionStatsRow(model, displayModel: displayModel)
+ sessionControls(model, account: account, project: project, displayModel: displayModel)
+ }
+ .padding(8)
+ .background(.white.opacity(0.04),
+ in: RoundedRectangle(cornerRadius: DesignRadius.field, style: .continuous))
+ }
+
+ private func sessionStatsRow(_ model: SessionCardModel, displayModel: String?) -> some View {
+ HStack(spacing: 8) {
+ if let pct = model.capture?.contextUsedPercentage {
+ Text("ctx \(Int(pct.rounded()))%")
+ .font(.caption2).foregroundStyle(.secondary)
+ }
+ if let displayModel {
+ Text(displayModel)
+ .font(.caption2.monospaced()).foregroundStyle(.tertiary)
+ }
+ if let effort = model.capture?.effort {
+ Text("effort \(effort)")
+ .font(.caption2).foregroundStyle(.tertiary)
+ }
+ Spacer()
+ }
+ }
+
+ /// Resume + model/effort default pickers (writing the SESSION's owning project)
+ /// + "Relaunch with ". The card offers the session's OWN account (V1),
+ /// so relaunch routes straight through `relaunchSession`. Snapshot-safe: real
+ /// Pickers/Buttons render as error placeholders offscreen, so swap in static
+ /// lookalikes when `isSnapshotRender`.
+ @ViewBuilder
+ private func sessionControls(_ model: SessionCardModel, account: AccountConfig,
+ project: ProjectConfig?, displayModel: String?) -> some View {
+ let session = model.session
+ HStack(spacing: 6) {
+ if isSnapshotRender {
+ Text("Resume").font(.caption2).foregroundStyle(Palette.primary)
+ SnapshotPickerLookalike(text: project?.defaultModel ?? "(default)")
+ SnapshotPickerLookalike(text: project?.defaultEffort ?? "(default)")
+ Text("Relaunch").font(.caption2).foregroundStyle(Palette.primary)
+ } else {
+ Button("Resume") {
+ Task { await state.resumeSession(session, as: account) }
+ }
+ .controlSize(.small)
+ if let project {
+ modelPicker(project: project)
+ effortPicker(project: project)
+ }
+ Button("Relaunch with \(displayModel ?? "model")") {
+ Task {
+ await state.relaunchSession(session, as: account,
+ model: project?.defaultModel,
+ effort: project?.defaultEffort)
+ }
+ }
+ .controlSize(.small)
+ .help("Resumes under \(state.displayName(for: account)) with the project's current model/effort")
+ }
+ Spacer()
+ }
+ }
+
+ private func modelPicker(project: ProjectConfig) -> some View {
+ ModelPicker(model: modelBinding(project), size: .small)
+ }
+
+ private func effortPicker(project: ProjectConfig) -> some View {
+ Picker("", selection: effortBinding(project)) {
+ Text("(default)").tag(String?.none)
+ ForEach(ClaudeService.effortLevels, id: \.self) { e in
+ Text(e).tag(String?.some(e))
+ }
+ }
+ .pickerStyle(.menu).labelsHidden().controlSize(.small).fixedSize()
+ }
+
+ private func modelBinding(_ project: ProjectConfig) -> Binding {
+ Binding(
+ get: { (state.config.projects.first { $0.id == project.id })?.defaultModel },
+ set: { state.setProjectModel(projectID: project.id, model: $0) })
+ }
+
+ private func effortBinding(_ project: ProjectConfig) -> Binding {
+ Binding(
+ get: { (state.config.projects.first { $0.id == project.id })?.defaultEffort },
+ set: { state.setProjectEffort(projectID: project.id, effort: $0) })
+ }
+
+ private func relativeActivity(_ model: SessionCardModel) -> String {
+ let date = model.capture?.capturedAt ?? model.session.lastActivity
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .abbreviated
+ return formatter.localizedString(for: date, relativeTo: Date())
+ }
+}
diff --git a/Sources/GroveAppKit/Views/CodeStatsScreen.swift b/Sources/GroveAppKit/Views/CodeStatsScreen.swift
new file mode 100644
index 0000000..d309dac
--- /dev/null
+++ b/Sources/GroveAppKit/Views/CodeStatsScreen.swift
@@ -0,0 +1,1298 @@
+// SPDX-License-Identifier: GPL-3.0-only
+// Copyright (C) 2026 Artem Efimov
+
+import SwiftUI
+import GroveCore
+
+/// Code-stats tab (Stage 5): a per-project cloc-style breakdown rendered from the
+/// pure presentation models in CodeStatsPresentation. Gray cards stacked in a
+/// scroll: a totals header (big numbers), a language breakdown (horizontal bars +
+/// a small per-language table), a cumulative lines-over-time chart stacked by repo
+/// (one bar per day, height = total lines across repos, biggest repo at the bottom).
+/// Folder/file exclusion now lives on a separate page (StatsSettingsScreen),
+/// reached via the gear in the Totals header.
+///
+/// The scan is lazy: `.task(id:)` triggers `state.refreshCodeStats` whenever the
+/// selected project changes, so opening the tab is what kicks off the (off-main)
+/// walk — the global 15s refresh never pays for stats. The stacked chart is a
+/// hand-drawn SwiftUI bar strip (no Swift Charts), but a `ScrollView`'s content is
+/// still not laid out under ImageRenderer, so `isSnapshotRender` swaps in a manual
+/// edge-to-edge bars fallback (same landmine the rest of the app handles).
+struct CodeStatsScreen: View {
+ @ObservedObject var state: AppState
+ @Environment(\.isSnapshotRender) private var isSnapshotRender
+
+ /// The window the Totals card + per-repo blocks recompute their deltas over.
+ /// Pure client-side recompute from the per-day history — never a re-scan.
+ @State private var period: StatsPeriod = .d30
+
+ /// Whether the Totals hero discloses its breakdown (code/data split + line kinds). The
+ /// chevron next to the hero toggles it; expanded by default so the breakdown shows.
+ @State private var totalsExpanded = true
+
+ /// Memoizes the subset re-aggregation (used only when some repos are excluded). A reference
+ /// type so writing it inside `body` does NOT itself trigger a re-render (it's not observed),
+ /// keyed on (project, exclusion set, scan time) so it recomputes when any of those change
+ /// but NOT on a hover tick (which leaves all three identical).
+ private final class SubsetAggCache {
+ var projectID: UUID?
+ var excluded: Set = []
+ var signature: Date?
+ var value: ProjectGitStats?
+ }
+ @State private var subsetCache = SubsetAggCache()
+
+ /// The cached subset aggregate (see `SubsetAggCache`). Recomputes via `GitStatsService`
+ /// only when the cache key changes; returns the cached `ProjectGitStats` otherwise.
+ private func subsetAggregate(_ repos: [RepoStats], excluded: Set,
+ signature: Date) -> ProjectGitStats {
+ if subsetCache.projectID == selectedProjectID, subsetCache.excluded == excluded,
+ subsetCache.signature == signature, let value = subsetCache.value {
+ return value
+ }
+ let value = GitStatsService.aggregate(repos, now: .now)
+ subsetCache.projectID = selectedProjectID
+ subsetCache.excluded = excluded
+ subsetCache.signature = signature
+ subsetCache.value = value
+ return value
+ }
+
+ /// Memoizes `stackedRepoSeries` (365-day stacked cumulative chart). Keyed on
+ /// `(projectID, scope, scannedAt, metric)` so hover ticks — which leave all four
+ /// identical — never recompute the O(365 × repos) series.
+ @State private var seriesCache = SeriesCache()
+
+ /// Memoizes `repoCells` (the per-repo delta cards). Keyed on
+ /// `(projectID, scope, scannedAt, period)`. A period change is the only user action
+ /// that needs a recompute; hover ticks don't.
+ @State private var repoCellsCache = RepoCellsCache()
+
+ /// Stable `Date` anchor captured once when the selected project changes (or on
+ /// first appear). Used as the `now` argument to `stackedRepoSeries` and `repoCells`
+ /// so the memo keys stay identical across render ticks (including hover ticks). The
+ /// chart's 365-day window doesn't need sub-render freshness — a stable anchor per
+ /// scan is correct and sufficient.
+ @State private var anchorNow: Date = .now
+
+ /// The currently-tapped day in the "Lines over time" stacked chart, if any. Tapping a
+ /// bar selects that calendar day → a tooltip above the chart with the day's total
+ /// codebase size + a per-repo breakdown; tapping it again clears. Live-only (the
+ /// snapshot path renders every bar without a selection).
+ @State private var selectedDay: Date?
+
+ /// Which `LanguageStats` field drives the Languages card's bars + sort order, picked
+ /// from the in-card metric selector. Default `.code` (the historical behavior).
+ @State private var languageMetric: LanguageMetric = .code
+ /// Which cumulative quantity the "over time" chart plots (Lines/Code/Data).
+ @State private var growthMetric: GrowthMetric = .lines
+
+ /// Repositories EXCLUDED from every stats block by the persistent repo+branch panel
+ /// (empty == all repos counted, the default). The included set (all repos minus this)
+ /// drives the Totals, Languages, growth chart, deltas, and per-repo blocks: when it's a
+ /// strict subset, the screen re-aggregates the included repos with
+ /// `GitStatsService.aggregate`; when it's everything, it uses the precomputed project
+ /// aggregate untouched. The panel never lets the last repo be unchecked, so this can
+ /// never blank the screen. Stale names from a previous project simply don't match.
+ @State private var excludedRepos: Set = []
+
+ /// Whether the repo+branch scoping panel (the per-repo checkboxes + branch switchers) is
+ /// disclosed. Hidden by default so the controls strip is just the period row; the
+ /// "Repositories" text button on the LEFT of that row toggles it, revealing the panel
+ /// beneath. View-local UI state only — it never affects scoping (that's `excludedRepos`).
+ @State private var showRepoPanel = false
+
+ /// The language whose bar row the cursor is over, if any → a small details overlay
+ /// anchored to that row (files / code / comment / blank / %). Live-only: `.onHover`
+ /// never fires under ImageRenderer, so snapshots draw the bars with no overlay.
+ @State private var hoveredLanguage: String?
+
+ /// The cursor's position WITHIN the hovered language bar row (row-local coordinates),
+ /// so the details overlay floats next to the pointer instead of pinning to the row's
+ /// trailing edge. Live-only: continuous-hover never fires under ImageRenderer, so the
+ /// snapshot path never reads it.
+ @State private var hoveredLanguagePosition: CGPoint?
+
+ /// The day the cursor is over in the "Lines over time" strip, if any → a hover tooltip
+ /// (date + total + per-repo breakdown). Live-only (continuous-hover never fires
+ /// offscreen); falls back to the tap-selected day when nothing is hovered.
+ @State private var hoveredDay: Date?
+
+ var body: some View {
+ Group {
+ if let stats = state.codeStats[selectedProjectID ?? UUID()] {
+ content(stats: stats)
+ } else if state.isStatsScanning {
+ scanningState
+ } else {
+ emptyState
+ }
+ }
+ .task(id: selectedProjectID) {
+ guard let id = selectedProjectID else { return }
+ // A day selection is scoped to one project's series; clear it so a stale date
+ // from the previous project can't resolve against a same-calendar day in the
+ // new one (bar dates are GMT start-of-day). The repo scope is the SAME hazard:
+ // repos are matched by name, so a stale name would either silently re-scope the
+ // new project to its own same-named repo (a name collision the user never chose)
+ // or, when the new project lacks that name, leave the chip on "All repos" while
+ // the menu marks nothing active and the stale value keeps filtering. Reset both
+ // so every project opens at "All repos".
+ selectedDay = nil
+ excludedRepos = []
+ anchorNow = .now // capture a fresh anchor before the scan starts
+ await state.refreshCodeStats(projectID: id)
+ anchorNow = .now // refresh anchor after the scan so bars are up to date
+ }
+ }
+
+ private var selectedProjectID: UUID? { state.selectedProjectID }
+
+ /// The selected project's per-day aggregate history (oldest first), or empty.
+ /// The Totals deltas, growth bars, and selection readout all derive from this.
+ private var history: [CodeStatsPoint] {
+ state.codeStatsHistory[selectedProjectID ?? UUID()] ?? []
+ }
+
+ // MARK: - Populated content
+
+ @ViewBuilder
+ private func content(stats: CodeStats) -> some View {
+ // Every block scopes to the INCLUDED repos (the panel's checked set == all repos
+ // minus `excludedRepos`): the Totals headline + Languages bars read the scoped
+ // aggregate, the deltas read the scoped history, the per-repo blocks + growth chart
+ // read the scoped list. When every repo is included (the default) we use the
+ // precomputed project aggregate as-is; when it's a strict subset we re-aggregate the
+ // included repos with the same summing the scan uses, so the headline/chart reflect
+ // exactly the chosen repos. The shared controls strip (the persistent repo+branch
+ // panel + period) sits ABOVE the first card as a plain row, not a glassCard.
+ let repos = state.repoStats[selectedProjectID ?? UUID()] ?? []
+ // Exclusion is keyed by repoPath (unique) — leaf repoNames can collide across a
+ // multi-repo project, which would let one checkbox toggle two repos.
+ let included = repos.filter { !excludedRepos.contains($0.repoPath) }
+ let scopedRepos = included.isEmpty ? repos : included // never blank
+ let isSubset = scopedRepos.count != repos.count
+ // The subset re-aggregation is O(days×repos); a hover sweep re-evaluates this body once
+ // per crossed bar, so memoize it (keyed on project + exclusion + scan time) instead of
+ // re-summing every tick. The default/unfiltered path never aggregates here.
+ let subsetAgg = isSubset
+ ? subsetAggregate(scopedRepos, excluded: excludedRepos, signature: stats.scannedAt)
+ : nil
+ let scopedStats = subsetAgg?.aggregate ?? stats
+ let scopedHistory = subsetAgg?.aggregateHistory ?? history
+ let cards = VStack(spacing: 8) {
+ let cacheID = selectedProjectID ?? UUID()
+ let cacheScannedAt = scopedStats.scannedAt
+ let cacheScope = Set(scopedRepos.map(\.repoPath))
+ sharedControlsStrip(repos: repos)
+ totalsCard(stats: scopedStats, scopedHistory: scopedHistory)
+ growthCard(repos: scopedRepos, projectID: cacheID, scope: cacheScope,
+ scannedAt: cacheScannedAt)
+ reposCard(repos: scopedRepos, projectID: cacheID, scope: cacheScope,
+ scannedAt: cacheScannedAt)
+ languageCard(stats: scopedStats)
+ }
+ .padding(8)
+ // ScrollView content isn't rendered offscreen — a plain VStack in snapshots,
+ // same trick GraphScreen.graphBody uses.
+ if isSnapshotRender {
+ cards
+ } else {
+ // Only the OUTER vertical scroll collapses the search; the inner
+ // horizontal chart scroll has its own axis and is left untouched.
+ ScrollView { cards.collapsesSearchOnScroll() }
+ }
+ }
+
+ // MARK: - Shared controls strip (scopes EVERY block below)
+
+ /// A plain row ABOVE the first card (not a glassCard) holding the controls that apply to
+ /// ALL stats blocks: the repository selector (scopes Totals/Languages/chart/deltas to one
+ /// repo or all) and the period selector (drives every net delta). Both write `@State`
+ /// that the pure `filter*ByRepo` helpers + the delta recompute read. The stats-settings
+ /// gear lives here too (it was in the Totals header) so the Totals card is pure data.
+ private func sharedControlsStrip(repos: [RepoStats]) -> some View {
+ // spacing 14 separates the period row from the repo panel; the .padding(.bottom, 6)
+ // below adds to the cards VStack's uniform 8 (≈14) so the panel reads as its own
+ // section, distinct from both the period row above and the Totals card beneath.
+ VStack(alignment: .leading, spacing: 14) {
+ // Top row: the repo-panel toggle (left), then the period selector + settings gear
+ // (right). The repo+branch scoping panel is disclosed on demand from the toggle
+ // rather than always-visible, so the strip is just this row by default.
+ HStack(spacing: 8) {
+ // Discloses the repo+branch panel below. Reuses the Repositories card's icon
+ // so it reads as "repositories"; the chevron rotates 90° when open. Disabled
+ // when there are no repos to scope.
+ Button {
+ withAnimation(.easeInOut(duration: 0.15)) { showRepoPanel.toggle() }
+ } label: {
+ HStack(spacing: 4) {
+ Image(systemName: "shippingbox")
+ .font(.caption)
+ Text("Repositories")
+ .font(.caption)
+ Image(systemName: "chevron.right")
+ .font(.system(size: 9, weight: .semibold))
+ .rotationEffect(.degrees(showRepoPanel ? 90 : 0))
+ }
+ .foregroundStyle(.secondary)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(repos.isEmpty)
+ .help(showRepoPanel ? "Hide repositories" : "Show repositories — scope & branch")
+ Spacer(minLength: 0)
+ periodControl
+ // Opens the separate stats-settings page (the directory+file exclusion tree);
+ // disabled until a project is selected.
+ Button {
+ if let id = selectedProjectID { state.open(.statsSettings(id)) }
+ } label: {
+ Image(systemName: "gear")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .disabled(selectedProjectID == nil)
+ .help("Stats settings — file tree & exclusions")
+ }
+ // The repo+branch panel: hidden by default, disclosed by the toggle above. No
+ // surface/backdrop. Each row picks whether the repo counts (checkbox) and which
+ // branch it's scanned on. Gated here so the spacing-14 VStack collapses to just
+ // the period row when hidden (no empty gap).
+ if showRepoPanel {
+ repoBranchPanel(repos: repos)
+ }
+ }
+ .padding(.horizontal, 4)
+ .padding(.bottom, 6)
+ }
+
+ /// The persistent repo+branch panel — the always-visible settings that scope EVERY
+ /// block below. No surface/backdrop: it's a plain column of rows sitting under the
+ /// period row. Each row is one repo: a checkbox toggling whether it counts toward the
+ /// Totals/Languages/chart/deltas (multi-repo projects only — there's nothing to scope
+ /// with one repo), the repo name, and the branch switcher (moved here out of the
+ /// Repositories card so all per-repo scoping lives in one place). The panel never lets
+ /// the last included repo be unchecked, so the stats can't go blank.
+ @ViewBuilder
+ private func repoBranchPanel(repos: [RepoStats]) -> some View {
+ let multi = repos.count > 1
+ VStack(spacing: 6) {
+ ForEach(repos, id: \.repoPath) { repo in
+ let included = !excludedRepos.contains(repo.repoPath)
+ HStack(spacing: 8) {
+ if multi {
+ Button {
+ // Count the CURRENTLY-present included repos (a stale excluded path
+ // from a vanished repo mustn't block a valid toggle).
+ let includedCount = repos.filter { !excludedRepos.contains($0.repoPath) }.count
+ if included {
+ // Keep at least one repo counted — never blank the screen.
+ if includedCount > 1 { excludedRepos.insert(repo.repoPath) }
+ } else {
+ excludedRepos.remove(repo.repoPath)
+ }
+ } label: {
+ Image(systemName: included ? "checkmark.square.fill" : "square")
+ .font(.system(size: 13))
+ .foregroundStyle(included ? Color.accentColor : .secondary)
+ }
+ .buttonStyle(.plain)
+ .help(included ? "Exclude from totals" : "Include in totals")
+ }
+ Text(repo.repoName)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(included ? .primary : .secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Spacer(minLength: 8)
+ panelBranchSwitcher(repo)
+ }
+ // Frame each repo as its own row so the repo↔branch pairing reads as a unit
+ // (same field radius as the branch chip / stat block → one chip family).
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: DesignRadius.field, style: .continuous)
+ .fill(.white.opacity(0.04))
+ )
+ }
+ }
+ }
+
+ /// The per-repo branch switcher inside the panel: thin forwarder that passes only
+ /// STABLE VALUE-TYPED inputs into `BranchSwitcher` so a scan republish of
+ /// `isStatsScanning`/`codeStats`/`repoStats` does NOT re-init this subtree and
+ /// cannot interrupt an in-flight NSMenu presentation.
+ @ViewBuilder
+ private func panelBranchSwitcher(_ repo: RepoStats) -> some View {
+ let effective = state.selectedStatsBranchByRepo[repo.repoPath] ?? repo.defaultBranch
+ let branches = state.branchesByRepo[repo.repoPath] ?? [repo.defaultBranch]
+ BranchSwitcher(
+ repoPath: repo.repoPath,
+ selected: effective,
+ branches: branches,
+ isSnapshotRender: isSnapshotRender
+ ) { branch in
+ if let id = selectedProjectID {
+ state.setStatsBranch(projectID: id, repoPath: repo.repoPath, branch: branch)
+ }
+ }
+ // Stable identity keyed on the repo path so SwiftUI never destroys and
+ // re-creates the NSMenu-backed Menu mid-gesture on a parent re-render.
+ .id(repo.repoPath)
+ }
+
+ // MARK: - Totals header
+
+ private func totalsCard(stats: CodeStats, scopedHistory: [CodeStatsPoint]) -> some View {
+ // The HERO is the total line count (all kinds), the headline the user wants raised
+ // above the parts. The disclosed breakdown shows TWO partitions of that same total —
+ // each carrying its OWN net delta at the number's TOP edge, abbreviated to whole
+ // thousands ("+11K"): the language split (code vs data/prose, with file counts) and
+ // the line-kind split (code/comment/blank). Both sum to the hero, so the card stays
+ // self-consistent. Deltas come from the per-day git history; comment/blank line KINDS
+ // have no per-day series, so only the total + the two language groups carry deltas.
+ // All values are pure recomputes — switching the period never rescans. `stats`/
+ // `scopedHistory` are already scoped to the selected repos by the caller.
+ let breakdown = dataProseBreakdown(stats)
+ let netByCat = netLinesDeltaByCategory(scopedHistory, period: period, now: .now)
+ let totalDelta = compactDeltaTriangle(net: netLinesDelta(scopedHistory, period: period, now: .now))
+ return VStack(alignment: .leading, spacing: 10) {
+ CardLabel(title: "Totals", systemImage: "chart.pie.fill")
+ // Hero row: the big total + "lines" + the disclosure chevron on the left, the
+ // period delta pinned to the number's TOP edge on the right (HStack .top).
+ Button {
+ withAnimation(.easeInOut(duration: 0.15)) { totalsExpanded.toggle() }
+ } label: {
+ HStack(alignment: .top, spacing: 8) {
+ HStack(alignment: .center, spacing: 6) {
+ Text(groupedThousands(stats.totalLines))
+ .font(.system(size: 26, weight: .semibold, design: .rounded))
+ .monospacedDigit()
+ .lineLimit(1).minimumScaleFactor(0.6)
+ .foregroundStyle(.primary)
+ Text("lines")
+ .font(.caption).foregroundStyle(.secondary)
+ Image(systemName: totalsExpanded ? "chevron.down" : "chevron.right")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 8)
+ Text(totalDelta.label)
+ .font(.caption.weight(.semibold)).monospacedDigit()
+ .foregroundStyle(triangleColor(totalDelta.direction))
+ .fixedSize()
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ HStack(spacing: 6) {
+ Text("\(groupedThousands(stats.totalFiles)) files")
+ .font(.caption).foregroundStyle(.secondary)
+ if state.isStatsScanning { ProgressView().controlSize(.small) }
+ }
+ if totalsExpanded {
+ VStack(alignment: .leading, spacing: 12) {
+ // Language split: code vs data/prose, each with its file count + own delta.
+ HStack(alignment: .top, spacing: 20) {
+ headlineNumber(value: breakdown.codeLinesText,
+ caption: "code · \(breakdown.codeFilesText) files",
+ delta: compactDeltaTriangle(net: netByCat.code))
+ headlineNumber(value: breakdown.dataProseLinesText,
+ caption: "data · \(breakdown.dataProseFilesText) files",
+ delta: compactDeltaTriangle(net: netByCat.dataProse))
+ }
+ // Line-kind split: the SAME total partitioned by Code / Comment / Blank.
+ // No deltas — git numstat can't classify kinds over time.
+ VStack(alignment: .leading, spacing: 3) {
+ Text("LINE KINDS")
+ .font(.system(size: 9, weight: .semibold))
+ .tracking(0.6)
+ .foregroundStyle(.secondary)
+ HStack(spacing: 12) {
+ metric("Code", stats.code, Palette.primary)
+ metric("Comment", stats.comment, Palette.primary.opacity(0.5))
+ metric("Blank", stats.blank, Palette.neutral)
+ }
+ }
+ }
+ .padding(.top, 2)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12)
+ .glassCard()
+ }
+
+ /// The 7d / 30d / 90d / 180d / 360d segmented control. Pure-SwiftUI (Buttons) so it renders
+ /// identically live and offscreen — AppKit Picker(.segmented) draws as an error
+ /// placeholder under ImageRenderer, and the live-only nature of selection means a
+ /// snapshot just shows the current period highlighted.
+ private var periodControl: some View {
+ HStack(spacing: 0) {
+ ForEach(StatsPeriod.allCases) { p in
+ let selected = p == period
+ Button {
+ period = p
+ } label: {
+ Text(p.rawValue)
+ .font(.caption2.weight(selected ? .semibold : .regular))
+ .foregroundStyle(selected ? Color.white : Color.secondary)
+ .frame(minWidth: 30)
+ .padding(.vertical, 3)
+ .padding(.horizontal, 6)
+ .background {
+ if selected {
+ RoundedRectangle(cornerRadius: DesignRadius.field - 2, style: .continuous)
+ .fill(Palette.primary.opacity(0.85))
+ }
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(2)
+ .background(.white.opacity(0.06),
+ in: RoundedRectangle(cornerRadius: DesignRadius.field, style: .continuous))
+ .overlay(RoundedRectangle(cornerRadius: DesignRadius.field, style: .continuous)
+ .strokeBorder(.white.opacity(0.10)))
+ }
+
+ /// Map a delta direction to the brand tint: growth blue / decline pink / neutral gray.
+ private func triangleColor(_ direction: DeltaTriangle.Direction) -> Color {
+ switch direction {
+ case .up: return Palette.primary
+ case .down: return Palette.negative
+ case .flat: return Palette.neutral
+ }
+ }
+
+ /// One Totals language-group column (code / data): a prominent number with its period
+ /// net delta pinned to the value's TOP edge (HStack `.top`) — a superscript at the top-
+ /// right, tinted blue ▲ when it grew, pink ▼ when it shrank — over a "