# The `.reeltone` Skin Format — Reference

A skin is a `.reeltone` file: a plain zip archive with a `skin.json`
manifest at its root, plus (optionally) the font files it references. It
retheme's Reeltone's colors and typography. **It cannot change layout** —
where things sit on screen, what screens exist, and how navigation works are
fixed by the app, not the skin.

This document is the precise reference for the format. If you just want to
start from a working example, see `docs/skins/examples/` — eight skins you
can copy and recolor, or open `docs/skins/index.html` for a friendlier,
illustrated walkthrough.

## The package

```
mypack.reeltone         (a zip archive — rename after zipping, or zip with this name)
├── skin.json            required, must be at the root of the archive
├── SomeFont.ttf          optional, any font files your manifest references
└── AnotherFont.ttf
```

- The archive must contain `skin.json` at its top level. A pack with no
  `skin.json`, or one buried in a subdirectory, is rejected
  (`missingManifest`).
- Font files referenced by `fonts.*.file` (see below) are resolved by name
  against the package root. Keep them alongside `skin.json` — flat is
  simplest, and nothing about the format requires subdirectories.
- Total **uncompressed** size across every file in the archive must be
  64 MiB or under. This is checked against the zip's own declared sizes
  before anything is decompressed, so an oversized pack fails fast
  (`payloadTooLarge`).
- No symlinks, and no entry may reference anything outside the archive
  (`../` or an absolute path). Both are rejected outright — this is a
  security boundary, not a style preference, and it isn't configurable.

## `skin.json`

Every field except `formatVersion`, `id`, and `name` is optional. An
omitted field falls back to Reeltone's built-in LCD theme, so **a skin that
only recolors the screen is a handful of lines** — see the worked example
at the bottom.

| Field | Type | Required | Falls back to |
|---|---|---|---|
| `formatVersion` | integer | **yes** | — |
| `id` | string | **yes** | — |
| `name` | string | **yes** | — |
| `author` | string | no | omitted (not shown) |
| `version` | string | no | omitted (not shown) |
| `license` | string | no | omitted — informational only, see below |
| `colors.screen` | hex color | no | built-in screen color |
| `colors.ink` | hex color | no | built-in ink color |
| `colors.inkDim` | hex color | no | **this skin's own `ink` at 35% opacity** — not the built-in's dim value, see below |
| `colors.panel` | hex color | no | built-in panel color |
| `colors.panelText` | hex color | no | built-in panel-text color |
| `fonts.display` | font source | no | built-in display face |
| `fonts.digits` | font source | no | built-in digits face |
| `fonts.body` | font source | no | built-in body face |
| `fonts.bodyBold` | font source | no | built-in bold-body face |
| `sprites.reelRim` | sprite | no | vector dotted/solid rim |
| `sprites.reelSpokes` | sprite | no | vector spoke-and-hub drawing |
| `sprites.background` | sprite | no | flat `colors.screen` fill |
| `sprites.keyNormal` | sprite | no | vector bordered key |
| `sprites.keyPressed` | sprite | no | vector inverted key |

### `formatVersion`

An integer. Reeltone currently understands `formatVersion: 1`. This is the
**only** field the app is strict about:

- A manifest declaring a version higher than the app supports is refused
  outright, with a "made for a newer Reeltone" style error, before anything
  else about the pack is even looked at.
- Any field in the JSON that this document doesn't list is silently
  ignored, not rejected — so a skin built against a future `formatVersion:
  1.x` addition still imports on this version of the app; it just doesn't
  get the new field's effect. This is what makes it safe to add fields in
  a future release without breaking existing packs, and it also means a
  typo'd field name (`"colours"` instead of `"colors"`) fails silently
  rather than with an error — double-check your spelling.

### `id`

A short, stable string that identifies this skin — think of it like a
bundle identifier. Reversed-domain style (`com.yourname.skin-name`) is
conventional but not enforced.

`id` becomes the name of the directory Reeltone installs the skin into, so
it has the same constraints as a single path component: it must not be
empty, must not contain `/` or `\`, and must not be exactly `.` or `..`.
Anything else is rejected before the pack touches disk.

Reimporting a pack with an `id` that's already installed **replaces** the
existing install — this is how you ship an update to your own skin.

### `name`, `author`, `version`

Free-text display strings. `name` is required and is what the user sees in
their skin list. `author` and `version` are optional and purely
informational.

### `license`

An optional free-text field — an SPDX identifier like `"OFL-1.1"` or
`"CC0-1.0"` is conventional, but the app does not parse, validate, or
enforce it. It exists so a pack can state its own terms for whoever finds
it; take that statement as seriously as you'd want your own license taken.
(This is separate from the license of any font you bundle — see
"Font licensing" below.)

### `colors`

Six-digit hex, `#RRGGBB`, or eight-digit hex with alpha, `#RRGGBBAA`.
Three-digit shorthand (`#FFF`) is **not** accepted — write the full six (or
eight) digits. Hex digits are case-insensitive.

| Field | What it's used for |
|---|---|
| `screen` | The LCD background — the app's dominant surface color. |
| `ink` | Primary text/foreground color drawn on `screen`. |
| `inkDim` | A secondary, de-emphasized foreground — timestamps, inactive states. |
| `panel` | Background for chrome outside the "screen" area — bars, sheets. |
| `panelText` | Foreground drawn on `panel`. |

**`inkDim`'s fallback is not what you'd expect from the table above.** Every
other omitted color field falls back to the *built-in theme's* value. `inkDim`
is different: when omitted, it's derived from **this skin's own resolved
`ink`** at 35% opacity, not from the built-in theme's dim color. Practically:
if you set `colors.ink` to a bright orange and omit `inkDim`, you get a
dimmed *orange*, not the built-in theme's dimmed near-black — which is
almost always what you want, since a hardcoded dim value from a different
palette would look wrong against your `screen`/`ink` pair.

### `fonts`

Four independent slots — `display`, `digits`, `body`, `bodyBold` — each
holding a **font source**, which is one of two shapes:

**A font the app already ships**, by PostScript name:

```json
{ "builtin": "Silkscreen-Bold" }
```

The four names Reeltone ships, keyed to the slot each is the built-in
default for:

| Slot | Built-in PostScript name |
|---|---|
| `display` | `DSEG14Classic-Regular` |
| `digits` | `DSEG7Classic-Regular` |
| `body` | `Silkscreen-Regular` |
| `bodyBold` | `Silkscreen-Bold` |

You can point any slot at any of these four names — e.g. using
`DSEG7Classic-Regular` for `display` too, for an all-digits look. A
`builtin` name that isn't one of these four **is not caught at import** (see
Troubleshooting on the site) — it just silently falls back to the system
font wherever it's used, since the app only checks a `builtin` name against
its own bundle at render time, not against a known list at import time. Stick
to the four names above.

**A font shipped inside your package**, by filename plus its real
PostScript name:

```json
{ "file": "MyFont.ttf", "postScriptName": "MyFont-Regular" }
```

`file` must name a file actually present in the archive next to
`skin.json`. `postScriptName` must be that font's *real* internal
PostScript name — not the filename, not the "font family" name you'd see in
a word processor's font menu. **A mismatch here is the single most common
authoring mistake** — see "Finding a font's PostScript name" on the site for
exact commands. Import registers the font and then checks the name you
declared against what the font actually reports; a mismatch fails the
import with `fontNameMismatch` rather than silently falling back to a
system font, precisely so you find out at your desk instead of guessing why
a shipped skin looks wrong.

Any slot you omit falls back to the built-in face for that slot — so a
skin can override just `display` and leave `digits`/`body`/`bodyBold` on
the built-in faces.

### Font licensing

If you bundle a font file, you need the right to redistribute it as part
of your skin. Fonts under the SIL Open Font License (OFL) explicitly permit
this — most "geeky" display and monospace fonts (VT323, Nixie One, Cardo,
and most of Google Fonts) are OFL. Ship the font's license file alongside
`skin.json` in your package (it doesn't need to be referenced from the
manifest — it's there for whoever reads your pack), and don't bundle a font
whose license doesn't allow redistribution. `docs/skins/examples/nixie/`
and `docs/skins/examples/paper/` show the pattern.

### `sprites`

Five independent slots let a skin replace part of Reeltone's vector art
with a bitmap image. **Every slot is optional and falls back on its own**
to the vector drawing it replaces — a skin supplying only `reelRim` gets a
bitmap rim with the vector spoke assembly still spinning inside it. There
is no way to make the app render a blank screen by omitting a sprite.

```json
"sprites": {
  "reelRim":    { "file": "rim.png" },
  "reelSpokes": { "file": "spokes.png" },
  "background": { "file": "bg.png", "mode": "tile" },
  "keyNormal":  { "file": "key.png",  "capInsets": [6, 6, 6, 6] },
  "keyPressed": { "file": "keyd.png", "capInsets": [6, 6, 6, 6] }
}
```

| Slot | What it replaces | Notes |
|---|---|---|
| `reelRim` | The tape reel's static outer ring | Drawn at a fixed size, never rotated. |
| `reelSpokes` | The tape reel's rotating spoke-and-hub assembly | Rotation is applied to the whole image — ship one "spokes + hub" picture, not a separate hub. |
| `background` | The Now Playing screen's backdrop | Behind the reels and transport keys. Tiled or stretched — see `mode` below. |
| `keyNormal` | A transport key's un-pressed fill | Resizable — see `capInsets` below. |
| `keyPressed` | A transport key's pressed fill | Independent of `keyNormal` — a skin can supply either one alone and the other state keeps its vector look. |

Each sprite entry is:

| Field | Type | Required | Meaning |
|---|---|---|---|
| `file` | string | **yes** | Filename, resolved against the package root — same rule as `fonts.*.file`. |
| `capInsets` | `[top, left, bottom, right]` | no | Nine-patch-style stretch region; see below. Only meaningful for `keyNormal`/`keyPressed`. |
| `mode` | `"stretch"` or `"tile"` | no, defaults to `"stretch"` | Only meaningful for `background`; see below. |

**No `@2x`/`@3x`.** Every slot has an app-defined point size; ship one
PNG at whatever resolution looks sharp there and Reeltone scales it — you
don't provide multiple resolutions. Import rejects any sprite over
**2048 pixels on a side**, and enforces a **total decoded-byte budget of
64MB** across every sprite in the package (this is decoded RGBA memory —
width × height × 4 bytes per image, summed — not file size; a solid-color
PNG can be tiny on disk and still count against this because of how large
it decodes to). Both limits are checked at import, before anything
installs, so you find out at your desk.

**`capInsets`** — `[top, left, bottom, right]` in pixels, the same order
`UIEdgeInsets` uses — marks the border region of a `keyNormal`/`keyPressed`
image that must **not** stretch. Reeltone's transport keys come in several
widths (a full-width play button, a narrow shuffle toggle), so a key
sprite has to stretch to fit whichever one it's drawn for. Without cap
insets, stretching a bordered key art smears the border into a blur; with
`capInsets: [6, 6, 6, 6]`, the outer 6px on every edge is preserved
pixel-for-pixel and only the interior (and a corresponding strip along
each edge) repeats to fill the extra width or height. Omit `capInsets`
and the whole image stretches uniformly instead — fine for art with no
border to protect.

**`mode`** — only read for `background`. `"stretch"` (the default) scales
the image to exactly fill the screen, distorting its aspect ratio if
needed. `"tile"` repeats the image at its native pixel size instead —
better for a seamless pattern that would look soft if stretched to fill a
whole screen.

Reeltone never generates `@2x`/`@3x` variants, crops, or otherwise
reinterprets your sprite beyond the stretch/tile rule above — what you
ship is what renders.

### Contrast

Reeltone computes the WCAG contrast ratio between your `ink` and `screen`
colors on import. Below **4.5:1** (the WCAG AA threshold for normal-size
text), the skin still imports — an authentically dim, low-contrast LCD is a
legitimate look Reeltone deliberately doesn't block — but the user sees a
low-contrast warning before they commit to it. Authors: aim for 4.5:1 or
above unless low contrast is specifically your intent. The gallery's
"Dim (Test Fixture — Low Contrast)" example exists purely to demonstrate
that warning and is not a look to actually use.

If you want to check your own pair before importing, the formula is
standard WCAG relative luminance:

```
L = 0.2126·R + 0.7152·G + 0.0722·B   (R,G,B linearized from sRGB first)
ratio = (Lighter + 0.05) / (Darker + 0.05)
```

`scripts/makeskin.swift` (see below) prints this ratio for you.

## Worked example: a six-line recolor

Everything not shown here — fonts, `inkDim`, `panel`, `panelText` — falls
back to the built-in theme, so this is a complete, valid skin:

```json
{
  "formatVersion": 1,
  "id": "com.example.midnight",
  "name": "Midnight",
  "colors": { "screen": "#0A1E33", "ink": "#5FD4FF" }
}
```

Zip that file up as `Midnight.reeltone` (see the site for exact zip
commands) and it imports as a cyan-on-navy variant of the built-in theme,
untouched fonts and panel colors included.

## Verifying a pack before you ship it

`scripts/makeskin.swift` zips a directory into a `.reeltone` and runs the
same checks the app runs at import — path safety, size, manifest decoding,
color parsing, and (where possible outside the app) contrast — so you find
mistakes at your desk instead of on a device. See its header comment for
usage.

## See also

- `docs/skins/examples/` — eight complete, working skins to fork.
- `docs/skins/index.html` — an illustrated walkthrough: zipping a
  pack, finding a font's PostScript name, and troubleshooting every
  rejection the app can produce.
- `docs/skins/editor.html` — an in-browser skin editor: live preview,
  a WCAG contrast readout using the same maths as `SkinContrast.swift`, and
  a one-tap Export that produces a real `.reeltone` (its own store-only ZIP
  writer, verified against the app's actual `SkinImporter`). Covers colors,
  fonts, and metadata only — see `#sprites` above for hand-authoring those.
- `docs/plans/2026-08-03-user-skins-design.md` — the design rationale, for
  the curious.
