@vectramindnpm/unified-design-system
v0.2.3
Published
Standalone, publishable UI component library + design tokens extracted from Firstpass-Neo (src/designSystem + src/components/ui), decoupled from app-specific i18n/Redux/session wiring.
Downloads
463
Readme
unified-design-system
Standalone, publishable UI component library extracted from Firstpass-Neo's
src/designSystem (design tokens) and src/components/ui (29 components), with the
app-specific couplings removed so it can be installed as a plain npm dependency in any
React app — and, later, browsed in its own Storybook.
What's here
src/
tokens/ ← designTokens.js → semanticTokens.js → componentTokens.js (+ index.js barrel)
lib/utils.ts ← cn() (clsx + tailwind-merge)
types/view-mode.ts ← local EViewMode enum (stand-in for the app's models/utilitiesModel)
components/ui/ ← all 29 components — implementation only (.tsx/.ts/.css), no stories
stories/ ← one *.stories.tsx per component, kept separate from components/ui/
styles/
tokens.css ← design-token CSS custom properties (from the app's variables.css)
shadcn-base.css ← the shadcn-style base HSL vars a few components use
theme.css ← Tailwind v4 `@theme inline` bridge (auto-generated, see below)
components.css ← custom classNames the components render beyond tokens/Tailwind
icon-font.css ← the `fp-neo` icon font (icon-neo-*) — see below
fonts.css ← Inter variable-font @font-face
fonts/ ← the actual font files (fp-neo.*, Inter .woff2)
index.css ← styles.css entry: imports all of the above
index.ts ← the package's public barrel export
tailwind-tokens.cjs ← flat Tailwind v3 color/radius/spacing map (from the app's tailwind.tokens.cjs)
tailwind-preset.js ← Tailwind v3 preset for consumers still on Tailwind v3The Rsbuild app in the rest of this repo (src/App.tsx, .storybook/) is a local dev
harness / smoke test for the same src/ — it is not what gets published.
What changed vs. the original app code
Per docs/design-system-npm-storybook-guide.md's Phase 1 decoupling, plus a few couplings that guide's scan didn't catch:
semanticTokens/designTokens/componentTokens— everyui/*file that imported these through../../helpers/helpersIndexnow imports directly from../../tokens.- i18n removed entirely (per explicit request, not just made optional) —
icon.tsx,buttonIcon.tsx,delete-action.tsxno longer importreact-i18nextorgetTranslationKey. Tooltip/aria labels fall back to a static string (the iconname, or a hardcoded label like"Delete"/"Refresh") instead of a translation lookup. If you need i18n later, wrap the exported components in your own thin wrapper that passes a translatedtooltipTextprop — every component that shows a label already accepts one. view-toggle.tsx— droppedgetSessionStateKey/getViewType(app'sinternaljscontrols.ts) anduseSelector(Redux).ViewToggleis now a controlled component (viewMode+onViewModeChangeprops);useViewMode()is a plainuseState+localStoragehook with no Redux/app dependency.truncated-text.tsx— same Redux/session coupling removed; takes aviewModeprop instead of reading it from the app's store.toast.tsx— theToastcomponent itself was already prop-driven. ItsuseToast()hook (which dispatched into the app's own Redux alert store) was app-specific state wiring, not a reusable UI primitive, so it's not included — drive<Toast onClose={...} />from your own state instead.loader.tsx— dropped theuseSelectorread of the app'sLayout.loadingRedux state;Loadernow just uses its ownisLoadingprop. The loading GIF is bundled into the package (inlined as a data URI at build time) instead of importing from the app'ssrc/icons/.time-picker.scss→time-picker.css— the original Sass file was nested under the app's#fpneoTailwind-important selector (would never match elsewhere) and used Sass-only nesting. Flattened to plain, framework-agnostic CSS with its@applyutilities inlined as literal styles, so it needs no Sass or Tailwind build step at all.models/utilitiesModel.EViewMode→ copied locally astypes/view-mode.ts(it was a two-value enum with no other app dependency).- A couple of components (
dropdown-menu.tsx,popover.tsx,tooltip.tsx,expandable-search.tsx,table.tsx) needed small, behavior-preserving TypeScript fixes (a stricteruseRef/RefObjectgeneric, atype-only import,JSX.Element→React.JSX.Element) to type-check as an isolated package — same runtime behavior, just satisfies a standalonetsupbuild.
Untouched: all component markup, class names, and visual variants — this was a decoupling pass, not a redesign.
Component-level styles (beyond tokens)
Several components render classNames that are neither Tailwind utilities nor covered by
tokens.css/shadcn-base.css — plain custom classes like .app-card, .switch-pill,
.view-toggle__btn, .dropdownHead, .item-table__head, .fp-loader-overlay, and the
icon-neo-* icon font every Icon/ButtonIcon/DeleteAction/Toast/TimePicker glyph depends
on. These lived in the source app's src/styles/common.scss, mainstyles.css, and
font-icons.css — SCSS nested under that app's #fpneo Tailwind-important wrapper, with
@apply directives, so they'd neither match nor build here as-is.
src/styles/components.css— every one of those classes, flattened to plain CSS (@applyresolved to literal declarations,#fpneo/SCSS nesting stripped). Page- and business-specific rules were left out on purpose: Table's per-page*-colsgrid-column layouts (.flows-cols,.appt-cols, ...) are data-shape-specific, not reusable design system material — bring your own to match your own columns. (Two oftable.tsx's own variant keys,users→.users-colsanddevices→.devices-cols, turned out to have no corresponding CSS anywhere in the source app either — likely dead/mismatched keys there already, not something introduced here.)src/styles/icon-font.css+src/styles/fonts.css— thefp-neoicon font (icon-neo-*) and Inter variable font@font-facerules, copied verbatim (already plain CSS) with only theirurl()paths adjusted to this package's ownsrc/styles/fonts/copy of the fourfp-neo.*files and the Inter.woff2.- All of the above are wired into
src/styles/index.css(and so intostyles.css), alongsidetokens.css/shadcn-base.css/theme.css— importing that one file is enough. Each is also independentlyexports-mapped (./components.css,./icon-font.css,./fonts.css) if you'd rather cherry-pick.
Verified via npm run build-storybook and npm run build (the demo app): Rsbuild/Rspack's
asset pipeline picks up and correctly hashes all five font files from the url()
references in this CSS — proof the paths resolve. (No headless browser was available in
this environment to capture an actual rendered screenshot; that's the one thing not
independently confirmed visually.)
Button variant redesign
Button originally had 19 variant values conflating four unrelated things: color/
emphasis, shape (pill vs. rounded), fixed footer sizing, and one-off page-specific skins.
Redesigned into independent, composable axes instead:
variant: "primary" | "secondary" | "destructive" | "outline" | "ghost" | "link" // 6 semantic colors
| "borderPrimary" | "deptedit" | "submenu" // page-context presets
| "cancel" | "save" | "close"
| "formButtonSubmit" | "formButtonCancel" | "formButtonBack"
size: "default" | "sm" | "lg" | "icon" | "iconLg" // unchanged
shape?: "default" | "pill" // NEW
selected?: boolean // unchangedOriginally shipped (v0.1.x) with only the 6 semantic colors — the 9 "preset" variants below
were deliberately left out, on the theory that fixed page-context styling (specific sizing +
a one-off color skin) belonged in a consumer's own className, not the shared component.
That held up fine in isolation, but once a real consumer (Firstpass-Neo) migrated ~90 call
sites, the same className string ended up duplicated across dozens of files with no single
place to update it. Re-added in v0.2.0 as named variants for exactly that reason — each is a
self-contained preset (see PRESET_BASE/PRESET_OVERRIDE in button.tsx) that doesn't
interact with shape/selected at all, so it's safe to combine with them if you ever need to
(you generally won't - just use the variant alone).
Mapping from the original 19 to the current API:
| Old variant | Current equivalent | Note |
|---|---|---|
| default (purple) and primary (blue) | variant="primary" | Consolidated to one color — the token-driven interactive-brand (purple), not the hardcoded blue. The two hex values are ~identical anyway (#5D62B5 vs #5c62bc), so this is not a visible change. |
| secondary, destructive, outline, ghost | same name, unchanged | No visual change. |
| lang | variant="ghost" | One-off page skin, not worth a dedicated preset for its single call site — the only difference from ghost was a missing hover effect, not worth preserving. |
| pill | shape="pill" (with any variant) | Combine, e.g. variant="outline" shape="pill". |
| borderPrimary, deptedit, submenu, cancel, save, close, formButtonSubmit, formButtonCancel, formButtonBack | same name, now a real variant again | Just variant="cancel" etc. — no shape/className needed, each preset is fully self-contained. deptedit no longer includes the legacy dept-edit-btn className-sniffing hack (pass the variant explicitly). |
| refreshIcon | removed — use <ButtonIcon variant="refresh" /> | That's already ButtonIcon's job; Button doesn't need to duplicate it. |
| selectedIcon | removed — use <ButtonIcon variant="filled" /> or <Button variant="primary" size="icon"> | Same reasoning. |
link is genuinely new (no old equivalent) — a text-only, underline-on-hover variant with
no padding/height, the one common pattern the original 19 never actually had.
Using it in this repo right now
import { Button, Badge, Card } from './index'; // see src/App.tsx for a working exampleBuilding the package
npm run build:lib # tsup → dist/{index.cjs,index.mjs,index.d.ts} + dist/*.cssThis has been verified end-to-end: npm run build:lib succeeds, npm pack produces a
clean ~390 KB tarball, and installing that tarball into a scratch project and
require()-ing it resolves all exports correctly (with react, react-dom, and
formik installed as peers).
Installing it in another app
npm install /path/to/unified-design-system-0.1.0.tgz # or from your registry once publishedimport { Button, Icon, Table } from 'unified-design-system';Peer dependencies you must have installed: react, react-dom, and formik (4 of the
29 components — FormField, Input with as="field", Textarea with as="field",
ScrollToFieldError — call real Formik APIs at runtime, so it's a required peer, not
optional).
Styling — pick one based on your Tailwind version:
- Tailwind v4:
@import "unified-design-system/styles.css";in your global CSS, alongside your own@import "tailwindcss";. This one import already includes the icon font, component classes, and tokens. - Tailwind v3: add
presets: [require("unified-design-system/tailwind-preset")]to yourtailwind.config.js, and@import "unified-design-system/tokens.css";+@import "unified-design-system/shadcn-base.css";+@import "unified-design-system/components.css";@import "unified-design-system/icon-font.css";+@import "unified-design-system/fonts.css";in your global CSS.
- No Tailwind: same four
@imports as above (skiptokens.css/shadcn-base.cssonly if you don't need the CSS-variable-driven components) — most components read CSS custom properties (var(--color-...)) rather than Tailwind utility classes.
Any icon (<Icon name="icon-neo-trash" />, etc.) needs icon-font.css loaded — without
it the glyph classes resolve to nothing and icons render blank.
Publishing (once you're ready)
npm run build:libnpm version patch|minor|majornpm publish(add--registryfor a private/internal registry)- Tag the release in git.
This package has no consumers yet, so no versioning discipline is enforced — once something depends on it, breaking changes need a major bump.
Storybook
Set up via the official installer (npm create storybook@latest, framework
auto-detected as react-rsbuild), which added @storybook/addon-a11y and
@storybook/addon-docs to .storybook/main.ts and to devDependencies.
The generic Storybook onboarding tutorial (the original src/stories/ — its demo
Button/Header/Page components, their .css, Configure.mdx, and the screenshot/icon
images under assets/) was removed entirely; it wasn't part of this design system. That
same src/stories/ path has since been repurposed to hold this design system's own
stories instead (see below) — a fresh, unrelated folder with the same name.
Every one of the 29 components has its own *.stories.tsx in src/stories/ — kept
in a separate folder from src/components/ui/ (which holds only .tsx/.ts/.css
implementation files, no stories), and importing from there
(import { Button } from "../components/ui/button"). .storybook/main.ts's
../src/**/*.stories.tsx glob picks them up regardless of where under src/ they live,
so this split needed no config change. Each story has a Default plus variant/state
stories and, for the compound components (Dialog, DropdownMenu, Popover, Tooltip, Table,
AppList, TimePicker, Toast, ViewToggle, ...), a composed working example; components with
a real cva variant/size enum (Button, Badge, Icon, ButtonIcon, Checkbox, RadioButton,
Tab) also get an AllVariants/AllColors/AllSizes-style story enumerating every value
from their actual source, cross-checked against it rather than sampled. Every meta also
carries tags: ["autodocs"], a one-line parameters.docs.description.component
explaining what the component is/isn't (e.g. Icon vs. ButtonIcon, Popover/Tooltip/DropdownMenu
being hand-built rather than Radix-based), and a layout parameter ("centered" for
atomic controls, "padded" for larger/compound ones) so each renders sensibly on its own
Docs page.
One correctness fix worth knowing about: .storybook/preview.tsx originally imported
src/styles/index.css directly, which never triggers Tailwind itself — same as
src/App.css needing its own @import 'tailwindcss'; alongside the design-system
styles. Without it, Storybook's build silently produced zero Tailwind-generated utility
classes (bg-interactive-brand, rounded-md, inline-flex, ...), so every
utility-class-driven style rendered as if unstyled even though the underlying CSS
variables were present. Fixed via .storybook/preview.css (@import 'tailwindcss';
- the design-system styles) — verified by diffing Storybook's compiled CSS against the demo app's byte-for-byte, not just a clean build.
Verified working: npm run build-storybook produces a static build with exactly 29
autodocs pages (one per component) alongside 106 individual story variants — confirmed by
inspecting the build's index.json, not just a successful exit code. npm run storybook
boots and serves at http://localhost:6006/. npm run lint reports zero errors across
the story files.
npm run storybook # dev server
npm run build-storybook # static build, e.g. for Chromatic/GitHub Pages