Consumer-side changes, in the order they need to happen. Nothing here has been applied to either repo — this is the plan, not a record.
Both consumers are SvelteKit apps in a bun workspace behind a docker-bake build, so the shape is the same for each:
| engine | rgs | |
|---|---|---|
| Workspace root | front/ |
services/front/ |
| Apps | apps/provider |
apps/operator, apps/admin |
| Dockerfile | front/Dockerfile |
deploy/front/Dockerfile |
| Bake file | deploy/studio-front/docker-bake.hcl |
deploy/front/docker-bake.hcl |
| Stylesheet | front/packages/shared/src/app.css |
same path under services/front |
None. @engineio/ui is public on npmjs, so nothing needs configuring — no
.npmrc, no token, no packages: read in CI, no secret mounted into the Docker
build. bun install resolves it like any other dependency.
This section used to be the longest in this document. The package was on GitHub Packages, which requires an access token to install even for public packages — so every developer needed a classic PAT, SSO-authorised and renewed on expiry, and every CI job and Docker build needed the same credential plumbed in. Moving to npmjs deleted all of it.
A .npmrc mentioning npm.pkg.github.com in either repo is left over from that
period and can go.
cd front && bun add @engineio/uiThen in packages/shared/src/app.css — and this is the step that matters most,
because the package and the existing file both define the same tokens:
@import "./fonts.css";
@import "tailwindcss";
+@import "@engineio/ui/styles";
+@source "../../../node_modules/@engineio/ui/dist";
@import "tw-animate-css";Then delete from app.css:
- the entire
@theme { … }block — colours, greys, radii, shadows, easings. It now comes from the package, and two@themeblocks defining--color-primarydifferently is a coin flip. @utility field { … }— shipped instyles/utilities.css.- the
color-scheme, scrollbar and.scrollbar-hiderules — same. - the
@import "./fonts.css"line, once you deletefonts.cssand the seven.otffiles instatic/fonts/(the package references its own by relative path). KeepLeagueGothic*if a product still uses it.
Keep in app.css: --gradient-brand*, the @keyframes, .floating,
.markdown-body, the input[aria-hidden] fix, carta.css and
container.css — product-specific, not brand.
@source resolves relative to the CSS file that declares it. From
front/packages/shared/src/app.css the workspace node_modules is three levels
up (src → shared → packages → front); rgs is the same depth under
services/front. Get it wrong and everything renders unstyled with no error, so
check it before wondering why the tokens did not land.
Two visible changes you should expect from the token layer, both deliberate:
Proxima Nova Semibold now maps to weight 600 rather than 500, so
font-semibold labels will render in the correct cut for the first time; and
--color-destructive becomes the real danger red #FF3B30 rather than the
oklch value inherited from shadcn, which had no brand owner.
Both repos alias $lib to the shared package via kit.files.lib, so this is a
find-and-replace with no config change:
-import { Button } from "$lib/components/ui/button/index.js"
+import { Button } from "@engineio/ui"Do it per component, not in one sweep, and delete
packages/shared/src/lib/components/ui/<name>/ only once nothing imports it.
The 35 primitives that are not in the package stay exactly where they are —
@engineio/ui and the local ui/ folder coexist fine.
Where you will hit friction — Button. The package drops tab,
tab-active, drawer, play and filled. Provider has 100+ button call
sites, some on those variants. Extend rather than fork — see the recipe in the
README — and put the extension in $lib/components/ui/button-variants.ts. Its
success variant does survive, now on the real status token rather than the
stock green it used to be.
Badge is a restyle, not a migration. Badge and Tag are merged into one
soft chip under the name Badge, and every variant provider actually uses —
default (9 sites), secondary (11), outline (11), success (3),
destructive (2) — survives with the same name. Nothing needs renaming.
What changes is how they look. The chip goes soft: default becomes a magenta
tint with primary-300 ink instead of a solid magenta fill with white text,
secondary becomes a translucent wash instead of an opaque grey, and the size
goes from 11px to 13px. There is no solid variant, because an opaque fill can
only be correct on one surface. Expect to eyeball the 35 call sites rather than
edit them, and note the change fixes a real defect: the old solid badge's
white-on-magenta at 11px measured 3.9:1.
Tag is gone entirely. Neither repo has a single <Tag> call site, so this
costs nothing.
There is now a status palette — success, warning and danger — which is what
lets destructive resolve to a real red instead of to magenta. If a product
has been faking status colours with stock Tailwind hues (there are ~724
off-palette utility usages in the engine repo), those are now replaceable with
real tokens. That is a good second pass, not part of this migration.
(Class names are written descriptively rather than literally throughout this file on purpose. Tailwind 4's automatic source detection scans markdown as well as source, so a literal utility mentioned in prose gets generated into the package's CSS — which is how two unused green custom properties ended up shipping in the first build.)
rgs's services/front/package.json is missing bits-ui and
tailwind-variants, which nearly every primitive imports. They are regular
dependencies of @engineio/ui, so bun add @engineio/ui pulls them in — no
action needed, but that is why the install is larger than it looks.
The operator app's 461 hand-written .ig-* selectors in
lib/styles/integration.css are the real prize here: most of them reimplement
Button, Card, Pill, Field and Modal because rgs never had access to the
primitives. Porting them is a separate, larger piece of work and should not be
bundled into this migration.
A check that tells you when a consumer has fallen behind, without failing anyone's build:
- name: Design system freshness
continue-on-error: true
run: |
have=$(node -p "require('./front/node_modules/@engineio/ui/package.json').version")
want=$(gh api /orgs/engineio/packages/npm/ui/versions --jq '.[0].name' 2>/dev/null || echo "$have")
[ "$have" = "$want" ] || echo "::warning::@engineio/ui $have installed, $want available"Warn, never fail. Nothing about this setup should be able to block a product release on a design system upgrade.
Ship §1 and §2 alone first — registry access plus the token swap, no component
changes. That is the highest-value, lowest-risk half: it puts both products on
one set of tokens and fixes the Semibold mapping. Then migrate components at
whatever pace suits, and treat the .ig-* port as its own project.