vault keeps colour and type on disk
The problem
A hex off a poster, a typeface from a client deck, a ratio that worked on the last project. These end up in screenshots, notes apps and half-finished Figma files, none of it attached to the project it came from. Vault is one local place to put them and then work on them.
It is two tools in one binary. A library you capture colours and fonts into, and a studio that generates palettes and type scales out of them. Assets get tagged into a project, which is what connects the two halves.
The generators run in two processes
A tonal palette is generated twice. The renderer computes a preview as you drag the seed, and main regenerates the swatches from that seed inside the palette:create-tonal handler before writing them to SQLite. Two copies of that maths would drift, and the palette you saved would stop matching the one you previewed.
So the generators sit in shared/, outside both processes: pure functions with no DOM, no Node and no Electron, imported by each side. The preview and the persisted result are the same call over the same input.
For type, the renderer computes the steps and sends the finished rows to main, which only writes them. Nothing is recomputed, so shared/ is doing a different job there: it holds the ratio presets and the rule for what counts as a hand-tuned scale, and the create flow, the viewer and the card all read them from the same place.
A seed hex is captured.
// shared/lib/lightnessScale.ts: main persists with it, the renderer previews with it
export function generateLightnessScale(hex, { steps = 10, minL = 8, maxL = 97 } = {}) {
const [, c, h] = chroma(hex).lch()
return Array.from({ length: steps }, (_, i) => {
const t = steps === 1 ? 0 : i / (steps - 1)
const L = maxL - t * (maxL - minL)
const C = c * (1 - Math.abs(t - 0.5) * 0.9) // ease chroma toward the mid-tones
return lchToGamutHex(L, C, h) // bisect chroma into sRGB, holding L and h
})
}The renderer runs context-isolated with nodeIntegration off, so it has no require and no way to reach the filesystem or the database directly. Everything privileged goes through one typed window.api object (VaultApi), exposed over contextBridge by a preload that forwards calls and holds no logic. One object means the compiler checks every call across the boundary, and a renamed handler is a build error rather than a runtime undefined in a shipped binary.
// preload/index.ts: one typed window.api (VaultApi), the only route to Node
const api: VaultApi = {
colour: {
create: (hex, name) => ipcRenderer.invoke('colour:create', hex, name),
list: () => ipcRenderer.invoke('colour:list'),
},
palette: {
createTonal: (name, seedHex, seedColourId, ramps) =>
ipcRenderer.invoke('palette:create-tonal', name, seedHex, seedColourId, ramps),
},
}
contextBridge.exposeInMainWorld('api', api)Naming a hex
Every captured colour gets a name from a dataset of about 31,900. RGB distance is the wrong metric for that match, because two colours can sit close together in RGB and look nothing alike, so the search runs in a perceptual space instead. It takes two passes: a cheap CIE76 scan cuts tens of thousands of candidates down to a handful, then CIEDE2000 re-scores the survivors. CIEDE2000 is the accurate one and the expensive one, which is why it only ever sees the shortlist. Counting how many names land inside the "same colour" band around your hex gives the confidence read.
The search came out of hexicon, which already matched against the same dataset, so it moved into Vault rather than being written twice. Then it was written a third time, in haus, and the dataset went with it: the 764KBcolornames.json Vault used to commit is haus-colour-names now, and this repo installs it. The two-pass search stayed here, because the app needs the confidence band and the de-duplicated names that a ranked top-N does not return. The demo runs over a curated slice, so the counters underneath are slice counts rather than the full 31,900, and the confidence pill is calibrated for the whole corpus and so reads more confidently here than in the app. The strip beneath is the lightness ramp every captured colour carries.
The hue families went the same way, and that one was a bug. Vault binned OKLCH hue using the boundaries of an HSL wheel, which is a different wheel: sRGB red is OKLCH hue 29 rather than 0, so every family sat about one bin anticlockwise and 17 of 27 canonical colours came out wrong, red among them. The corrected bins live in haus-colour-utils, which is also how the second correction arrived here for nothing: 0.3.0 refits the boundaries against 4,275 colours a person had named, because a family is not always centred on the colour it is named after.
- Maroon FlushΔE 4.1
- Tyrian PurpleΔE 7.2
- Medium Violet RedΔE 10.4
- FandangoΔE 11.3
// lib/colourNames.ts: two passes over a ~31,900-name dataset
const candidates = []
for (const e of entries) {
const d = Math.sqrt((lab.l - e.L)**2 + (lab.a - e.a)**2 + (lab.b - e.b)**2)
if (d < CIE76_RADIUS) candidates.push(e) // pass 1: fast, coarse CIE76
}
const scored = (candidates.length ? candidates : entries)
.map(e => ({ name: e.name, hex: e.hex, deltaE: deltaE(hex, e.hex) })) // pass 2: CIEDE2000
.sort((a, b) => a.deltaE - b.deltaE) // rank on the raw distance
.map(m => ({ ...m, deltaE: Math.round(m.deltaE * 10) / 10 })) // round for display onlyContrast, in the drawer
Opening a colour shows its WCAG contrast both ways: an “Aa” tile on white and on black, the ratio to two decimals, and badges for the two thresholds that matter for body text, AA at 4.5:1 and AAA at 7:1.
The strip below the chips puts the same seed through the lightness ramp from earlier and scores every stop, marking the crossover: the lightest stop that still holds white text at AA.
Contrast
Across the lightness ramp
ratios against white- 101.1—
- 201.4—
- 301.9—
- 402.5—
- 503.5—
- 605.0AA
- 707.2AA
- 8010.3AA
- 9014.1AA
- 10017.9AA
Two ways to build a palette
A palette comes from your library colours, two ways. Tonal takes one seed and expands it into semantic ramps: a primary built from the seed, a near-grey neutral at the same lightness, and success, warning and error hues rotated to fixed angles but kept in the seed's chroma family. Expressive takes several seeds and fills out a multi-hue set, three ways. Cohesive, the default, adds each new hue by maximising its perceptual distance from the hues already chosen, so the set stays distinct rather than muddy. Interpolate walks the gaps between the seeds you gave it. Harmony places the extras at classical intervals from the first seed. The demo below runs Cohesive.
Either result is then measured for hue coverage, and any two swatches close enough to read as duplicates get flagged. Changing the model or the seeds recomputes the palette and the analysis together.
Type scales on a ratio
A scale is a base size and a modular ratio, with every named step sitting at a fixed power of that ratio. Vault ships two step sets instead of a free-form list: Product (Display down to Label) and Web / Markup (h1 to h6 plus paragraph and small). Sizes are stored in pixels and the viewer converts units as you switch them. Tracking is the exception: it is stored in em, displayed in whichever unit you picked, and converted back to em on export, because CSS drops a percentage letter-spacing silently.
- Grumpy wizards
- Grumpy wizards
- Grumpy wizards
- Grumpy wizards
- Grumpy wizards
- Grumpy wizards
- Grumpy wizards
- Grumpy wizards
// shared/lib/typeScale.ts: each step is base × ratio^exponent
export function generateTypeScaleSteps(baseSize, ratio, kind = "semantic") {
return STEP_PRESETS[kind].map((s, i) => ({
step_name: s.name,
size: Math.round(baseSize * Math.pow(ratio, s.exponent)),
weight: s.weight,
line_height: s.lineHeight,
letter_spacing: s.letterSpacing,
sort_order: i,
}))
}The command palette
⌘K opens a palette over whatever you are doing: jump to a section or a project, start a colour, font, palette or type scale, or search the library itself. At rest it lists only actions, a short and predictable set.
Ranking is a subsequence matcher, so tsc finds Type Scales. Each matched character scores a point, a run of adjacent characters scores three more, a match at the start of a word scores two, and skipped characters cost a little, up to a cap. A short label gets a small edge over the same match buried in a long one, which stops a three-letter query from surfacing the longest thing it happens to fit. The matcher is a pure function with no dependencies, so it has its own unit test instead of being covered through the component, and this page runs an exact copy of it.
- Add colourCreate
- Add fontCreate
- New paletteCreate
- New type scaleCreate
- Open hipuku.devProject
- Open PendulaProject
// renderer/lib/commandFilter.ts: subsequence score, higher is better
export function fuzzyScore(text, query) {
const t = text.toLowerCase(), q = query.toLowerCase()
let score = 0, from = 0, prev = -2
for (const ch of q) {
const at = t.indexOf(ch, from)
if (at === -1) return null // not a subsequence
score += 1
if (at === prev + 1) score += 3 // contiguous run
if (at === 0 || /[\s\-_/]/.test(t[at - 1])) score += 2 // word boundary
score -= Math.min(at - from, 4) * 0.1 // capped gap penalty
prev = at; from = at + 1
}
return score + Math.max(0, 12 - t.length) * 0.05 // brevity edge
}Design decisions
One accent. A deep ruby, built as its own OKLCH ramp, carries every action in the app. It is reserved for the wordmark, primary actions, focus rings and the active nav item; everything else is a calm neutral grey. One amber sits outside that rule, on the favourite star, because a marker of state should not read as something to click. The demos above run in that ruby; the corona around them is this site's hue for Vault rather than Vault's own.
Manrope, self-hosted. A local-first app cannot depend on a CDN font, so the weight axis is bundled. I tried Cal Sans for more personality and reverted: it ships a single weight, which flattened the hierarchy and, worse, broke the type-scale tool whose entire job is to demonstrate weight steps.
Light mode only. One paper-like surface keeps the colours and type you are collecting in front, and it kept the design work to one theme. A dark theme is mostly a matter of overriding the semantic aliases, so it is deferred rather than ruled out.
Electron over Tauri. Tauri produces a far smaller binary, which for a personal tool bought me nothing. Vault reads the Font Book set through system_profiler, opens native file dialogs and copies font bytes into its own storage, and Electron's native surface for that is mature and one language across all three processes. The cost is a binary in the hundreds of megabytes, and a runtime that has to be kept current. Going from Electron 33 to 44 meant moving the SQLite driver at the same time, because the newer driver is built against an N-API level that the older Electron's Node does not provide. Eleven packages reach the shipped app, four of them the icon set and the typeface.
better-sqlite3 over an ORM. Six tables and a two-table tag join. A query builder would have added a layer to learn and a migration story to maintain in exchange for SQL I can already read. The driver is synchronous, which in main is a feature: no await ceremony around a local file.
The interface says Projects. The schema underneath is a generic tags / asset_tags join, because letting any asset belong to many labels is the flexible model. Nobody opening the app thinks in tags though. They think in the project they are working on, so that is the word the interface uses.
Generated artifacts are read-only. A palette or type scale is tuned while you create it and frozen afterwards. Editing a swatch later would leave an artifact that no longer matches the seed and ratio it claims to come from. Tuning a step past the ratio during creation flips the meta pill to Custom, so it still describes itself accurately.
Tests, CI, storage
Three hundred and eight Vitest tests across thirty-five files cover the exporters, the colour maths, the palette generators, the gamut mapping, the type-scale units and the command filter, in the same shared/ and lib/ folders the demos above pull from, and every component that owns behaviour, each with an axe assertion. CI runs lint, format, typecheck, test and build as five jobs on every push, and a tag builds unsigned .dmg installers for Apple silicon and Intel and publishes them to GitHub Releases. Storage is one better-sqlite3 file in the app's data directory, with the bytes of uploaded and installed fonts copied in beside it, so moving or deleting an original never breaks the library. Only Google Fonts goes out to the network: a font added from Google is stored as a reference and rendered from Google's CDN.
The schema carries a version. An older database is backed up before startup touches it, and one written by a newer build refuses to open, with a message saying to update Vault. Three things lock the window down: the renderer cannot navigate away from the app, external links are handed to the system browser, and every font path arriving over IPC is resolved and checked to sit inside Vault's own storage before anything reads or deletes it.
Where it stands
Vault is at v0.3.0 and I use it. Three things are open. Data lives in the app's userData directory, which survives a moved source file but cannot be backed up or synced; the fix is a nominated folder holding the assets and the database together, the way Obsidian does it. Installed-font import reads one weight per face out of system_profiler, so a variable font arrives as a single static weight instead of its axes. And the build ships unsigned, so first launch needs the Gatekeeper step in the README. An Apple developer account is hard to justify for a tool with one user.
The full decision log, with the alternatives weighed, in DESIGN.md →