Design & engineering · 2025 – ongoing

vault keeps colour and type on disk

An offline Mac app for the colours, fonts and type scales a project accumulates. SQLite on disk, no account.

  • Electron
  • React
  • TypeScript
  • SQLite
  • OKLCH
  • Vitest

The problem

Design work accretes tokens. A hex noticed on a poster, a typeface from a client deck, a ratio that felt right on one project: they scatter across screenshots, notes apps and half-finished Figma files, and none of it is tied to the project it belongs to. I wanted one local place to capture those fragments and then to shape them, offline and without an account.

Vault has two tools in one binary: a library you capture into, and a studio that generates palettes and type scales from what you have captured. Four asset types, tied together by projects.

library · captureColoursFontsstudio · generatePalettesType scalesProjectsall four asset types
a project gathers all four asset types.

One source of truth across processes

An Electron app runs in two worlds at once: a renderer that draws the UI and a main process that owns the database and the disk. A palette is generated in both, at different moments. The renderer computes a live preview as you drag a control, and main regenerates the swatches from the seed inside the palette:create-tonal handler before writing them. If those two computations were written twice, they would drift, and the palette you saved would not be the one you previewed.

So the generators do not live in either process. They live in a shared/ folder of pure, framework-free functions, with no DOM and no Node, imported by both sides. The live preview and the persisted result run the identical code, byte for byte, so they cannot disagree.

Type scales take the other route. The renderer materialises the steps and passes the rows to main, which only persists them. There is nothing to regenerate, so the shared module is shared for a different reason: the viewer, the card and the create flow all read the same ratio presets and the same rule for what counts as a hand-tuned scale.

seed hex#e15e42shared/libgenerateLightnessScale()pure · no DOM · no Noderendererlive previewmain → SQLitepersisted

A seed hex is captured.

the preview and the saved palette are the same call.
// 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 = 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 chroma.lch(L, C, isNaN(h) ? 0 : h).hex()
  })
}

The renderer cannot reach Node to run that code itself. It is context-isolated with nodeIntegration off, so the filesystem, the database and the network are all on the far side of one door: a single typed window.api surface (VaultApi), forwarded by a preload that does nothing but pass typed calls over contextBridge. Because that surface is one object, 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.

rendererReact · context-isolatedno nodeIntegrationwindow.apione typed VaultApi surfacepreloadthin · ipcRenderer.invokemain (Node)better-sqlite3filesystemGoogle Fontsfont-file storageinvokecontextBridge boundary
the database, the disk and the network all live in main.
// preload/index.ts — one typed window.api (VaultApi), the only door 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)

Perceptual colour, not RGB distance

Every colour is auto-named. Naming a hex well means matching it the way a person would, which RGB distance does not do: two colours can be close in RGB and look nothing alike. So the match runs in a perceptual space. It is a two-pass search over a dataset of about 31,900 names: a fast CIE76 scan narrows tens of thousands of candidates to a handful, then CIEDE2000, which is accurate but expensive, re-scores only the survivors. The confidence read comes from how many names land in the "same colour" band around your hex.

The search came out of hexicon, where the same dataset was already being matched, and moved into Vault rather than being written twice. The demo runs it over a curated slice of the dataset, so the counters below the result are slice counts, not the full 31,900. The strip beneath is the perceptual lightness ramp every captured colour carries.

RubyΔE 0Unambiguous
  • Maroon FlushΔE 4.1
  • Tyrian PurpleΔE 7.2
  • Medium Violet RedΔE 10.4
  • FandangoΔE 11.3
lightness rampL 38 · C 60 · H 4
190 names→ CIE76 →8 near→ CIEDE2000 →ranked
CIE76 narrows the field, CIEDE2000 ranks it.
// 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)

Readability, checked where you pick

Every colour in the library carries a WCAG contrast read-out in its drawer: an “Aa” tile rendered 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 runs the same seed through the lightness ramp from earlier, then scores every stop, and marks the crossover: the lightest stop that still holds white text at AA.

Contrast

Aaon white7.19:1AAAAA
Aaon black2.92:1AAAAA

Across the lightness ramp

ratios against white
AA from here
  1. 101.1
  2. 201.4
  3. 301.9
  4. 402.5
  5. 503.5
  6. 605.0AA
  7. 707.2AA
  8. 8010.3AA
  9. 9014.1AA
  10. 10017.9AA
white text passes AA from stop 60 downtiles pick their own readable ink
perceptual evenness sets the steps, not the AA threshold.

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, choosing each new hue by greedily maximising its perceptual distance from the hues already chosen, so the set stays distinct rather than muddy.

The same result is then measured: the arc of hue coverage, and a flag for any two swatches close enough to read as duplicates. Switching models or changing the seeds recomputes both the palette and its analysis.

primary
neutral
success
warning
error

Type scales on a ratio

A type scale is a base size and a modular ratio, with each named step placed at a fixed power of that ratio. Rather than a free-form list, Vault ships two presets: Product (Display down to Label) and Web / Markup (h1 to h6 plus paragraph and small). Sizes persist in pixels and the viewer converts units live, so what you see is what you export.

  • Display72px700
    Grumpy wizards
  • Headline48px700
    Grumpy wizards
  • Title36px600
    Grumpy wizards
  • Body Large18px400
    Grumpy wizards
  • Body16px400
    Grumpy wizards
  • Body Small14px400
    Grumpy wizards
  • Caption12px400
    Grumpy wizards
  • Label11px500
    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,
    sort_order: i,
  }))
}

Everything reachable from the keyboard

⌘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.

The ranking is a subsequence matcher, not a substring test, so tsc finds Type Scales. Each character scores a point, a run of adjacent characters scores three more, a match at the start of a word scores two, skipped characters cost a little up to a cap, and a short label gets a small edge over the same match buried in a long one. That last rule is what stops a three-letter query from surfacing the longest thing it happens to fit. The matcher is pure and framework-free, so it has a unit test of its own rather than being checked through the component, and so it runs on this page as an exact copy rather than a reimplementation.

  • Go to ColorsNavigate
  • Go to FontsNavigate
  • Go to PalettesNavigate
  • Go to Type ScalesNavigate
  • Add colourCreate
  • Add fontCreate
  • New paletteCreate
  • New type scaleCreate
  • Open hipuku.devProject
  • Open PendulaProject
↑↓ move run esc clearactions only; type to search the library
a subsequence, not a substring. three letters reach the label they abbreviate.
// 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, is the only accent 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. When the accent means "act here", a busy accent palette would only dilute the signal. The demos above run in that ruby; the corona around them is this site's hue for Vault, not 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 attention on the content, the colours and type you are collecting, and let me spend the whole design budget on one polished theme instead of two adequate ones. The two-tier token system means a dark theme is mostly overriding the semantic aliases: deferred, not designed 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.

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.

"Projects", not "tags". The schema is a generic tags / asset_tags join, because any asset belonging to many labels is the flexible model. Nobody opening the app thinks in tags; they think in the project they are working on. So the interface says Projects and the generic join stays underneath, which keeps the model open without putting its plumbing in the language.

Generated artifacts are immutable. A palette or type scale is tuned while you create it and read-only afterwards. Editing a swatch after generation would leave an artifact that no longer matches the seed and ratio it claims. Hand tuning a step past the ratio during creation flips the meta pill to Custom, so the artifact still says what it is.

Tested logic, local by construction

Ninety-nine Vitest tests across nine files cover the generators, exporters and colour maths in the same shared/ and lib/ folders the demos above pull from. CI runs lint, typecheck, test and build as four jobs on every push, and a tag builds unsigned .dmg installers for Apple silicon and Intel and publishes them to GitHub Releases. Storage is a single better-sqlite3 file in the app's data directory, with imported font bytes copied in beside it, so a moved or deleted original never breaks the vault, and the app works fully offline.

Where it stands

Vault is at v0.1.0 and I use it. Three things are open. The data lives in the app's userData directory, which survives a moved source file but is not backed up and cannot be synced; the planned fix is a nominated vault 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 rather than its axes. And the build ships unsigned, so first launch needs a Gatekeeper step the README spells out, because an Apple developer account is not worth it for a tool with one user.

The full decision log, with the alternatives weighed, in DESIGN.md →