# Plain embeds — contract v1

The second way to put PlaceFull on a studio's own website (build-plan/182). The first is the
booking frame (`?embed=1`, build-plan/174): the whole experience, in an iframe. This one is the
opposite trade: **plain semantic HTML rendered into the studio's own page**, which takes the
site's fonts and colours, styled by a small CSS layer the studio picks, writes, or has an agent
write. It lists what can be booked and when; **every time is a link to the hosted PlaceFull
page**, where booking and paying happen.

This file is the public contract. CSS written against it keeps working: everything named here is
frozen for the life of v1 (`test/unit/plain-embed-v1.test.ts` holds the default output).

## The code a studio pastes

```html
<script src="https://placefull.ai/embed/v1.js" async></script>
<placefull-schedule location="ash-street-yoga">
  <a href="https://placefull.ai/ash-street-yoga">Book a class</a>
</placefull-schedule>
```

The link inside the tag is what shows until the script runs, and what stays if it never does.
The same code works in a page's HTML (WordPress, Squarespace, hand-built), as a Wix **Custom
Element** (tag name + script URL; paid Wix plan with a connected domain), and inside Wix's
**Embed HTML** box (there the box is Wix's own frame, so the site's CSS does not reach it — the
theme carries the look, and links open in a new tab).

## Tags

| Tag | Shows | Attributes |
|---|---|---|
| `<placefull-schedule>` | The next 14 days of classes, by day; each row books that date | `location` |
| `<placefull-classes>` | Every published class, with a link to its page | `location` |
| `<placefull-appointments>` | Every published appointment | `location` |
| `<placefull-rentals>` | Every published rental | `location` |
| `<placefull-listings>` | Every published listing listed under one heading on the studio's page — *Parties*, *Workshops* — any kind (build-plan/291); every listing with no `group` | `location`, `group` |
| `<placefull-times>` | One class, appointment or rental: its upcoming times, paged, with a date jump | `location`, `listing` |
| `<placefull-walkins>` | Live walk-in spots right now — status, detail, Book | `location`; optional `listing`, `book`, `hide-when`, `target` |

Every tag also takes `theme` (below). Unknown attributes are ignored. A tag whose location or
listing is not found keeps its fallback content.

## Markup

All hooks are `data-pf` attributes. **We ship no class names and no ids.** Text is the
storefront's own words (*spot*, *Full — join waitlist*, *Appointment*, *Rental*).

### Lists — `classes`, `appointments`, `rentals`

```html
<div data-pf="list" data-pf-kind="class" data-pf-v="1">
  <ul data-pf="items">
    <li data-pf="item">
      <h3 data-pf="name"><a data-pf="link" href="https://placefull.ai/ash-street-yoga/slow-flow">Slow Flow</a></h3>
      <p data-pf="meta">
        <span data-pf="next">Tuesdays · 6:00 PM</span>
        <span data-pf="duration">60 min</span>
        <span data-pf="price">$22</span>
      </p>
      <p data-pf="description">An unhurried hour…</p>
    </li>
  </ul>
</div>
```

`data-pf-kind` is `class`, `appointment` or `rental`, or `group` from `<placefull-listings>`
(the same items, in the storefront's order). `group` matches the heading a listing is *Listed
under* — its own word, else the studio's word for its kind — in either form and any case:
`group="Parties"` and `group="party"` are the same group. A `meta` span is omitted when there is
nothing to say (an unpriced listing has no `price`), and the whole `meta` line when every span
is. A room carries one more span after the price — `<span data-pf="mode">per person</span>` or
`whole space` — because its price means nothing without it. `description` is plain text, at most
a couple of lines. With nothing published the section holds
one `<p data-pf="empty">` and no list.

### Schedule

```html
<div data-pf="schedule" data-pf-v="1">
  <div data-pf="day">
    <h3 data-pf="day-name"><time datetime="2026-09-21">Mon, Sep 21</time></h3>
    <ul data-pf="items">
      <li data-pf="item">
        <time data-pf="time" datetime="2026-09-21T23:30:00.000Z">5:30 PM</time>
        <a data-pf="link" href="https://placefull.ai/ash-street-yoga/slow-flow">Slow Flow</a>
        <span data-pf="duration">60 min</span>
        <span data-pf="instructor">Sam</span>
        <span data-pf="price">$22</span>
        <span data-pf="spots">4 spots left</span>
        <a data-pf="book" href="https://placefull.ai/ash-street-yoga/slow-flow?time=…"><span data-pf="label">Book</span></a>
      </li>
    </ul>
  </div>
</div>
```

The nearest days read *Today* and *Tomorrow*; the `datetime` is always the date. A full date
carries `data-pf-full` on its `item` and its `book` link reads *Waitlist*. A studio
that cannot take bookings right now gets *Details* links with no time.

### Times

```html
<div data-pf="times" data-pf-v="1">
  <div data-pf="head">
    <h3 data-pf="name">Slow Flow</h3>
    <p data-pf="meta"><span data-pf="price">$22</span> <span data-pf="duration">60 min</span></p>
  </div>
  <form data-pf="jump" method="get" action="…/embed/v1/ash-street-yoga/times/slow-flow">
    <label>Jump to a date <input type="date" name="from" min="2026-09-20"></label>
    <button type="submit"><span data-pf="label">Show</span></button>
  </form>
  <div data-pf="day">
    <h4 data-pf="day-name"><time datetime="2026-09-21">Mon, Sep 21</time></h4>
    <ul data-pf="items">
      <li data-pf="item">
        <a data-pf="book" href="https://placefull.ai/ash-street-yoga/slow-flow?time=…"><time datetime="2026-09-21T23:30:00.000Z">5:30 PM</time></a>
        <span data-pf="spots">4 spots left</span>
      </li>
    </ul>
  </div>
  <p data-pf="pages"><a data-pf="page" rel="next" href="…/times/slow-flow?after=2026-09-24T…">Later times</a></p>
</div>
```

A page is whole days, up to about two dozen times. `Later times` and the date jump swap the
section in place (the script intercepts them). No times in the window: one
`<p data-pf="empty">` and the jump form.

### Walk-ins

```html
<div data-pf="walkins" data-pf-v="1">
  <span data-pf="status" data-pf-state="open" data-pf-level="plenty" data-pf-spots="14" aria-live="polite">
    <span data-pf="label">Walk-ins welcome</span>
    <span data-pf="detail">plenty of room · 14 spots open now</span>
  </span>
  <a data-pf="book" href="https://placefull.ai/ash-street-yoga?ref=…"><span data-pf="label">Book</span></a>
</div>
```

`data-pf-state` is `open` | `busy` | `full` | `closed`. `data-pf-level` carries the raw fill
band when open (`plenty` | `filling_up` | `nearly_full` | `full`). Spots are the sum of open
seats across walk-in rooms (published per-person space offerings), with a last-entry rule: a
room whose stay would run past closing contributes 0. Optional attributes on the tag:

| Attribute | Effect |
|---|---|
| `listing="slug[,slug]"` | Count only rooms that host those walk-in offerings. A slug that matches none keeps the fallback and logs one `console.warn` — never "0 open". A room shared by two listed offerings is counted once. |
| `book="slug"` | Book → goes to that listing (with `ref`). |
| `hide-when="closed\|full\|off"` | Render nothing when the state matches (no reserved height). Off / unknown / no capacity / paused always collapse; closed and full still render unless listed here. |
| `target="_self\|_blank"` | Sets the Book link's target. |

The script refreshes every **30 seconds** (the fragment is cached for 15s — never poll faster
than the cache), pauses while the tab is hidden, and runs at once when it becomes visible.
It dispatches `placefull:walkins` on the element on first paint and whenever the state
changes: `{ state, level, spotsOpen, until, listing }`. Status text sits in
`aria-live="polite"`; unchanged refreshes do not rewrite the DOM, so they are not announced.

```html
<script src="https://placefull.ai/embed/v1.js" async></script>
<placefull-walkins location="ash-street-yoga" listing="pyop,clay" book="pyop">
  <a href="https://placefull.ai/ash-street-yoga">Walk-in availability at Ash Street Yoga</a>
</placefull-walkins>
```

```css
placefull-walkins { --pf-accent: #5b7a5b; --pf-status-open: #5b7a5b; }
placefull-walkins [data-pf="book"] { text-transform: uppercase; }
```

## Themes

`theme="clean"` is the default: spacing and list layout only; fonts and colours are the site's.
`theme="card"` draws each item as a card. `theme="studio"` is `card` in the studio's own Look
(Your page → Branding: accent, ground, type, corners). `theme="none"` loads no CSS at all —
not a theme, and not a location's own stylesheet (below).

**Our CSS is weaker than yours.** A rule we ship outweighs a site's bare element rule
(`ul {}`, `a {}`, `button {}` — nearly every site has them, and they would wreck a list or a
button) and nothing else: any selector you write on purpose — a `data-pf` hook, a class, an id —
wins without `!important`, and the variables are set at no weight at all, so
`placefull-schedule { --pf-accent: … }` always takes. A Book button's words are their own element
(`label`, or the `time`) so they keep `--pf-accent-text` even where a site colours every link. A
theme styles only the tags that ask for it, so one page can hold tags in different themes.
Wrappers are plain `<div>`s on purpose — a site's `section {}`, `header {}` or `nav {}` rule has
nothing of ours to land on — while headings, lists, `<time>`, links and the form stay semantic,
and take your site's heading and list styles gladly.

Themes are driven by these variables — set them on the tag or any parent:

| Variable | Default |
|---|---|
| `--pf-font` | `inherit` |
| `--pf-text` | `currentColor` |
| `--pf-muted` | `color-mix(in srgb, currentColor 62%, transparent)` |
| `--pf-accent` | your site's link or text colour (`studio`: the studio's accent) |
| `--pf-accent-text` | `Canvas` (`studio`: the accent's text colour) |
| `--pf-line` | `color-mix(in srgb, currentColor 16%, transparent)` |
| `--pf-card` | `transparent` (`card`, `studio`: a ground) |
| `--pf-radius` | `8px` |
| `--pf-gap` | `0.75rem` |
| `--pf-status-open` | green (walk-ins status dot) |
| `--pf-status-busy` | amber |
| `--pf-status-full` | red |

```css
placefull-schedule { --pf-accent: #7a2e2e; --pf-radius: 0; }
placefull-schedule [data-pf="book"] { text-transform: uppercase; }
```

## Your stylesheet

A location can keep its own CSS in PlaceFull (Your page → Embed → **Customize**), and every tag
of that location wears it, on every site it is pasted on — nothing to paste again. It is served
exactly as saved, with a minute's cache, so a Save reaches the site within a minute:

```
https://placefull.ai/embed/v1/{location}/custom.css
```

The script links it for you: once per location however many tags and scripts the page holds,
after the theme's sheet (so at equal weight yours wins), and only when a fragment answers with
`X-Placefull-Css: 1`. A location that saved none costs no extra request, and its tags render
exactly as before. A fragment waits for the sheet — 1.5 seconds at most — before it shows, so
the first paint is already styled. The sheet is overrides on whichever theme the tag wears; a
tag with `theme="none"` never asks for it. PlaceFull's own pages — the listing, checkout, the
receipt — never load it.

**Every rule starts with one of the six tags.** The sheet loads into your website's page, so
that is what keeps it to the tags. Nesting inside a tag rule is fine — it stays inside the tag:

```css
placefull-schedule, placefull-times { --pf-accent: #5b7a5b; --pf-radius: 999px; }
placefull-schedule [data-pf="day-name"] { font-variant: small-caps; }
placefull-schedule [data-pf="item"] {
  border-bottom: 1px solid var(--pf-line);
  & [data-pf="book"]:hover { opacity: 0.85; }
}
```

Save refuses a sheet with any of these, and says which line
(*Line 18 — this rule would reach your whole website. Start it with the tag.*):

- A rule that does not start with a tag: a bare `a {}`, `:root`, `*`, a hook on its own
  (`[data-pf="book"] {}`), or a comma list with one bare part.
- A `+` or `~` straight after the tag (`placefull-schedule ~ p`): it reaches the page beside
  the tag. Further in is fine (`[data-pf="item"] + [data-pf="item"]`). A nested selector may
  not start with `+`, `~`, `& +` or `& ~`, nor hold `&` anywhere but the start (`:not(&)`).
- `@import` and `@font-face` (the tags already wear your site's fonts), and every at-rule but
  `@media`, `@supports` and `@container` (`@keyframes` and `@layer` included); the rules inside
  those three are held to the same rule.
- An address that is not `https://` — in `url()`, `image-set()` or `src()`.
- `expression()`, `behavior:` and `-moz-binding`; braces that do not match; more than 32 KB.

`!important` saves, with a warning: it is never needed. A rule that starts with the tag already
outweighs the theme, and at a tie your sheet comes after it.

A rule names the tag, not the location: to aim at one location where two share a page, add the
attribute (`placefull-schedule[location="ash-street-yoga"] …`). The same CSS works in your
site's own stylesheet; saved here, it follows the tags to every site they are on. Customize
offers six starters to begin from — **Plain** (every variable written out and commented, so it
changes nothing), **Cards**, **Table**, **Pills**, **Dark** and **Classic** — and what you start
from is yours to edit.

## What v1 promises

- The tag names, attributes, `data-pf` values, elements and nesting shown above, variable names
  and theme names do not change or disappear.
- **Additions are opt-in.** New content arrives behind a new attribute or as a new tag; a
  snippet pasted today renders the same markup for as long as v1 is served.
- A change that would rename or remove anything ships as `/embed/v2.js` beside v1.
- Write CSS against `data-pf` hooks, not against position (`:nth-child`, `>` chains): hooks are
  the contract, incidental order is not.

## Coming back

A booking link carries where the customer came from (`ref`), and the hosted page offers
*← Back to {site}* through checkout and on the receipt, in place of the studio's mark in the
header, so it is the one way back on the page. It is offered only when that address is on the
studio's own website (the Website on Your page) — never for an arbitrary site.

A plain link from your site needs no `ref`: the page reads where the customer came from (the
browser's Referer, which is usually just your site's address) and offers the way back when that
is your website. A tap takes them back to the page they were on, through the tab's own history,
whenever that page is still behind this one; otherwise the link goes to your site's front page,
or to the page `ref` names when you add one.

## Sizing the booking frame

Not this contract — the other embed, `?embed=1` in an iframe — but it belongs where a developer
looks. Paste the frame's snippet with its `<script src="https://placefull.ai/embed.js" async>`
and nothing below is your problem: that script sizes every `iframe[data-placefull]` to the page
inside it and keeps the frame in view. Size the frame yourself and you are reading these
messages, so here is all of one:

```js
{ type: 'placefull:resize', height: 1170, navigated: true }
```

- `height` — the document's height in CSS pixels. Set the iframe to it.
- `navigated` — present, and `true`, **only on a page's first message**. It is how you know the
  customer moved (schedule → listing → checkout) rather than the same page growing.

Two rules a host has to keep, both learned from a real site:

- **Check the origin.** Take messages from `https://placefull.ai` and ignore every other sender.
- **Scroll the frame back into view on `navigated`.** A customer who taps Book at the end of a
  long schedule is scrolled far past the frame's top; the page that replaces it is shorter, so
  without this they are left looking at empty ground under it.

```js
addEventListener('message', (e) => {
  if (e.origin !== 'https://placefull.ai') return;
  const d = e.data;
  if (!d || d.type !== 'placefull:resize' || !(d.height >= 0)) return;
  for (const f of document.querySelectorAll('iframe[data-placefull]')) {
    if (f.contentWindow !== e.source) continue;
    f.style.height = d.height + 'px';
    if (d.navigated === true && d.height > 0 && f.getBoundingClientRect().top < 0) {
      f.scrollIntoView({ block: 'start' });
    }
  }
});
```

Height **0** is intentional: the walk-ins bar (`?embed=live`) collapses when off. The snippet
still reserves 48px until the first resize message arrives.
The frame sends nothing else, and reads nothing the host sends.

## For developers and agents

The fragments are plain URLs, CORS-open, cacheable for a minute, and can be fetched server-side
and printed into a page (better for search engines than script-inserted content):

```
GET https://placefull.ai/embed/v1/{location}/schedule
GET https://placefull.ai/embed/v1/{location}/classes | appointments | rentals
GET https://placefull.ai/embed/v1/{location}/times/{listing}?from=YYYY-MM-DD | ?after={ISO}
GET https://placefull.ai/embed/v1/{location}/walkins?listing=slug,slug&book=slug&target=_blank
GET https://placefull.ai/embed/v1/themes/{clean|card}.css
GET https://placefull.ai/embed/v1/{location}/theme.css        (studio)
GET https://placefull.ai/embed/v1/{location}/custom.css       (the location's own stylesheet)
```

A fragment of a location with its own stylesheet answers with `X-Placefull-Css: 1` (exposed to a
cross-origin `fetch`): a page that prints fragments server-side reads it to know whether to print
the `custom.css` link itself, after the theme's. `custom.css` answers an empty comment when
nothing is saved, and 404 for a location we cannot find.

Walk-ins answers **204** when the bar must collapse (switch off, paused, no capacity), **404**
with `X-Placefull-Walkins: unknown_listing` when `listing=` matches nothing (keep the fallback),
and **200** with the fragment otherwise. Cache is 15 seconds.
