Reeltone · Skin Author Guide

Make your Reeltone look like your Reeltone.

A .reeltone skin recolors the app and can swap its fonts. It cannot move anything. This page teaches the format and gets you from an idea to a file on your phone.

⚠️ Layout is fixed. Skins change colors and typography, not what screens exist, where things sit, or how navigation works. If you're picturing a different arrangement of buttons, a skin can't do that — see What a skin is.
🛠️ Don't want to hand-write JSON? Build your skin in the browser instead — live preview, real contrast maths, and it exports a working .reeltone file you can open straight into Reeltone from your phone.

What a skin is

A skin is a .reeltone file — a zip archive holding a skin.json manifest and, optionally, font and image files. Import it (Settings ▸ Appearance ▸ Import Skin…) and Reeltone re-renders using the colors, fonts, and sprites it declares.

What a skin can change:

What a skin cannot change:

Full field-by-field reference: docs/skins/format.md. This page is the illustrated walkthrough; that one is the precise spec.

The anatomy of skin.json

Only three fields are required — formatVersion, id, name. Everything else falls back to Reeltone's built-in LCD theme when omitted, so a recolor can be as short as this:

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

That alone is a complete, valid, importable skin — a cyan-on-navy recolor of the built-in theme, untouched fonts and panel colors included.

Colors

FieldBuilt-in valueWhat it's for
colors.screen #A9BE8E The LCD background — the dominant surface color.
colors.ink #0B0B0B Primary text/foreground, drawn on screen.
colors.inkDim ink @ 35%* De-emphasized foreground — timestamps, inactive states.
colors.panel #000000 Background outside the screen area — bars, sheets.
colors.panelText #A9BE8E Foreground drawn on panel.

* inkDim's fallback is special: if you omit it, it's derived from your own resolved ink at 35% opacity — not from the built-in theme's dim value. Override ink without touching inkDim and you still get a sensibly dimmed version of your color, not a mismatched one from a different palette.

Accepted formats: #RRGGBB or #RRGGBBAA (with alpha). Three-digit shorthand like #FFF is not accepted — write the full six or eight digits.

Fonts

Four slots, each independently optional: display, digits, body, bodyBold. Each is either a name of a face Reeltone already ships:

"digits": { "builtin": "DSEG7Classic-Regular" }

— one of exactly these four PostScript names —

SlotBuilt-in PostScript name
displayDSEG14Classic-Regular
digitsDSEG7Classic-Regular
bodySilkscreen-Regular
bodyBoldSilkscreen-Bold

— or a font file your package ships:

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

postScriptName must be the font's real internal name, verified against the actual file on import. Get this wrong and you'll hit fontNameMismatch — see Finding a font's PostScript name below, the single most common authoring mistake.

Everything else

FieldNotes
formatVersionrequiredInteger. Currently 1. A pack declaring a newer version than the app understands is refused outright.
idrequiredStable identifier, reverse-DNS style is conventional (com.you.skin-name). Becomes the on-device install directory name — no /, no \, not . or ...
namerequiredWhat the user sees in their skin list.
authoroptionalFree text.
versionoptionalFree text, your own version string.
licenseoptionalFree text (e.g. an SPDX id like CC0-1.0) — informational, not enforced by the app.

Sprites

Five slots, each independently optional: reelRim, reelSpokes, background, keyNormal, keyPressed. Each falls back to Reeltone's own vector drawing on its own — supply just reelRim and you get a bitmap rim with the vector spoke assembly still spinning inside it. There's no way to omit a sprite into a blank screen.

"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] }
}
SlotReplaces
reelRimThe tape reel's static outer ring. Never rotates.
reelSpokesThe reel's rotating spoke-and-hub assembly. Rotation applies to the whole image — one "spokes + hub" picture, not a separate hub.
backgroundThe Now Playing screen's backdrop, behind the reels and transport keys.
keyNormalA transport key's un-pressed fill.
keyPressedA transport key's pressed fill. Independent of keyNormal — supply either alone and the other state keeps its vector look.

No @2x/@3x. Ship one PNG at whatever resolution looks sharp and Reeltone scales it to the slot's point size — you don't provide multiple resolutions. Import rejects anything over 2048px on a side, and caps the total decoded size across every sprite at 64MB (decoded RGBA memory — width × height × 4 bytes per image, summed — not file size on disk). Both are checked at import, so you find out before it's on a device; scripts/makeskin.swift checks them too, and prints the decoded total.

capInsets[top, left, bottom, right] in pixels — marks the border of a keyNormal/keyPressed image that must stay crisp while the rest stretches to fit whichever transport key it's drawn for (a full-width play button and a narrow shuffle toggle need the same art at different widths). Without it, a bordered key smears at the edges when stretched. Omit it and the whole image stretches uniformly instead.

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

A sprites key that isn't one of the five names above is silently ignored, the same as any other field this version of the app doesn't recognize — it just does nothing, which reads as "my sprite isn't showing up" if you've mistyped a slot name. scripts/makeskin.swift warns about this specifically; double-check spelling if your sprite doesn't appear.

Finding a font's PostScript name

A font file's PostScript name is not its filename, and often isn't its "family name" either. Reeltone verifies your declared name against the font's real one at import — a mismatch fails cleanly with fontNameMismatch rather than silently substituting the system font, which is what would otherwise happen on every single screen the font is used.

macOS — Font Book

  1. Double-click the .ttf/.otf to open it in Font Book, or select it in Font Book's library.
  2. Open the info panel (⌘I, or File ▸ Get Info).
  3. Look for PostScript name — copy it exactly, including hyphens and capitalization.

Command line — fontconfig

If you have fc-scan (Linux, or macOS via brew install fontconfig):

fc-scan --format "%{postscriptname}\n" MyFont.ttf

Command line — Python / fontTools

pip install fonttools
python3 -c "
from fontTools.ttLib import TTFont
f = TTFont('MyFont.ttf')
name = f['name']
print(name.getDebugName(6))  # 6 = PostScript name
"

Whichever method you use, paste the exact output into postScriptName — don't retype it by hand. A single mismatched character produces fontNameMismatch.

Zip it, get it onto a device

Recommended: scripts/makeskin.swift

From a checkout of the Reeltone repo, this validates your skin directory the same way the app does — manifest decoding, color parsing, the size ceiling, real font registration and PostScript-name verification, and the contrast ratio — and then zips it:

swift scripts/makeskin.swift path/to/your-skin-dir  MySkin.reeltone

Fix whatever it flags before you try importing on-device — it catches everything the app's import gauntlet would reject, before you leave your desk.

Manual zip

A skin directory holds skin.json at its top level, plus any font files it references, flat alongside it. From inside that directory:

cd your-skin-dir
zip -X -r ../MySkin.reeltone .

-X strips extended attributes macOS otherwise adds (resource forks, Finder metadata) that have no place in the archive. Rename the result to end in .reeltone if it doesn't already.

Getting it onto a device

Reeltone has no in-app gallery or hosting — distribution is Files / share-sheet only, the same way most VirtualDJ-style skin ecosystems work. AirDrop the .reeltone to the device, or save it into Files (iCloud Drive, a shared folder, email attachment — anywhere Files can reach), then in Reeltone go to Settings ▸ Appearance ▸ Import Skin… and pick it. Reeltone previews the skin before it's applied, so an unreadable result never becomes your active theme by accident.

Troubleshooting

Every rejection the importer can produce, and what it means.

ErrorWhat happenedFix
missingManifest No skin.json at the top level of the archive. Zip the contents of your skin folder, not the folder itself — skin.json must be at the archive root, not one level down inside a subfolder.
unsupportedFormatVersion formatVersion in your manifest is higher than this build of Reeltone understands. Set formatVersion to 1 unless you know the installed app supports something newer.
malformedColor A colors.* value isn't #RRGGBB or #RRGGBBAA. Check for a missing #, 3-digit shorthand (not supported — spell out all six/eight digits), or stray characters.
unsafeSkinID id is empty, is ./.., or contains / or \. Use a plain identifier with no path separators — com.you.skin-name, not a path.
unsafeEntryPath An entry inside the zip climbs outside the archive (../ somewhere in its path) or is an absolute path. Rebuild the archive from a clean directory — this usually means the zip was built from an odd tool or a manually edited archive. scripts/makeskin.swift or a plain zip -r from inside the folder won't produce this.
disallowedSymlink The archive contains a symbolic link. Replace the symlink with a real copy of the file it points to before zipping.
payloadTooLarge Total uncompressed size across every file exceeds 64 MiB. Shrink or drop font/sprite files you don't need. This is the zip's on-disk size — see spriteBudgetExceeded below for the separate decoded-memory limit on sprites specifically.
missingFontFile A fonts.*.file entry names a file that isn't actually in the archive. Check the filename matches exactly (case-sensitive), and that the font is zipped alongside skin.json, not left out.
fontRegistrationFailed The font file itself is malformed — Core Text couldn't register it. Verify the file opens correctly in Font Book/a font viewer. Re-export from your font tool if it doesn't.
fontNameMismatch The font registered fine, but its real PostScript name doesn't match what postScriptName declared. See Finding a font's PostScript name above — this is the most common mistake in the whole format. Copy the name exactly; don't guess or retype it.
missingSpriteFile A sprites.*.file entry names a file that isn't actually in the archive. Same as missingFontFile — check the filename matches exactly and the image is zipped alongside skin.json.
unreadableSprite The declared file exists but isn't decodable image data. Confirm it actually opens as an image (PNG is the safe choice) and isn't corrupt or a renamed non-image file.
spriteTooLarge A sprite's pixel dimensions exceed 2048px on a side. Downscale the image — remember there's no @2x/@3x here, so one moderately-sized image is all you need.
spriteBudgetExceeded Every sprite's decoded size (width × height × 4 bytes), summed across the whole package, exceeds 64 MB. This is decoded memory, not file size — a small PNG can still decode huge. Drop a slot you don't need, or shrink the largest sprites first — scripts/makeskin.swift prints the decoded total so you can see which skins are close to the ceiling.

One more thing you might see that isn't a rejection: a low-contrast warning on import, when your ink/screen pair scores below a 4.5:1 WCAG ratio. The skin still imports — a legitimately dim LCD is a valid look — you just get a heads-up before committing to it. The gallery's "Dim" skin below exists to demonstrate exactly this.

Testing your skin

Reeltone warns on import when ink and screen fall below a 4.5:1 contrast ratio, but it doesn't block you — an authentically murky LCD is a legitimate look, and the app would be a worse tool if it policed taste. It does guarantee a way back: Appearance's reset control is drawn in fixed colors that no skin can touch, and shaking the device restores the built-in theme from anywhere.

Dim exists to prove that. It measures 1.13:1 — far past unusable — so importing it exercises the whole hostile path end to end: the warning fires, you can apply it anyway, and you can still get back out.

12:04 DIM

Dim test fixture

The panel on the left is illegible on purpose. That's the skin rendering honestly at 1.13:1 — if you can't read it, it's working. Not a theme to use; a probe for the warning and the escape hatches.

skin.json →