Files
so-many-crows/RIVE_CONTRACT.md
T
SmokeyandClaude Opus 5.5 1008683b1b Pen-and-ink art direction with stipple shading
- Cream paper, hand-drawn ink borders, engraving-era type
  (IM Fell English SC, EB Garamond)
- Stipple shadows from a seamless, randomly dotted tile (fixed seed)
- Trust badges are ink stars; Sentry and Elder marked by nameplate
- Smokey's crow art unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
2026-10-27 11:58:32 -06:00

163 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Rive contract: the `crows.riv` file
The animator builds `crows.riv` against this file, and the web code drives it from the events in `EVENTS.md`. Both sides build against this file, so change it first and then the code. Each animation exists only because an `EVENTS.md` event fires it.
Style: Duolingo-like behavior, pen-and-ink look: cream paper, ink linework, stipple shading (see `ui/src/style.css`). Smokey's crow art (`art/crow.svg`) is the character; keep its silhouette. Chunky, expressive crows with big squash-and-stretch reactions and readable silhouettes at 96 pixels. The approve and reject buttons stay in HTML for v1 (see "Why buttons stay in HTML").
## Rive approach: data binding, not inputs
Rive now marks state machine inputs as deprecated and recommends data binding for new work ([migration guide](https://rive.app/docs/editor/data-binding/migration-guide), [inputs](https://rive.app/docs/runtimes/inputs)). So this file uses **view models** in place of inputs:
- A **view model** defines the data shape. A **view model instance** holds the values, one per crow on screen ([view models](https://rive.app/docs/editor/data-binding/view-models.md)).
- Number, Boolean, Trigger, Enum, String and Color properties can drive state machine transition conditions or bind straight to scene elements ([property types](https://rive.app/docs/editor/data-binding/property-types.md), [transitions](https://rive.app/docs/editor/state-machine/transitions)).
- An enum can drive a Solo when its values match the Solo children's **names and order**, and those names must be exported ([enums](https://rive.app/docs/editor/data-binding/enums.md)).
- Layers play at the same time, one state per layer, and the lower layer wins when two layers animate the same property ([layers](https://rive.app/docs/editor/state-machine/layers.md)). Any State reaches a state from wherever the layer is. Exit State stops a layer ([states](https://rive.app/docs/editor/state-machine/states.md)).
## 1. Artboards
| Artboard | v1? | Purpose |
|---|---|---|
| `Crow` | yes | One reusable character. It covers all 8 crows plus the Sentry and Elder, using a `role` enum that switches the costume. |
| `Perch` | later | A scene with a branch of crows rendered as an Artboard List of `Crow` items ([lists](https://rive.app/docs/editor/data-binding/lists.md)), plus flock-wide celebrations. |
| `Splat` | later | A full-screen "gift on your windshield" overlay for the prank. |
In v1, the web page puts one `Crow` canvas per visible crow and lays them out with HTML and CSS. The Sentry and Elder are **variants** of `Crow`, not separate artboards, so the whole cast shares one rig.
Artboard spec: 256 × 256, transparent background, crow centered with its feet on a baseline 40 pixels from the bottom, so the crows line up in a row.
## 2. `Crow` artboard
State machine: `CrowMachine`. View model: `CrowModel`, set as the artboard's default view model.
### `CrowModel` properties
| Property | Type | Values / range | Drives |
|---|---|---|---|
| `role` | Enum `Role` | `crow`, `sentry`, `elder` | Solo `Costume` (children named `crow`, `sentry`, `elder`, in that order). The Sentry wears a lookout cap and the Elder wears spectacles and a gray ruff. |
| `name` | String | e.g. `Hugin`, `Sentry` | Text run `nameplate` under the feet |
| `pose` | Enum `Pose` | `perched`, `flying`, `waiting`, `grounded` | `Body` layer |
| `trust` | Number | integer 0–2 (0 stranger, 1 regular, 2 trusted) | `Badge` layer |
| `cheer` | Trigger | | `Reaction` layer: happy hop with a wing pump |
| `sulk` | Trigger | | `Reaction` layer: droop, single feather falls. Sad, never angry. |
| `levelUp` | Trigger | | `Reaction` layer: the level-up celebration (spin, confetti, badge shine). It's the biggest moment in the file. |
| `levelDown` | Trigger | | `Reaction` layer: badge dims, sheepish shrug |
| `prank` | Trigger | | `Reaction` layer: mischievous grin, flies off frame, a "plop" sound cue, returns smug |
The 8 crows tell themselves apart by `name` in v1. A `plumage` Color property for per-crow tints is later polish.
### Layers, states and transitions
**Layer `Body`**, a loop that's always running:
| State | Loop | Notes |
|---|---|---|
| `perched` | yes | Entry state. Idle breathing, blinking, a head tilt every few seconds. |
| `flying` | yes | Wings flapping, a slight bob. It reads as "working". |
| `waiting` | yes | Holds a rolled scroll up toward the viewer and taps a foot. It reads as "your turn". |
| `grounded` | yes | Sitting dazed with stars circling. It means failure or timeout. |
Transitions: Any State → each state when `pose` equals that value, with a duration of 200 milliseconds (ms). There's no Exit Time, so a pose change is instant to read.
**Layer `Reaction`**, one-shots on top of `Body`:
- Entry → `none`, an empty state.
- Any State → `cheer`, `sulk`, `levelUp`, `levelDown` or `prank` on the trigger of the same name.
- Each one-shot → `none` with Exit Time at 100%.
- Lengths: `cheer` and `sulk` at most 0.8 seconds, `levelUp` at most 2 seconds, `levelDown` at most 1 second, `prank` at most 2.5 seconds.
- Animate only what `Body` doesn't need to own (face, wings, effects), or put `Reaction` below `Body` in the layer list so the reaction wins while it plays.
**Layer `Badge`**:
- Any State → `badge0`, `badge1` or `badge2` when `trust` equals 0, 1 or 2, with a duration of 150 ms.
- `badge0` shows no badge, `badge1` a bronze chest feather, `badge2` a gold chest feather with a gentle shimmer loop.
## 3. Event → animation mapping
The web code keeps a map from `item_id` to `crows[]` (from `item.answered`) so that it can aim `verdict` at the right crows. The `crow` field values `Sentry` and `Elder` select the instances with `role` set to `sentry` and `elder`.
| `EVENTS.md` event | Condition | Instance(s) | Set |
|---|---|---|---|
| `list.started` | | Sentry | Create and show the Sentry. `pose = perched`. |
| `item.triaged` | `needs_approval` true | Sentry | `pose = waiting` |
| `item.triaged` | `needs_approval` false | | No animation. The HTML shows the mode and reason. |
| `crow.flying` | | `crow` (create on first sight) | `pose = flying` |
| `crow.landed` | `ok` true | `crow` | `pose = perched` |
| `crow.landed` | `ok` false | `crow` | `pose = grounded` |
| `item.answered` | `needs_approval` true | each of `crows[]` | `pose = waiting` |
| `item.answered` | `needs_approval` false | | No animation. A `verdict: auto` follows. |
| `verdict` | `target` triage, `approved` | Sentry | `cheer`, then `pose = perched` |
| `verdict` | `target` triage, `rejected` or `timeout` | Sentry | `sulk`, then `pose = perched` |
| `verdict` | `target` triage, `auto` | | No animation. A trusted Sentry just keeps going. |
| `verdict` | `target` output, `approved` or `auto` | each of the item's `crows[]` | `cheer`, then `pose = perched` |
| `verdict` | `target` output, `rejected` or `timeout` | each of the item's `crows[]` | `sulk`, then `pose = perched` |
| `trust.changed` | `holder` is a crow, `to` > `from` | `holder` | `trust = to`, then `levelUp` |
| `trust.changed` | `holder` is a crow, `to` < `from` | `holder` | `trust = to`, then `levelDown` |
| `trust.changed` | `holder` is `flock` | | No animation in v1 (HTML banner). Later: a `Perch` celebration. |
| `item.skipped` | | | No animation. The HTML strikes the item through, and any instance still `flying` for that item goes to `pose = perched`. |
| `requester.pranked` | | one random crow on screen, or the Sentry if it's alone | `prank`. Later: the `Splat` overlay. The HTML shows `message` and a `cooldown_s` countdown. |
| `list.finished` | | every crow on screen | `pose = perched`, then `cheer` |
Two web-side notes:
- **Pranks.** `POST /api/lists` answers a pranked requester with a 429 error and no `list_id`, so there may be no event stream to deliver `requester.pranked`. The web code treats that 429 response the same as the event and fires `prank` on the Sentry.
- **Badges.** `trust` is per crow *and* per task type, so a badge shows the level for the task type of the item the crow is working on now. The web code seeds `trust` from `GET /api/trust` when it creates an instance, and updates it on `trust.changed` only when `task_type` matches.
## 4. Naming conventions
The web code matches these strings exactly, so they're case-sensitive.
| Thing | Case | Names |
|---|---|---|
| File | lowercase | `crows.riv` |
| Artboards | PascalCase | `Crow`, `Perch`, `Splat` |
| State machines | PascalCase + `Machine` | `CrowMachine`, `PerchMachine` |
| View models | PascalCase + `Model` | `CrowModel` |
| Enum types | PascalCase | `Role`, `Pose` |
| Enum values | lowercase, one word, matching server strings where they exist | `crow`, `sentry`, `elder`, `perched`, `flying`, `waiting`, `grounded` |
| Properties (including triggers) | camelCase | `role`, `name`, `pose`, `trust`, `cheer`, `sulk`, `levelUp`, `levelDown`, `prank` |
| States and layers | camelCase states, PascalCase layers | `perched`, `badge2`, `Body`, `Reaction`, `Badge` |
| Exported objects | as listed | Solo `Costume` (children `crow`, `sentry`, `elder`), text run `nameplate` |
The web code binds one blank instance per crow, using API names from the [web data binding docs](https://rive.app/docs/runtimes/web/data-binding):
```js
const r = new rive.Rive({ src: "crows.riv", canvas, artboard: "Crow",
stateMachines: "CrowMachine", autoplay: true, onLoad: () => {
const vmi = r.viewModelByName("CrowModel").instance();
r.bindViewModelInstance(vmi);
vmi.enum("role").value = "crow";
vmi.string("name").value = "Hugin";
vmi.number("trust").value = 1;
vmi.enum("pose").value = "flying"; // crow.flying
vmi.trigger("cheer").trigger(); // verdict approved
} });
```
Keep `autoBind` off. Every crow needs its own instance, or setting one crow's `pose` would move them all.
## 5. v1 minimum vs. later polish
**v1 minimum**, the smallest set that makes the web UI work:
1. The `Crow` artboard, the `CrowMachine` state machine and the `CrowModel` view model, named exactly as in section 4.
2. The `role` Solo with all three costumes. The Sentry and Elder must read as different from the crows at a glance.
3. The `name` text run.
4. The `Body` layer with all four `pose` states.
5. The `Reaction` layer with `cheer`, `sulk` and `levelUp`.
6. The `Badge` layer with all three `trust` states.
If `levelDown` or `prank` doesn't exist yet, the web code falls back to `sulk` and `cheer`, so they can ship a beat later without breaking anything.
**Later polish**, in rough order:
1. `levelDown` and `prank` reactions.
2. A `plumage` Color property so each of the 8 crows has a distinct tint.
3. The `Splat` artboard for the windshield gift.
4. The `Perch` scene: an Artboard List of crows, murder huddles and split formations, and a flock `levelUp` on `trust.changed` with `holder` set to `flock`.
5. A streak ring, a Duolingo-style progress bar toward promotion at 10 clean approvals. `EVENTS.md` carries no streak count today, so this needs a `streak` field added to `verdict` or `trust.changed` first.
6. A Rive-drawn approve or reject button, if HTML buttons ever feel flat.
## Why buttons stay in HTML
Approval is the safety gate, and the server treats anything other than an explicit yes as a no. Real `<button>` elements give keyboard, screen reader and focus support for free, and an animation glitch can never swallow a click. Rive decorates the moment by having the crow hold up its scroll. The click itself belongs to the browser.