<!-- Generated by scripts/build_spec.py from docs/, examples/gallery/ and the package. Edit the source and regenerate; do not edit this file. -->

<!-- scenet-spec:part=preamble -->
# Scenet specification pack

Everything needed to write a Scenet panel, in one file. Published at
<https://creatoan.github.io/scenet/scenet-spec.md> and regenerated from the project's own sources on every build,
so it describes the language the compiler actually accepts.

> This project is deliberately AI-generated: the compiler, its documentation and this
> file are almost entirely written by AI under human direction and review. Treat it as
> an experiment first and a tool second.

## What Scenet is

Scenet compiles a *semantic* description of a comic panel — who is in it, how they
relate, what they say — into SVG. It is a deterministic compiler built on constraint
solving and geometry. **No generative image model is involved.** The same document
always produces the same picture, byte for byte.

You describe the panel; the compiler decides every coordinate. **Never write
coordinates**, pixel positions for figures, or balloon placements: the language has
nowhere to put them, and unknown keys are rejected.

## Two ways to write a panel

1. **YAML** — `*.panel.yaml` for one panel, `*.scene.yaml` for a sequence. The whole
   language; see the `language` part.
2. **Comic script** — `*.script`. The format comic writers already use: `PANEL 1`,
   `@shot:`, a character cue, the dialogue under it, with a short YAML front matter
   declaring the cast. See the `comic-script` part.

**If you cannot run code** — a chat app, NotebookLM — write a comic script. It is the
closest thing to what you would write anyway, and the person you are helping compiles
it with one command: `scenet build panel.script`.

## The loop

1. Write the document.
2. Run `scenet check FILE`. It reports every fault at once, each with a rule id such as
   `scenet/unknown-actor`, a line and column, and what to do about it. Add
   `--format sarif` for a machine-readable report.
3. Fix what it reports and check again, until it prints `ok`.
4. Run `scenet build FILE` to write the SVG.

With the Scenet MCP server connected, the `validate`, `compile` and `render` tools do
the same without a shell.

**A schema-valid document is not yet a valid one.** No JSON Schema can catch the faults
that matter most — an actor id that is not in `cast`, a `left_of` ordering that loops —
so always check.

## The mistakes generators make most

- **Every actor id used in `staging` or `script` must be a key of `cast`.** Ids are
  case-sensitive.
- **`reference` names a puppet in the library.** The shipped puppets are `alice`, `bob`.
  `pose` and `expression` must be names that puppet declares; see the `characters`
  part. Do not invent them.
- **Two figures need a left-to-right order**, from `at:` or from an explicit
  `a left_of b`. There is no unordered "beside".
- **Script order is reading order.** A balloon may never sit above-and-left of the one
  before it, so write the lines in the order they are read.
- **Shots are named** — `medium_shot`, `close_up` — never a percentage of the panel.
- **Prose in a comic script is kept and never interpreted.** Staging that matters goes
  in the front matter.

## Contents

- `preamble` — Start here: what Scenet is, the two ways to write a panel, the check-and-fix loop, and the mistakes generators make most
- `language` — The language reference: every block and key of a panel or scene document
- `shot-types` — Normative: what each camera shot frames, in head-heights
- `comic-script` — The comic-script format: PANEL 1, @shot:, character cues, dialogue
- `characters` — The shipped puppets, with every pose and expression each declares
- `diagnostics` — Every rule `scenet check` reports, and what to do about each
- `schema` — The JSON Schema for a single-panel YAML document
- `gallery` — Every gallery example in full, each known to compile

<!-- scenet-spec:part=language -->
# The Scenet language

> **Status:** this is the specification. It is not a report of what is implemented — see
> [implementation status](https://creatoan.github.io/scenet/explanation/status.html#implementation-status), which is authoritative on
> what actually runs. Panels and sequences compile end to end today, from either frontend; page
> composition and the style layer do not exist yet.

A panel source is a YAML document describing **what is in a panel**, never **where things are drawn**.
Coordinates do not appear anywhere in the language; producing them is the compiler's entire job.

## The layers

A panel description has five authored layers. A sixth — resolution — is computed, and a seventh —
rendering — is emitted.

| Layer | Block | What it says |
|---|---|---|
| Frame | `panel` | Size and margins of the panel |
| Camera | `camera` | How the scene is framed |
| Setting | `setting` | Where and when it happens |
| Cast and staging | `cast`, `staging` | Who is present, and how they relate |
| Narrative | `script` | What is said, and in what order |

Layers are separable on purpose. Changing `camera.shot` re-frames the same scene without touching
anything else, exactly as transposing a score changes its key without rewriting the melody.

## A complete example

```yaml
panel:
  size: [1000, 1000]

camera:
  shot: medium_shot
  angle: eye_level

setting:
  place: docks
  time: night
  weather: rain

cast:
  alice: {reference: alice, pose: pointing,     at: left_third,  facing: right}
  bob:   {reference: bob,   pose: arms_crossed, at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice looking_at bob
  - alice ground_shared_with bob

script:
  - say: {by: alice, text: "You forgot your umbrella!", prefer: top_left}
  - say: {by: bob,   text: "I know."}
```

## `panel`

| Key | Type | Default | Meaning |
|---|---|---|---|
| `size` | `[width, height]` | required | Panel dimensions in panel units |
| `margin` | number | `0` | Inset all content by this much |

Panel units are arbitrary and internally consistent; they become SVG user units.

## `camera`

| Key | Values | Default |
|---|---|---|
| `shot` | see [shot types](https://creatoan.github.io/scenet/reference/shot_types.html) | `medium_shot` |
| `angle` | `low`, `eye_level`, `high` | `eye_level` |

`shot` determines the **scale** of every actor, by naming where the frame cuts the body rather than
what fraction of the panel a figure fills. This is the single most consequential value in a panel.

## `setting`

Where and when the panel happens, drawn as **tonal masses** rather than as geometry.

```yaml
setting:
  place: docks       # a named place, which expands into masses
  horizon: mid       # high | mid | low
  time: night        # dawn | day | dusk | night
  weather: rain      # clear | rain | fog | snow
```

A panel with no `setting` renders exactly as it always did: figures on white.

### Backdrops are never author-drawn

Two reasons, and the first is structural. Crisp architecture needs a vanishing point, and this is
deliberately a flat, orthographic compiler — a tilted camera does not foreshorten anything — so
drawn buildings would fight the compiler's own model. Soft tonal masses have no perspective to get
wrong.

The second is that this is how comics actually establish place:

- **[Notan](https://mitchalbala.com/the-wisdom-of-notan/)**, the Japanese light/dark mass
  principle, which entered Western art teaching through Arthur Wesley Dow's *Composition* (1899):
  place is read from the *arrangement of masses*, not from rendered detail.
- **Layered silhouette depth**: foreground near-black, each receding plane paler.
- **[Aerial perspective](https://en.wikipedia.org/wiki/Aerial_perspective)** supplies the
  parametric rule for free — with distance, value contrast drops toward the atmosphere. Two numbers
  per hour, monotonic in depth. That is notation, not interpretation, which is why it belongs in a
  compiler.

### `masses`

A place is a convenience. Underneath it is a list of masses, which stays authorable whenever you
want control — and which is *exactly* what a place expands into:

```yaml
setting:
  horizon: mid
  masses:
    - {kind: building, plane: far,  spans: full}
    - {kind: plant,    plane: mid,  spans: left}
    - {kind: ground,   plane: near, spans: full}
```

| Key | Values | Default | Meaning |
|---|---|---|---|
| `kind` | see below | required | What the mass is made of, which decides its silhouette |
| `plane` | `foreground`, `near`, `mid`, `far` | `mid` | How far back it sits |
| `spans` | `full`, `left`, `center`, `right` | `full` | How much of the width it covers |

**`kind`** is subsetted from the *supercategories* of
[COCO-Stuff](https://arxiv.org/pdf/1612.03716), the canonical taxonomy of **stuff** — "amorphous
background regions" as opposed to *things* with a well-defined shape. Taken from an existing
vocabulary for the same reason the predicates were taken from Visual Genome. Twelve of them, seven
outdoor and five indoor:

| | Kinds |
|---|---|
| Outdoor | `building` `ground` `plant` `sky` `solid` `structural` `water` |
| Indoor | `ceiling` `floor` `furniture` `wall` `window` |

Deliberately *not* COCO-Stuff's leaf names. Its actual classes are `building-other`, `sky-other`,
`wall-brick`, `water-other`; the `-other` suffix marks the catch-all inside a supercategory, and
`building-other` is not a word anyone should have to type. Its `textile`, `food` and `rawmaterial`
supercategories are left out: drapery and objects, not scene-defining masses.

**`plane`** decides two things at once, and neither is a new mechanism. It maps onto the same
integer painter's order `in_front_of` already uses — the three backdrop planes take negative
depths, and `foreground` takes one above the frontmost actor, so a foreground mass draws over the
cast the way a silhouetted doorway does. And it decides **value**: reading front to back, a mass
never gets darker.

Value comes from the plane and from **nothing else** — not from the kind. That is what keeps the
notan reading literal: masses at one distance read as one mass, and their arrangement is what
carries the place. Two kinds sit off their own plane's rung, and both stay on the ladder rather
than beside it: `sky` is at infinite distance so it always takes the atmosphere's value, and
`window` is a hole showing a more distant plane so it takes the rung one step farther back.

A **nearer plane is also drawn larger**, which is size perspective alongside aerial perspective: a
near hill is not merely darker than a far one, it is bigger.

**`spans`** is an absolute extent, and that is not cosmetic. Any construct that would reintroduce a
left/right *disjunction* has to resolve it before the solver — see
[why ordering must be explicit](#why-ordering-must-be-explicit). A span is an extent rather than a
relation, which is what stops masses becoming an unordered `beside`. `left` and `right` overlap
slightly in the middle so that using both leaves no seam down the centre of the panel.

One authored mass may resolve to **several polygons**: furniture is a few separate blocks, a wall
holds several windows. Joining them into one comb with a zero-height baseline would be a lie about
the shape.

### `place`

The headline surface, because the thing an author wants to write is *where the scene is*, not a
list of shapes.

| Place | Expands into |
|---|---|
| `alley` | sky·far, building·near·left, building·near·right, ground·near, building·foreground·left |
| `desert` | sky·far, solid·far·right, ground·mid, ground·near |
| `docks` | sky·far, building·far·left, water·mid, structural·mid·right, ground·near |
| `field` | sky·far, plant·far, ground·mid, ground·near |
| `forest` | sky·far, plant·far, plant·near·left, plant·near·right, ground·near |
| `mountain` | sky·far, solid·far, solid·mid·left, plant·mid·right, ground·near |
| `office` | wall·far, window·far·center, ceiling·mid, floor·near, furniture·near |
| `room` | wall·far, window·far·right, ceiling·mid, floor·near, furniture·near·left |
| `shore` | sky·far, water·mid, ground·near |
| `street` | sky·far, building·far, building·mid·left, building·mid·right, ground·near |

**The rule that keeps `place:` honest**: a preset expands into a mass list the author could have
written themselves, and is never a second opaque format. A library for convenience, not a parallel
language. The expansion happens in the frontend, exactly as `alice left_of bob` is expanded into a
relation — so by the time anything downstream sees a backdrop, there is one representation of it.

`place` and `masses` are **mutually exclusive**. A place *is* a mass list; writing both asks two
questions at once, and the compiler will not guess which was meant.

**Free prose is deliberately not offered.** `setting: "a rainy street corner at midnight"` needs
language understanding, and the comic-script frontend already refuses to interpret prose on the
grounds that guessing produces panels that are confidently wrong. A named place is the honest
middle: it reads like a description and resolves deterministically. `scenet check` reports an
unmatched name as `unknown-place`, listing the ones that exist.

### `horizon`

One line for the whole panel, which every mass is composed against: masses of the ground sort start
at it and run down, masses that stand in the world rise from it. A **high** horizon sits nearer the
top of the frame, so more ground is in view.

Ground, floor and water all run to the bottom edge, so a near quayside drawn from the horizon would
bury the water behind it. Each starts lower than the plane behind it, and that stack of receding
bands is the depth cue. The exception is the *farthest* one in a panel, which meets the horizon
itself: there is nothing behind it to reveal.

### `time` and `weather`

`time` does not tint a daytime panel. It supplies the two ends of the value ladder — the value of
the foreground and the value of the atmosphere — and the planes are spaced evenly between them in
[OKLab](https://bottosson.github.io/posts/oklab/) lightness, which predicts perceived lightness
well. So `night` is a darker, *narrower* ladder, which is what night does to a drawn scene, and the
ladder stays monotonic in depth at every hour by construction rather than by tuning.

`weather` adds a layer over that. `clouds` and `fog` are first-class stuff in COCO-Stuff, so this
vocabulary did not have to be invented either:

| `weather` | What it does |
|---|---|
| `clear` | Nothing. The panel has no atmosphere layer at all |
| `fog` | A dense, low-frequency noise veil, tinted with the atmosphere itself |
| `rain` | The same veil as cloud — nearer, so darker than the sky — plus slanted streaks |
| `snow` | The same veil, plus flecks |

The veil sits over the backdrop and *under* the cast: fog between the reader and the figures would
be the more literal reading and would bury them. Falling weather goes over everything, because it
**is** between the reader and the panel — which is why it crosses the figures.

Rain flips to ink over a bright sky and to paper over a dark one, as inkers do, because a white
streak over noon is invisible and a black one over midnight is too. Snow never flips: snow is
white, and the overcast veil is what gives it something to read against.


## `cast`

A mapping of actor id to properties. Ids are chosen by the author and referenced everywhere else.

| Key | Type | Meaning |
|---|---|---|
| `reference` | asset name | Which puppet to pull from the library |
| `pose` | pose name | A named joint configuration declared by that puppet |
| `expression` | expression name | A named face declared by that puppet |
| `marks` | list of marks | Emanata drawn around the character: `plewds`, `squeans`, `grawlixes`, `briffits` |
| `at` | anchor | Horizontal placement preference |
| `facing` | `left`, `right` | Which way the figure is turned |

`at` accepts `left_third`, `center`, `right_third`, `left_edge`, `right_edge`. It is a
**preference, not a command** — see [conflicts](#when-constraints-conflict).

`expression` is selected by name exactly as `pose` is, because a face is the same kind of thing as a
body: a small closed set of arrangements a character can be in. The shipped puppets declare ten —
`neutral`, `happy`, `laughing`, `coy`, `bored`, `scared`, `sad`, `angry`, `shouting`, `surprise` —
and it defaults to `neutral`. They are a **drawing convention**, the small closed set of faces comics
actually draw, and not a claim about what a person feeling anger looks like. What a face is made of,
and how to give your own puppet one, is in the [asset contract](https://creatoan.github.io/scenet/reference/asset_contract.html#faces).

A character's pupils follow whoever they are `looking_at`. Nothing else about the face depends on the
rest of the panel, and nothing about the face changes the layout: to the solver a face is still one
disc that balloons may not cover.

### `marks`

```yaml
cast:
  alice: {reference: alice, expression: angry, marks: [grawlixes, plewds]}
```

`marks` are what a comic draws *around* a character rather than on them. The vocabulary is Mort
Walker's, from *The Lexicon of Comicana*, and it is closed:

| Mark | What is drawn | What it says |
|---|---|---|
| `plewds` | Droplets flying off the head, mostly off the back | sweating: effort, heat, nerves |
| `squeans` | Little starbursts and circles in an arc over the head | dizzy, drunk, or sick |
| `grawlixes` | A spiral, a star, a bolt and a `#` over the head | swearing |
| `briffits` | Puffs of dust at the feet, behind the figure | gone, fast |

They are a **list**, not a second `expression:`, because they compose: a character can be angry *and*
sweating. Order does not matter, and a mark listed twice is an error. They need nothing from the
puppet — every mark is placed from the face circle, the facing and the feet — so every character can
have every mark. In a scene, `marks: []` under `over:` clears the ones a panel inherited, because
lists replace rather than merge.

Emanata are drawn **outside** the head, in the space balloons are placed in, so unlike a face they
matter to the layout — but only softly:

- **They never move anybody.** They stay out of the hull, so staging, the camera and every figure are
  exactly where they would be without them.
- **A balloon prefers not to cover them.** Each mark has a zone the solver reads as a cost, weighted
  above covering a body, with no forgiveness for the speaker. A crowded panel still compiles: a balloon
  covers a mark before it fails.
- **The camera makes no room for them.** It frames by body landmarks, so a tight shot — or the head of
  the tallest character, which the frame is fitted to — can crop marks over the head, and a shot that
  cuts at the waist leaves briffits below the frame. `scenet build` reports any mark that runs off the
  panel.

At a wide framing a plewd is a dot: below a head size, each mark collapses to a dot where it would
have been drawn, and a character too small to have a face has no marks at all. See the
[asset contract](https://creatoan.github.io/scenet/reference/asset_contract.html#emanata).

An oath written in a balloon — `"@#$%!"` — is dialogue, and already works. `grawlixes` is the other
convention, the symbols over a head.

`reference`, `pose` and `expression` are validated against the puppet library, not just against the
language's own grammar — a misspelled pose is a perfectly good string as far as the grammar is
concerned, so nothing at that level can tell `pointing` from `smirking`. `scenet check` resolves the
library and reports an unmatched name as `unknown-puppet`, `unknown-pose` or `unknown-expression`,
each naming the field and, for `pose` and `expression`, listing the names that puppet does declare.
See [`scenet check`](https://creatoan.github.io/scenet/reference/cli.html#scenet-check).

## `staging`

A list of relations between actors, written `subject predicate object`. This is a scene graph: the
cast are nodes, these are the edges. Predicates come from the spatial subset of the Visual Genome
vocabulary rather than being invented here, so a Scenet scene remains convertible to and from the
scene-graph representations used elsewhere in computer vision.

| Predicate | Effect |
|---|---|
| `left_of`, `right_of` | Fixes horizontal ordering |
| `in_front_of`, `behind` | Fixes draw order and occlusion |
| `looking_at` | Sets the subject's gaze vector toward the object |
| `ground_shared_with` | Places both actors on the same ground line |

### Why ordering must be explicit

`left_of` looks redundant next to `at: left_third`, but it is not. The layout engine is a **linear**
constraint solver, and "A and B must not overlap" is a *disjunction*: A is left of B, **or** B is
left of A. Linear solvers cannot express that choice.

So the language resolves it instead. By the time the solver runs, ordering is already decided — by
`at`, or by an explicit `left_of` — and what reaches the solver is a linear system it can always
solve. This is the reason there is no unordered `beside` predicate, and why any future construct
must resolve its own ordering in the frontend.

## `script`

An ordered list of narrative events, each tagged by a verb. **Order is meaningful**: it is the order
the reader reads them, and it constrains where the boxes may be placed.

There are two verbs. `say` puts a line in a balloon; `caption` puts one in a box.

```yaml
script:
  - caption: {text: "Midnight. The docks.", kind: locale, prefer: top_left}
  - say: {by: alice, text: "You forgot your umbrella!", prefer: top_left}
  - say: {by: bob,   text: "I know.", kind: whisper}
```

### `say`

| Key | Type | Meaning |
|---|---|---|
| `by` | actor id | Who speaks; the tail points at this actor's mouth |
| `text` | string | The dialogue. Line breaking is computed, not authored |
| `prefer` | zone | A hint about placement, honoured when possible |
| `kind` | `speech`, `thought`, `whisper`, `shout` | Balloon styling |

`text` is never pre-wrapped by the author. Wrapping is computed from real font metrics during
compilation, because a balloon's size determines whether it fits where it is wanted — so the
compiler must decide the line breaks before it can place anything.

### `caption`

A caption is the panel speaking in its own voice. It is what lets a panel say *where* and *when* it
happens without a character having to explain it out loud — which is the thing writers are told not
to do. Comics solved this long before they had reliable backgrounds.

| Key | Type | Meaning |
|---|---|---|
| `text` | string | What the box says. Line breaking is computed, as for dialogue |
| `kind` | `locale`, `monologue`, `spoken`, `editorial` | What the box is doing |
| `tone` | `paper`, `pale`, `ink` | What the box is filled with. Defaults to `paper` |
| `prefer` | zone | Where it would like to sit. Defaults to `top_left` |
| `by` | any name | Who is speaking, for a `spoken` caption only |

The four kinds are the letterers' own vocabulary, taken from Blambot's *Comic Book Grammar &
Tradition* rather than invented — for the same reason the predicates were taken from Visual Genome.
Note that "narration", the obvious guess, is not one of them.

| Kind | What it is | How it is set |
|---|---|---|
| `locale` | Location and time — "Midnight. The docks." | Italic |
| `monologue` | A character's inner voice | Italic |
| `spoken` | Off-panel dialogue | Roman, in quotation marks |
| `editorial` | The voice of the writer or editor | Italic |

`monologue` has largely replaced the thought balloon in modern comics, so a panel has two ways to
render an inner voice: a `monologue` caption and a `thought` balloon. Both are correct. They are
different eras of the same convention, not a duplication.

**Quotation marks are applied by the compiler**, not by you. In a run of consecutive `spoken`
captions, each opens with a quote and only the last one closes — the run is one continuous line of
off-panel speech, and closing every box would read as a series of interruptions. Write the words;
the marks are lettering.

**`by` is the one place an actor id is allowed not to resolve.** Everywhere else, naming somebody
who is not in `cast` is an error. A `spoken` caption's speaker is *off panel* by definition, so
requiring them to be cast would defeat the purpose. It is accepted only on `spoken`; on any other
kind it is a mistake and is rejected.

#### `tone`

A caption box is opaque, so its lettering is never the thing at risk — the text sits on the fill
whatever is behind it. What a tone changes is whether the **box** reads. Against the value ladder
the `setting` block produces, a white box on a noon sky is 1.16:1: legible, and invisible.

| Tone | Fill | Where it comes from | Lettered in |
|---|---|---|---|
| `paper` | `#ffffff` | the paper the panel is printed on — **the default** | ink |
| `pale` | `#adadad` | the `day` row of the value ladder, far plane | ink |
| `ink` | `#090909` | the `day` row of the value ladder, foreground | paper |

Two of the three are rungs of the ladder in `solve/backdrop.py`, taken by index rather than restated
as literals, so lettering and backdrop cannot drift apart as either is tuned. A tone is **fixed, not
a function of the panel's `time`**: a caption's value is a property of the caption, and letting it
follow the hour would make the table below a function of the panel.

`ink` produces what letterers call reversed type. **The inversion is decided by the compiler**, by
contrast, and travels in Panel Core as a resolved value — the same rule and the same reason as
falling rain, which is inked over a bright sky and papered over a dark one.

Two floors, and only the first is a rule every tone must meet:

- **Lettering, 4.5:1.** A caption's text against its own fill, WCAG AA for body text. This is the
  contrast a reader actually gets, and every tone in the palette clears it with room — 18.9:1,
  8.4:1 and 19.9:1 respectively.
- **Separation, 3:1.** The box against the plane behind it. *No tone is required to clear this on
  every rung*, and the default does not: white on a noon sky is the case that motivated the feature.
  What the palette owes you is an escape from every background the compiler can produce — at least
  one tone above 3:1 for every rung of every hour — and that is what the test suite checks.

There is no free-form `fill:`, and no yellow. An open colour field would be this language's one open
vocabulary and would let you produce an unreadable box; the classic yellow `locale` caption would be
the first non-neutral value in the codebase, and there is no colour policy to put it under yet.

**Text is set flush left.** This is a *convention*, not a rule the way reading order is: the
lettering references describe left alignment as the norm while calling it a house preference. It is
the default because it is what letterers do, and it is recorded here as a choice rather than a law —
unlike `shot_types.md`, which is normative. Balloons, by contrast, centre their text.

Captions are placed by the same machinery as balloons — the same face avoidance, the same silhouette
occlusion cost, the same hard reading-order rule — because the placement principles for floating
text are the ones the balloon solver already implements. What differs is the pull toward the frame:
a balloon mildly dislikes hugging the panel edge, and a caption is looking for exactly that corner.

### Reading order is enforced, not suggested

Balloons and captions are placed in script order, in one pass, and a box may never sit
above-and-left of the one before it. Violating this is not a cosmetic flaw: it makes the panel read
in the wrong order, which is a correctness bug in a comic. The constraint is therefore hard, and a
panel whose boxes cannot be placed without breaking it is rejected rather than rendered wrongly.

Captions take their turn in that sequence rather than being placed first as a layer. A caption
written between two lines of dialogue is read between them; one written last is read last.

## When constraints conflict

Placement values are preferences of differing strength, and the solver resolves conflicts by
priority rather than by failing:

| Strength | Examples |
|---|---|
| **Required** | Actors stay inside the panel; balloons never cover a face; reading order holds |
| **Strong** | Declared `left_of` / `right_of` ordering |
| **Weak** | `at:` anchors, `prefer:` balloon hints |

So two actors both asked to stand at `center` will be pushed apart rather than overlapping: the
required non-overlap wins and the weak anchors yield. This is why `at` is documented as a
preference. If you need a guarantee, express it as a relation in `staging`.

## Determinism

The same source always compiles to byte-identical output. No wall-clock time, no unseeded
randomness, no dependence on mapping iteration order. This is what makes a panel description a
durable artifact rather than a prompt: it will render the same in five years as it does today.

Backdrop silhouettes are generated, so they are seeded — from the declared setting and the panel
size, through a content hash. Never a clock, and never Python's `hash()`, which is salted per
process and would agree with itself all day while disagreeing with tomorrow's build.

### The contract is on the SVG text, not on pixels

Worth stating explicitly, because it is exactly the kind of assumption that rots silently.

> **The determinism contract is on the emitted SVG text, which stays byte-identical. It has never
> been on pixels.**

The `feTurbulence` filter that draws fog and cloud is reproducible *by definition* — SVG has Perlin
noise built in, the specification includes reference code, and the `seed` is fixed by the compiler
— so the emitted document is identical every time. In practice browsers agree only
[approximately](https://tympanus.net/codrops/2019/02/19/svg-filter-effects-creating-texture-with-feturbulence/)
on what to paint from it. That is fine, and it was already true of every glyph outline and every
antialiased edge in the file.

Golden-file tests therefore target Panel Core and SVG **text**, never a raster. If a raster check is
ever wanted, [resvg](https://github.com/linebender/resvg) is the candidate: it supports
`feTurbulence`, aims at the whole specification rather than the common cases, and ships around 1600
SVG-to-PNG regression tests — which matters because the W3C SVG test suite was abandoned long ago,
making resvg's the practical conformance reference.

<!-- scenet-spec:part=shot-types -->
# Shot types (normative)

How the camera's `shot` value determines the scale of a figure in the panel.

## The unit: head-heights

Figures are measured in **head-heights**, the standard figure-drawing unit. An adult is
conventionally about 7.5 heads tall. Every puppet declares `units_per_head` and a set of vertical
**landmarks** measured downward from `head_top`, so the compiler can reason about anatomy without
knowing anything about the artwork.

## Why not "60% of panel height"

A tempting shortcut is to define each shot as a fixed fraction of panel height. It is wrong, because
what a shot type actually names is **where the frame cuts the body** — the waist, the chest, the
shoulders. The fraction of the panel that a figure then occupies falls out of that crop, and differs
between a child and an adult, or between a standing and a seated pose. Encoding the fraction instead
of the crop line bakes in one body and one pose.

So a shot type is defined by two things: a **crop landmark**, and a **headroom** fraction — the empty
space above the head that stops the figure colliding with the top edge.

## Resolution

```
visible_height_native = landmark[crop].y - landmark[head_top].y
available_height      = panel_height * (1 - headroom - footroom)
scale                 = available_height / visible_height_native
```

The scaled figure is then anchored so its crop line meets the bottom of the available area.
`footroom` defaults to `0.0`; a panel may raise it to lift figures off the lower edge.

This anchoring rule is right whenever the crop landmark sits at the **edge** of what the shot
frames -- feet at the bottom of a long shot, chin at the bottom of a big close-up. It goes wrong at
the one rung where the landmark sits **inside** the thing being framed: `extreme_close_up` crops at
`eyes`, and bottom-anchoring the eyes puts the whole eye region below the panel, framing forehead
and eyebrows instead. Headroom cannot fix this -- it shifts the figure down and shrinks it by
exactly the same amount, so the crop line stays pinned to `panel_height * (1 - footroom)`
regardless of headroom. Footroom is the only lever that moves a crop line up from the bottom edge,
which is why `extreme_close_up` is the one shot in the table that needs it despite showing no feet.

## Table

| `shot` | Crop landmark | Headroom | Footroom | Reads as |
|---|---|---|---|---|
| `long_shot` (alias `wide`) | `feet` | 0.28 | 0.10 | Figure small in its environment |
| `full_shot` | `feet` | 0.05 | 0.04 | Whole body, environment secondary |
| `medium_full` | `knees` | 0.08 | — | The three-quarter shot |
| `cowboy` | `mid_thigh` | 0.08 | — | Stance and confrontation |
| `medium_shot` | `waist` | 0.10 | — | The conversational default |
| `medium_close_up` | `chest` | 0.10 | — | Emphasis on the speaker |
| `close_up` | `shoulders` | 0.08 | — | Emotion; face dominates |
| `big_close_up` | `chin` | 0.05 | — | Intensity |
| `extreme_close_up` | `eyes` | 0.00 | 0.45 | Crops through the face deliberately |

**`wide` is an exact synonym for `long_shot`.** The two are used interchangeably in the
literature and the language keeps both because writers reach for both.

**`medium_full` and `cowboy` are not synonyms**, though they were briefly implemented as
though they were. Medium full — the three-quarter shot — cuts at the knees. The cowboy
or American shot cuts at mid-thigh, a framing that comes from 1930s Westerns needing the
holster in shot. Naming two shots and drawing one collapses a rung of the ladder, and
nothing about the output makes that visible.

**Footroom is space left below the crop line.** For the two shots that show feet, that space
reads as ground, and it is not decoration. The crop lands the `feet` *landmark* on the frame
edge, but the drawing continues past it: the ankle joint sits exactly on that landmark and the
shin is drawn as a round-capped stroke, so half its width falls below. Without footroom a long
shot clipped the feet by nine panel units — the one thing a long shot is defined by not doing.
It is also compositionally right on its own: a figure standing on the exact bottom edge reads
as falling out of the panel rather than standing on anything.

`extreme_close_up` uses footroom for a different reason: its crop landmark (`eyes`) is inside
the face rather than at its edge, and footroom is the only lever that moves a crop line up off
the bottom edge — see **Resolution**, above.

The table is **monotonic**: reading down it, the figure never gets smaller. That is what
makes it a ladder, and it is enforced by a test rather than left to inspection —
`long_shot` and `full_shot` crop at the same landmark, so only headroom separates them,
and having those two the wrong way round inverted the ladder at its widest end without
anything noticing.

**`long_shot` and `full_shot` crop at the same landmark**, so headroom and footroom are all
that separate them. Until the [setting layer](https://creatoan.github.io/scenet/reference/language.html#setting) existed the gap between them
had to stay modest: with nothing behind the figure, air is just white, and a small figure alone on
a page reads as a full shot with a generous margin rather than as a long shot. Now the air is the
environment — which is what a long shot is *about* — so the gap is a real rung, and a long shot
frames the figure at roughly two thirds the size a full shot does.

## Angle

`angle` selects where the eye-line sits vertically within the panel:

| `angle` | Eye-line | Effect |
|---|---|---|
| `low` | Lower third | Figure looms; viewer looks up |
| `eye_level` | Upper third | Neutral (default) |
| `high` | Upper edge | Figure diminished; viewer looks down |

**Current limitation:** angle shifts the eye-line only. It does not yet apply true perspective
projection or foreshortening, so extreme angles will read as vertical repositioning rather than as a
genuine change of viewpoint. This is a known gap, not an oversight.

<!-- scenet-spec:part=comic-script -->
# Write a panel as a comic script

Comic writers already have a format. It is not YAML, it has been in use for decades, and
asking someone to abandon it in order to try a compiler is a poor trade.

So Scenet reads it.

```
PAGE ONE

PANEL 1
@shot: full_shot
CAPTION: Midnight. The docks.
Alice and Bob face each other on a rainy street corner. She is exasperated.

ALICE
You forgot your umbrella!

BOB
I know.

PANEL 2
@shot: medium_close_up
Closer now. Bob will not meet her eye.

BOB (whisper)
I left it on purpose.

ALICE (shouting)
You what?!
```

Save that as `umbrella.script` and compile it exactly like any other document:

```bash
scenet build umbrella.script --strip
```

## The rules

| Line | Means |
|---|---|
| `PANEL 1` | Starts a new panel. Anything before the first one is an error. |
| `@shot: full_shot` | A directive. `@shot` and `@angle` set the camera; anything else sets a top-level panel key. |
| `ALICE` (all caps, alone) | The next lines are dialogue spoken by `ALICE`. |
| `BOB (whisper)` | Same, with a balloon kind. |
| `CAPTION: Midnight.` | A caption box. The text is on the same line. |
| `CAPTION (monologue): ...` | Same, with a caption kind. |
| Anything else | Prose. Preserved, never interpreted. |
| `PAGE ONE` | Ignored. Pages are not modelled yet. |

The one detail that trips people up: **a speaker cue is recognised by the name being all
caps, not the whole line.** `BOB (whisper)` qualifies, because only `BOB` is tested.

`CAPTION` is checked before speaker cues, because as far as the cue pattern is concerned it is a
perfectly good character name. That is also why the text has to be on the same line: a bare
`CAPTION` line is rejected rather than read as a character about to speak.

One thing the YAML syntax can express and this cannot: a `spoken` caption's `by`, naming the
off-panel speaker. Write that panel in YAML, or leave the speaker unnamed — nothing in the drawn
panel depends on it, since a caption has no tail.

## Prose is never interpreted

"Alice and Bob face each other on a rainy street corner" is not parsed, not
natural-language-processed, and does not affect the output in any way. It is kept because
a script is a document people read, and stripping the description would make the file
worse for its primary audience.

If you want the rain, you have to say so in the panel description — and rain is
[not yet a construct in the language](https://creatoan.github.io/scenet/explanation/status.html).

## Front matter

A script is dialogue and camera direction. It has no way to say who `ALICE` *is*, which
puppet she uses, or where she stands. That comes from a YAML preamble between `---`
fences:

```
---
panel:
  size: [900, 700]
cast:
  ALICE: {reference: alice, pose: pointing,     at: left_third}
  BOB:   {reference: bob,   pose: arms_crossed, at: right_third, facing: left}
staging:
  - ALICE left_of BOB
  - ALICE looking_at BOB
  - ALICE ground_shared_with BOB
---

PAGE ONE

PANEL 1
...
```

Everything in the front matter is a default every panel inherits — exactly the same
mechanism as [shared defaults in a sequence](https://creatoan.github.io/scenet/howto/compile_a_sequence.html#shared-defaults).

Note the actor ids are written in caps here, to match the speaker cues. That is a
convention, not a requirement; the ids simply have to agree.

## From Python

```python
from scenet import parse_script

panels = parse_script("""---
cast:
  ALICE: {reference: alice}
---

PANEL 1
@shot: close_up
A quiet room.

ALICE
Is anyone there?

PANEL 2
ALICE (whisper)
Anyone?
""")

# Panels are named by the number in their heading.
assert list(panels) == ["1", "2"]
assert panels["1"].camera.shot.value == "close_up"
assert panels["1"].script[0].text == "Is anyone there?"

# The prose line is preserved in the source and interpreted nowhere.
assert panels["2"].script[0].kind.value == "whisper"
```

Panel names come straight from the heading, so `PANEL 1` becomes `"1"`. The CLI uses
them as filename suffixes: `umbrella.script` compiles to `umbrella.1.svg`,
`umbrella.2.svg`.

{func}`parse_script <scenet.frontends.script_front.parse_script>` gives you IR;
{func}`load_script <scenet.frontends.script_front.load_script>` reads a file; and
{func}`compile_document <scenet.pipeline.compile_document>` dispatches on the `.script`
extension so you do not have to care.

## The front matter is not optional in practice

A script with no cast will not parse at all. Validation is total, so a speaker cue naming
somebody who is not in the cast is an error at parse time rather than a blank balloon
discovered later:

```python
from scenet import ScriptSyntaxError, parse_script

try:
    parse_script("PANEL 1" + chr(10) + "ALICE" + chr(10) + "Hello.")
except ScriptSyntaxError as exc:
    assert "unknown actor" in str(exc)
    assert "ALICE" in str(exc)
```

That is the first error you will hit when adapting an existing script: every speaker cue
needs a matching entry in the front-matter cast.

## Both frontends produce the same IR

This is the point of the whole arrangement. `script_front` computes no coordinates and
knows nothing about geometry; it produces the same
{class}`PanelIR <scenet.ir.PanelIR>` the YAML frontend does, and everything downstream is
unaware that a second syntax exists.

Adding a third syntax means adding a parser and one line in the extension table. Nothing
else changes.

<!-- scenet-spec:part=characters -->
# Characters

The puppets shipped with Scenet. A cast member's `reference` names one of these, and its `pose` and `expression` must be names that puppet declares. `pose` defaults to `standing_neutral` and `expression` to `neutral`.

| Puppet | Heads tall | Poses | Expressions |
|---|---|---|---|
| `alice` | 7.5 | `arms_crossed`, `hands_on_hips`, `pointing`, `standing_neutral` | `angry`, `bored`, `coy`, `happy`, `laughing`, `neutral`, `sad`, `scared`, `shouting`, `surprise` |
| `bob` | 7.5 | `arms_crossed`, `hands_on_hips`, `pointing`, `standing_neutral` | `angry`, `bored`, `coy`, `happy`, `laughing`, `neutral`, `sad`, `scared`, `shouting`, `surprise` |

One puppet may appear several times in a panel under different actor ids. Your own puppets are described in [the asset contract](https://creatoan.github.io/scenet/reference/asset_contract.html).

<!-- scenet-spec:part=diagnostics -->
# Diagnostics

Every rule `scenet check` can report. Each finding carries one of these ids, a message naming the offending construct, and a line and column. The ids are stable across releases.

### `scenet/balloon-placement`

A balloon or caption has no legal position. Every candidate position covered a face, left the panel, overlapped a box already placed, or would have broken reading order.

**Fix:** Widen the panel, shorten the line, or split it across two panels.

### `scenet/composition`

An `over:` chain cannot be resolved. A panel inherits from one that does not exist, or the chain is cyclic and so has no fixed point to resolve to.

**Fix:** Check the panel name in `over:` and that the chain terminates.

### `scenet/conflicting-setting`

A setting names a place and lists masses. A place *is* a mass list -- the preset expands into exactly what an author could have written -- so naming one and listing the other asks two questions at once, and the compiler will not guess which was meant.

**Fix:** Keep the place, or keep the masses. docs/reference/language.md prints what each place expands into.

### `scenet/internal`

The compiler failed in a way it does not have a rule for. A Scenet error reached the checker without a more specific rule. Worth reporting: either the document found something genuinely new, or a rule is missing from the catalogue.

**Fix:** Please open an issue with the document that produced it.

### `scenet/invalid-field`

A field has the wrong shape or value. The key is known but what it holds is not what the language accepts there -- a string where a number belongs, or a value outside the allowed set.

**Fix:** Check the field's type and permitted values in docs/reference/language.md.

### `scenet/layout`

No layout satisfies the panel's constraints. Nothing is misspelled and nothing is missing, but the panel as described has no solution -- most often two actors each required to be left of the other. Panel bounds are deliberately not required, so a merely crowded panel is not this.

**Fix:** Relax or remove a conflicting staging relation.

### `scenet/missing-field`

A required field is missing. The value is required and was not supplied.

**Fix:** Add the field. `scenet schema` prints the full shape the compiler accepts.

### `scenet/not-a-mapping`

The document is not a mapping. A panel document is a mapping of top-level keys -- panel, camera, cast, staging, script. A list or a bare scalar cannot be one.

**Fix:** Wrap the content in top-level keys, or check you meant to compile this file.

### `scenet/ordering-cycle`

Horizontal ordering contains a cycle. `left_of` and `right_of` are resolved into a linear order before the solver runs, because Cassowary is a linear solver and cannot express the disjunction 'A left of B or B left of A'. A cycle has no linear order and so no solution.

**Fix:** Remove one of the relations in the cycle; the message names an actor on it.

### `scenet/panel-geometry`

The panel has no usable area. Panel dimensions must be positive, and the margins must leave something between them. A panel with no interior has nothing to compose in.

**Fix:** Give the panel a positive size, and a margin smaller than half its shorter side.

### `scenet/reflexive-relation`

A relation relates an actor to itself. No predicate in the language means anything reflexively, so an actor placed left of itself is always a typo for a second actor's id.

**Fix:** Name a different actor as the object of the relation.

### `scenet/syntax`

The document is not valid YAML. The file could not be parsed at all, so nothing further could be checked. Usually an unclosed bracket or quote, or a line indented inconsistently with the ones around it.

**Fix:** Fix the reported position; YAML errors cascade, so re-check after each fix.

### `scenet/unknown-actor`

Reference to an actor that is not in the cast. Every actor id named in `staging` or `script` must exist in `cast`. This is the check no JSON Schema can perform -- the value is a perfectly good string, it just does not resolve -- and it is among the most common faults in generated documents.

**Fix:** Correct the id, or add the actor to `cast`. The message lists the cast.

### `scenet/unknown-expression`

Reference to an expression the character does not have. A cast member's `expression` names one its puppet does not declare. The counterpart of `unknown-pose`: an expression is selected by name exactly as a pose is.

**Fix:** Check the name against the puppet's declared expressions.

### `scenet/unknown-key`

Unknown key. Validation is strict everywhere: unknown keys are rejected rather than ignored. A misspelled key that was silently dropped would produce a panel that is subtly wrong with no indication of why, which for a language meant to be precise is the worst possible failure.

**Fix:** Check the spelling against docs/reference/language.md.

### `scenet/unknown-place`

Reference to a place the library does not have. `setting.place` names a preset that does not exist. The counterpart of `unknown-pose`: the key is a real field and the value is a perfectly good string, it just does not resolve to a place the compiler can expand.

**Fix:** Check the name against the list in docs/reference/language.md.

### `scenet/unknown-pose`

Reference to a pose the character does not have. A cast member's `pose` names one its puppet does not declare. The key is spelled correctly -- `pose` is a real field -- but the value does not resolve to anything the referenced character can do.

**Fix:** Check the name against the puppet's declared poses.

### `scenet/unknown-puppet`

Reference to a character the library does not contain. A cast member's `reference` names a puppet that is not installed.

**Fix:** Check the name against the shipped library, or supply your own.

<!-- scenet-spec:part=schema -->
# JSON Schema

The schema for a single-panel YAML document, generated from the compiler's own models. It describes the syntax people write, including staging sentences such as `alice left_of bob`. The multi-panel scene schema is at <https://creatoan.github.io/scenet/schemas/scene.schema.json>.

Conforming to it is necessary, not sufficient: see the note on schema validity in the preamble.

```json
{
  "$defs": {
    "AnchorX": {
      "description": "Where along the panel width an actor would like to stand.\n\nHorizontal only. Actors stand on a ground line, so their vertical position is\nderived from the camera rather than requested -- which is why this has no vertical\ncounterpart and :class:`PlacementZone <scenet.ir.PlacementZone>`, used for balloons, does.\n\nThese are *weak* preferences. Non-overlap and declared left-to-right ordering are\nrequired constraints and will override an anchor without complaint; two actors both\nasking for `center` will simply be pushed apart around it.",
      "enum": [
        "left_edge",
        "left_third",
        "center",
        "right_third",
        "right_edge"
      ],
      "title": "AnchorX",
      "type": "string"
    },
    "BalloonKind": {
      "description": "What kind of balloon carries a line, which is how it gets drawn.\n\nThe kind changes the outline and the tail, never the placement: a whisper is\nsubject to exactly the same face-avoidance and reading-order rules as a shout.\n\n| Kind | Outline | Tail |\n|---|---|---|\n| `speech` | plain ellipse | tapered pointer |\n| `thought` | scalloped cloud | trail of bubbles |\n| `whisper` | dashed ellipse | tapered pointer |\n| `shout` | jagged burst | tapered pointer |",
      "enum": [
        "speech",
        "thought",
        "whisper",
        "shout"
      ],
      "title": "BalloonKind",
      "type": "string"
    },
    "CameraAngle": {
      "description": "The camera's height relative to the subject.\n\nAffects headroom rather than perspective: this is a flat, orthographic compiler, so\na tilted camera does not foreshorten anything. What it changes is how much air sits\nabove the head -- which is the compositional cue readers actually take from an\nangle, and one that survives being drawn flat.\n\nA **low** camera looks up and the subject looms, so the head rides high in the frame\nwith little space above it. A **high** camera looks down, so the head sits lower and\nmore space opens up above. See\n:func:`headroom_for <scenet.solve.camera.headroom_for>` for the exact factors.",
      "enum": [
        "low",
        "eye_level",
        "high"
      ],
      "title": "CameraAngle",
      "type": "string"
    },
    "CameraSpec": {
      "additionalProperties": false,
      "description": "How the panel is framed.\n\nAttributes:\n    shot: Requested framing; an upper bound on tightness, see\n        :class:`ShotType <scenet.ir.ShotType>`.\n    angle: Camera height, see :class:`CameraAngle <scenet.ir.CameraAngle>`.\n\nThere is exactly **one camera per panel**, and every actor is drawn at the scale it\nimplies. Scaling each actor to its own crop landmark instead would make everybody\nthe same apparent height and erase the body differences a comic uses to tell\ncharacters apart.",
      "properties": {
        "angle": {
          "$ref": "#/$defs/CameraAngle",
          "default": "eye_level"
        },
        "shot": {
          "$ref": "#/$defs/ShotType",
          "default": "medium_shot"
        }
      },
      "title": "CameraSpec",
      "type": "object"
    },
    "CaptionEvent": {
      "additionalProperties": false,
      "description": "One caption box: the panel speaking in its own voice.\n\nA caption is what lets a panel say where and when it happens without a character\nhaving to explain it out loud. `MIDNIGHT. THE DOCKS.` in the corner does the work\nof an establishing shot with no artwork at all, which is how comics established\nplace long before they had reliable backgrounds.\n\nAttributes:\n    verb: Always `caption`, written as `- caption: {...}`.\n    text: What the box says. As with dialogue, line breaking is computed.\n    kind: What the box is doing, which decides how it is set.\n    tone: What the box is filled with. Defaults to `paper`, the white every caption\n        has been since captions shipped, so no existing panel moves.\n    prefer: Where it would like to sit. Defaults to `top_left`, which is where a\n        `locale` caption conventionally goes.\n    by: Who is speaking, for a `spoken` caption only.\n\n**A caption is not a fifth balloon kind.** It has no speaker to point at and no\ntail, and :attr:`CoreBalloon.tail <scenet.core.CoreBalloon.tail>` is required -- a\nfifth kind would mean inventing a speaker and leaving a field dead.\n\n`by` is the one place where the rule that every actor id resolves does not hold,\nand deliberately: an off-panel speaker is not in the panel, so requiring them to be\nin the cast would defeat the point of saying they are off panel.\n\nExample:\n    >>> from scenet.ir import CaptionEvent\n    >>> CaptionEvent(text=\"Midnight. The docks.\").kind.value\n    'locale'",
      "properties": {
        "by": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "By"
        },
        "kind": {
          "$ref": "#/$defs/CaptionKind",
          "default": "locale"
        },
        "prefer": {
          "$ref": "#/$defs/PlacementZone",
          "default": "top_left"
        },
        "text": {
          "minLength": 1,
          "title": "Text",
          "type": "string"
        },
        "tone": {
          "$ref": "#/$defs/CaptionTone",
          "default": "paper"
        }
      },
      "required": [
        "text"
      ],
      "title": "CaptionEvent",
      "type": "object"
    },
    "CaptionKind": {
      "description": "What a caption box is doing, which is how it gets set.\n\nThese four are the letterers' own vocabulary, taken from Blambot's *Comic Book\nGrammar & Tradition* rather than invented -- for the same reason the predicates\nwere taken from Visual Genome. Note that \"narration\", the obvious guess, is not\namong them.\n\n| Kind | What it is | How it is set |\n|---|---|---|\n| `locale` | Location and time -- \"Midnight. The docks.\" | Italic |\n| `monologue` | A character's inner voice | Italic |\n| `spoken` | Off-panel dialogue | Roman, in quotation marks |\n| `editorial` | The voice of the writer or editor | Italic |\n\n`monologue` has largely replaced the thought balloon in modern comics, so a panel\nhas two ways to render an inner voice: this and\n:attr:`BalloonKind.THOUGHT <scenet.ir.BalloonKind>`. Both are correct. They are\ndifferent eras of the same convention, not a duplication.",
      "enum": [
        "locale",
        "monologue",
        "spoken",
        "editorial"
      ],
      "title": "CaptionKind",
      "type": "string"
    },
    "CaptionTone": {
      "description": "The value a caption box is filled with.\n\nA caption box is opaque, so its lettering is never at risk: the text sits on the\nfill whatever is behind it. What a tone changes is whether the *box* reads. On a\nclear day the atmosphere is `#eeeeee` and a white box on it is 1.16:1 -- legible,\nand invisible. The failing case is the pale end, not the dark one.\n\n| Tone | Fill | Where it comes from | Lettered in |\n|---|---|---|---|\n| `paper` | `#ffffff` | the paper the panel is printed on | ink |\n| `pale` | `#adadad` | the `day` row of the value ladder, far plane | ink |\n| `ink` | `#090909` | the `day` row of the value ladder, foreground | paper |\n\n**Drawn from the ladder in `solve/backdrop.py`, not from a second palette.** Two of\nthe three are rungs of it, taken by index rather than restated, which is what keeps\nlettering and backdrop from drifting apart as either is tuned. The `day` row is the\nladder at its widest, so tones taken from it span the most ground.\n\n**A tone is fixed, not a function of the panel's hour.** A caption's value is a\nproperty of the caption; letting it drift with `time` would make the contrast table\na function of the panel and the legibility floor unenforceable.\n\n`ink` produces what letterers call reversed type: the lettering inverts to paper,\nbecause black type on a near-black box is not lettering, it is a filled rectangle.\nWhich mark reads is resolved by the solver -- see\n:attr:`CoreCaption.ink <scenet.core.CoreCaption>` -- exactly as falling rain's is.\n\nThere is no free-form colour here, and no yellow. A `fill:` taking any string would\nbe the language's one open vocabulary and would let an author produce an unreadable\nbox; the classic yellow `locale` caption would be the first non-neutral value in the\ncodebase, and the language has no colour policy yet to put it under.",
      "enum": [
        "paper",
        "pale",
        "ink"
      ],
      "title": "CaptionTone",
      "type": "string"
    },
    "CastMember": {
      "additionalProperties": false,
      "description": "One character present in the panel.\n\nAttributes:\n    reference: Name of a puppet in the library. This is what gets drawn; the key\n        this member is filed under in `cast` is the actor id used everywhere else.\n    pose: Named pose from that puppet's declared set.\n    expression: Named expression from that puppet's declared set. Selected by name\n        exactly as a pose is, because a face is the same kind of thing as a body:\n        a small closed set of arrangements the character can be in.\n    marks: Emanata drawn around the character -- sweat, dizziness, swearing, a\n        hasty exit. A list, because they compose with each other and with the\n        expression. Kept sorted, so the order they were written in never\n        changes the output.\n    at: Preferred horizontal anchor.\n    facing: Which way the figure is turned.\n\nThe split between actor id and `reference` is what lets one puppet appear twice in\na panel as two different people:\n\n    cast:\n      guard_left:  {reference: bob, pose: arms_crossed}\n      guard_right: {reference: bob, pose: standing_neutral, facing: left}",
      "properties": {
        "at": {
          "$ref": "#/$defs/AnchorX",
          "default": "center"
        },
        "expression": {
          "default": "neutral",
          "title": "Expression",
          "type": "string"
        },
        "facing": {
          "$ref": "#/$defs/Facing",
          "default": "right"
        },
        "marks": {
          "default": [],
          "items": {
            "$ref": "#/$defs/Mark"
          },
          "title": "Marks",
          "type": "array"
        },
        "pose": {
          "default": "standing_neutral",
          "title": "Pose",
          "type": "string"
        },
        "reference": {
          "title": "Reference",
          "type": "string"
        }
      },
      "required": [
        "reference"
      ],
      "title": "CastMember",
      "type": "object"
    },
    "Facing": {
      "description": "Which way an actor is turned.\n\nMirroring the whole puppet, gaze vector included. Defaults to `right`, so a cast\nwritten left to right ends up looking into the panel rather than out of it.",
      "enum": [
        "left",
        "right"
      ],
      "title": "Facing",
      "type": "string"
    },
    "Horizon": {
      "description": "Where the ground meets whatever is behind it.\n\nOne line for the whole panel, which every mass is composed against: masses of the\nground sort start at it and run down, masses that stand in the world rise from it.\nNamed rather than given as a number for the same reason `at:` is -- the author is\nsaying how the panel is composed, not typing a coordinate.",
      "enum": [
        "high",
        "mid",
        "low"
      ],
      "title": "Horizon",
      "type": "string"
    },
    "Mark": {
      "description": "Something drawn around a character, rather than on them, to say how they are.\n\nThe vocabulary is Mort Walker's, from *The Lexicon of Comicana* (1980), which grew\nout of his 1964 National Cartoonists Society piece \"Let's Get Down to Grawlixes\".\nThe book is tongue-in-cheek, but the terms entered real use, and they are comics'\nown names for comics' own conventions. So the set is closed and citable, and nothing\nin it is invented here.\n\n| Mark | What it is | What it says |\n|---|---|---|\n| `plewds` | Droplets flying off the head | sweating: effort, heat, nerves |\n| `squeans` | Little starbursts and circles over the head | dizzy, drunk, or sick |\n| `grawlixes` | Symbols over the head standing in for words | swearing |\n| `briffits` | A dust cloud left at the feet | gone, fast |\n\n\"Emanata\" is Walker's general term for these, which is why it names the module that\ndraws them and the Core field that holds them.\n\nA mark is not an expression. A character can be angry *and* sweating, so marks are\na list that composes with `expression:` rather than a second one. Grawlixes are\ndrawn as symbols -- a jarn (spiral), a nittle (bursting star), a bolt and a hash --\nrather than typed: an oath written as `@#$%!` already works, as dialogue.\n\nAll four are drawn **outside** the head circle, in the space balloons are placed\nin. They never move a character. A balloon prefers not to cover them, and covers\nthem anyway rather than fail when a panel is too crowded to oblige.",
      "enum": [
        "plewds",
        "squeans",
        "grawlixes",
        "briffits"
      ],
      "title": "Mark",
      "type": "string"
    },
    "Mass": {
      "additionalProperties": false,
      "description": "One tonal mass in the backdrop: what it is, how far back, how wide.\n\nAttributes:\n    kind: What the mass is made of, which decides its silhouette.\n    plane: How far back it sits, which decides its value and its draw order.\n    spans: How much of the panel's width it covers.\n\n**Backdrops are never author-drawn**, and there are two reasons. The structural one:\ncrisp architecture needs a vanishing point, and this is deliberately a flat,\northographic compiler, so drawn buildings would fight the compiler's own model.\nSoft tonal masses have no perspective to get wrong.\n\nThe second is that this is how comics actually establish place. Notan -- the\nJapanese light/dark mass principle, which reached Western art teaching through\nArthur Wesley Dow's *Composition* (1899) -- says place is read from the arrangement\nof masses rather than from rendered detail.\n\nExample:\n    >>> from scenet.ir import Mass, MassKind\n    >>> Mass(kind=MassKind.SKY).plane.value\n    'mid'",
      "properties": {
        "kind": {
          "$ref": "#/$defs/MassKind"
        },
        "plane": {
          "$ref": "#/$defs/Plane",
          "default": "mid"
        },
        "spans": {
          "$ref": "#/$defs/Spans",
          "default": "full"
        }
      },
      "required": [
        "kind"
      ],
      "title": "Mass",
      "type": "object"
    },
    "MassKind": {
      "description": "What a tonal mass in the backdrop is made of.\n\nSubsetted from the **supercategories** of\n[COCO-Stuff](https://arxiv.org/pdf/1612.03716), the canonical taxonomy of *stuff* --\n\"amorphous background regions\" as opposed to *things* with a well-defined shape.\nIts own argument is that stuff classes explain scene type and the geometric\nproperties of a scene, which is exactly the job here. Taken from an existing\nvocabulary for the same reason the predicates were taken from Visual Genome.\n\n**Deliberately not the leaf names.** COCO-Stuff's actual classes are\n`building-other`, `sky-other`, `wall-brick`, `water-other` and so on, where the\n`-other` suffix marks the catch-all inside a supercategory. `building-other` is not\na word anyone should have to type.\n\nSeven are outdoor -- `building`, `ground`, `plant`, `sky`, `solid`, `structural`,\n`water` -- and five indoor: `ceiling`, `floor`, `furniture`, `wall`, `window`.\nCOCO-Stuff's own indoor/outdoor split is where that distinction comes from, so it\ndid not have to be invented either. Its `textile`, `food` and `rawmaterial`\nsupercategories are left out: drapery and objects, not scene-defining masses.\n\nA kind decides the **shape** a mass takes, never its value. Value comes from the\nplane, which is what keeps the notan reading honest -- see\n:class:`Plane <scenet.ir.Plane>`.",
      "enum": [
        "building",
        "ceiling",
        "floor",
        "furniture",
        "ground",
        "plant",
        "sky",
        "solid",
        "structural",
        "wall",
        "water",
        "window"
      ],
      "title": "MassKind",
      "type": "string"
    },
    "PanelSpec": {
      "additionalProperties": false,
      "description": "The panel's own dimensions.\n\nAttributes:\n    size: `(width, height)` in panel units. Everything else in the language is\n        expressed relative to these, so they set what a unit means.\n    margin: Inset on all four sides. Balloons are kept inside it; actors may bleed\n        past it, which is ordinary comics practice.\n\nExample:\n    >>> from scenet import PanelSpec\n    >>> PanelSpec(size=(1200.0, 600.0)).width\n    1200.0",
      "properties": {
        "margin": {
          "default": 0.0,
          "minimum": 0.0,
          "title": "Margin",
          "type": "number"
        },
        "size": {
          "default": [
            1000.0,
            1000.0
          ],
          "maxItems": 2,
          "minItems": 2,
          "prefixItems": [
            {
              "type": "number"
            },
            {
              "type": "number"
            }
          ],
          "title": "Size",
          "type": "array"
        }
      },
      "title": "PanelSpec",
      "type": "object"
    },
    "Place": {
      "description": "A named setting, expanded into masses by the frontend.\n\nTen to start with, chosen to span the distinctions that change how a backdrop is\nbuilt rather than to be a catalogue: exterior and interior, built and natural, open\nand enclosed. `alley` is the one with a foreground mass, which is what makes it\nread as a place you are standing *in* rather than looking at.\n\nExample:\n    >>> from scenet.places import PLACES, Place\n    >>> [mass.kind.value for mass in PLACES[Place.SHORE]]\n    ['sky', 'water', 'ground']",
      "enum": [
        "alley",
        "desert",
        "docks",
        "field",
        "forest",
        "mountain",
        "office",
        "room",
        "shore",
        "street"
      ],
      "title": "Place",
      "type": "string"
    },
    "PlacementZone": {
      "description": "Where in the panel a balloon would prefer to sit.\n\nTwo-dimensional, unlike `AnchorX`: an actor is placed along the ground line and\nso only needs a horizontal anchor, whereas a balloon floats and needs both axes.\nThese are hints of the weakest priority -- occlusion and reading order override\nthem freely.",
      "enum": [
        "top_left",
        "top_center",
        "top_right",
        "middle_left",
        "middle_center",
        "middle_right",
        "bottom_left",
        "bottom_center",
        "bottom_right"
      ],
      "title": "PlacementZone",
      "type": "string"
    },
    "Plane": {
      "description": "How far back a mass sits, which decides both its draw order and its value.\n\nFour planes, ordered from the back of the panel forward. They map onto the existing\ninteger :attr:`CoreActor.depth <scenet.core.CoreActor.depth>` painter's order rather\nthan introducing a second ordering mechanism: the three backdrop planes take\nnegative depths, and `foreground` takes one above the frontmost actor, so a\nforeground mass draws over the cast the way a silhouetted doorway does.\n\nValue follows from the plane and from nothing else, which is what makes the\n[aerial perspective](https://en.wikipedia.org/wiki/Aerial_perspective) rule\nparametric: with distance, contrast drops toward the atmosphere. Reading front to\nback, a mass never gets darker. See\n:func:`tone_for <scenet.solve.backdrop.tone_for>`.",
      "enum": [
        "foreground",
        "near",
        "mid",
        "far"
      ],
      "title": "Plane",
      "type": "string"
    },
    "Predicate": {
      "description": "How one actor stands in relation to another.\n\nDrawn from the spatial subset of the Visual Genome vocabulary rather than invented,\nso a scene stays convertible to and from the scene-graph representations used\nelsewhere in computer vision.\n\n`left_of` and `right_of` are the load-bearing ones: they are resolved at parse time\ninto a linear ordering, because Cassowary cannot express the disjunction \"A left of\nB *or* B left of A\".",
      "enum": [
        "left_of",
        "right_of",
        "in_front_of",
        "behind",
        "looking_at",
        "ground_shared_with"
      ],
      "title": "Predicate",
      "type": "string"
    },
    "Relation": {
      "anyOf": [
        {
          "description": "A staging sentence, `subject predicate object` -- for example `alice left_of bob`. Predicates: left_of, right_of, in_front_of, behind, looking_at, ground_shared_with.",
          "pattern": "^\\s*\\S+\\s+(left_of|right_of|in_front_of|behind|looking_at|ground_shared_with)\\s+\\S+\\s*$",
          "patternErrorMessage": "Write 'subject predicate object', where the predicate is one of: left_of, right_of, in_front_of, behind, looking_at, ground_shared_with",
          "type": "string"
        },
        {
          "additionalProperties": false,
          "properties": {
            "object": {
              "title": "Object",
              "type": "string"
            },
            "predicate": {
              "$ref": "#/$defs/Predicate"
            },
            "subject": {
              "title": "Subject",
              "type": "string"
            }
          },
          "required": [
            "subject",
            "predicate",
            "object"
          ],
          "type": "object"
        }
      ],
      "description": "One staging fact, written as a sentence.\n\nAttributes:\n    subject: Actor id the sentence is about.\n    predicate: What relation holds.\n    object: The other actor id.\n\nAuthored as `alice left_of bob` rather than a three-key mapping because staging is\nread far more often than it is written, and a sentence is legible at a glance.\n\nRaises:\n    pydantic.ValidationError: The subject and object are the same actor. No\n        predicate here is meaningful reflexively.",
      "title": "Relation"
    },
    "SayEvent": {
      "additionalProperties": false,
      "description": "One line of dialogue.\n\nAttributes:\n    verb: Always `say`. The tag the surface syntax writes as `- say: {...}`,\n        carried into the model so that a script entry knows which sort of event it\n        is without the frontend having to remember.\n    by: Actor id of the speaker; must be in the cast.\n    text: What is said. Line breaking is the compiler's job, so write it as one\n        string and do not insert newlines yourself.\n    prefer: Optional hint about where the balloon should sit. The weakest of all\n        the placement terms -- face avoidance and reading order override it.\n    kind: Which sort of balloon carries it.\n\nScript order **is** reading order, and reading order is a hard constraint. Reorder\nthese and you reorder the panel.",
      "properties": {
        "by": {
          "title": "By",
          "type": "string"
        },
        "kind": {
          "$ref": "#/$defs/BalloonKind",
          "default": "speech"
        },
        "prefer": {
          "anyOf": [
            {
              "$ref": "#/$defs/PlacementZone"
            },
            {
              "type": "null"
            }
          ],
          "default": null
        },
        "text": {
          "minLength": 1,
          "title": "Text",
          "type": "string"
        }
      },
      "required": [
        "by",
        "text"
      ],
      "title": "SayEvent",
      "type": "object"
    },
    "SettingSpec": {
      "additionalProperties": false,
      "description": "Where and when the panel happens, as tonal masses rather than drawn geometry.\n\nAttributes:\n    horizon: Where the ground meets what is behind it.\n    masses: The backdrop, back to front. Written directly, or produced by naming a\n        place in the surface syntax.\n    time: When it happens, which shifts the value ladder.\n    weather: What the air is doing.\n\n**There is no `place` field here, deliberately.** `place: docks` is surface syntax\nthat the frontend expands into exactly the mass list an author could have written\nthemselves -- the same treatment `alice left_of bob` gets, which reaches the IR as a\n:class:`Relation <scenet.ir.Relation>` and never as text. That is what keeps a\npreset a library for convenience rather than a second, opaque format: by the time\nanything downstream sees a backdrop, there is one representation of it.\n\nA panel with no masses and clear weather has no backdrop at all, which is what every\npanel written before this block existed still gets.\n\nExample:\n    >>> from scenet import parse_panel\n    >>> panel = parse_panel(\"setting: {place: docks, time: night}\")\n    >>> panel.setting.time.value, len(panel.setting.masses) > 0\n    ('night', True)",
      "not": {
        "required": [
          "place",
          "masses"
        ]
      },
      "properties": {
        "horizon": {
          "$ref": "#/$defs/Horizon",
          "default": "mid"
        },
        "masses": {
          "default": [],
          "items": {
            "$ref": "#/$defs/Mass"
          },
          "title": "Masses",
          "type": "array"
        },
        "place": {
          "$ref": "#/$defs/Place",
          "description": "A named place, which stands for the list of masses it expands into. Write this or `masses`, not both."
        },
        "time": {
          "$ref": "#/$defs/TimeOfDay",
          "default": "day"
        },
        "weather": {
          "$ref": "#/$defs/Weather",
          "default": "clear"
        }
      },
      "title": "SettingSpec",
      "type": "object"
    },
    "ShotType": {
      "description": "How tightly the camera frames the cast.\n\nOrdered from widest to tightest, and the order is enforced by a test: reading down\nthe ladder, the figure never gets smaller.\n\nA shot type is defined by two things in two different units. The **crop landmark**\nis anatomical -- the waist, the chest, the shoulders -- which is what stops a shot\ntype baking in one body and one pose; naming a fraction of panel height instead\nwould do exactly that. The **headroom** is a plain fraction of panel height, because\nit is about composition within the frame rather than anatomy.\n`docs/reference/shot_types.md` is normative.\n\nThe requested shot is an *upper bound on tightness*, not a promise. If the cast\ncannot fit across the panel at that framing the camera retreats, and says so in\n:attr:`CompileResult.notes <scenet.pipeline.CompileResult.notes>`.\n\nExample:\n    >>> from scenet import ShotType\n    >>> ShotType(\"close_up\")\n    <ShotType.CLOSE_UP: 'close_up'>",
      "enum": [
        "long_shot",
        "wide",
        "full_shot",
        "medium_full",
        "cowboy",
        "medium_shot",
        "medium_close_up",
        "close_up",
        "big_close_up",
        "extreme_close_up"
      ],
      "title": "ShotType",
      "type": "string"
    },
    "Spans": {
      "description": "How much of the panel's width a mass covers.\n\nResolved to an extent in the frontend, and that is **not cosmetic**. `CLAUDE.md`\nrequires any construct that would reintroduce a left/right disjunction to resolve it\nbefore the solver, because Cassowary cannot express \"A left of B *or* B left of A\".\nA span is an absolute extent rather than a relation, which is what stops masses\nbecoming an unordered `beside`.",
      "enum": [
        "full",
        "left",
        "center",
        "right"
      ],
      "title": "Spans",
      "type": "string"
    },
    "TimeOfDay": {
      "description": "When the panel happens, which shifts the whole value ladder.\n\nEach time supplies two numbers -- the value of the foreground and the value of the\natmosphere -- and the planes are spaced evenly between them. So `night` is not a\nblue filter over a daytime panel: it is a darker, more compressed ladder, which is\nwhat night actually does to a drawn scene. The ladder stays monotonic in depth at\nevery time of day, by construction rather than by tuning.",
      "enum": [
        "dawn",
        "day",
        "dusk",
        "night"
      ],
      "title": "TimeOfDay",
      "type": "string"
    },
    "Weather": {
      "description": "What the air is doing between the reader and the panel.\n\n`clouds` and `fog` are first-class *stuff* in COCO-Stuff, so this vocabulary did not\nhave to be invented either. `fog` renders as a turbulence veil over the backdrop;\n`rain` and `snow` add that veil as cloud and put falling marks over everything,\nbecause weather is between the reader and the figures rather than behind them.",
      "enum": [
        "clear",
        "rain",
        "fog",
        "snow"
      ],
      "title": "Weather",
      "type": "string"
    }
  },
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "additionalProperties": false,
  "description": "A complete, validated panel: the language's real definition.\n\nEvery frontend produces one of these and nothing else, which is what lets the YAML\nsyntax and the comic-script syntax coexist without the solver knowing either exists.\n**Nothing here carries a coordinate** -- computing those is the solver's job, and\nkeeping them out is what makes a panel reusable at any size.\n\nAttributes:\n    panel: Dimensions and margin.\n    camera: Framing and angle.\n    cast: Actor id to character. Declaration order is not significant; `staging`\n        decides left-to-right order.\n    staging: Spatial and attentional relations between actors.\n    script: Dialogue and captions, in reading order.\n\nValidation is strict and total: unknown keys are rejected, every actor id mentioned\nin `staging` or `script` must exist in `cast`, and the ordering relations must not\ncontain a cycle. A misspelled key that was silently ignored would produce a panel\nthat is subtly wrong with no indication of why, which for a language meant to be\nprecise is the worst possible failure.\n\nExample:\n    >>> from scenet import parse_panel\n    >>> panel = parse_panel(\"panel: {size: [800.0, 600.0]}\")\n    >>> panel.panel.width, panel.camera.shot.value\n    (800.0, 'medium_shot')\n\nSee Also:\n    :func:`compile_ir <scenet.pipeline.compile_ir>`, to turn one of these into geometry.",
  "properties": {
    "camera": {
      "$ref": "#/$defs/CameraSpec",
      "default": {
        "angle": "eye_level",
        "shot": "medium_shot"
      }
    },
    "cast": {
      "additionalProperties": {
        "$ref": "#/$defs/CastMember"
      },
      "title": "Cast",
      "type": "object"
    },
    "panel": {
      "$ref": "#/$defs/PanelSpec",
      "default": {
        "margin": 0.0,
        "size": [
          1000.0,
          1000.0
        ]
      }
    },
    "script": {
      "default": [],
      "items": {
        "anyOf": [
          {
            "additionalProperties": false,
            "properties": {
              "say": {
                "$ref": "#/$defs/SayEvent"
              }
            },
            "required": [
              "say"
            ],
            "type": "object"
          },
          {
            "additionalProperties": false,
            "properties": {
              "caption": {
                "$ref": "#/$defs/CaptionEvent"
              }
            },
            "required": [
              "caption"
            ],
            "type": "object"
          }
        ]
      },
      "title": "Script",
      "type": "array"
    },
    "setting": {
      "$ref": "#/$defs/SettingSpec",
      "default": {
        "horizon": "mid",
        "masses": [],
        "time": "day",
        "weather": "clear"
      }
    },
    "staging": {
      "default": [],
      "items": {
        "$ref": "#/$defs/Relation"
      },
      "title": "Staging",
      "type": "array"
    }
  },
  "title": "Scenet panel",
  "type": "object"
}
```

<!-- scenet-spec:part=gallery -->
# Gallery

Every example the playground offers, in the order it offers them. The test suite compiles each one, so all of them are known to work.

## Two characters

`01-two-characters.panel.yaml`

```yaml
# The panel from the README. Two actors, an ordering constraint, a gaze, a shared
# ground line, and two lines of dialogue.

panel:
  size: [1000, 800]

camera:
  shot: medium_shot

cast:
  alice: {reference: alice, pose: pointing,     at: left_third}
  bob:   {reference: bob,   pose: arms_crossed, at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice looking_at bob
  - alice ground_shared_with bob

script:
  - say: {by: alice, text: "You forgot your umbrella!", prefer: top_left}
  - say: {by: bob,   text: "I know."}
```

## The shot ladder

`02-shot-ladder.scene.yaml`

```yaml
# Every shot type, on the same character, from widest to tightest.
#
# A shot type is a crop landmark plus headroom, measured in HEAD-HEIGHTS -- not as a
# fraction of panel height. Watch where the frame cuts the body as you go down: feet,
# feet, knees, mid-thigh, waist, chest, shoulders, chin, eyes.
#
# `wide` and `long_shot` are DELIBERATELY IDENTICAL. They are synonyms -- the literature
# uses them interchangeably -- and the language keeps both because writers reach for
# both. The two panels look the same because they are the same shot, not because
# something is broken.
#
# `medium_full` and `cowboy` are NOT synonyms, though they were briefly implemented as
# though they were. Medium full cuts at the knees; the cowboy shot cuts at mid-thigh,
# a framing that comes from 1930s Westerns needing the holster in frame.
#
# The two widest shots leave ground beneath the feet. That is footroom, and it is not
# decoration: the crop lands the FEET landmark on the frame edge, but the shin is drawn
# as a round-capped stroke that continues past it, so without footroom a long shot
# clipped the feet off.

panel: {size: [420, 560]}

cast:
  alice: {reference: alice, pose: pointing}

panels:
  long_shot:        {camera: {shot: long_shot}}
  wide:             {camera: {shot: wide}}          # same as long_shot, on purpose
  full_shot:        {camera: {shot: full_shot}}
  medium_full:      {camera: {shot: medium_full}}   # knees
  cowboy:           {camera: {shot: cowboy}}        # mid-thigh
  medium_shot:      {camera: {shot: medium_shot}}
  medium_close_up:  {camera: {shot: medium_close_up}}
  close_up:         {camera: {shot: close_up}}
  big_close_up:     {camera: {shot: big_close_up}}
  extreme_close_up: {camera: {shot: extreme_close_up}}
```

## Camera angles

`03-camera-angles.scene.yaml`

```yaml
# The three camera heights, at one shot type.
#
# This compiler is orthographic, so an angle cannot foreshorten anything. What it
# changes is HEADROOM -- the air above the head -- which is the cue readers actually
# take from an angle, and one that survives being drawn flat.
#
# A low camera looks up and the subject looms, so headroom tightens. A high camera
# looks down and it opens out.

panel: {size: [500, 620]}
camera: {shot: medium_close_up}

cast:
  bob: {reference: bob, pose: hands_on_hips}

panels:
  low:       {camera: {angle: low}}
  eye_level: {camera: {angle: eye_level}}
  high:      {camera: {angle: high}}
```

## Every balloon kind

`04-balloon-kinds.panel.yaml`

```yaml
# Four kinds of balloon: speech, thought, whisper, shout.
#
# The kind changes the outline and the tail, never the placement. A whisper obeys
# exactly the same face-avoidance and reading-order rules as a shout.

panel:
  size: [1300, 900]

camera:
  shot: medium_full

cast:
  alice: {reference: alice, pose: pointing,     at: left_third}
  bob:   {reference: bob,   pose: arms_crossed, at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice ground_shared_with bob

script:
  - say: {by: alice, text: "Did you take the last one?", kind: whisper, prefer: top_left}
  - say: {by: bob,   text: "I did not!",                 kind: shout,   prefer: top_right}
  - say: {by: alice, text: "Then where is it?",          kind: speech,  prefer: bottom_left}
  - say: {by: bob,   text: "She is going to notice.",    kind: thought, prefer: bottom_right}
```

## Placement zones

`05-placement-zones.panel.yaml`

```yaml
# `prefer:` names one of nine zones -- top/middle/bottom crossed with
# left/center/right.
#
# It is the WEAKEST term in the whole system. Covering a face is illegal, overlapping
# another balloon is illegal, and breaking reading order is illegal; only after all of
# that does a preference get a vote. Change these and watch how often the compiler
# quietly overrules you.

panel:
  size: [1200, 900]

camera:
  shot: full_shot

cast:
  alice: {reference: alice, at: center}

script:
  - say: {by: alice, text: "Top left.",     prefer: top_left}
  - say: {by: alice, text: "Top right.",    prefer: top_right}
  - say: {by: alice, text: "Bottom left.",  prefer: bottom_left}
  - say: {by: alice, text: "Bottom right.", prefer: bottom_right}
```

## Reading order is a hard rule

`06-reading-order.panel.yaml`

```yaml
# Script order IS reading order, and it is enforced rather than preferred.
#
# A balloon may never sit above AND left of one that precedes it -- checked against
# every predecessor, not just the last one, because the relation is not transitive.
#
# Try reordering these four lines. The layout changes, but it never asks you to read
# up and to the left.

panel:
  size: [1200, 800]

camera:
  shot: medium_full

cast:
  alice: {reference: alice, at: left_third}
  bob:   {reference: bob,   at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice ground_shared_with bob

script:
  - say: {by: alice, text: "First."}
  - say: {by: bob,   text: "Second."}
  - say: {by: alice, text: "Third."}
  - say: {by: bob,   text: "Fourth."}
```

## Gaze steers the balloons

`07-gaze.panel.yaml`

```yaml
# `looking_at` moves nothing. It gives a character a gaze vector, and the space in
# front of their eyes becomes expensive for a balloon to occupy -- because a balloon
# parked in somebody line of sight reads as an obstruction.
#
# Delete the `looking_at` lines and compile again. The figures do not move; the
# balloons do.

panel:
  size: [1200, 800]

camera:
  shot: medium_full

cast:
  alice: {reference: alice, pose: pointing,     at: left_third}
  bob:   {reference: bob,   pose: arms_crossed, at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice looking_at bob
  - bob looking_at alice
  - alice ground_shared_with bob

script:
  - say: {by: alice, text: "Look at me when I am talking to you."}
  - say: {by: bob,   text: "I am looking."}
```

## Feet align, heads do not

`08-ground-and-height.panel.yaml`

```yaml
# `ground_shared_with` puts two characters on the same ground line -- their FEET,
# not their heads.
#
# Alice is 7.5 heads tall and Bob is taller, deliberately. Aligning heads would put
# them on a staircase. It also shows why there is one camera per panel and one scale:
# scaling each actor to its own crop landmark would make everybody the same apparent
# height and erase exactly the difference a comic uses to tell people apart.

panel:
  size: [1100, 800]

camera:
  shot: full_shot

cast:
  alice: {reference: alice, at: left_third}
  bob:   {reference: bob,   at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice ground_shared_with bob

script:
  - say: {by: alice, text: "You have grown."}
```

## Depth and overlap

`09-depth.panel.yaml`

```yaml
# Depth, and the two ways to say every ordering.
#
# `in_front_of` and `behind` set painter order, so one figure is drawn over another.
# Non-overlap is a REQUIRED constraint and the solver normally pushes actors apart;
# depth is what lets them share space on purpose.
#
# `left_of` and `right_of` are the same relation written from either end. Both are
# resolved into a single linear ordering at parse time, because Cassowary is a linear
# solver and cannot express the disjunction "A left of B OR B left of A". Any construct
# that reintroduces a disjunction has to be resolved in the frontend, not the solver.

panel:
  size: [1200, 800]

camera:
  shot: medium_shot

cast:
  back:  {reference: alice, pose: hands_on_hips, at: left_third}
  near:  {reference: bob,   pose: arms_crossed,  at: center, facing: left}
  far:   {reference: alice, pose: pointing,      at: right_third}

staging:
  - back left_of near
  - far right_of near
  - near in_front_of far
  - back behind near

script:
  - say: {by: far, text: "Move.", prefer: top_right}
```

## The camera retreats

`10-crowd-pullback.panel.yaml`

```yaml
# Four characters at a close-up cannot fit side by side. A real camera operator
# would step backwards, so that is what happens: everybody gets smaller and more of
# the body comes into view.
#
# The requested shot is an UPPER BOUND ON TIGHTNESS, not a promise -- and the retreat
# is reported rather than done silently, because a panel that is quietly not the shot
# you asked for is a panel you cannot debug. Look at the note under the picture.

panel:
  size: [1400, 700]

camera:
  shot: close_up

cast:
  alice: {reference: alice}
  bob:   {reference: bob,   pose: arms_crossed}
  carol: {reference: alice, pose: hands_on_hips}
  dave:  {reference: bob,   pose: pointing}

staging:
  - alice left_of bob
  - bob left_of carol
  - carol left_of dave
  - alice ground_shared_with bob
  - bob ground_shared_with carol
  - carol ground_shared_with dave

script:
  - say: {by: alice, text: "Is everyone here?"}
  - say: {by: dave,  text: "Looks like it."}
```

## Poses, facing, and one puppet twice

`11-poses.panel.yaml`

```yaml
# A pose is a set of joint angles, not a drawing -- which is what avoids one image
# per pose per expression per facing direction.
#
# All four shipped poses. Note that `a` and `c` use the SAME puppet: the key in `cast`
# is the actor id, `reference` is which character gets drawn, and the two are
# independent. That is what lets one puppet play two parts.

panel:
  size: [1500, 700]

camera:
  shot: full_shot

cast:
  a: {reference: alice, pose: standing_neutral, at: left_edge}
  b: {reference: bob,   pose: arms_crossed,     at: left_third}
  c: {reference: alice, pose: pointing,         at: right_third}
  d: {reference: bob,   pose: hands_on_hips,    at: right_edge, facing: left}

staging:
  - a left_of b
  - b left_of c
  - c left_of d
  - a ground_shared_with b
  - b ground_shared_with c
  - c ground_shared_with d
```

## A sequence, by sparse override

`12-sequence.scene.yaml`

```yaml
# Consecutive panels share nearly everything. `over:` names a parent and states only
# the difference -- borrowed from OpenUSD composition arcs, where it means exactly
# this.
#
# Mappings merge recursively; lists replace wholesale. Anything alongside `panels:` is
# a default every panel inherits.

panel: {size: [700, 560]}

cast:
  alice: {reference: alice, pose: pointing,     at: left_third}
  bob:   {reference: bob,   pose: arms_crossed, at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice ground_shared_with bob

panels:
  establishing:
    camera: {shot: full_shot}
    script:
      - say: {by: alice, text: "We need to talk."}

  reaction:
    over: establishing
    camera: {shot: medium_close_up}
    script:
      - say: {by: bob, text: "Do we.", kind: whisper}

  closer:
    over: reaction
    camera: {shot: close_up, angle: low}
    cast:
      bob: {pose: hands_on_hips}
    script:
      - say: {by: bob, text: "Fine. Talk.", kind: shout}
```

## Written as a comic script

`13-comic-script.script`

```text
---
panel:
  size: [900, 640]
cast:
  ALICE: {reference: alice, pose: pointing,     at: left_third}
  BOB:   {reference: bob,   pose: arms_crossed, at: right_third, facing: left}
staging:
  - ALICE left_of BOB
  - ALICE looking_at BOB
  - ALICE ground_shared_with BOB
---

PAGE ONE

PANEL 1
@shot: full_shot
Alice and Bob face each other on a rainy street corner. She is exasperated.

ALICE
You forgot your umbrella!

BOB
I know.

PANEL 2
@shot: medium_close_up
Closer now. Bob will not meet her eye.

BOB (whisper)
I left it on purpose.

ALICE (shouting)
You what?!
```

## How lines get broken

`14-lettering.panel.yaml`

```yaml
# Never break lines yourself. Where text breaks is decided during compilation,
# measured against the real metrics of the real font, and scored on how close the
# resulting block comes to the shape a letterer would choose.
#
# Two rules the scoring exists to enforce: a phrase that fits on one line is not
# split, even where splitting scores marginally better on aspect ratio; and no line is
# left stranded far shorter than its neighbours.

panel:
  size: [1100, 850]

camera:
  shot: medium_shot

cast:
  alice: {reference: alice, at: left_third}
  bob:   {reference: bob,   at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice ground_shared_with bob

script:
  - say: {by: alice, text: "You forgot your umbrella!", prefer: top_left}
  - say: {by: bob,   text: "I know."}
  - say: {by: alice, text: "It has been raining since six this morning and you knew that."}
```

## Margins, and bleeding off the edge

`15-margin.panel.yaml`

```yaml
# `margin` insets the usable area. Balloons are kept inside it; actors may bleed
# past it, which is ordinary comics practice.
#
# That asymmetry is deliberate. Panel bounds are a STRONG constraint on actors rather
# than a required one, so a crowded panel lets a figure run off the edge instead of
# refusing to compile. Lettering has no such licence: text that leaves the panel is
# text nobody can read.

panel:
  size: [900, 700]
  margin: 60

camera:
  shot: medium_close_up

cast:
  alice: {reference: alice, at: left_edge}
  bob:   {reference: bob,   at: right_edge, facing: left}

staging:
  - alice left_of bob

script:
  - say: {by: alice, text: "Room enough?"}
  - say: {by: bob,   text: "Only just."}
```

## Captions: where and when

`16-captions.panel.yaml`

```yaml
# Four kinds of caption: locale, monologue, spoken and editorial.
#
# A caption is the panel speaking in its own voice, which is how a comic says where and
# when it happens without a character having to explain it out loud. The vocabulary is
# the letterers' own: three of the four are set in italic, and `spoken` -- somebody
# talking from off panel -- takes quotation marks instead.
#
# Captions are placed by the same machinery as balloons, so they never cover a face and
# they take their turn in reading order. What differs is the pull: a balloon mildly
# dislikes the panel edge, and a caption is looking for exactly that corner.

panel:
  size: [1400, 950]

camera:
  shot: medium_full

cast:
  alice: {reference: alice, pose: arms_crossed, at: left_third}
  bob:   {reference: bob,   pose: pointing,     at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice ground_shared_with bob

script:
  - caption: {text: "Midnight. The docks.", kind: locale, prefer: top_left}
  - say: {by: alice, text: "You said he would be here."}
  - caption: {text: "He had said a great many things.", kind: monologue, prefer: middle_right}
  - caption: {text: "Put that down!", kind: spoken, by: harbourmaster, prefer: bottom_left}
  - caption: {text: "Continued next issue.", kind: editorial, prefer: bottom_right}
```

## Ten expressions

`17-expressions.scene.yaml`

```yaml
# Ten expressions, which is every face the shipped puppets can make.
#
# An expression is to features what a pose is to joints: the puppet declares feature
# points that ride the head, declares named expressions as a state per feature, and the
# panel selects one by name. Nothing here touches the solver -- a face is still one disc
# that balloons may not cover, exactly as it was.
#
# The vocabulary is Comic Chat's emotion wheel plus `surprise`, which is to say it is a
# **drawing convention**: the small closed set of faces comics actually draw. It is not
# a claim that a person feeling anger produces this face.
#
# Watch the pupils. `looking_at` has always turned a figure toward its target and made
# the space in front of them expensive for a balloon; now it aims their eyes as well.

camera:
  shot: medium_shot

cast:
  alice: {reference: alice, pose: arms_crossed, at: left_third}
  bob:   {reference: bob,   pose: hands_on_hips, at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice ground_shared_with bob
  - alice looking_at bob
  - bob looking_at alice

panels:
  confrontation:
    cast:
      alice: {expression: angry}
      bob:   {expression: shouting}

  surprised:
    cast:
      alice: {expression: neutral}
      bob:   {expression: surprise}

  delighted:
    cast:
      alice: {expression: happy}
      bob:   {expression: laughing}

  unimpressed:
    cast:
      alice: {expression: coy}
      bob:   {expression: bored}

  afraid:
    cast:
      alice: {expression: sad}
      bob:   {expression: scared}
```

## A face, at last

`18-a-face.panel.yaml`

```yaml
# A big close-up, which is the framing that pays for the face rig: the head fills the
# panel, and until expressions existed it was a circle.
#
# One character on purpose. A big close-up of two is not a big close-up for long -- the
# camera retreats to fit them both across the panel, and the note it prints says so.

panel:
  size: [900, 900]

camera:
  shot: big_close_up

cast:
  alice: {reference: alice, expression: angry}

# No lettering. At this framing the head fills the panel and there is nowhere a balloon
# could sit without covering the face, which the compiler refuses to do -- and a silent
# reaction panel is what a letterer would have drawn here anyway.
```

## Masses, planes and the value ladder

`19-setting.scene.yaml`

```yaml
# Masses, planes and the notan value ladder -- written out by hand.
#
# `place:` is the headline surface, and 20-places shows it. This is the layer
# underneath: the mass list a place expands into, which stays authorable when you want
# control. Every one of the twelve mass kinds appears across these two panels.
#
# Value comes from the *plane*, never from the kind. Reading front to back a mass never
# gets darker, which is aerial perspective as a parametric rule -- and it is why two
# masses at one distance read as one mass, which is the whole notan argument.

panel:
  size: [1000, 620]

camera:
  shot: long_shot

panels:
  # Outdoors: the seven exterior kinds, stacked back to front.
  outside:
    setting:
      horizon: mid
      masses:
        - {kind: sky,        plane: far}
        - {kind: solid,      plane: far,  spans: left}
        - {kind: building,   plane: mid,  spans: right}
        - {kind: water,      plane: mid}
        - {kind: structural, plane: near, spans: right}
        - {kind: plant,      plane: near, spans: left}
        - {kind: ground,     plane: near}
    cast:
      alice: {reference: alice, at: center}
    script:
      - caption: {text: "Seven kinds of stuff, four planes deep.", kind: editorial}

  # Indoors: the five interior kinds. A `window` is toned one plane *farther* than the
  # wall it is cut into, because a window is a hole showing a more distant plane.
  inside:
    setting:
      horizon: mid
      time: dusk
      masses:
        - {kind: wall,      plane: far}
        - {kind: window,    plane: far,  spans: right}
        - {kind: ceiling,   plane: mid}
        - {kind: floor,     plane: near}
        - {kind: furniture, plane: near, spans: left}
    cast:
      bob: {reference: bob, at: right_third, facing: left}
    script:
      - caption: {text: "Indoors, at dusk.", kind: locale}
```

## Ten places, by name

`20-places.scene.yaml`

```yaml
# The place library: all ten, one panel each.
#
# `place:` is the headline surface, because the thing an author wants to write is where
# the scene is, not a list of shapes. Each of these expands into exactly the mass list
# you could have typed yourself -- 19-setting shows that layer -- which is the rule that
# keeps a preset a library rather than a second, opaque format.
#
# Free prose is deliberately not offered. `setting: "a rainy street corner at midnight"`
# needs language understanding, and guessing produces panels that are confidently wrong.
# A named place is the honest middle: it reads like a description and resolves
# deterministically.

panel:
  size: [900, 560]

camera:
  shot: long_shot

cast:
  alice: {reference: alice, at: center}

panels:
  docks:    {setting: {place: docks,    time: night}}
  street:   {setting: {place: street}}
  alley:    {setting: {place: alley,    time: dusk}}
  room:     {setting: {place: room}}
  office:   {setting: {place: office,   horizon: high}}
  forest:   {setting: {place: forest,   time: dawn}}
  field:    {setting: {place: field,    horizon: low}}
  shore:    {setting: {place: shore,    time: dusk}}
  desert:   {setting: {place: desert}}
  mountain: {setting: {place: mountain, time: dawn}}
```

## Time of day, and weather

`21-atmosphere.scene.yaml`

```yaml
# Time of day and weather, on one place.
#
# `time` does not tint a daytime panel. It supplies the two ends of the value ladder --
# the foreground and the atmosphere -- and the planes are spaced between them. So night
# is a darker, narrower ladder, which is what night actually does to a drawn scene, and
# the ladder stays monotonic in depth at every hour.
#
# `weather` adds a layer over that. Fog is a turbulence veil tinted with the atmosphere
# itself, because fog *is* the atmosphere arriving in the foreground. Rain and snow carry
# the same veil as cloud -- nearer, and so darker than the sky it covers -- and put
# falling marks over everything, because weather is between the reader and the figures
# rather than behind them. Rain flips to ink over a bright sky and to paper over a dark
# one; snow never flips, because snow is white.
#
# The determinism contract is on the **emitted SVG text**, which stays byte-identical.
# It has never been on pixels: `feTurbulence` is reproducible by specification -- the
# spec ships reference code and the seed is fixed -- and browsers still agree only
# approximately on what to paint from it. See docs/reference/language.md.

panel:
  size: [1100, 620]

camera:
  shot: long_shot

cast:
  alice: {reference: alice, at: left_third, pose: arms_crossed}
  bob:   {reference: bob,   at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice looking_at bob
  - alice ground_shared_with bob

panels:
  midnight:
    setting: {place: docks, time: night, weather: rain}
    script:
      - caption: {text: "Midnight. The docks.", kind: locale, prefer: top_left}
      - say: {by: alice, text: "You said you would be here at ten."}
      - say: {by: bob, text: "I was.", kind: whisper}

  morning:
    setting: {place: docks, time: dawn, weather: fog}
    script:
      - caption: {text: "Six hours later.", kind: locale, prefer: top_left}

  noon:
    setting: {place: docks, time: day, weather: clear}
    script:
      - caption: {text: "It never rains at noon.", kind: editorial, prefer: top_right}

  winter:
    setting: {place: docks, time: dusk, weather: snow}
    script:
      - caption: {text: "December.", kind: locale, prefer: top_left}
```

## Caption tones, and what they read against

`22-caption-tones.panel.yaml`

```yaml
# Three caption tones, over the background that makes the point.
#
# A caption box is opaque, so its lettering was never at risk: the text sits on the fill
# whatever is behind it. What was at risk is the *box*. On a clear day the atmosphere is
# `#eeeeee`, and the white box that every caption has been since captions shipped is
# 1.16:1 against it -- legible, and invisible. Only the stroke separates it at all.
#
# So the failing case is the pale end, not the dark one, and this panel is that case:
# a noon sky, with the same three boxes tucked into three corners of it. `paper` all but
# vanishes into the sky, `pale` sits on it quietly, and `ink` reads at 17.16:1 -- which
# costs the lettering an inversion, because black type on a near-black box is not
# lettering, it is a filled rectangle.
#
# The values are rungs of the same ladder the backdrop uses, not a second palette.

panel:
  size: [1400, 900]

camera:
  shot: long_shot

setting:
  place: field
  horizon: low
  time: day

cast:
  alice: {reference: alice, pose: standing_neutral, at: center}

script:
  - caption: {text: "Noon. Nothing for miles.", kind: locale, tone: paper, prefer: top_left}
  - caption: {text: "She had walked since dawn.", kind: monologue, tone: pale, prefer: top_right}
  - caption: {text: "Continued next issue.", kind: editorial, tone: ink, prefer: bottom_right}
```

## Emanata: sweat, dizziness, oaths and dust

`23-emanata.scene.yaml`

```yaml
# Emanata: what a comic draws around a character, rather than on them.
#
# Mort Walker named them in The Lexicon of Comicana, and four of his names are the
# vocabulary: `plewds` fly off a sweating head, `squeans` circle a dizzy one,
# `grawlixes` stand in for an oath, and `briffits` are the dust somebody leaves behind.
#
# They are a list, not a second `expression:`, because they compose -- Alice is scared
# *and* sweating, angry *and* swearing.
#
# Unlike a face, they are drawn outside the head, in the space balloons go, so they
# matter to the layout. How much is the decision this example shows: a balloon pays to
# cover them, so in `oath` Alice's line settles beside her grawlixes rather than on
# them. But they never move anybody. Strip every mark from this file and both
# characters stand exactly where they stand now.
#
# That cuts both ways. The camera frames by the body and makes no room for marks, so
# over the taller character's head, at this framing, they would run off the top of the
# panel -- which is why they are on Alice. Try them on Bob: `scenet build` says so.

panel:
  size: [1000, 700]

camera:
  shot: medium_shot

cast:
  alice: {reference: alice, at: left_third}
  bob:   {reference: bob,   pose: arms_crossed, at: right_third, facing: left}

staging:
  - alice left_of bob
  - alice ground_shared_with bob
  - alice looking_at bob
  - bob looking_at alice

panels:
  heat:
    cast:
      alice: {expression: scared, marks: [plewds]}
      bob:   {expression: angry}
    script:
      - say: {by: bob, text: "Where is my umbrella?"}

  oath:
    over: heat
    cast:
      alice: {expression: angry, marks: [grawlixes]}
    script:
      - say: {by: alice, text: "It was hideous!", prefer: top_left}

  dizzy:
    over: heat
    cast:
      alice: {expression: sad, marks: [squeans]}
      bob:   {expression: bored}
    script:
      - say: {by: alice, text: "I spun it round. Very fast. Then it was gone."}

  gone:
    camera: {shot: full_shot}
    cast:
      alice: {expression: surprise}
      bob:   {pose: pointing, expression: shouting, marks: [briffits], facing: right}
    staging:
      - alice left_of bob
      - alice ground_shared_with bob
    script:
      - say: {by: alice, text: "Bob?"}
```
