This guide explains the UI package import boundary, the shadcn-svelte style system it uses, and the component update workflow.
This package is a vendored fork of shadcn-svelte (1.x) on the Vega preset, plus a few shadcn-svelte-extras and Epicenter-specific components.
It uses shadcn-svelte's cn-* style system: component markup carries semantic
hook classes (cn-button-variant-default, cn-dialog-content) and the actual
styling lives in CSS:
src/styles/style-vega.css(vendored): the Vega preset. Allcn-*rules, scoped under.style-vega.src/styles/shadcn-base.css(vendored): the upstream base (data-* custom variants,no-scrollbar, accordion keyframes) thecn-*rules depend on.src/styles/epicenter-overlay.css: Epicenter style deltas (custom variants and per-component overrides). The single place our styling diverges from Vega.
Apps activate the preset with class="style-vega" on their root element; the
cn-* rules are scoped under it, so without the class nothing is styled. The
preset is a one-class swap (e.g. to style-rhea). Background and rationale:
specs/20260606T160000-ui-shadcn-cn-style-migration-vega.md.
This package is the shared Epicenter product system, not a place for one-off app branding. Its job is to make product UI consistent, accessible, and easy to compose across apps. The baseline is intentionally restrained: Vega component structure, Geist typography, semantic tokens, and app-level composition for character.
When a screen should feel more designed, improve the workflow first: hierarchy, density, copy, loading and empty states, grouping, keyboard affordances, and the shape of the user's next action. Add a new global token, font, animation system, or component variant only when several apps need the same product concept or the variant owns an accessibility or interaction contract.
Bespoke visual direction belongs in an app, prototype, landing page, or explicit redesign until the pattern proves it is shared. Once it is shared, move the stable primitive here and keep app-specific behavior out of it.
Use the component whose interaction contract matches the job:
DialogorAlertDialog: confirmations, simple yes/no prompts, display-only content, and simple action confirmations.ConfirmationDialog: reusable simple confirmations before one-off alert dialog markup.Modal: forms, typing, dropdowns, multi-step input, or any workflow that collects user data.Modalrenders as a dialog on desktop and a drawer on mobile.SheetorDrawer: secondary panels, mobile-friendly drawers, and side surfaces.CommandorCommandPalette: command menus, filtered actions, and search empty states.Item,SectionHeader,ButtonGroup,InputGroup,CopyButton,Kbd, andSidebar.*: repeated list rows, page sections, grouped controls, inline input actions, copy actions, keyboard hints, and app chrome.Field,Input,Textarea,Select,Switch,Checkbox, andRadioGroup: forms and settings surfaces.FileDropZone,NaturalLanguageDateInput,TimezoneCombobox,TreeView, andMarkdown: stable reusable product widgets. Use them before rebuilding their behavior in an app.SonnerandtoastOnError: toasts and result-aware error notifications.
Dialog, Modal, Sheet, and Drawer surfaces need accessible titles. Use an
sr-only title when the visual design already supplies equivalent context.
Use Loading for generic full-surface pending states that only need the
standard spinner shell and an optional caption:
<Loading class="h-dvh" label="Checking session" />
<Loading class="flex-1" label="Loading tabs..." />
<Loading class="h-full" />Loading wraps the local Empty.Root plus Spinner structure, so it keeps the
same centering, text alignment, and aria-live="polite" behavior without making
every app hand-compose it.
Use Empty.Root directly for actual empty, error, or prompt states. Also keep
Empty.Root for loading states that need a title, description, custom media,
actions, or exact visual parity with a nearby empty/error branch.
Use Spinner for every spinning affordance. Do not hand-roll animate-spin,
LoaderCircleIcon, or Loader2Icon in app code. Use Skeleton for known
content shapes instead of raw animate-pulse placeholder blocks. A pulsing
status dot is fine when the pulse is the content, not a fake-content skeleton.
Button and Link take a tooltip prop. Prefer it over hand-wrapping
Tooltip.Root, Tooltip.Trigger, and Tooltip.Content when the content is a
simple label:
<Button size="icon" variant="ghost" tooltip="Delete recording" onclick={deleteRecording}>
<TrashIcon />
</Button>The prop expects a Tooltip.Provider above the trigger. Hand-roll Tooltip.*
only when the trigger is not a Button or Link, or the content is more than a
simple label.
Before copying component internals or adding a new package primitive, escalate in this order:
- Use an existing local component and its variants.
- Pass a
classor supported prop. - Add a local variant to the wrapper component.
- Wrap the component for a real composition boundary: scroll containment, pane sizing, table cell structure, sticky headers.
- Copy upstream component code only when Epicenter needs to own behavior, tokens, persistence, shortcuts, or app state.
A new primitive belongs in packages/ui only when it is stable, visual, and
shared. If it depends on one app's data model, persistence, or product policy,
keep it in the app and compose UI primitives there.
Apps import UI through the public package API:
import { Button } from '@epicenter/ui/button';
import { Loading } from '@epicenter/ui/loading';
import { cn } from '@epicenter/ui/utils';
import '@epicenter/ui/app.css';Files inside packages/ui/src import other UI files with relative paths:
import { Button } from '../button/index.js';
import { cn } from '../utils.js';Direct raw file imports use the same rule:
import Button from '../button/button.svelte';Do not add app aliases or tsconfig paths that point to packages/ui/src.
Do not add kit.alias entries such as:
kit: {
alias: {
'#': '../../packages/ui/src',
},
}The UI package has no private import aliases. Apps should never define aliases
for packages/ui/src.
Our package.json exposes only the public API for app consumers:
{
"exports": {
"./*": "./src/*/index.ts",
"./utils": "./src/utils.ts",
"./utils/*": "./src/utils/*.ts",
"./app.css": "./src/app.css"
}
}Consumers import components through the package API; UI source imports siblings with relative paths.
Every packages/ui/src/<folder>/index.ts file is a public
@epicenter/ui/<folder> subpath. Raw .svelte files are private to the package
and should not be imported by apps.
Component styling lives in cn-* classes, not inline Tailwind. Keep component
markup byte-identical to upstream Vega so it stays trivially re-vendorable, and
put every Epicenter style delta in src/styles/epicenter-overlay.css.
Custom variants (no upstream equivalent) become a cn-* class. Example, the
button ghost-destructive variant:
/* epicenter-overlay.css, under .style-vega */
.cn-button-variant-ghost-destructive {
@apply text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/20;
}// button.svelte tv()
'ghost-destructive': 'cn-button-variant-ghost-destructive',Per-component overrides redefine the Vega cn-* class. The overlay is
imported after style-vega.css (same layer(base)), so a same-specificity rule
here wins:
.cn-select-content { @apply max-w-min; }Invariant: every cn-* class a component emits must be defined in
style-vega.css or epicenter-overlay.css. An undefined cn-* class is a dead
no-op; do not emit one.
Two kinds of delta stay inline, by necessity:
- Utilities-layer overrides of a property Vega sets inline in the component
(notably z-index). A base-layer
@applycannot beat a utilities-layer inline utility, so override inline, or use@apply ...!in the overlay. - Structural divergences (extra wrapper elements,
<svelte:element>, an injected actions overlay) are markup, not CSS, and live in the component.
epicenter-overlay.css is the self-documenting source of truth for custom
variants (button ghost-destructive, alert warning, badge
id/success/status.*) and per-component overrides (cn-table-row,
cn-select-content, cn-dialog-content, cn-resizable-panel-group,
cn-sidebar-inset, cn-item-*, cn-item-media-variant-icon). Each block is
commented there.
The deltas that must stay inline (structural markup, or a property Vega sets inline that a base-layer rule cannot beat) are listed here so they are not lost on a re-vendor:
| Inline delta | File | Why it stays inline | Still needed? |
|---|---|---|---|
relative + [a]:hover:bg-accent/50 on the item base |
item/item.svelte |
positioning context for the actions overlay; accent hover uses an arbitrary [a&]-style selector |
yes |
Scroll wrapper <div class="flex-1 overflow-y-auto"> |
drawer/drawer-content.svelte |
structural (extra element) | yes |
onOpenAutoFocus preventDefault |
drawer/drawer-content.svelte |
workaround for vaul-svelte vs bits-ui 2.x focus recursion | remove when vaul-svelte supports bits-ui 2.x |
| Viewport ring/outline styling | scroll-area/scroll-area.svelte |
Vega has no cn-scroll-area-viewport |
until Vega defines it |
| Handle base styling | resizable/resizable-handle.svelte |
Vega has no cn-resizable-handle (only -icon) |
until Vega defines it |
<svelte:element> span-or-anchor |
badge/badge.svelte |
structural (element choice) | yes |
showOnHover actions overlay |
item/item-actions.svelte |
structural (absolute overlay + gradient) | yes |
tooltip prop (wraps in Tooltip) |
button/button.svelte, link/link.svelte |
Epicenter feature; upstream Button and Link have none | yes |
| Standard loading shell | loading/loading.svelte |
Epicenter wrapper around Empty.Root + Spinner for generic pending panes |
yes |
Orientation sizing data-[orientation=horizontal]:h-px … |
separator/separator.svelte |
byte-identical to upstream; the size is gated on a Tailwind variant, which only attaches to real utilities. Routing it through cn-separator-horizontal (a plain class, not an @utility) makes the variant emit nothing, so the divider collapses (a fat bar inside field-separator, 0px standalone). Do not cn-ify it. |
yes |
Correctness wiring (making Vega work, not Epicenter style): switch and
alert-dialog carry a size prop that emits data-size; sidebar menu and
sub-menu buttons emit data-active only when active. Keep these.
Components are (mostly) byte-identical to upstream shadcn-svelte Vega markup, so updates are a careful copy plus a translation step.
- Copy the component's
cn-*Vega markup from upstream shadcn-svelte (or generate it in a scratch project pinned to a 1.x version). - Normalize imports to relative paths (
../utils.js,../button/index.js). The committed package has no generator aliases. - Strip
IconPlaceholder; use direct@lucide/svelte/icons/*imports. - Move every Epicenter style delta into
epicenter-overlay.css(see Styling above). Do not leave inline override args stacked on acn-*class. - Confirm the invariant: every
cn-*class the component emits is defined. - Run
bun run check:ui-boundaryand build a consuming app.
To pull a newer Vega, replace src/styles/style-vega.css (and shadcn-base.css
if the base changed) with the upstream file, then re-check the invariant. Newly
defined cn-* classes may let you drop overlay overrides; newly emitted hooks in
components may need definitions.
- Regenerate with the shadcn-svelte CLI and copy blindly. It pulls
IconPlaceholderand generator aliases, and reintroduces the inline form. - Stack inline Tailwind overrides on top of a
cn-*class. Use the overlay.
// App code
import { Button } from '@epicenter/ui/button';
// UI package source
import { Button } from '../button/index.js';
import { cn } from '../utils.js';Every src/<folder>/index.ts is a public @epicenter/ui/<folder> subpath. The
inventory is grouped by job so it does not duplicate ls.
- Foundations:
button,badge,card,separator,skeleton,spinner,tooltip,sonner,utils,hooks. - Forms and inputs:
field,input,textarea,select,checkbox,radio-group,switch,toggle,toggle-group,input-group,natural-language-date-input,timezone-combobox,file-drop-zone. - Overlays and menus:
dialog,alert-dialog,modal,drawer,sheet,popover,dropdown-menu,context-menu,command,command-palette,confirmation-dialog. - Layout and navigation:
accordion,breadcrumb,collapsible,resizable,scroll-area,sidebar,tabs,table,tree-view,section-header,item,button-group. - Content and app widgets:
avatar,chart,chat,copy-button,emoji-picker,github-button,kbd,light-switch,link,loading,markdown,pm-command,progress,snippet,star-rating. - Styles:
styles/shadcn-base.css,styles/style-vega.css,styles/epicenter-overlay.css,prose.css, andapp.css.
Add a folder only when the component is a shared visual primitive or stable product widget. App-owned behavior should stay in the app and compose these parts.
- Keep Components Pure: no business logic in UI components.
- Use Barrel Exports: each component folder has an
index.ts. - Style in the overlay: Epicenter deltas go in
epicenter-overlay.css, not inline on the component, unless they must stay inline (see Styling). - Hold the invariant: never emit a
cn-*class that is not defined. - Consistent Imports: relative inside
packages/ui/src;@epicenter/uionly from consumers outside this package.
Run the boundary check after changing UI imports or app config:
bun run check:ui-boundaryThe executable source of truth is scripts/check-ui-boundary.ts.
The check fails when app configs point at packages/ui/src, when app configs
or package manifests add private UI import paths, when app source imports
private UI import names, when app or package source imports packages/ui/src
directly, or when UI source imports itself through private aliases or
@epicenter/ui/....
If imports are not resolving:
- Check that the component is exported by
@epicenter/ui. - Ensure your IDE recognizes the package's TypeScript config.
- Restart the TypeScript language server.
If a custom style is not applying:
- Confirm the rule is in
epicenter-overlay.css(imported afterstyle-vega.css, so it wins at equal specificity). - If you are overriding a value Vega sets inline in the component (e.g.
z-index), a base-layer
@applywill not win: override inline or use@apply ...!. - Confirm the app root carries
class="style-vega"; without it thecn-*rules do not apply at all.
When updating breaks functionality:
- Check the shadcn-svelte changelog.
- Review the overlay and any inline overrides.
- Build a consuming app before committing.