@divtech/odyssey-ui
v0.7.3
Published
Odyssey design system — React + Tailwind + shadcn components
Readme
@divtech/odyssey-ui
Odyssey design system — React 19 + Tailwind v4 + shadcn-style components, published publicly to npmjs.org.
Quick start
pnpm install
pnpm storybook # http://localhost:6006Scripts
| Command | What it does |
| ---------------------- | -------------------------------------------------------- |
| pnpm storybook | Storybook dev server with a light/dark toolbar toggle |
| pnpm build | Tokens → dist/ (ESM + CJS + .d.ts + styles.css) |
| pnpm tokens | Regenerate src/styles/tokens.css from tokens/*.json |
| pnpm icons | Regenerate src/icons/ from the Foxic API |
| pnpm typecheck | tsc --noEmit |
| pnpm build-storybook | Static Storybook into storybook-static/ |
Layout
One folder per component. Everything a component owns lives beside it:
src/
├── components/
│ └── button/
│ ├── button.tsx
│ ├── button.stories.tsx
│ └── index.ts
├── icons/ generated — 301 Foxic icons
├── styles/ tokens.css (generated) + theme.css (hand-owned)
├── hooks/ ThemeProvider / useTheme
└── lib/utils.ts cn()Figma tokens
tokens/ holds one file per Figma mode, exported straight from Figma's
Export variables. Currently four, 169 variables each:
Light Desktop.tokens.json Light Mobile.tokens.json
Dark Desktop.tokens.json Dark Mobile.tokens.jsonpnpm tokens runs scripts/build-tokens.mjs and regenerates src/styles/tokens.css.
Mode names come from $extensions["com.figma.modeName"] and split on two axes —
/dark/i → theme, /mobile/i → breakpoint. Light + desktop is the base; every other
mode is emitted as a diff, so it is obvious which tokens are actually mode-specific:
:root { /* light desktop, all 169 */ }
.dark { /* only what dark changes */ }
@media (max-width: 767px) { /* only what mobile changes */ }Token paths become kebab-case --od-* variables:
color/Control/Primary/BgHover → --od-color-control-primary-bg-hover.
The script also handles generic DTCG, Tokens Studio, and the Figma REST
/variables/local response, in case the export route ever changes.
⚠️ Dark mode is not populated in Figma
The two Dark * exports contain byte-for-byte the same colour values as the light
ones — color.Bg.Default is #FFFFFF in the dark mode too. So .dark is currently
empty and dark mode renders identically to light. Nothing in the code needs to change
when this is fixed: assign real values to the dark mode in Figma, re-export over
tokens/, and run pnpm tokens. The generator prints a warning while this is the case.
Three layers
| Layer | File | Owner |
| ------------------------ | ----------------------- | --------- |
| Figma primitives --od-* | src/styles/tokens.css | generated |
| Semantic names | src/styles/theme.css | hand-owned |
| Tailwind utilities | @theme inline in theme.css | hand-owned |
Components consume semantic names only (bg-primary, hover:bg-primary-hover,
px-200) — never --od-* directly. That indirection is what lets a Figma re-export
restyle the library without touching component code.
Figma's space/* and rounding/* scales are exposed as Tailwind utilities:
px-200 → 16px, gap-150 → 12px, rounded-lg → 12px, rounded-full → 200px.
See the Button → Tokens story for a live swatch of the whole set.
Theming
Dark mode is a .dark class on an ancestor (usually <html>). Either drive it yourself
or use the bundled provider:
import { ThemeProvider, useTheme } from "@divtech/odyssey-ui";
import "@divtech/odyssey-ui/styles.css";
<ThemeProvider defaultTheme="system">
<App />
</ThemeProvider>;Icons
src/icons/ is generated from the public Foxic
project odyssey (301 icons, v0.0.2) by pnpm icons. The output is committed, so builds
and CI never depend on the Foxic API being up — re-run only when the icon set changes.
Re-runs are cheap: each file records its icon's checksum and unchanged icons are not refetched.
The generator fixes two things about the source SVGs: it injects the viewBox (which only
exists in the API metadata, not the markup) and rewrites baked hex fills to currentColor,
so icons inherit text colour.
Icons ship as a separate entry point, because tsup bundles each entry into one file and folding 301 components into the main barrel would defeat tree-shaking:
import { CheckOutlineIcon } from "@divtech/odyssey-ui/icons";
<CheckOutlineIcon /> {/* native size: 16, 20 or 24 per icon */}
<CheckOutlineIcon size={32} />
<CheckOutlineIcon className="size-6 text-tone-alert" />Fonts
The design uses TT Commons Pro (TypeType, commercial). The library declares it in
--font-sans but ships no font files — drop your licensed webfonts into src/fonts/
and run pnpm fonts, which generates src/styles/fonts.css.
The generator reads weight and style off the filename (*-DemiBold.woff2 → 600,
*-MediumItalic.woff2 → 500 italic) and merges multiple formats of one face into a single
@font-face with woff2 first. A *-Variable.woff2 wins outright: the static faces are
skipped so the browser never downloads both. See src/fonts/README.md for the full table.
Without the files everything still builds — fonts.css comes out empty and --font-sans
falls through to the system UI stack. Layout stays correct; only the typeface is wrong.
The published package carries no fonts — by design
src/fonts/* is gitignored and src/styles/index.css does not import the generated
fonts.css, so neither the repo nor the npm tarball ever contains a woff2. On a public
registry that is not optional: redistributing TT Commons Pro would breach its licence.
Consumers self-host the face and declare it themselves — see Consuming → Fonts.
Storybook loads src/styles/fonts.css directly, so local dev is unaffected.
Still open: font-display: swap will flash the fallback before the webfont lands.
Fixing it properly needs a size-adjust/ascent-override fallback face computed from the
real font metrics — worth doing once the files are in place.
Adding a component
src/components/<name>/<name>.tsx—cvafor variants,cnfor class merging, matchingbutton.tsx.src/components/<name>/<name>.stories.tsx— include aVariantsstory so light/dark is easy to eyeball.src/components/<name>/index.ts— re-export the public surface.- Export it from
src/index.ts.
Pulling one from upstream shadcn works too: copy the source in, then swap @radix-ui/react-*
imports for the unified radix-ui package this repo uses.
Two gotchas
- Never hardcode a hex or a raw
--od-*. Use a semantic name fromtheme.css. If the role you need doesn't exist, add it there rather than reaching past it. - New scales must be registered with tailwind-merge in
src/lib/utils.ts. It cannot telltext-body(a font size) fromtext-tone-white(a colour) on its own — it groups both astext-<unknown>and lets the last one win, which silently deletes the colour.
Publishing
Published publicly to npmjs.org as @divtech/odyssey-ui. The repo carries no .npmrc — it is
gitignored, so a stray token can never be committed; the publish credential is supplied per
machine or by CI.
One-time setup on npmjs.org:
- Create the
divtechorganization (npmjs.com → Add organization → Free). The free plan allows unlimited public packages; it is what makes the@divtechscope yours. The plainodyssey-uiname was not available — an unrelated package has held it since v0.0.5 — which is why the package is scoped. - Generate a granular access token (Access Tokens → Generate → Granular) with
read/write on the
@divtechscope, and tick Bypass two-factor authentication. That checkbox defaults to off, and without it the publish dies onE403 … two-factor authentication or granular access token with bypass 2fa enabled is required— the token authenticates fine (npm whoamisucceeds), it just cannot publish. The setting cannot be added later, so a token created without it has to be replaced. Classic Automation tokens used to work here; npm removed classic tokens in November 2025, so granular is the only option now. - Store it in GitLab as the CI/CD variable
NPM_TOKEN, masked. Leave Protected unchecked unless Settings → Repository → Protected tags actually carries av*pattern — otherwise the variable never reaches the tag pipeline and the job fails on thetest -n "$NPM_TOKEN"guard instead.
publishConfig.access is public — without it npm defaults a scoped package to restricted
and refuses to publish on a free account.
Releases run off tags:
npm version patch && git push --follow-tagsThe publish job fires on any v*.*.* tag and pushes to npmjs.org.
To publish by hand instead, npm login once, then pnpm publish.
Consuming
Public package on the default registry — nothing to configure:
pnpm add @divtech/odyssey-uiOnly if a project routes installs through a private proxy (Nexus, Verdaccio) does it need
one line in its .npmrc, so the scope skips the proxy:
@divtech:registry=https://registry.npmjs.org/Then in the app root:
import "@divtech/odyssey-ui/styles.css";
import { Button } from "@divtech/odyssey-ui";
import { IconChevronDown } from "@divtech/odyssey-ui/icons";styles.css is prebuilt by Tailwind here, so consumers do not add an @source for
node_modules/@divtech/odyssey-ui. Peer deps are just react and react-dom (18 or 19).
Fonts
The package ships no font files. TT Commons Pro is a commercial TypeType face and
putting the woff2 files in a public tarball would be redistribution, which its licence does
not permit. dist/styles.css therefore carries no @font-face — until the host app
supplies one, components render in the fallback stack.
Apps with a licence declare the two families themselves, under exactly these names:
@font-face {
font-family: "TT Commons Pro"; /* weights 450, 500, 600 */
font-weight: 500;
font-display: swap;
src: url("/fonts/TT_Commons_Pro_Medium.woff2") format("woff2");
}
@font-face {
font-family: "TT Commons Pro Expanded"; /* weight 600 — Avatar initials only */
font-weight: 600;
font-display: swap;
src: url("/fonts/TT_Commons_Pro_Expanded_DemiBold.woff2") format("woff2");
}The names are what --font-sans and --font-initials ask for in theme.css; getting them
wrong is a silent fallback, not an error. Inside this repo the faces still load — Storybook
imports src/styles/fonts.css directly, which index.css no longer pulls in.
License
MIT — see LICENSE.
That covers the code in this repository only. TT Commons Pro is not ours to license: it is a commercial TypeType face, no part of it ships here, and using it in your app needs a licence bought from TypeType. The MIT grant on this package confers no right to the typeface.
