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:
- Five colors: screen, ink, dimmed ink, panel, and panel text.
- Four typefaces: the segment display face, the digits face, body text, and bold body text — either one of Reeltone's own four built-in faces, or a font file the skin bundles.
- Five bitmap art slots: the tape reel's rim and spoke assembly, the Now Playing background, and the transport keys' normal/pressed fill — see Sprites below. Every slot is optional and falls back to Reeltone's own vector art on its own, so a skin with no sprites at all looks exactly like it did before this section existed.
What a skin cannot change:
- Layout — screen structure, button placement, navigation, spacing. All fixed by the app.
- Behavior — a skin is declarative data (colors, fonts, images). It contains no code and cannot run anything.
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
| Field | Built-in value | What 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 —
| Slot | Built-in PostScript name |
|---|---|
display | DSEG14Classic-Regular |
digits | DSEG7Classic-Regular |
body | Silkscreen-Regular |
bodyBold | Silkscreen-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
| Field | Notes | |
|---|---|---|
formatVersion | required | Integer. Currently 1. A pack declaring a newer version than the app understands is refused outright. |
id | required | Stable identifier, reverse-DNS style is conventional (com.you.skin-name). Becomes the on-device install directory name — no /, no \, not . or ... |
name | required | What the user sees in their skin list. |
author | optional | Free text. |
version | optional | Free text, your own version string. |
license | optional | Free 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] }
}
| Slot | Replaces |
|---|---|
reelRim | The tape reel's static outer ring. Never rotates. |
reelSpokes | The reel's rotating spoke-and-hub assembly. Rotation applies to the whole image — one "spokes + hub" picture, not a separate hub. |
background | The Now Playing screen's backdrop, behind the reels and transport keys. |
keyNormal | A transport key's un-pressed fill. |
keyPressed | A 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
- Double-click the
.ttf/.otfto open it in Font Book, or select it in Font Book's library. - Open the info panel (⌘I, or File ▸ Get Info).
- 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.
| Error | What happened | Fix |
|---|---|---|
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.
Gallery
Seven complete, working skins — fork any of them as a starting point.
Each links to its skin.json source in
docs/skins/examples/. An eighth
pack, Dim, ships as a deliberately unusable test
fixture rather than a theme.
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.