npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@geonosis/themekit

v3.1.0

Published

The tokens contract, a variable map that is data, and DTCG in and out — themes as files, never components.

Readme

@geonosis/themekit

The tokens contract, a variable map that is data, and DTCG in and out. Themes are files; the components are yours (D-010) — nothing here renders, and nothing here is React.

The theme contract is a Medusa storefront's, generalised, measured first in docs/themekit-inventory-2026-08-30.md. Zero runtime dependencies.

The gate this package is held to

src/byte-identity.test.ts runs a storefront UI kit's own compiler on a theme authored for it, both in src/__fixtures__, and requires the kit's output to match it variable for variable, value for value, in order — 36 custom properties, three of them derived color-mix() strings, two of them camelCase escape-hatch keys.

Not a recorded snapshot. A snapshot proves the kit still agrees with itself; this proves it agrees with the implementation it replaces, and it goes red the day the two diverge.

The three inputs

import { compile, defineTheme } from '@geonosis/themekit'

const theme = defineTheme({
  id: 'example-storefront',
  name: 'Example Storefront',
  colors: { primary: 'oklch(0.91 0.17 202)', onPrimary: 'oklch(0.22 0.02 240)', /* … */ },
  fonts: { body: "'Example Sans', Helvetica, Arial, sans-serif", display: "'Example Display', Georgia, serif" },
  radii: { none: '0px', sm: '4px', md: '8px', lg: '16px', full: '9999px' },
  shadows: { level1: '0 1px 5px rgb(0 0 0 / 0.12)' },
  extra: { brandFade: 'linear-gradient(100deg, …)' },
})
  1. The theme — the values. Colour roles are a named set (they are design-system vocabulary, not any repo's); fonts, radii and shadows are open maps, because font roles are data and an app may have no radii at all. Anything the contract does not model goes in extra.

  2. The emission map — which custom properties those values compile to, in what order. This is the part that is one repo's stylesheet, so it is data and never a default (law 6):

    compile(theme, {
      emit: [
        { role: 'colors.primary', variable: '--mc-primary' },
        { mix: { role: 'colors.primary', amount: '85%', with: '#000000' }, variable: '--mc-primary-hover' },
        { each: 'extra', prefix: '--mc-' },
      ],
    })

    Three entry kinds: role copies a value, mix writes color-mix(in <space>, <value> <amount>, <with>), each writes one variable per key of an open map, verbatim — --mc-brandFade stays camelCase, because it is what the stylesheet already ships is reading.

    Two behaviours that look like details and are not: an emission whose role resolves to nothing is skipped (that is how --accent stays conditional, with no special case — and why nothing ever emits --accent: ;), and a later entry naming an earlier variable replaces its value while keeping its position (that is how extra overrides a derived colour).

    defaultEmission('--theme-') is what a repo that has declared nothing gets: one variable per role, complete, and nobody's house style.

  3. The themeBlock namer — Tailwind v4 has no config file, so a utility exists because a variable exists. Hand it a function and it generates the @theme inline block from the same emission list, so a variable can never be mapped that was never written.

The four outputs

| | what it is | who reads it | |---|---|---| | cssVariables | Record<'--…', string>, in emission order | spread straight into a style prop | | css | <selector> { … }, default :root | a stylesheet, a <style> tag | | themeBlock | @theme inline { --color-…: var(--…); } | Tailwind v4 | | plain | role path → value: plain['colors.primary'] | react-email inline styles, <svg fill>, canvas | | object | the theme, unchanged | a switcher, a logo lookup |

plain is keyed by role, not by variable, on purpose: a mail client that drops var() needs the value, and which variable a repo calls it by is not its business. It also carries roles no variable was emitted for — a Medusa storefront's map emits nothing for success or warning, and an email that wants the success green must still be able to ask.

DTCG, and the one place this deviates

toDtcg(theme) and fromDtcg(document). Groups, $value, $type, $extensions — the Design Tokens Community Group draft, with one deliberate deviation: $value is the CSS string, exactly as authored, where the draft wants an object.

The draft types a colour as { colorSpace, components }, a dimension as { value, unit }, a shadow as five named fields. Three kinds of real value cannot become those objects at all:

  • var(--mc-gray-c) has no colour space — and a DTCG {alias} is not a substitute, because an alias resolves inside the file while var() resolves in the browser, and that late binding is the whole point of a palette layer;
  • color-mix(in srgb, … 85%, #000000) is a computation, not a colour;
  • 0 1px 5px rgb(0 0 0 / 0.12) is a spelling — parse it into five fields, print it back, and you have the same colour and different bytes.

Different bytes is the whole problem: the gate on this extraction is that a consumer's stylesheet is unchanged afterwards, so a format that re-spells a value on the way through is not an interchange, it is a rewrite.

So the deviation is declared and measured, never assumed:

validateDtcg(document)                    // is it this profile? errors + `lossy`
validateDtcg(document, { strict: true })  // which values could the DRAFT not hold?

strict names them one by one — the extension the draft would need, per token, instead of a paragraph in a README nobody diffs. lossy names what a round trip through the contract would drop (today: $description, which the contract has nowhere to put), so nothing evaporates quietly.

Two laws hold and are tested: toDtcg(fromDtcg(x)) is x, and a theme that has been through JSON.parse(JSON.stringify(toDtcg(…))) still compiles byte-identically. Brand identity (id, name) rides in the root group's $extensions under com.microcompanies.geonosis, because it is metadata with no $value — a logo, likewise, belongs in brand.json beside the tokens and not in the token file.

The registry

import { compile, loadThemekit } from '@geonosis/themekit'

const kit = loadThemekit('themekits/example-storefront', { pinned: '1.0.0' })
const { css } = compile(kit.theme, { emit: kit.emit, selector: `[data-brand="${kit.brand.id}"]` })

A themekit is a directory — themekit.json (name, version, complete, which map to compile through), theme.tokens.json, brand.json, CHANGELOG.md. This package ships none of them (files: ["dist"]), so installing a tokens compiler never installs somebody else's palette; see themekits/ for why that follows from D-010, D-026 and D-027.

{ pinned } is what makes a bump invisible until the consumer moves the pin: a directory whose version has moved is refused, naming both. A pin nobody checks is a comment.

loadThemekit validates and refuses; it never repairs. It stops on a directory that is not a themekit, a manifest with no version, tokens that are not this profile (naming the token), a manifest that claims complete while a required role is empty, and a JSON file that will not parse — read as {}, that last one is a themekit that compiles to nothing and a page that renders unstyled with nothing in the log.

An incomplete themekit is allowed and is reported: missingRoles names each required role the theme does not fill. themekits/example-app is one — two colour roles out of fourteen, the shape of an app that styles the rest from Tailwind classes directly, and inventing its primary is the one thing a registry must never do.

Figma variables in

import { fromFigmaVariables } from '@geonosis/themekit'

const { themes, unmapped, unsupported } = fromFigmaVariables(payload, {
  map: { 'color/primary': 'colors.primary', 'radius/md': 'radii.md' },
  floatUnit: 'px',
})

Over the shape GET /v1/files/:key/variables/local documents. Modes become themes: a file's Semantic collection with Light and Dark yields two DTCG documents, each of which validates and compiles. Pulling the payload is your step — this package makes no network calls.

Aliases are resolved, which is most of the work. A real Figma file is a Primitives collection nobody themes and a Semantic one whose every variable aliases into it; an importer that did not follow those would write VariableID:1 where a colour goes and — worse — every mode would come out identical, because an id does not change per mode. The chain is followed in the mode being imported, falling back to a collection's own default when it does not have that mode (Primitives has never heard of Dark), and a cycle is reported rather than run.

Nothing is dropped quietly. map is the designer's vocabulary meeting the repo's, and it is data you pass (law 6). A variable nobody mapped goes into extra and is listed in unmapped. A variable that cannot become a token at all is listed in unsupported, with the reason:

  • a BOOLEAN is not a design token;
  • a FLOAT carries no unit — Figma has 8 and nothing anywhere says px or rem. Guessing px is right most of the time and produces, the rest of the time, a layout nobody can trace to an import. Pass floatUnit and it imports;
  • a STRING mapped where a colour goes, named with the variable rather than surfacing later as an anonymous validation error.

The migration, for a repo that has a theme object already

const asTheme = (brand: BrandTheme): Theme => ({
  ...brand,
  fonts: { ...brand.fonts },
  radii: { ...brand.radii },
  shadows: { ...brand.shadows },
})

That is all of it, and the spreads are not ceremony: this contract widened those three to open maps, and TypeScript will not let an interface stand in for a Record<string, string> — an interface can be augmented later, so it carries no implicit index signature. Spreading copies strings, so the compiled output does not move.

The migration, for a repo that has only a stylesheet

npx geonosis-themekit ingest --from <the stylesheet> --out themekits/<name>
npx geonosis-themekit drift  --from <the stylesheet> --kit themekits/<name>

ingest reads every custom property one selector declares and writes the four themekit files. drift is the gate: zero, or a refusal naming the variable. It is what a migration nobody may risk is allowed to stand on, and it is a gate rather than a review because a stylesheet is where a wrong value is invisible until somebody looks at the page.

Why the gate compares declarations and not pixels

Because the compiled block is the source block. A value is stored exactly as authored and never re-spelled, so a themekit that emits the same declarations in the same order produces a byte-identical :root { … } — and two byte-identical blocks render identically without anyone opening a browser.

A pixel comparison would be weaker here, not stronger, and a Workers + D1 app's sheet is where that is measured rather than argued: across the 19 declarations its three theme selectors carry, 10 are oklch() and the rest are rgb() and #hex. A rasteriser inside this package would need a colour library to resolve those, which is the dependency the byte-identity gate was built to do without; and every value it could not resolve would compare equal in the frame, blind exactly where a migration is riskiest.

So a one-unit move is a refusal, quoted from the real run over a Workers + D1 app's stylesheet:

geonosis-themekit: --color-stripe-pattern: "#dbdbdb" in the stylesheet, "#dbdbdc" from the themekit
geonosis-themekit: 1 drift over 7 declaration(s) of :root — the themekit does not render what globals.css renders.

--roles, and what happens without it

{ "--color-page": "colors.background", "--color-container": "colors.surface" }

A variable a repo has not mapped lands in extra under its own name minus the leading dashes, which the contract keeps verbatim; the emission map names the same custom property back. There is no default mapping and there never will be one: which role --color-page plays is a claim only a Workers + D1 app can make (law 6). Without a map the migration is still worth running — the tokens become DTCG, the drift becomes a gate — and ingest prints the required roles nobody filled.

What it refuses rather than drops

The contract stores one non-empty string per token, so four things are named and nothing is written:

| refused | why the contract has no slot | |---|---| | --x: ; | an empty value; '' is how the contract spells absent, so storing it would delete the token | | --x declared twice in one selector | one slot, two values | | @media (…) { :root { --x: … } } where --x was taken | a condition is not a slot; ingesting one half silently drops the other | | --x mapped onto a path the contract has no role for | the map is the repo's, and a typo in it would compile a complete set of variables under wrong names |

Only blocks with a selector are read. An at-rule's own declarations — Tailwind's @theme, @property, @font-face — are not one selector's tokens and are not ingested.

Two selectors are two themekits

npx geonosis-themekit ingest --from globals.css --out themekits/night --selector html.night

One selector is one theme, because a theme is one set of values. A consumer's sheet measured here holds three, and all three ingest and gate at 0 drift on their own.

Adoption status — NOT adopted (D-027)

A package is DONE when every consumer depends on the published version and has deleted its copy. Neither has, yet:

| consumer | today | the five-line migration | |---|---|---| | a repo with a theme object | its own contract and defineTheme | (1) depend on @geonosis/themekit; (2) const asTheme = (b: BrandTheme): Theme => ({ ...b, fonts: { ...b.fonts }, radii: { ...b.radii }, shadows: { ...b.shadows } }); (3) const kit = loadThemekit('themekits/<name>', { pinned: '1.0.0' }); (4) themeToCssVars(t) → compile(asTheme(t), { emit: kit.emit }).cssVariables; (5) delete the contract and the factory, keep the React provider — it is React and stays. The stylesheet is untouched. | | a repo with only a stylesheet | no theme object at all | (1) depend on @geonosis/themekit; (2) geonosis-themekit ingest --from <the stylesheet> --out themekits/<name>, measured at 0 drift on 2026-09-02; (3) read kit.missingRoles — fourteen with no --roles map, and they are honest; (4) compile(kit.theme, { emit: kit.emit }); (5) nothing to delete. Each dark selector is its own --selector ingest; a mode axis inside one theme is still not in the contract. |

The byte-identity evidence for step (4) is proofs/022-W6-byte-identity; what the two conformance rules find in each tree, read-only, is proofs/022-W6-consumers.

What is deliberately not here

  • No components, no React, no provider. D-010: the factory stores themes and the contract, never components. A provider is 70 lines a consumer writes against its own framework.
  • No browser and no capture. Per-theme visual baselines are @geonosis/visual-diff's job, and what to capture is yours. The recipe — one baseline per <surface>@<brand> per breakpoint, at threshold: 0, with the compiled CSS injected rather than re-implemented — is in docs/themekit-visual-and-switcher.md, together with the three non-obvious parts of a Storybook switcher (the portal/document.body mirror above all).
  • No brand names, no token names, no font roles as defaults. They are data in themekits/<brand>/ or options. The default emission map is deliberately generic and deliberately not a Medusa storefront's.
  • No dependencies. The moment this package grew a colour library to parse oklch(), it would also grow that library's opinion about how to print one back — the byte-identity gate lost to a transitive dependency.