# OlloVue TV apps — the platform guidelines

A TV app on OlloVue is a scene: one HTML fragment that plays full-screen (or in a tile) on a
venue's screens. Apps are built in **Vibe Studio** by people talking to a coding agent, or by
developers in the portal. Either way the app is written once and plays in **any venue** — the
venue's own name, logo, colours, menu and events flow into it at play time. This document is
the contract: what an app may rely on, what it must not do, and what review looks for.

The house look is **Marquee** (SCENE-STYLE.md). This file is about the platform; that one is
about taste. Both apply.

<!-- agent:start -->
## 1. The shell an app lands in

Your fragment is dropped inside `<body>` of a shell that already provides:

- **Ink and type.** `background: var(--brand-ink)`, white text, box-sizing reset, the system
  font stack. A display face is available as `var(--brand-display)` for headlines.
- **Brand variables**, painted from the venue the screen belongs to (never from you):
  `--brand-primary`, `--brand-secondary`, `--brand-accent`, `--brand-turquoise`, `--brand-sky`,
  `--brand-lime`, `--brand-ink`, `--brand-white`. Use them for every colour that means "the brand".
- **Tile fitting.** Everything is sized in `vmin`. The shell measures the tile the scene landed
  in (a whole TV, a half, a ticker strip) and exposes `html[data-shape]` = `full | panel | wide |
  tall | inset`. Position against the full frame with absolute `left/top/right/bottom` in vmin.
- **The Scene Kit** (`/scene-kit.js`): `window.VENUE` and the `<ov-*>` parts below.

## 2. The venue: `window.VENUE`

| Call | Gives |
|---|---|
| `VENUE.ready` | a promise of the venue record: `name, shortName, tagline, logo, brand, addressText, locale, social` |
| `VENUE.menuItems()` | the live menu as a flat list `{name, price, desc, photo, cat}` |
| `VENUE.events()` | upcoming events `{name, date, …}` |
| `VENUE.stats()` | live numbers the venue publishes |
| `VENUE.qr(pathOrUrl)` | the URL of a QR image that points at this venue's page (e.g. `/order`) |
| `VENUE.fields` / `VENUE.field(key, default)` | the values the venue typed when installing the app (see §4) |

Everything resolves against the venue the screen belongs to, in any venue, automatically. Never
hard-code a venue's name, logo URL, address, phone, hex colours, or menu — those are what make
an app belong to one place. An app belongs to the network.

## 3. The parts: `<ov-*>` elements

Compose with these before writing your own; they carry the house scale and the brand.

| Part | Use |
|---|---|
| `<ov-eyebrow>TONIGHT</ov-eyebrow>` | the small accent label with the pulsing dot, top-left; `nodot` to drop the dot |
| `<ov-glow x="30%" y="70%" size="120vmin" opacity=".22"></ov-glow>` | the breathing brand-accent glow; `color` overrides |
| `<ov-hero accent="Tuesday">Taco Tuesday<br>$2 all night.</ov-hero>` | the huge headline (12vmin, 800); `accent` colours that word; `size` overrides |
| `<ov-name></ov-name>` / `<ov-name short></ov-name>` / `<ov-tagline></ov-tagline>` | the venue's name / short name / tagline |
| `<ov-logo h="14"></ov-logo>` | the venue's logo at `h` vmin tall; falls back to the name in type |
| `<ov-qr path="/order" verb="Order from your seat" promise="Scan · pick · it lands at your table" pos="br"></ov-qr>` | the glass QR card; `pos` = `br` (default), `bc`, `bl`; `url="https://…"` for an outside link |
| `<ov-timebar seconds="9"></ov-timebar>` | the thin time bar along the bottom edge |
| `<ov-clock></ov-clock>` / `<ov-clock format="24">` | the live time |
| `<ov-countdown to="17:00" done="It's on."></ov-countdown>` | a countdown to a time today (or an ISO date) |
| `<ov-rotate every="9"><ov-slide>…</ov-slide><ov-slide>…</ov-slide></ov-rotate>` | one item at a time, huge, with the time bar — the Marquee way to show several things |
| `<ov-menu n="6" cat="tacos"></ov-menu>` | live menu rows, name on dotted leaders to price; hides itself when the menu is empty |
| `<ov-events n="4"></ov-events>` | the next events; hides itself when there are none |
| `<ov-field key="headline">default text</ov-field>` / `<img data-field-src="photo">` / `<span data-field="price">` | a field the venue filled in (§4) |

Your own CSS and markup are welcome around them. Keep three or four elements on screen with
generous space; the content is the hero.

## 4. Fields: what the venue fills in

Anything specific to one place or one night — a headline, a price, a date, a photo, a name — is
a **field**, declared once and filled by the venue when it installs the app. Declare fields in a
manifest before the scene:

```
<fields>[
  {"key":"headline","label":"Headline","type":"text","default":"Taco Tuesday"},
  {"key":"price","label":"Price","type":"text","default":"$2"},
  {"key":"photo","label":"Photo","type":"image","default":""}
]</fields>
```

Types: `text`, `number`, `image`, `color`, `url`, `time`, `select` (with `"options":[…]`). Every
field has a sensible `default` so the app looks finished before anyone types. Reference a field
in the markup with `<ov-field key="…">` or `data-field="…"` (text) / `data-field-src="…"` (image src),
or read `VENUE.field("key")` in a script. Six fields is plenty; one or two is usually right.
A show board's cameras and cast are fields too (`url` and `text`), so another venue can run the
same board for its own show.

## 5. Apps, not just slides: state, the phone side, the remote, sound

An app can be a **show board** (live cameras, a cast, points from the room), a **jukebox**
(a queue the room fills), a **game** (scores, rounds), a **poll**, a **countdown the room
controls**, a **board that reacts to the room**, a **game played across every venue on the network**.
Six things make that possible:

**State — `VENUE.state`.** Every app instance has one shared JSON document on the hub. The
wall reads it and watches it; phones change it; every change reaches everyone in under a
second. Dot paths, small values, nothing the app cannot rebuild.

```
VENUE.state.on(function (doc) { render(doc); });      // wall + phone: runs now and on every change
VENUE.state.inc("points.crusita");                      // +1
VENUE.state.set("nowPlaying", { id: "abc", title: "…" });
VENUE.state.push("queue", { id, title, by: name });     // append (arrays cap at 500)
VENUE.state.pull("queue", { id: id });                  // remove by match
VENUE.state.once("vote:round3", { inc: { "points.x": 1 } });   // one time per person — a vote
VENUE.state.patch({ set: {…}, inc: {…}, push: {…}, del: […] });   // several at once, one revision
```
`once` returns `{ok:false, error:"already"}` the second time the same person tries. The hub
identifies people the way every game here does (their sign-in), so one phone is one vote.

**The phone side — `<phone>`.** An app that guests act on ships a second fragment: the page
their phone opens from the wall's QR. It gets the same kit, the same `VENUE.state`, and
`VENUE.me` (a promise of `{name, token}` — the guest signed in once; the sign-in sheet appears
by itself). Put `<ov-qr phone verb="…" promise="…">` on the wall; it points at the phone page
automatically. Phone markup is a page, not a tile: real pixels, tap targets ≥ 44px, one action
per screen, the state rendered live so the person sees their tap land. Never ask for a name or
number — `VENUE.me` already has it.

**The remote — manifest `keys:true`.** The display forwards OK / back / up / down / left /
right to the wall: `VENUE.on("key", function (k) { … })`. Use it for camera switching, menus,
next/previous. Keep the wall usable without it.

**Sound — manifest `audio:true`.** Only the screen the venue marked as the sound owner has
`VENUE.audio === true`; every other screen playing the same app must stay silent. Check it
before unmuting a player. Music and video embeds: YouTube, Vimeo, Twitch, Spotify players only
(`<iframe src="https://www.youtube.com/embed/…">`). Use the YouTube iframe API for
play/next/volume; `VENUE.fetch("https://www.youtube.com/oembed?url=…&format=json")` resolves
a title.

**Own look — manifest `look:"own"`.** A show, a game or a brand may have its own identity
(black and red for a reality show, neon for an arcade). Then the app keeps its own palette and
name, and only the *venue's* identity is bound (`<ov-name>` where the venue is mentioned, the
venue's QR pages). Default is `look:"venue"`: the app wears the venue's brand.

**The outside world — `VENUE.fetch(url)`.** Public JSON through the hub's allowlist (YouTube
oEmbed, noembed, GitHub, ESPN scoreboard, Open-Meteo, iTunes search, CoinGecko, Wikipedia,
TheSportsDB, dad jokes, quotes). Cached a minute. Anything else is refused.

**The manifest.** `<manifest>{"look":"own","keys":true,"audio":true,"state":true}</manifest>`
before the scene. Only the flags that are true need saying.

**The room — manifest `room:true`, `VENUE.room`.** The hub sees everything that happens in the
venue; an app can react to it as it happens. Events: `order` (`{channel, items:[{name,qty}], total,
table, n}`), `song` (`{title, artist, by}`), `review`, `shoutout` (`{text}`), `checkin` (`{event,
qty, name}`), and `app:<type>` from other apps on these screens. They carry only what the TVs
already show — never a phone number or an address.

```
VENUE.room.on("order", function (e) { flash(e.items[0].name); });   // someone just ordered
VENUE.room.on("song", function (e) { marquee(e.title + " — " + e.artist); });
VENUE.room.on("*", render);                                          // everything
VENUE.room.recent("order").then(function (L) { … });                 // the last 40, for a first paint
VENUE.room.emit("goal", { team: "home" });                           // tell the room: arrives as app:goal
```
Wall and phone both get the stream; an app that only listens needs nothing else. An app that
emits is rate-limited like state writes, and its events are credited to it.

**The venue decides.** On its Screens page a venue has a master switch (apps may react to the
room: on/off), a checkbox per kind of event, and a mute per app or scene. Whatever you built,
you hear only what that venue allows — an unchecked kind is never emitted, a muted app's stream
is closed and refused. Design for silence: the app must look right when nothing ever arrives.

**The network — manifest `fleet:true`, `VENUE.network`.** An app can span venues. With
`fleet:true` its `VENUE.state` document is ONE document for the whole network: a vote cast in
Elizabeth and a vote cast in Austin land in the same doc, and every venue's wall sees both under
a second. `once` keys are per person per venue. Key the doc by venue where it matters —
`VENUE.state.inc("cheers." + VENUE.network.self)` — so a leaderboard can show each venue's row.
`VENUE.network.venues()` resolves to every venue on OlloVue (`{slug, name, shortName, logo,
addressText, events:[{title, when}]}`) and `VENUE.network.self` is this venue's slug, so "what's
on tonight across the network" is a fetch, not a feature request. Without `fleet:true` state stays
per venue exactly as before.

### Two shapes to start from

*A show board (own look, keys, state, phone).* Wall: a YouTube live embed filling 62% left,
a strip of other cameras under it (◄ ► switches — `keys`), a ranked cast list right with
photos and points read from `state.points`, an `<ov-qr phone>` card. Phone: the cast as big
buttons; a tap calls `VENUE.state.once("vote:" + roundId, {inc: {["points." + id]: 1}})`,
then shows "Ya diste tu punto" with a countdown to the next round (round id from
`state.round`). Manifest `{"look":"own","keys":true,"state":true}`.

*A jukebox (venue look, audio, state, phone).* Wall: now playing huge with the artwork blown
out and blurred as the background, the next five in a rail, an `<ov-qr phone verb="Pick the
next song">`. The sound-owner screen embeds the YouTube player for `state.nowPlaying`, and on
end shifts `state.queue` into `nowPlaying` (`VENUE.state.patch`). Phone: a search box
(`VENUE.fetch("https://itunes.apple.com/search?term=…&limit=8")` for suggestions, or a plain
YouTube id/URL field), a tap pushes `{id, title, by}` onto `state.queue`, the queue shown live
with the person's own entry highlighted. Manifest `{"audio":true,"state":true}`.

## 6. Hard limits (review rejects any of these)

- Inline `<style>` and inline `<script>` only. **No outside scripts or stylesheets, no fetches
  except `VENUE.*`.** Embeds only from the players in §5.
- Images from `/uploads/…`, `/assets/…`, the venue logo via `<ov-logo>`, an `image` field, or
  an https image the person gave you. Never invent an image URL.
- Wall under 60 KB, phone under 40 KB. No `alert/prompt/confirm`, no `localStorage`, no cookies,
  no `<form>` (buttons and inputs are fine).
- No literal venue identity (name, logo URL, address, phone) baked into the markup; with
  `look:"venue"`, no literal hex colours either — the brand variables.
- Nothing that loops faster than a breath: glows drift 9–14 s, entrances rise .6 s, time bars are
  the only always-moving thing. Respect `prefers-reduced-motion`.
- Reads well from across a room: hero 8–14vmin, secondary text ≥ 2vmin, contrast on ink.
- Looks finished with no data: an empty menu, no events, blank fields → still a good screen
  (an invitation, never "no data").
- Family-safe. Public screens in someone's business; nothing a venue would be embarrassed by.
<!-- agent:end -->

## 7. From the studio to the store

1. **Compose** in Vibe Studio on a screen. The agent designs for that venue — its name, logo and
   colours are in the markup, which is right for that wall.
2. **Save** keeps it in the maker's account and in that venue's Scenes library.
3. **Submit to the App Store** runs the *portability pass*: the agent rewrites the scene against
   §2–§4 — identity becomes `<ov-name>`/`<ov-logo>`/brand variables, anything venue-specific
   becomes a declared field with the current value as its default — and the result is validated
   against §5. The listing (name, tagline, description, maker) goes to the review queue with a
   screenshot rendered at TV size.
4. **Review.** A person on the platform approves or sends it back with a note. Nothing reaches
   another venue's screen without that.
5. **Listed.** The app appears in every venue's store gallery, credited to its maker. Installing
   asks the venue for its field values (defaults prefilled) and drops an `{type:"app"}` item into
   a playlist; the hub resolves the item at play time with `?ov_hub=<venue>&ov_f=<fields>`, so the
   same app renders with each venue's identity and data.
6. **Versions.** Editing a listed app makes a new draft; the listed version keeps playing until
   the new one is approved.
7. **A page of its own.** Every listed app has a public page (`/app?id=…`) with a live preview in
   the visiting venue's brand, the fields it asks for, the maker's credit, and an install button.
   Makers share it from "My scenes"; a venue that isn't on OlloVue yet is pointed at ollovue.com.
8. **Paid apps.** A maker may set a monthly price at submission. A venue subscribes (Stripe, on
   the platform) before it can install; the app plays only where a licence is active and stops
   when the subscription lapses. 70% of every paid month is booked to the maker; OlloVue keeps
   30% and pays makers out. Earnings and installs show under "My scenes".
9. **From any venue.** A studio on a venue's own hub submits into the same platform queue,
   signed like everything that crosses the fleet; makers there see the same statuses.
10. **Getting paid.** A maker connects a Stripe account once ("Get paid" under My scenes, or
    Payouts in the developer portal — a Stripe Express onboarding). On the 1st of every month, or
    when the platform admin sends them early, everything owed is transferred to that account and
    the paid months are marked. Earned / owed / paid show in the same place. Nothing is owed until
    a venue's subscription actually pays an invoice.

## 8. What review looks for

Marquee (one hero, huge; ink not slabs; the eyebrow; a QR with a home; empty = invitation; fits
any tile), the §5 limits, portability (no baked identity, fields declared with defaults), and
that the screenshot would look right on a wall the reviewer has never seen.
