Dev notes
Extracting tokens and components out of this app into a shared design system, then wiring it back in alongside an Angular consumer. Four npm packages, one source of truth.
This app and the Angular desktop simulator both define their own colors, spacing, typography, and component styles. When a color changes, it changes in two places. When a button style gets updated, the Angular version drifts. There's no shared language between the two apps.
The design system uses CSS custom properties (--paul-color-primary-500) as the single source of truth. Not Sass variables, not JS-in-CSS, not Tailwind theme values. CSS custom properties work everywhere without a build step. A vanilla HTML page can link the stylesheet and use the tokens immediately.
The tokens build process generates CSS, SCSS, and JSON outputs from a single JavaScript definition. Consumers pick the format that fits their stack.
The system ships as four npm packages under the @paul-portfolio scope:
@layer for specificity managementThis app uses Tailwind CSS v4, which reads design tokens from CSS custom properties via an @theme block in globals.css. The bridge is a tokens.css file that aliases every --paul-* variable to the unprefixed name the app already uses:
--color-primary-600: var(--paul-color-primary-600); --radius-md: var(--paul-radius-md); --shadow-sm: var(--paul-shadow-sm);
Every Tailwind utility like bg-primary-600 now reads from the design system. Change a color in the tokens package, rebuild, and both apps update.
Button and Input were migrated to thin adapters wrapping @paul-portfolio/react. The adapters preserve the existing prop API so no call sites needed changes. Components with app-specific behavior (Modal with Framer Motion animations, Tooltip with fixed positioning and delays, Chip with color props) were left as-is. They still consume the shared tokens through CSS.
The Angular desktop simulator has a fundamentally different visual identity (macOS-style chrome, traffic light buttons, dock magnification). Replacing its components with the design system would break the aesthetic. Instead, a token-bridge.scss maps shared concepts like typography scales, motion durations, border radii, and z-index layers. Desktop-specific tokens (colors, window chrome, dock) stay local.
Packages are published under the @paul-portfolio npm scope with public access. During development, both consumer apps used file: paths pointing at the local monorepo. For CI, those were swapped to version ranges (^0.1.3) so the runner can resolve dependencies from the registry.
Nine bugs surfaced across the integration. The first three appeared at publish time. The next two were visual regressions in the consumer app. Two showed up when wiring Storybook and Chromatic for visual regression testing in CI. The last two were interaction bugs that only appeared after real usage.
0.5, so the tokens package generated --paul-spacing-0.5. Some browsers tolerate this, but Next.js's SWC CSS parser does not — it reads the dot as a number literal and crashes. The fix was replacing dots with underscores in the build script (--paul-spacing-0_5). The lesson: test your token output against the strictest CSS parser in your toolchain, not just the browser.npx tsc as their build script. The tsconfig included src/ which also compiles src/__tests__/. The test files import vitest matchers that extend the assertion types, so tsc failed trying to resolve them. The first publish shipped empty dist folders. The fix was excluding __tests__ from tsconfig. The real fix is a proper bundler (tsup or Vite library mode) that only builds what you tell it to.ButtonAsButton | ButtonAsAnchor. The disabled prop only existed on the button branch, so TypeScript couldn't guarantee it was available on both. Moving shared props to a common base type fixed the type error. Similarly, the app-level adapter couldn't spread button-typed event handlers (onClick: MouseEventHandler<HTMLButtonElement>) into the anchor branch. The fix was splitting the render path by checking href and only passing shared props to the anchor variant.@theme block in globals.css had entries like --color-background: var(--color-background). This looks harmless — it seems like it's just forwarding the value from tokens.css. But @theme creates new CSS custom properties. So the variable references itself, and per the CSS spec a self-referencing custom property resolves to the "guaranteed-invalid value." Every Tailwind utility that used these tokens — colors, shadows, border radii — silently broke. Everything was square, shadowless, and missing colors. The fix was pointing each entry at the --paul-* prefixed source token instead.@paul-portfolio/css/index.css brought in the full design system: a CSS reset, base typography, heading sizes, button resets, and all component CSS. The reset's @layer declarations conflicted with Tailwind v4's own layer system, and the base styles overrode the app's heading sizes and link colors. Spacing, layout, and typography all shifted. The fix was removing the import entirely — this app uses the React component package (which handles its own CSS classes) and only needs the tokens CSS for the raw --paul-* custom properties.../../react/src/. Locally this works because the source files are right there. In CI, the react package's exports field points at dist/ which doesn't exist until after a build step that CI never ran. The fix was switching imports to the package name (@paul-portfolio/react) and adding a Vite alias in the Storybook config to resolve it back to source. This also required setting esbuild.jsx: 'automatic' — without it, esbuild compiled the source TSX files using classic JSX mode (React.createElement), but the source files don't import React.createPortal to render into document.body, which puts the dialog outside Chromatic's capture root. The interactive story that clicks "Open Modal" and then asserts the dialog is visible crashed during Chromatic's snapshot. The fix was disabling the interactive story for Chromatic and adding a separate "Open" story that renders the Modal in a static open state — no user interaction needed for the visual snapshot.--paul-spacing-1_5), but the CSS component package still referenced the old escaped-dot names (--paul-spacing-1\.5). These don't match. Buttons, chips, badges, and tooltips all lost their padding. The fix was updating all five CSS files to use underscore names. The lesson: when you rename tokens, grep every consumer package — the CSS package is a consumer too, not just the apps.useEffect had handleKeyDown in its dependency array. handleKeyDown depends on onClose, which is an inline arrow function that gets a new reference on every parent render. Every time TanStack Query's background polling re-rendered the calendar page, the effect re-ran and called requestAnimationFrame(() => focusable[0].focus()), stealing focus from whatever input you were typing in. The fix was storing the handler in a ref so the effect only runs when open changes.The original fix for the CSS reset conflict was to remove the @paul-portfolio/css import entirely and import individual component files when needed. That works but it's fragile — every time you add a design system component, you have to remember to add another import line.
The proper fix was adding a components.css entry point to the CSS package. It imports all component and utility styles but skips the reset and base layers entirely. Tailwind consumers use this instead of index.css:
@import "@paul-portfolio/css/components.css";
Now this app gets all design system component styles through one import with no reset conflicts. When new components are added to the design system, they're automatically available here. The index.css entry point still exists for consumers that want the full package — vanilla HTML apps that don't bring their own reset. This also made @paul-portfolio/css an explicit dependency in package.json rather than relying on it being a transitive dependency of the React package.
npm pack --dry-run and inspect every file in the tarball. If a file is 0 bytes or a test file, the build is wrong.file: paths in PRs that go through CI. They work locally but fail on any runner that doesn't have the sibling repo checked out. Publish first, then open the consumer PR with version ranges.tsconfig.build.json that only includes source files.@theme block is a definition, not a passthrough. Tailwind v4's @theme creates new custom properties. Writing --color-X: var(--color-X) looks like it's forwarding a value, but it's actually creating a circular reference that silently resolves to nothing. Always reference a differently-named source variable.@layer declarations on top of Tailwind. Use components.css instead — it ships only component styles with no reset.exports point at dist/, add a Vite alias to resolve the package to source. And if the source files use JSX without importing React, set esbuild.jsx: 'automatic' in the Vite config.createPortal renders outside it. Interactive stories that open portalled content will crash the snapshot. Add a separate story that renders the component in its open state without interaction.--paul-spacing-0.5 to --paul-spacing-0_5 in the tokens package is only half the fix. The CSS component package is a consumer too — it was still referencing the old escaped-dot names and silently failing. Treat token renames as cross-package breaking changes and grep everything.onClose prop is typically an inline arrow function — new reference on every parent render. If your useEffect depends on a callback derived from it, the effect re-runs on every render. If the effect manages focus, it steals focus from inputs. Store the handler in a ref instead.