Skip to content

build(tsdown): replace Babel with tsdown across all gamut packages - #12

Open
dreamwasp wants to merge 14 commits into
mainfrom
cass-gmt-1741
Open

dreamwasp wants to merge 14 commits into
mainfrom
cass-gmt-1741

Conversation

@dreamwasp

@dreamwasp dreamwasp commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Overview

Replaces Babel with tsdown (rolldown) as the JS build for all seven publishable packages
(variance, gamut-styles, gamut, gamut-icons, gamut-patterns, gamut-illustrations,
gamut-tests), keeping tsc --emitDeclarationOnly for declarations — a hybrid build.

Declarations stay on tsc because tsdown's own bundled .d.ts produces 50× TS4023 in
downstream packages (a bundled declaration file doesn't re-export the names its dependents
need); keeping tsc also makes rolldown-plugin-dts#174 (directives leaking into .d.ts)
structurally unreachable, since declarations carry no statements to leak through.

Along the way this also fixes a live tree-shaking bug (three packages had no sideEffects
field at all — the most conservative possible value), adds strict exports maps where none
existed, and closes a correctness gap left by dropping @emotion/babel-plugin from the
production build (it stamps a target on every styled() call, which is what makes
component-selector interpolation work — one site needed it explicit, and there's now a lint
rule enforcing that going forward).

PR Checklist

  • Related to designs: N/A
  • Related to JIRA ticket: GMT-1741
  • Version plan added/updated
  • I have run this code to verify it works
  • This PR includes unit tests for the code change (new eslint rule test; existing suites
    re-verified against the new build output)
  • This PR includes testing instructions for the code change

Testing Instructions

  1. yarn install
  2. yarn build — full monorepo build via the new tsdown pipeline. Should complete cleanly for
    all 9 projects.
  3. yarn test — full jest suite (1276 tests across gamut + siblings). Nothing here changes
    behaviorally; this confirms the new build didn't break anything the old one didn't.
  4. yarn verify — typecheck pass, now with isolatedModules: true enforced repo-wide.
  5. yarn nx run-many --target=verify-package --allpublint + attw against the real built
    packages. Should report 0 errors (warnings/suggestions about engines.node and the
    deliberate false-cjs tradeoff are expected and non-blocking).
  6. node script/verify-exports.mjs — resolves every package's public surface from the repo
    root. Should print All export checks passed. with one flagged (not failing) lodash/ESM
    warning. You'll also see a wall of Node Warning: Accessing non-existent property ... inside circular dependency lines printed after the success message — that's a real,
    pre-existing circular import in gamut's Tip/shared/styles (composeVariantsUtils.ts
    styles.ts), surfaced because this script require()s the whole module graph.
    Confirmed benign (the values are only read inside function bodies, never captured at
    module-eval time, so the real values come back correct by call time) but noisy. Left as
    a follow-up rather than fixed here — either suppress it in the script or fix the actual
    circular import.
  7. yarn build-storybook && npx nx run styleguide:storybook-test — full Storybook build +
    Playwright/a11y pass (493 tests). Confirms the Storybook-only alias fixes and the
    deep-import cleanups didn't break any story, including the icon galleries
    (Atoms/Icons/Mini, Atoms/Icons/Regular) and the typography-variant table
    (Typography/Text) that the new aliases specifically unblock.
  8. Manual spot-check: open the BarChart story in Storybook and hover/focus a row — confirm
    the CategoryLabel underline still applies. This is the one change (the target fix) that
    nothing in the test suite can verify directly, since jest transforms src through Babel
    (which still runs @emotion/babel-plugin) and is structurally blind to a production-only
    regression here.

More info + other approaches

Results

Delivered: all seven publishable packages (variance, gamut-styles, gamut,
gamut-icons, gamut-patterns, gamut-illustrations, gamut-tests) off Babel entirely, on
a working tsdown + tsc hybrid build, verified end-to-end (yarn build/test/verify,
publint + attw on every package, a Node-resolver script, a 493-story Storybook pass —
all green from a cold cache).

Fixed, not just faster — three bugs that predate this PR and were live in production:

  • Today's output isn't valid Node ESM. Babel emits export * from './core'-style
    extensionless relative imports with no exports map; Node's own resolver rejects that
    outright (ERR_MODULE_NOT_FOUND). It only ever worked because every consumer bundles. Fixed:
    real, extensioned relative imports, verified by loading the built output directly.
  • Dead-code elimination was never possible on variance, gamut-styles, or gamut-tests.
    None of the three had a sideEffects field — the most conservative value there is — which
    blocks tree-shaking on the packages everything else in the ecosystem depends on. Fixed:
    sideEffects: false, verified safe (no module-level side effects in either package).
  • A real regression was caught before it could ship. The first pass at this used tsdown's
    default single-bundle output; a single-component import measured 15x larger than the old
    Babel build (1.59MB vs 103KB) because nothing could be proven dead once every export shared
    one module scope. unbundle: true (tsdown emits one file per source file, same shape as
    today, still via the fast bundler) fixed it completely — 112KB, back at parity with today.
    Same fix, same order of magnitude, on gamut-icons (586KB → 73KB for a single icon import).

New automated safety net that didn't exist at all before this PR: publint + attw +
a resolver script now run in CI on every package, checking that the actual exports/main/
module/types fields resolve the way a real consumer would. This is what caught the
regression above, plus a stale files array and three missing types fields — all before
merge, with nothing in the existing test suite (which never touches built dist output at
all) capable of catching any of it.

Build speed — honest numbers, not the spike's

Per-package build is tsdown (JS) + tsc --emitDeclarationOnly (types, unchanged from
before). Measured directly on gamut (426 files, the largest package):

step before (Babel) after (tsdown)
JS layer alone ~4.9s ~0.6–0.9s
types (tsc, identical either way) ~10–12s ~10–12s
full package build ~15–17s ~11–13s

A consistent ~3x per-file cost tied to Emotion type density, not raw file count.

As components move off styled()'s deep generic types onto plain explicit prop
types, tsc's declaration cost per file should drop toward the gamut-icons end of that
range — and that's also the point where a genuinely fast, non-tsc declaration emitter
(anything built on TypeScript's isolatedDeclarations, e.g. a future rolldown-plugin-dts
isolated mode, oxc, or a tsgo-style checker) becomes realistic to adopt, since it needs far
fewer manual type annotations to satisfy.

Investigated and rejected for now: isolatedDeclarations. Enabling the flag doesn't make
tsc itself faster — measured directly on variance: 1.29s with the flag vs 1.51s without,
noise-level. It's a static check that qualifies code for a different, non-checking emitter;
the speed win only exists if you also swap tools. Even variance (zero Emotion) had 8
violations; gamut/gamut-styles export 100+ styled() results whose types are inferred
from Emotion's generics, not something safely hand-annotated without risking a silent type
narrowing that breaks a consumer relying on the wider inferred type. Worth revisiting once the
static-styles migration above has simplified enough of the exported surface to make that
annotation burden small and low-risk — not before.

Build migration

  • packages/{variance,gamut-styles,gamut,gamut-icons,gamut-patterns,gamut-illustrations,gamut-tests}/tsdown.config.ts
    — new tsdown config per package. dts: false (tsc still owns declarations), unbundle: true
    (mirrors src/ file-by-file instead of concatenating into one file — a single bundled
    dist/index.mjs measured 15× larger for a single-component import, since nothing can be
    proven dead once everything shares one module scope), clean: false (declarations share
    dist/ with tsc's output).
  • packages/*/project.jsonbuild target commands swap babel ./src --out-dir ./dist for
    tsdown, keeping the existing tsc --emitDeclarationOnly / cpy steps around it. SVGR
    codegen steps (icon/pattern generation) are untouched.
  • packages/*/package.json — real exports maps added (gamut, gamut-styles, and
    gamut-illustrations previously had no types field at all). main/module point at the
    real filenames tsdown produces (dist/index.js for CJS, dist/index.mjs for ESM — not
    .cjs/.mjs as an earlier draft assumed, since none of these packages set "type": "module").
    gamut-styles' files array dropped four entries (core, utils, core.scss, utils.scss)
    that don't exist on disk.
  • tsconfig.base.jsonisolatedModules: true, ratcheting against the type-only re-export
    debt rolldown can't see through (verified 0 errors across every affected package before
    enabling).
  • package.json — adds tsdown, publint, @arethetypeswrong/cli as root devDependencies,
    all version-pinned exactly (not ^) — each is pre-1.0, and an unpinned minor bump could
    silently change tsdown's build output or add a new publint/attw check that flips CI red.
  • Considered, tested, and rejected: setting "type": "module" (or "commonjs") on any of the
    seven package.jsons to remove the "Node may attempt to detect the package type" perf-hint
    publint suggests. Every package's root directory holds both an ESM tsdown.config.ts
    (import { defineConfig } from 'tsdown') and a CJS babel.config.js
    (module.exports = {...}, still needed for jest/Storybook). Node's "type" field applies to
    every extensionless .js/.ts file in that directory tree — setting either value breaks one
    of the two config files' loading. Verified directly: "type": "commonjs" broke tsdown's own
    config loading (Cannot use import statement outside a module); "type": "module" broke
    every jest suite the same way in the other direction (ReferenceError: module is not defined in ES module scope on babel.config.js). No "type" field is the only value compatible with
    both toolchains coexisting — and it costs nothing for real consumers, since the shipped
    dist/index.js / dist/index.mjs extensions already disambiguate module kind regardless of
    the package's own "type".

Tree-shaking fix

  • variance, gamut-styles, gamut-testssideEffects: false added; none had the field at
    all, which is the most conservative possible value and blocked dead-code elimination on the
    packages everything else depends on.
  • gamut's existing sideEffects glob reduced to ["**/*.css", "**/*.scss"] — the two
    dist/**/[A-Z]**/*.js globs (added in #932, a real 2020 fix for Emotion-10-era style-order
    drift) matched nothing once unbundle: true changed dist's shape. Re-verified the 2020 bug
    doesn't reproduce under Emotion 11: a 493-story Emotion-CSS diff across the glob on/off came
    back byte-identical, under real tree-shaking (not just structurally unreachable, actually
    exercised).

Emotion target correctness

  • packages/gamut/src/BarChart/BarRow/elements.tsx@emotion/babel-plugin stamps a target
    on every styled() call automatically; that's what makes the ${CategoryLabel}
    component-selector interpolation work. The plugin doesn't run through tsdown, so this is the
    one site (repo-wide) that needed an explicit target to avoid a silent .undefined fallback
    in production.
  • packages/eslint-plugin-gamut/src/require-styled-target-for-selector.ts (new) — flags any
    future ${Component} style-selector interpolation whose styled() call has no explicit
    target. Added to recommended.ts. Verified zero violations across all five component
    packages' real source (--no-inline-config, ignoring existing disable comments).

Storybook fixes

packages/styleguide/.storybook/main.ts already aliases every bare @skillsoft/gamut*
specifier straight to that package's ../src — Storybook has never actually consumed any
package's built dist, independent of this migration. Adding strict exports maps broke the
handful of imports that carry an extra /src path segment and so don't match that alias:

  • 7 files (GamutTheme.ts, GamutThemeProvider.tsx, TokenTable/elements.tsx,
    ColorScale.tsx, Layout.mdx, Alerts.mdx) were importing symbols that are already exported
    at the package root — fixed by dropping the /src suffix.
  • 4 new Storybook-only webpack aliases added for genuinely internal groupings with no public
    equivalent (icon categories, raw typography-variant metadata, the full system-props registry)
    — each already carried an acknowledged // eslint-disable-next-line @skillsoft/gamut/import-paths
    comment, pre-existing debt this surfaced rather than introduced. Keeps every package's
    exports map an honest description of its published surface instead of growing it to fit
    docs tooling.

Packaging verification

Why both tools, and why now: before this branch, none of these seven
packages had an exports map or dual ESM+CJS output — there was nothing for either tool to
meaningfully check. This migration is exactly what introduces the class of bug both exist to
catch, and nothing else in the repo's own tooling catches it — tsc only validates imports
inside the compile, not what a downstream require()/import would resolve to, and jest
transforms src directly through Babel, never touching dist. publint and attw check two
different, non-overlapping things:

  • publint checks the packaging contract: do main/module/types/exports/files in
    package.json actually match what's on disk? It caught real bugs in this migration before
    attw even ran — gamut-styles' files array listed four entries (core, utils,
    core.scss, utils.scss) that don't exist on disk, and gamut, gamut-styles, and
    gamut-illustrations had no types field at all.
  • attw checks that the type declarations match the JS a consumer actually gets, across the
    different resolution strategies a consumer's own tsconfig might use (node10, node16 from
    CJS, node16 from ESM, bundler). It's what surfaced the false-cjs finding below —
    publint only flags that as an ambiguity warning, attw treats it as an outright resolution
    failure under the node16-from-ESM profile.

Since this repo currently has zero real consumers to catch a broken exports map in the wild
(see the "not published" note further down), these two are the substitute for that missing
feedback loop.

  • packages/*/project.json — new verify-package target (publint . +
    attw --pack . --ignore-rules false-cjs) on every publishable package.
    false-cjs is suppressed deliberately: it flags the hybrid's one accepted tradeoff (a single
    shared dist/index.d.ts serving both import/require conditions), which only affects
    moduleResolution: node16/nodenext consumers importing via ESM — this repo's own
    tsconfig.base.json uses legacy moduleResolution: "node", and the bundler profile (the real
    consumption path) is unaffected.
  • script/verify-exports.mjs (new) — resolves every package's exports map from the repo root
    the way a real consumer would: asserts the root subpath resolves and loads via both
    require() and import(), and asserts old deep dist/* paths are correctly blocked with
    ERR_PACKAGE_PATH_NOT_EXPORTED. Flags (doesn't fail on) one pre-existing gap: variance
    imports lodash via extensionless deep subpaths (lodash/get), which lodash's own lack of an
    exports map makes unresolvable under native Node ESM — bundlers handle it fine. Scoped out
    as a fast-follow rather than blocking here.
  • .github/workflows/test.yml — wires verify-package and verify-exports.mjs into the
    storybook-test job, ahead of the Storybook build.

Babel config cleanup

Babel is still load-bearing for two things this PR doesn't touch — babel-jest (every
package's jest.config.ts) and Storybook's Vite pipeline (packages/styleguide/.storybook/main.ts,
which adds @vitejs/plugin-react with @emotion/babel-plugin configured, since Storybook's
react-vite framework transpiles JSX with esbuild by default and doesn't add this itself).
With the library build off Babel entirely, the seven per-package babel.config.js files were
carrying duplicated dead weight: each declared the same presets array byte-for-byte,
differing only in whether @emotion/babel-plugin was added.

  • babel.defaults.js — now also exports the shared presets array (previously duplicated
    in all seven packages/*/babel.config.js). Verified Babel's extends merges arrays rather
    than overriding them (tested directly via @babel/core.transformFileSync), so moving the
    array up doesn't change behavior for any package.
  • packages/*/babel.config.js (all seven) — now just { extends: '../../babel.defaults.js' }
    plus, for the five packages that need it (variance, gamut-styles, gamut,
    gamut-illustrations, gamut-tests), the @emotion/babel-plugin block. gamut-icons and
    gamut-patterns need neither Emotion nor any other override.
  • packages/styleguide/.babelrc.json — deleted. Storybook's Vite migration means nothing
    reads it anymore; confirmed orphaned before removing it.
  • Verified via full yarn build/yarn test/yarn verify and a no-cache jest run (117
    suites, 1,563 tests) — all unchanged and passing, plus a direct
    @babel/core.transformFileSync check confirming both the JSX/TS transform and the
    Emotion target/label stamping still work identically per package.

A follow-up spike for removing Storybook's remaining Babel dependency entirely (swap
@vitejs/plugin-react for @vitejs/plugin-react-swc + @swc/plugin-emotion) is to be completed
separately: GMT-1795.

dreamwasp and others added 14 commits September 16, 2026 10:30
…ut-tests

None of these three packages declared a `sideEffects` field, which is the
most conservative default: webpack must assume every module in the barrel
is side-effectful and cannot drop unused exports. That currently applies to
the packages everything else depends on.

Verified safe to mark `sideEffects: false`:
- no module-level `document`/`window` access or `injectGlobal` in either
  package's source
- gamut-styles' globals/{Reboot,Variables,Typography}.tsx and
  GamutProvider.tsx use Emotion's `<Global>` component, which runs at
  render time inside React, not at module evaluation
- AssetProvider.tsx's createFontLinks is an exported function, not a
  module-eval call

This is PR 0a of the tsdown migration plan (GMT-1741) — a standalone fix
for a live tree-shaking bug on the current Babel build, landing ahead of
the build-tooling change. gamut and gamut-illustrations carry a separate,
deliberately-added sideEffects glob (added in #932 to fix real style-order
drift) that needs a CSS-diff experiment before it can be safely removed;
that's scoped to PR 0b.

Verified: `yarn build`, `jest --selectProjects gamut-styles variance
gamut-tests` (181/181 passing), and `yarn build-storybook` (production
webpack build) all succeed unchanged.
Replaces `babel ./src --out-dir ./dist` with `tsdown` for JS, keeping the
existing `tsc --emitDeclarationOnly` step for declarations (the hybrid).
Chain must migrate atomically (variance -> gamut-styles -> gamut) — a
half-migrated chain doesn't load, because Babel's ESM output has
extensionless relative specifiers that Node's own resolver rejects.

- dts: false in every tsdown.config.ts; tsc keeps emitting declarations
  into the same dist/ (clean: false in tsdown, single `rm -rf ./dist` at
  the head of the build target owns cleaning). Bundled tsdown
  declarations were tried and rejected: 50x TS4023 downstream, since a
  bundled .d.ts doesn't re-export names its dependents need.
- No external/deps.neverBundle needed for variance or gamut-styles —
  tsdown auto-externalizes anything in a package's own `dependencies` /
  `peerDependencies`, which already covers everything they import.
  gamut needs one exception: `deps.neverBundle: [/\.css$/]`, since tsdown
  refuses to bundle a relative .css import without @tsdown/css, and
  externalizing it matches the current cpy-based approach exactly.
- Output naming: since none of these packages set `"type": "module"`,
  tsdown names CJS `dist/index.js` and ESM `dist/index.mjs` (not
  `.cjs`/`.mjs` as sketched in early spike notes) — package.json
  `exports`/`main`/`module`/`types` updated to match the real filenames.
  Kept `main`/`types` accurate (not just `exports`), since this repo's
  tsconfig.base.json uses legacy `moduleResolution: "node"`, which
  ignores `exports` entirely for internal cross-package typechecking.
- gamut-styles: added the missing `types` field and dropped `files`
  entries (core, utils, core.scss, utils.scss) that don't exist on disk.
- gamut: sideEffects reduced to `["**/*.css", "**/*.scss"]` (the two
  `dist/**/[A-Z]**/*.js` globs from #932 are now dead — they matched
  gamut's old per-file dist shape, which no longer exists). Re-verify
  with the CSS-diff + esbuild single-import probe from the sideEffects
  investigation once the leaf packages (icons/patterns/illustrations)
  are also migrated, since gamut's own barrel depth was masking whether
  this field does anything until now.
- BarChart/BarRow/elements.tsx: `@emotion/babel-plugin` stamps a `target`
  on every styled() call unconditionally, and `target` is what makes the
  `${CategoryLabel}` component-selector interpolation work. The plugin
  only runs on the babel path now (jest/Storybook loader), not through
  tsdown, so this is the one site that needs an explicit target to avoid
  a silent `.undefined` fallback in the production bundle. Verified in
  the built dist/index.js: `target: "gmt-category-label"` survives.
- tsconfig.base.json: isolatedModules: true, ratcheting against the
  type-only re-export debt rolldown can't see through (verified 0 errors
  across every build-relevant package before enabling).
- gamut build:watch now runs `tsdown --watch` instead of a full rebuild
  on every source change (watch mode is otherwise unverified — worth
  confirming latency once used for real).

Verified: `yarn nx run-many --target=build --projects=variance,gamut-styles,gamut`,
full jest suite for all four affected packages (1457/1457 passing),
`yarn verify` (isolatedModules didn't break typecheck anywhere), and
direct CJS `require()` of variance's and gamut-styles' dist output.
gamut's own CJS load still fails via require() — but only because it
transitively depends on gamut-icons, which is still Babel-built and has
a pre-existing (unrelated) ERR_UNSUPPORTED_DIR_IMPORT under Node's
require(esm); confirmed identical failure on the untouched dist before
this change. Resolves once gamut-icons migrates next.

Part of the tsdown build migration (GMT-1741).
…ns, gamut-tests

Same hybrid pattern as the previous commit (tsdown for JS, tsc for
declarations), applied to the four remaining Babel-built packages. Nothing
depends on these, so they could lag the dependency chain — but since
gamut itself depends on gamut-icons/gamut-patterns/gamut-illustrations,
finishing them now is what actually resolves gamut's own CJS load (the
previous commit's ERR_UNSUPPORTED_DIR_IMPORT was gamut-icons transitively,
not gamut).

SVGR steps (icon/pattern codegen, the `-icon.svg` rename) are untouched —
tsdown/rolldown only replaces the babel step in each pipeline.

## unbundle: true, added to every package (retroactively, including the
## previous commit's three)

A single-component import probe (`import { FillButton } from
'@skillsoft/gamut'`, bundled with esbuild) found the default tsdown output
— one concatenated dist/index.mjs per package, root-only entry — pulls in
100% of that file's ~428KB plus every heavy dependency any of gamut's ~130
exports uses transitively (@vidstack/react, gamut-icons' whole barrel,
react-aria-components, framer-motion, @formatjs/*, react-select): 1.59MB
for one button, 15x today's Babel-build baseline of 103KB. Same problem on
gamut-icons: importing one icon pulled in all 377 (586KB).

`unbundle: true` (tsdown mirrors src/ file-by-file instead of
concatenating) fixes it completely — 112KB for the gamut probe, 73KB for
the icon probe, both at or below the Babel baseline — while keeping
tsdown's real relative-import extensions (.mjs/.js, not Babel's
extensionless ones) and the speed win. Applied to all seven packages for
consistency; the failure mode is structural (one shared module scope), not
size-dependent, so there's no reason to leave any package on the default.

One benign side effect, checked: unbundle's finer-grained CJS module graph
surfaces a pre-existing circular import in Tip's style utilities as a
Node "accessing non-existent property inside circular dependency" console
warning. Confirmed not a functional bug — the values are only read inside
function bodies, never captured at module-eval time, so by call time the
cycle has resolved; verified by requiring the built module directly and
calling it, real style values came back.

## Storybook deep imports into /src broke — and revealed Storybook doesn't
## actually consume any package's dist

packages/styleguide/.storybook/main.ts already aliases every bare
`@skillsoft/gamut*` specifier straight to that package's `../src`
directory (exact-match, `$`-anchored). That means Storybook has never
consumed built dist output, for any package, independent of this
migration — it only compiles raw TypeScript source through webpack. The
"Storybook proves the dist/exports map works" framing from earlier in
this migration doesn't hold; it only incidentally caught real breaks
because a handful of imports carry an extra `/src` path segment that
doesn't match the bare-specifier alias, so those specific ones fall
through to real node_modules -> exports resolution.

13 such imports broke once the exports map went strict:
- 7 were lazy — the symbols they wanted are already exported at the
  package root (theme, trueColors, Box, FillButton, etc). Fixed by
  importing from the root, matching the codebase's own
  `@skillsoft/gamut/import-paths` eslint rule.
- 6 reach into genuinely internal groupings with no public equivalent
  (icon categories, raw typography variant metadata, the full
  system-props registry for a docs table) — each already carried an
  acknowledged `// eslint-disable-next-line @skillsoft/gamut/import-paths`
  comment, pre-existing debt this migration surfaced rather than
  introduced. Fixed with four new Storybook-only webpack aliases pointing
  at the same monorepo source paths, keeping every package's `exports`
  map an honest description of its published surface instead of growing
  it to fit docs tooling.

Verified: full `yarn build`/`yarn test`/`yarn verify` (10 projects, 1276
tests), direct CJS `require()` of all seven packages' dist output
(matching export counts throughout), `yarn build-storybook` +
`nx run styleguide:storybook-test` (87 suites / 493 tests, including the
icon galleries and typography-variant table the new aliases unblock), and
a 493-story Emotion-CSS diff across sideEffects on vs. off — byte-identical
(0 differences), now under real tree-shaking (unbundle mode), confirming
the 2020 style-order bug (#932) genuinely doesn't reproduce under Emotion
11, not just that it was unreachable to test.

Part of the tsdown build migration (GMT-1741).
…olver, lint rule)

Closes out the remaining PR 1 verification scaffolding from the tsdown
migration plan (GMT-1741): the automated guard that substitutes for
having real published consumers to catch a broken exports map.

## verify-package nx target (publint + attw), all seven packages

Added `publint .` and `attw --pack . --ignore-rules false-cjs` as a new
`verify-package` target, depending on `build`, on every tsdown-migrated
package.

`--ignore-rules false-cjs` suppresses one accepted, deliberate tradeoff
of the hybrid build: attw flags "masquerading as CJS" because a single
shared `dist/index.d.ts` serves both the `import` and `require`
conditions (tsc only emits one declaration tree, not per-format
`.d.mts`/`.d.cts` copies). That only affects `moduleResolution: node16`/
`nodenext` consumers importing via ESM syntax — this repo's own
tsconfig.base.json uses legacy `moduleResolution: "node"`, and the
bundler profile (the actual consumption path) is unaffected. Splitting
declarations per format to satisfy this would mean tsc emitting twice
the output for a benefit no current consumer needs.

Tried adding `"type": "commonjs"` per publint's own suggestion (removes
a minor Node startup perf-hit note) and had to revert it immediately —
it broke tsdown's own ability to load `tsdown.config.ts`, which uses ESM
`import` syntax and lives in the same directory. Every package's config
file depends on the "type" field staying unset; verified by testing the
change, watching `variance:build` fail with "Cannot use import statement
outside a module", and reverting across all seven packages.

## script/verify-exports.mjs

Resolves every package's `exports` map from the repo root the way a real
consumer would: asserts the root subpath resolves AND loads via both
`require()` and `import()`, and asserts old deep `dist/*` paths are
blocked with ERR_PACKAGE_PATH_NOT_EXPORTED.

The ESM `import()` check surfaced a real, pre-existing gap: variance (and
everything depending on it) imports lodash via extensionless deep
subpaths (`lodash/get`, not `lodash/get.js`). lodash ships no `exports`
map, so Node's native ESM resolver — unlike `require()`, and unlike every
bundler's resolver (webpack/esbuild/rollup all probe extensions) —
refuses it outright. This is inherent to lodash's own packaging, predates
this migration, and only matters for a consumer running raw Node ESM
with no bundler in front, which isn't a real path for any package here.
Fixing it means rewriting ~25 files' lodash imports across 5 packages;
scoped out as a fast-follow rather than blocking here. The script treats
it as a clearly-flagged warning, not a failure.

## eslint rule: require-styled-target-for-selector

Codifies the BarChart `target` fix from the first tsdown-migration commit
as an enforced rule, added to `recommended.ts`. Flags a `styled()` call
used via `${Component}` interpolation in a computed style-object key
that has no explicit `target` — the exact silent-`.undefined`-in-production
failure mode `@emotion/babel-plugin` used to paper over automatically.

Verified against the real gamut/gamut-styles/gamut-icons/gamut-patterns/
gamut-illustrations source with `--no-inline-config` (ignoring existing
disable comments): zero violations, confirming BarChart was the only
site and there's nothing else to fix.

## CI

Wired `verify-package` and `verify-exports.mjs` into the storybook-test
job's build step, ahead of the Storybook build.

Verified: full `yarn build`/`yarn test`/`yarn verify`, `yarn nx run-many
--target=verify-package --all` (7/7 clean), `node script/verify-exports.mjs`
(all checks passed, lodash gap flagged not failed), and the new eslint
rule's test suite (60/60 passing across the whole plugin).

Part of the tsdown build migration (GMT-1741).
… into cass-gmt-1741

# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
The root tsconfig.json (not used by any real build — every package
verifies against its own scoped tsconfig via cwd) is what editors like
Zed fall back to for files no package-scoped tsconfig covers, which
today means tsdown.config.ts / tsdown.base.ts. Two problems stacked:

- The literal `.ts` extension on `import { baseConfig } from
  '../../tsdown.base.ts'` (required because tsdown's own config loader
  needs it) needs `allowImportingTsExtensions`, which itself requires
  `noEmit` or `emitDeclarationOnly`.
- tsdown itself ships no `main`/`types` fields, only a modern `exports`
  map, so it's completely unresolvable under the inherited
  `moduleResolution: "node"` — confirmed via tsc's own error message.

Scoped to this one root config, not tsconfig.base.json (which every
package inherits) — verified no real build/verify script uses the bare
root config, so this only affects editor diagnostics for the tsdown
config files, with zero effect on any package's real typecheck.

Part of the tsdown build migration (GMT-1741).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	packages/styleguide/.storybook/main.ts
#	yarn.lock
…babelrc

All 7 packages' babel.config.js had a byte-identical `presets` array
(preset-env + preset-react + preset-typescript), differing only in
whether @emotion/babel-plugin was added. Babel's own `extends` merges
arrays rather than overriding them, so moving the shared presets into
babel.defaults.js (which every package already extends, for its
`ignore` patterns) lets each package's own config shrink to just what's
actually different for it — the emotion plugin, or nothing at all.

Verified the merge behavior directly rather than assuming it: ran
@babel/core's transformFileSync against a real gamut-icons component
(JSX transform + TS stripping both applied) and a real gamut-styles
styled() call (target + label stamping both present) with the package's
own config reduced to bare `extends`. Confirmed with the full suite too:
build/test/verify all green, 117 test suites / 1563 tests with jest
cache cleared, and the BarChart Emotion-target site specifically
re-checked given its history this migration.

Also dropped gamut-tests' bogus top-level `include: ['./src/**/*']` key
while simplifying that file — a no-op given babel's only ever invoked
against files already under ./src.

Removed packages/styleguide/.babelrc.json — genuinely dead now that
Storybook's Vite migration (main #9) wires @emotion/babel-plugin
directly via @vitejs/plugin-react's babel option in .storybook/main.ts,
rather than reading this file. Confirmed nothing else discovers it
(no jest config references styleguide; it has no test target).

Part of the tsdown build migration (GMT-1741).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dreamwasp
dreamwasp marked this pull request as ready for review September 18, 2026 18:12

@aresnik11 aresnik11 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running yarn build gives me a failure for building variance

Error: Failed to import module "unrun". Please ensure it is installed.

This is what the robot said:

Root cause: The build packages (gamut, gamut-styles, variance, etc.) build with tsdown, and each has a TypeScript config file (tsdown.config.ts). To load a .ts config, tsdown needs a TypeScript loader. Its configLoader: "auto" logic is:

const nativeTS = process.features.typescript || process.versions.deno;
const autoLoader = isBun || nativeTS && isSupported ? "native" : "unrun";

This machine is on Node v22.14.0, where process.features.typescript is false (native TS type-stripping was only unflagged in Node 22.18.0). Since it's not Bun/Deno and native TS isn't available, tsdown falls back to the "unrun" loader — an optional peer dependency that isn't installed. Hence Failed to import module "unrun".

@@ -1,4 +1,5 @@
import { Box, RadialProgress, Video } from '@skillsoft/gamut';
import { Box, RadialProgress } from '@skillsoft/gamut';
import { Video } from '@skillsoft/gamut/Video';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i got rid of this Video, it was causing me issues so i just rendered the animating RadialProgress

Comment thread package.json
},
"devDependencies": {
"@babel/cli": "7.24.7",
"@arethetypeswrong/cli": "0.18.5",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a real package? lol

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants