A port is a mapping, not a copy of colours: it decides which key of an app’s config gets which role, and the tool resolves the rest. Read SPEC.md first — in particular the rule that a mapping never references the raw palette.
The canonical example is sp-night/ghostty.
Non-negotiables#
- No hex is chosen by hand. Not in a theme file, not in a README, not in a preview. If something needs to change, the mapping or the catalogue changes and everything is regenerated.
- Conventional Commits, in English, lower case, no trailing period:
feat(themes):,docs(readme):,feat(ports):,ci:,test:,chore:. - Never commit to
maindirectly. Branch → pull request → merge. - Public docs in English. Colour and flavour names stay Portuguese — they are the identity.
- MIT in every repository.
1 — List the target in the catalogue#
registry/ports.yml is what the README, the preview and the generated header all
read from. Nothing can be generated for a port that is not listed.
Start from a printed entry rather than a blank page:
spn entry <slug> >> registry/ports.ymlThat writes every required field, a mapping table to extend, and a complete preview — the fake session, span by span, in the shape the shipped previews share. It validates as printed, so the entry can be checked in while the TODOs are still being replaced. The preview is a starting point and nothing more: rewrite the session once the port has a voice of its own, the way eza’s did.
The fields it leaves you:
- slug: kitty # also the repository name under the org
name: kitty
group: terminal
blurb: One line on what the port covers.
homepage: https://sw.kovidgoyal.net/kitty/
repo: https://github.com/sp-night/kitty # derived from slug, and checked
install: ~/.config/kitty/sp_night_{flavor}.conf
activate: include sp_night_{flavor}.conf
template: kitty.conf.tmpl
mapping:
- key: "`background`"
role: "`ui.bg`"
meaning: "*laje* under the main text"
preview:
title: kitty — sp_night_{flavor}
frame: terminal # terminal | editor | app | pane
swatches: {label: palette 0–15, roles: [ansi.black, ...]}
body:
- - {t: "~/sp-night ", r: ui.accent}
- {t: "❯ ", r: ui.accent_alt}
- {t: "kitty +kitten themes --dump-theme", r: ui.fg}Then check it:
spn registry --registry registry/ports.yml --copy registry/copy.ymlmapping is not decoration — it is the table a reader checks before trusting a
port, and validation refuses an entry without one.
frame is the chrome the preview is drawn in, and it should be the kind of
window the app actually lives in. The body is only the session; the frame draws
the rest, so a port never has to fake a gutter or a statusline span by span.
| frame | for | what the renderer adds | takes |
|---|---|---|---|
terminal (default) | a terminal emulator | titlebar with three dots and the centred title | — |
editor | an editor | a bufferline with title as the open tab, numbered lines, a statusline | bar, cursor_line |
app | a full-screen TUI | title as the header, a bottom bar | bar |
pane | a CLI that runs in someone else’s terminal | nothing but the border | — |
bar is {left: [spans], right: [spans]} in the same span shape as the body;
cursor_line is the 1-based body line the editor highlights. Each frame fits a
fixed number of body lines above the colour strip (13 for a pane, 11 for the
rest), and spn registry refuses a session that runs past it.
2 — Create the repository#
gh repo create sp-night/<slug> --public --license mit \
--description "🌃 SP Night for <App> — a dark colour scheme with São Paulo as its reference"The initial commit carries only LICENSE. Clone it next to the others, then
scaffold:
spn new <slug>That writes the mapping stub with the canonical header already wired to the catalogue, the six-line workflow, the Renovate config, and the editor settings.
3 — Write the mapping#
This is the only file a human writes. Ask for a role, never a colour:
✗ background {{ .C.laje }}
✓ background {{ .R.ui.bg }}spn palette --roles lists every role and what it resolves to in each flavour.
What each one means is in SPEC.md.
The frontmatter declares the contract:
---
spn:
version: "1.2.0" # the exact engine this was generated with
matrix: [flavor] # one file per flavour
filename: "themes/sp_night_{{ .Flavor }}.conf" # where it goes
---Leave matrix out for an app that reads one fixed config file — eza is the case:
its mapping renders once, and each flavour still ships as a file in themes/.
version is an exact version, not a range, and nobody edits it by hand.
spn new writes the version of the binary that scaffolded the port, and from
then on the engine’s release fan-out bumps it in the same pull request that
regenerates the theme files.
That pairing is the point. The committed files and the version that produced them
are one fact, so a port’s CI installs the pinned engine and checks the files
against it. A range — ^1.0 was the original — makes the CI resolve to whatever
is newest, so a port’s pull request goes from green to red because the engine
released, with nothing in the port having changed. It also made the Renovate rule
that was supposed to propagate engine bumps inert: every 1.x satisfies ^1.0,
so there was never anything to bump.
Then generate everything else:
spn gen && spn readme && spn preview
spn lint && spn gen --check4 — Commit history#
On branch feat/<slug>-port, mirroring ghostty:
feat(themes): add the noite, garoa and jaragua flavours
docs(readme): add install guide and palette-generated previews
ci: check the mapping and the generated filesThen gh pr create, and merge through the pull request.
Before pushing, confirm the generated header points at this port’s repository and carries the install path and the activation line. If it does not, the catalogue entry is wrong — do not edit a generated file.
5 — The website, which is not a step#
There is nothing to do here, and nothing to edit. The website vendors this
catalogue rather than keeping its own list, so merging step 1 to main is what
publishes the port: the push triggers sync-ports.yml, whose site job copies
ports.yml and copy.yml across, redraws the previews, regenerates the site’s
derived assets, runs its suite, and opens a pull request. Merging that deploys
/ports/<slug> — a page built from the entry, with the install guide, the
key-to-role table and a screenshot per flavour.
Listing a port is publishing it. If you find yourself editing src/data/ by
hand, the change will disappear at the next sync without leaving a trace.
Checklist#
- Listed in
registry/ports.yml(start withspn entry <slug>),spn registryclean -
spn lintclean — the mapping asks for roles, never colours -
spn gen --check,spn readme --check,spn preview --checkall clean - Three flavours present (noite, garoa, jaragua)
- Generated header points at this port’s repository
-
.github/workflows/theme.ymlcalls the reusable workflow - Branch
feat/<slug>-port, Conventional Commits, merged via pull request - Catalogue merged to
main, and the website’s sync pull request merged
When a port needs something the roles do not offer#
That is a real finding, not a reason to reach for the palette. A missing role means the semantic layer has a hole — open an issue on this repository describing the key you could not map. Adding a role repaints every port that wants it; writing a colour into one mapping helps exactly one port and hides the gap.