Add web server, event stream, and Rive contract
- crows serve: HTTP API, Server-Sent Events per list, web approvals with a 10-minute fail-safe timeout; localhost only by default - Shared pipeline for command line and server; trust.changed events - EVENTS.md: server/UI contract; RIVE_CONTRACT.md: data-binding spec for the animator (one Crow artboard for v1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
This commit is contained in:
12 files changed
+1320
-129
No files matched your search
@@ -0,0 +1,162 @@
|
||||
# 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. 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.
|
||||
Reference in new issue
Block a user