Under the hood
Contributing
Thanks for taking the time. Everything below is specific to the renderer — the core has its own guide, and most of what makes a change good here comes down to one thing: a visual change needs eyes on it.
Get set up
git clone git@github.com:Industrieh/starmap.git
cd starmap
bun install
bun run checkBun is the only thing you need installed — bun install resolves the whole workspace.
You never have to build the core first
workspace:*, and its tests alias @industrieh/starmap to the core's sources. Change something in the core and the renderer's tests see it immediately.The commands
| Field | Type | Description |
|---|---|---|
bun run test | from the package | Vitest, this package only. |
bun run test:watch | from the package | The same, in watch mode. |
bun run typecheck | from the package | tsc --noEmit. |
bun run build | from the package | Typecheck, then build dist/ with tsdown. |
bun run check | from the root | Format, lint, types and tests over everything. Run it before you push. |
The house rule
src/imports@industrieh/starmap, or something relative. Nothing else.
No DOM, no framework, no stylesheet, no clock, no unseeded randomness, no .tsx, no .css. A purity test checks every source file, and the TypeScript config drops DOM from lib — so a stray document. does not even compile.
Two consequences worth stating on their own.
A drawing is a pure function of its arguments. Same data, same t, same theme, same string — byte for byte. That is what lets a caller cache it, diff it or snapshot it, and it is why Math.random() and Date.now() are banned rather than discouraged.
The package returns strings and never touches the page.If a change needs an element, a listener or a stylesheet, it belongs in the application — and the package's job is to give it the hooks it needs, which today are data-id, data-kind and data-layer.
What belongs here
| Field | Type | Description |
|---|---|---|
Drawing | belongs here | Shapes, layers, framing, colours. |
Interaction | does not | Pan, zoom, selection, hover, the clock — application state, handled differently by every project. The package draws one frame for one moment. |
Anything about the world | does not | If a value describes the universe rather than its picture, it goes in the core's data model. This package reads the model; it never asks for a field to be added to it for the sake of a drawing. |
The line, in one sentence
Checklists
Read the code first, in this order: Architecture for the module map, Drawing for what each object is made of, Theming for the colours. Then the sources: svg.ts, theme.ts, geometry.ts, root.ts, system.ts, galaxy.ts.
Change how an object looks
Edit the draw function
src/core/system.ts or galaxy.ts, the matching draw*.
Look at it
Below. A visual change needs eyes on it, not only a green test run.
Assert on what must be true
In tests/system.test.ts — that a ring is there, that an id is there. Not on the exact markup.
Update the description
docs/drawing.md, if the construction changed.
Sizes go through the file's Scale
u, k, font — never absolute units. A drawing has to hold up in a system 300 units across and in one 30 000 units across.Add a colour
src/core/theme.ts— add it to bothThemeandThemeInput, and give it a value inDEFAULT_THEME.- Use it in the drawing.
docs/theming.md— add the row, and the value in the defaults block.
A new keyed table also needs merging in resolveTheme() and a required default entry.
Add a render option
- The options interface in
system.tsorgalaxy.ts, with a@defaulttag. - The destructuring at the top of
renderXContent(), with the default value. - One test that it does something, one that the default is unchanged.
- The option table in
README.md.
An option that only some drawings honour is a smell. If it applies to both, put it in both.
Add a drawable object
- A
draw*function returning a string, and its place in the layer order — order is the composition, SVG has noz-index. data-kindanddata-idon its group, so an application can find it.- Its colour in the theme.
tests/fixture.ts— one of them, so every other test sees it too.docs/drawing.md— how it is built.
Touch the string primitives
src/core/svg.ts is the only file that writes a tag. A change there reaches every drawing, so it comes with tests in tests/svg.test.ts first, and the change to the callers second.
Looking at what you changed
Tests do not tell you whether a drawing looks right. Render one and open it:
# from packages/starmap-render/
bun -e '
import { renderSystem } from "./src/index"
import { sol } from "./tests/fixture"
await Bun.write("/tmp/sol.svg", renderSystem(sol, { t: 3, width: 700, height: 700 }))
' && open /tmp/sol.svgWorth a look every time:
- a large system and a small one;
- a system with no planets;
- the drawing at 64 pixels;
- a non-square
width/height— that last one is what catches a background that does not cover the letterboxed bands.
Writing tests
Vitest, in tests/. Assert on what must be true, not on the markup:
// good — survives any visual change that keeps the meaning
expect(svg).toContain('data-kind="planet" data-id="earth"')
expect(svg.match(/data-kind="planet"/g)).toHaveLength(3)
// bad — fails the next time a stroke width is tuned
expect(svg).toContain('<circle r="6" fill="#4fae6a" stroke-width="0.87"/>')tests/fixture.ts holds one universe carrying one of everything drawable. Start there; build a galaxy inline only for a case the fixture cannot express — a degenerate one, usually.
Two habits that pay for themselves:
- Check well-formedness with
xmlProblems()whenever you add markup. It catches an unclosed tag and an unescaped name, which are the two ways a string renderer breaks. - Cover the degenerate case. An empty galaxy, a system with nothing in it, a planet with no
type. The model makes most fields optional, so the drawing meets them.
Comments and docs
Every exported symbol has JSDoc: what it does in one line, then what a caller needs to know — what it returns when it finds nothing, what it costs, what has to be unique.
Comments in the body explain why. In this package that usually means why a number is what it is:
// The halo is six times the disc. It is what reads as brightness.
el('circle', { r: star.radius * 6, fill: `url(#${prefix}-glow)` })A magic number with no comment
The prose style is short sentences, code example first, explanation after. One idea per sentence.
| Field | Type | Description |
|---|---|---|
README.md | file | What the package is, and how to use it. |
docs/theming.md | file | Every colour, and how to replace it. |
docs/drawing.md | file | How each object is constructed. |
docs/recipes.md | file | How-to answers, task by task. |
docs/architecture.md | file | How the code is built, for contributors. |
Style
Formatting and lint are automatic — oxfmt and oxlint, configured at the root. No semicolons, single quotes, 100 columns. Run bun run fmt.
Two things the tools cannot decide:
- Explicit types on exports.
isolatedDeclarationsis on, so every exported declaration carries its type — including a parameter with a default value. - One dependency. The core, and nothing else. A change that adds a second one needs a very good reason.
Sending a change
git checkout -b feat/short-description
bun run check
git commit -m "feat: draw rings on ice giants"Commits follow Conventional Commits.
Show the drawing in the pull request
r * 6 becoming r * 5.5 looks like by reading it.Reporting a bug
Include the smallest galaxy that reproduces it, and say what you expected to see:
const galaxy = createGalaxy({
systems: [{ id: 'a', name: 'A', x: 0, y: 0, star: { radius: 10 } }],
})
renderSystem(galaxy.systems[0]) // expected …, got …If the output is malformed, paste it. If it merely looks wrong, attach the rendered image — that is faster than describing it.
Two things that are not bugs:
- A field left
undefineddrawing as the fallback. The core never invents a value, so the drawing has one for every table. - A drawing that does not react to a click. There is no interaction here on purpose —
data-idis the hook, and the handler is yours.