# `<tensr-workout>` — standalone workout embed

A custom element that renders a [workout.md](../spec/workoutmd/README.md) routine on any web page: days → movements → prescriptions, attribution, a source link, **Open in Tensr** and **Download**. No framework, no server-side parsing, no telemetry.

Design of record: GitHub issue #384, comment "Embed design: inputs, lazy parser, good-guest & security rules" (2026-11-09). This README describes what is built; the [checklist](#status-against-the-384-checklists) at the end says what is not.

MIT licensed, including the shared TypeScript parser. The site and docs builds
prepare `/embed/v1.0.0.js`, its lazy chunks, the moving `/embed/v1.js` alias,
SRI in `/embed/release.json`, and a self-hostable source archive. These paths
become public when this implementation is deployed. npm publication is separate.

## Use

```html
<script type="module" src="https://tensr.fitness/embed/v1.js"></script>

<!-- 1a. JSON from a file: no parser loaded -->
<tensr-workout json="/routines/barbend-cable-a-b.json"></tensr-workout>

<!-- 1b. JSON from a script tag on the page: no fetch, no parser -->
<tensr-workout json="#tensr-workout"></tensr-workout>
<script type="application/json" id="tensr-workout">
  { … }
</script>

<!-- 2. workout.md from a URL: parser lazy-loaded -->
<tensr-workout src="/routines/barbend-cable-a-b.workout.md">
  <a href="/routines/barbend-cable-a-b.workout.md">View this workout</a>
</tensr-workout>

<!-- 3. Inline workout.md: parser lazy-loaded -->
<tensr-workout>
  <script type="text/workout+markdown">
    # Day A
    ## Cable Rows: 3x12 rest:90
  </script>
</tensr-workout>

<!-- 4. Plain text between the tags (escape < and & yourself) -->
<tensr-workout> # Day A ## Cable Rows: 3x12 rest:90 </tensr-workout>
```

Use the [docs helper](https://docs.tensr.fitness/embed/) to generate a versioned snippet with SRI.

### Inputs and precedence

| Source                                        | How it is read                                                                                                | Network | Parser |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------- | ------ |
| `json="https://…"`                            | fetched, `JSON.parse`                                                                                         | one GET | never  |
| `json="#id"`                                  | a `<script type="application/json">` (or `application/vnd.tensr.workout+json`) found by id, `JSON.parse` only | none    | never  |
| `src="https://…"`                             | fetched as text                                                                                               | one GET | lazy   |
| `<script type="text/workout+markdown">` child | `textContent`, dedented — byte-exact otherwise                                                                | none    | lazy   |
| plain text between the tags                   | `textContent`, dedented; the browser has already decoded entities                                             | none    | lazy   |

**Precedence:** `json` > `src` > inline script > inline plain text. If more than one is present the highest wins and the embed logs one `console.warn`; the playground reports it as an error.

Two details worth knowing:

- Plain text counts as a _source_ only if it contains a markdown heading. So a fallback sentence or link placed between the tags next to `src=`/`json=` (recommended — it is what readers see before the script loads, or without JavaScript) is not a conflict.
- A literal `</script>` cannot appear inside the inline script form — HTML ends the block there. Use `src=` for such a file. The inline forms are the page author's own markup: never interpolate untrusted text into them.

Optional attribute: `heading-level="1…5"` (default `3`) sets the level of the title heading; day headings use the next level.

### Styling

The element inherits the page's font and text color and draws its borders and tints from `currentColor`, so it works on light and dark pages without configuration. Hooks:

- custom properties: `--tensr-workout-accent`, `--tensr-workout-on-accent`, `--tensr-workout-border`, `--tensr-workout-tint`, `--tensr-workout-muted`, `--tensr-workout-background`, `--tensr-workout-radius`
- parts: `::part(card)`, `::part(group)`, `::part(movement)`, `::part(attribution)`, `::part(actions)`

### Events and properties

`tensr-workout-state` (bubbles, composed) fires on every state change with `detail: { state, source, diagnostics, warnings, problem? }`. `state` is `idle` (waiting to scroll near) → `loading` → `ready` | `error`. The same values are readable as `el.state`, `el.diagnostics`, `el.warnings`, `el.problem`.

## What is in each file

| File                                    | Loaded                                                                 | Contents                                                                             |
| --------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `v<version>.js` (and the `v1.js` alias) | always                                                                 | the element, JSON validation (`workoutSpec/json.ts`), rendering, fetch rules, styles |
| `v<version>.parser.js`                  | only for `src=` / inline workout.md                                    | the reference parser (`workoutSpec/parse.ts` + siblings)                             |
| `v<version>.serializer.js`              | only when Open in Tensr or Download is clicked on a JSON-sourced embed | `workoutSpec/serialize.ts` + `fromWorkoutJson`                                       |

The parser, serializer and validator are **imported** from `tension-app/src/lib/workoutSpec/`, not copied, so the embed cannot drift from what the app parses. `build.mjs` fails if parser or serializer code ends up in the core, or if the core gains a static import. Sizes are printed by `npm run build` and written to `dist/integrity.json` together with `sha384` SRI hashes.

**Open in Tensr** builds `https://app.tensr.fitness/share#routine=<blob>`, where the blob is the workout.md text, gzipped and base64url-encoded — the framing of `textToFragmentBlob` in `tension-app/src/lib/sync/transport-fragment.ts`. The workout is in the URL fragment, which is never sent to a server. If the blob is over the app's 30 KB URL budget the button is withdrawn and only **Download** is offered. For a workout.md source the link carries the source text as written; for a JSON source it carries the serializer's canonical form.

## Rules the embed follows

### Being a good guest to the hosts of `src` / `json` URLs

- **Fetch late.** Nothing is requested until the element is within 400px of the viewport (`IntersectionObserver`). That includes the parser chunk for inline sources. `json="#id"` renders immediately — it needs no network.
- **One fetch per URL per page.** Requests are shared through an in-memory map; a successful result is reused for the life of the page.
- **Simple CORS request.** `GET`, no custom headers, so no preflight.
- **HTTP caching untouched.** Default cache mode; no cache-busting parameters.
- **No polling, no retry storms.** 10 s timeout; one retry after 1.5 s for network errors, timeouts and 5xx only; then a "couldn't be shown" state with a link to the file. 4xx, wrong content type and oversize are never retried.
- **256 KB cap.** `Content-Length` is checked first; otherwise bytes are counted while streaming and the download is aborted at the cap.
- **Content types:** `text/markdown`, `text/plain`, `application/json`, `application/vnd.tensr.workout+json`. Everything else — notably `text/html` and a missing type — is rejected.

If you host workout files for embedding, serve them with `Access-Control-Allow-Origin: *`, one of the content types above, and a cache header such as `Cache-Control: public, max-age=300, stale-while-revalidate=86400` plus an `ETag`.

### Protecting readers and host pages

- `credentials: "omit"` on every fetch.
- **https only**, for `src`/`json` (checked before the request and again after redirects) and for every rendered link — `source_url`, `source`, `based_on`, links in notes, image links. `javascript:`, `data:`, `file:`, `blob:`, `http:` and URLs with credentials are rejected: a rejected fetch shows an error without a request; a rejected link is rendered as plain text or dropped.
  - _One exception, for local development:_ `http:` to a loopback host (`localhost`, `127.0.0.1`, `[::1]`, `*.localhost`) is accepted **only while the page itself is served from a loopback host**. It is what lets the playground and the browser tests run from a plain local server. On any real site the rule is https-only.
- **Authored content is text.** The renderer never uses `innerHTML`; every authored string goes through `textContent`. JSON is checked with `validateWorkoutJson` and every field is re-checked where it is used.
- **Images in notes are never loaded.** They render as links (`referrerpolicy="no-referrer"`).
- Outbound links carry `rel="noopener noreferrer nofollow ugc"` and `target="_blank"`.
- **Isolation.** Shadow DOM; one shared constructable stylesheet (no `<style>`, no `style=""`); no globals; `customElements.define` is skipped if the name is taken.
- **No storage, no telemetry.** Nothing is written to `localStorage`, `sessionStorage`, IndexedDB or cookies, and the only requests are the workout file and the embed's own lazy chunks.

### Content-Security-Policy

The embed needs no `unsafe-inline` and no `unsafe-eval`. A host page with a strict policy needs:

```
script-src  <the embed's origin>      # covers v1.js and the lazy parser / serializer chunks
connect-src <each origin serving src= / json= files>
```

Nothing else: no `style-src` entry (styles are a constructable stylesheet), no `img-src` (no images are loaded). Open in Tensr opens a new tab and Download uses a `blob:` link; neither is restricted by `script-src`/`style-src`/`connect-src`. The playground itself ships with `default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self' https:`.

On browsers without constructable stylesheets (Safari < 16.4) the embed falls back to a `<style>` element in its shadow root; a strict CSP blocks that and the embed renders unstyled but complete.

### Versioned files and SRI

`npm run build` writes immutable, versioned files and a moving alias (`v1.js`, a byte copy of the current core), plus `dist/integrity.json`:

```html
<script
  type="module"
  src="https://tensr.fitness/embed/v1.0.0.js"
  integrity="sha384-…"
  crossorigin="anonymous"
></script>
```

Frozen release files live in `embed/releases/<version>/`. Builds retain older
releases and reject replacing a frozen version with different bytes. To prepare
a new release: bump `package.json` and its lockfile, run the complete test suite,
build the site, then run `node embed/scripts/freeze-release.mjs` from the repo
root and commit the new release directory. The source archive is retained too.

SRI covers the core file. The two lazy chunks are loaded with `import()`, which has no integrity attribute; the core refers to them by their full versioned names, so a pinned core always loads the chunks of the same build, from the same origin.

## Playground

```sh
cd embed
npm install
npm run playground      # builds, then serves http://localhost:4384/playground/
```

Edit a workout, see the real element render it, read diagnostics with line numbers (click a line number to jump to it), and copy a snippet in the `src`, inline-script or JSON form. "Check a snippet" parses pasted HTML inertly (`DOMParser`), applies the embed's rules — more than one source is an error; child elements in plain text are a warning — and previews it.

The playground is a static directory (`dist/playground/`); any static server works. Where it will be hosted is still open (#384, decision 9).

## Develop and test

```sh
npm run typecheck      # tsc --noEmit (includes the imported workoutSpec sources)
npm run test:unit      # vitest + jsdom
npm run build          # esbuild → dist/, prints sizes
npm run test:browser   # Playwright (Chromium) against dist/
npm test               # all of the above, in order
```

- **Unit tests** (`test/unit/`) cover the URL policy, dedent/entities/CRLF, source precedence, the fetch rules (with fake timers), rendering safety, the share link (decoded with the app's own `fragmentBlobToText`, and checked against `protocol/vectors/v1/transport-fragment.json`), and the element end to end — including all 22 cases of the shared conformance corpus (`spec/workoutmd/corpus/`) rendered in all five input forms.
- **Browser tests** (`test/browser/`) cover what jsdom cannot: real chunk requests, a real `IntersectionObserver`, real downloads, a real strict CSP, and the playground.
- `@playwright/test` is pinned to `1.60.0`. Run `npx playwright install chromium` once if that browser build is not already on the machine.
- There is no CI workflow for this package yet (it would live in `.github/workflows/`, outside this directory).

## Status against the #384 checklists

**Met and tested:** all of "Being kind to the hosts"; "HTTP cache plus in-memory dedupe only"; `credentials: "omit"`; https-only URLs and links (with the loopback development exception above); text-only insertion; images not auto-loaded; link `rel`; isolation; no telemetry in the embed; Open in Tensr via fragment with Download-only above the URL budget; and all six items under "Tests to add".

**Not met here (outside this package or awaiting a decision):**

- **JSON is schema-validated before rendering** — _partly_. The embed uses the shallow `validateWorkoutJson` from `workoutSpec/json.ts` and re-checks every field at the point of use. It does not run the full JSON Schema (`protocol/schema/workout/v1.json`); bundling `jsonSchemaValidate.ts` plus the schema would add about 11 KB minified / 3.3 KB gzipped to the core.
- **Hosted routine files** — site build emits `/routines/<id>.workout.md` and `.json`; Cloudflare Pages supplies ETags, CORS and revalidation headers.
- **Versioned scripts with SRI** — site/docs builds package the verified release. Lazy chunks use matching immutable version names (dynamic import has no SRI attribute).
- **Privacy-policy row** for `v1.js` request logs (`docs/legal/privacy-policy.draft.md` §5) — not edited.
- **Tensr's own blog pages** use the public script with `src="https://…workout.md"`. Optional `RoutineMicrodata.astro` JSON remains for search/import discovery and is not a preview input.
- **License** — MIT, covering this embed and the reference parser.

## License

MIT. See [`LICENSE.md`](./LICENSE.md) and `tension-app/src/lib/workoutSpec/LICENSE`.

The deployed `/embed/workout-embed-1.0.0-source.tar.gz` includes a standalone
source tree. Extract it, run `cd embed && npm ci && npm run build`, then host all
of `dist/` together. Keep versioned files immutable; retain older releases when
adding future versions. Source archives include the parser under its original
relative path so no second implementation is needed.
