@faclon-labs/fds
v1.0.2
Published
Faclon Design System — a CSS-first, token-driven React component library built on a 3-tier design-token architecture.
Readme
Faclon Design System
A CSS-first design system for CSS + React: 38 components on a
design-token core built on the Design-SDK's 3-tier architecture
(global primitives → semantic aliases → composed styles) with unprefixed
CSS custom properties (SDK convention). Original token values were
extracted from the Figma file Obsidian Design System (Community)
(kOyx6aRrB69ittd535ui3l); hue ramps anchored on those values and filled
from Tailwind (brand derives from the IOsense primary palette).
New here, or an AI building with FDS? Start with index.md: the global guardrails, one card per component and a map of the repo (generated —
npm run build:index). Installed the package instead? It ships atnode_modules/@faclon-labs/fds/dist/index.md, with AGENTS.md telling an AI to start there.
Architecture
CSS is the single source of truth — there is no parallel TS token layer.
src/
├── styles/
│ ├── tokens/
│ │ ├── colors/
│ │ │ ├── global.css Tier 1: --global-{family}-{step} ramps
│ │ │ │ (gray, gray-dark, brand, 8 hues + alpha ramps)
│ │ │ └── semantic.css Tier 2: --{background|text|border|icon}-
│ │ │ {role}-{variant}; dark = re-point block
│ │ │ (+ the old→new migration map in its header)
│ │ ├── typography/
│ │ │ ├── global.css Tier 1: --global-{fz|fw|lh}-*, families
│ │ │ ├── text-scale.css Tier 2: --font-size-*, weights, line-heights
│ │ │ └── styles.css Tier 3: .BodySmallMedium, .HeadingMedium,
│ │ │ .CodeSmall … (composed classes)
│ │ └── globals/
│ │ ├── spacing.css --spacing-N (name = px value) + input height
│ │ ├── border.css border widths, radius (s/m/l/xl/max), focus ring
│ │ ├── elevation.css --shadow-{s|card|l} + dark overrides
│ │ ├── motion.css the reference motion scale verbatim (duration/delay/easing)
│ │ └── display.css z-index scale, icon sizes, disabled opacity
│ ├── motion.css Central reduced-motion guard
│ └── index.css Single entry point (@imports in tier order)
├── components/ 38 component folders, each self-contained:
│ ├── button/ Component.tsx + component.css (Tier-2 tokens
│ ├── input/ only) + Component.guard.json (usage contract
│ ├── modal/ + `decision` block — staged rules, collisions,
│ └── … constraints; validated by guard.schema.json)
├── react/
│ ├── ThemeProvider.tsx Sets data-theme on <html>; useTheme() hook
│ └── useControllableState.ts Controlled/uncontrolled prop pattern
└── fonts.ts Side-effect entry loading Inter + Geist MonoStories and docs live outside src/ in storybook/components/<name>/
(*.stories.tsx + *.mdx per component).
Components
Explore components in Storybook: npm run storybook (port 6006; toolbar
has a light/dark theme switcher wired to data-theme). Every component has
its own docs page (MDX) and a machine-readable usage contract
(src/components/<name>/<Name>.guard.json); the Status page in
Storybook tracks where each planned component stands.
Every guard also carries a first-class decision block — staged
selection rules (first match per stage wins), collision resolutions, hard
constraints, a per-prop guide, worked situations and confidence/gap notes —
migrated from the FDS decision-doc set (all 41 components, 2026-08-26).
Validate structurally with npm run guards:check; doc-vs-code arbitrations
are logged in docs/DECISION-MIGRATION.md.
Shipped (38):
| Group | Components | | --- | --- | | Actions | Button, IconButton, LinkButton, ButtonGroup | | Inputs | TextInput, PasswordInput, TextArea, SelectInput, AutoComplete, CounterInput, DatePicker (single/range + presets + time + comparison), Checkbox (+Group), Radio (+Group), Switch, Chip (+Group), FileUpload, FormLabel/FormHint | | Feedback | Alert, Toast, ProgressBar, Spinner | | Overlays | Tooltip, Popover, Modal, Drawer, DropdownMenu (incl. nested submenus) | | Navigation | Tabs, Breadcrumb, Stepper, Pagination | | Display | Badge, Counter, Indicator, Tag, Avatar (+Group), Card, Accordion, ActionList, Code, Divider, EmptyState | | Data | Table (sorting, selection, toolbar search, bulk actions, pagination, sticky header/columns, merged cells) |
Charts is planned (see ROADMAP.md) — TimeModeProvider already ships as its time-mode seam (shift/comparison mode + availability, never dates).
Provenance model, applied per component and recorded in each file's header comments and guard: the reference (the reference) governs props/behaviour, the Figma file governs colour/geometry, and the local Design-SDK supplies specific refinements — conflicts get explicit, dated rulings.
Button (and the trio)
Two orthogonal axes, every combination defined: variant = emphasis
(primary / secondary / ghost) × tone = meaning (brand / neutral /
positive / negative / notice / info / white), sizes xs 28 / sm 32 /
md 36 / lg 40. Shape and element are capability-based, not variants:
Button is the real <button> (square via iconOnly), IconButton is
the dedicated icon control with its own guard, LinkButton is the
text-shaped one — any of them renders <a> when href is present.
<Button>Save</Button> {/* primary brand */}
<Button tone="negative" leadingIcon={<Trash2 size={16} />}>Delete</Button>
<Button variant="secondary" tone="neutral" size="sm">Cancel</Button>
<Button isLoading>Saving…</Button> {/* width-preserving overlay */}
<IconButton icon={Settings} accessibilityLabel="Settings" />
<LinkButton href="/docs">Docs</LinkButton>isDisabled || isLoading derives the real disabled state; loading keeps the
button's width, sets aria-busy, and pins an explicit spinner colour (the
disabled ink would be invisible on a primary fill).
The three tiers
- Tier 1 — global primitives (
--global-*): immutable, mode-agnostic ramps and scales. Color:gray/gray-darkneutrals plusbrandand 8 hue families, each a 50→1000 ramp with the pre-existing system hue as its 600 anchor, plus alpha ramps (brand-a12= brand at 12%). Typography: raw sizes/weights/line-heights and the two family stacks. Components never consume this tier. - Tier 2 — semantic aliases: the API components use. Color grammar is
--{property}-{role}-{variant}(e.g.--background-brand-default,--text-primary,--border-negative-subtle) — every value avar()over Tier 1, zero literals. Dark mode is one[data-theme='dark']block intokens/colors/semantic.cssthat re-points the mode-dependent tokens (primitives never change). Solid chromatic steps follow one rule: default = 600, hover = 500, active = 700. Spacing (--spacing-16is 16px), radius, borders, shadows, motion, z-index, and icon sizes are single-layer semantic scales intokens/globals/. - Tier 3 — composed text styles: PascalCase classes
(
.BodySmallMedium,.HeadingMedium,.CodeSmall) bundling family/size/weight/line-height from Tier 2 — apply the class instead of composing font vars.
Usage
CSS — import the single entry point and use Tier 2 --* variables:
@import './src/styles/index.css';
.card {
background: var(--background-surface-default);
color: var(--text-neutral-normal);
border: var(--border-width-thin) solid var(--border-neutral-default);
border-radius: var(--radius-m);
padding: var(--spacing-16);
box-shadow: var(--shadow-s);
font-family: var(--font-family);
}React — load fonts once, wrap the app, toggle themes:
import './src/fonts';
import { ThemeProvider, useTheme } from './src';
function ThemeToggle() {
const { theme, setPreference } = useTheme();
return (
<button onClick={() => setPreference(theme === 'dark' ? 'light' : 'dark')}>
{theme}
</button>
);
}
<ThemeProvider>
<App />
</ThemeProvider>;Motion — CSS-only. The vocabulary is the reference's motion scale,
verbatim (--global-duration/delay/easing-* in tokens/globals/motion.css);
components consume the tokens directly and own their animations locally:
.button {
transition:
color var(--global-duration-xquick) var(--global-easing-standard),
background-color var(--global-duration-xquick) var(--global-easing-standard);
}
.tooltip--in {
/* entrances decelerate; exits run one rung faster on easing-exit */
animation: tooltip-in var(--global-duration-xquick) var(--global-easing-entrance);
}Micro-interactions run xquick/standard and never exceed moderate (280ms);
the gentler rungs are for large-surface/ambient motion (spinner = 2xgentle).
Reduced motion is handled centrally in
src/styles/motion.css (blanket guard, spinner
exempted) — never add prefers-reduced-motion queries in components. A JS
animation library is deliberately not a dependency; if a future component
needs springs or layout animation, derive its presets from the motion tokens
rather than inventing new values.
Theming (experimental)
One validated brand colour re-themes every brand token in light and dark. It is an opt-in subpath — apps that never import it carry none of it:
import { validateBrandColor, BrandTheme } from '@faclon-labs/fds/theme';
const v = validateBrandColor('#ea580c'); // { ok, reasons, warnings } — only unreadable input blocks; warnings say what it costs
// <BrandTheme brandColor="#ea580c" /> inside ThemeProvider; unmount = shipped paletteThe ramp and the ink on brand fills follow the reference's createTheme: any valid
colour is accepted, the colour is step 500, and labels on brand fills are black or
white by contrast. What a colour costs is returned as notes, never a refusal. The
theme is a plain object of CSS custom properties (createTheme(...).theme), set inline
by applyTheme; no token changes which ramp step it reads. AppShell's Classic theme
follows an applied brand (its near-black chrome takes a deep tint) and is unchanged
without one. Usage, the kill switch and the removal recipe:
src/theme/README.md.
Dependencies
Nothing is bundled — everything below is a peer dependency the consumer
installs. See peerDependencies in
package.json and the rationale table in
ROADMAP.md.
| Peer | Required? | Used by |
| --- | --- | --- |
| react, react-dom, lucide-react | required | everything |
| @floating-ui/react | required | Tooltip, Popover, DropdownMenu (+submenus), Modal, Drawer, and BaseInput's in-field overlays (SelectInput/AutoComplete). Toast deliberately doesn't use it — the stack is plain position: fixed |
| highcharts, highcharts-react-official | optional | @faclon-labs/fds/chart (charts, sparkline, gauges) |
| react-markdown, remark-gfm, rehype-sanitize, remend, shiki | optional | @faclon-labs/fds/ai (markdown responses, highlighted code) |
| @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/modifiers | optional | @faclon-labs/fds/appcenter (drag to reorder) |
Optional peers are not installed by npm. Each belongs to a feature that has its own entry, and only that entry imports it — so install the peers of the entries you use and nothing else:
import { Button, Table } from '@faclon-labs/fds'; // needs no optional peer
import { ColumnChart } from '@faclon-labs/fds/chart'; // npm i highcharts highcharts-react-official
import { AiMessage } from '@faclon-labs/fds/ai'; // npm i react-markdown remark-gfm rehype-sanitize remend shiki
import { AppCenter } from '@faclon-labs/fds/appcenter'; // npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/modifiersThe main entry never imports them (a unit test holds that), so @faclon-labs/fds
builds with only the required peers installed.
Layering stays ours: the --z-* ladder in
display.css is the only source of
z-index — Floating UI has no opinion there, so never hand-pick one in
component CSS.
Provenance
The Figma file carries 34 variables in a single, light-only mode. All of
them are reproduced 1:1. Everything else was filled from Obsidian's official
default-theme values (user-approved), since the file's values match Obsidian's
published design system exactly. Where both sources define a value, Figma
wins (e.g. the accent #9478F0).
| Token group | From Figma | Filled from Obsidian defaults |
| --- | --- | --- |
| Neutral ramp | 12-step structure from Figma | Both ramps re-tinted toward the brand (hue 226, 2–7% blend toward #2F55D4 — "hint of blue", user decision); dark steps from Obsidian, same treatment |
| Hues | orange, yellow, cyan, blue, purple, pink from Figma | red #DC2626 / green #16A34A = Tailwind red-600/green-600 (shadcn destructive + success; user decision — Figma's read too pink/minty), hover/active = the 500/700 scale steps |
| Accent | --text-on-accent #FFFFFF | brand color #2F55D4 (user decision — overrides Figma's --interactive-accent #9478F0); hover #4163D9 (derived, +4% lightness); focus ring = brand at 24% alpha (40% in dark) |
| Semantic colors | --text-faint, --divider-color, --background-secondary, --tab-container-background, --tab-outline-color, --icon-color | background-primary(-alt), background-sidebar (shadcn sidebar surface, user decision), text-normal/muted, interactive-normal/hover, all dark-mode mappings |
| Spacing | 2-1, 4-1, 4-2, 4-3, --input-height | 2-2, 2-3, 4-4…4-18 |
| Typography | Inter, 12px (--font-ui-smaller → xs), semibold 600 | t-shirt size scale xs 12 / sm 14 / base 16 / lg 20 / xl 24 / 2xl 30 (user decision — replaced Obsidian's near-identical UI sizes 13/15); weights, line-heights; mono stack added per user decision (JetBrains Mono originally; switched to Geist Mono 2026-07 for Inter pairing + density) |
| Radius | — | s 4 / m 8 / l 12 / xl 16 |
| Shadows | — | shadow-s / shadow-l, light + dark |
| Status colors | hues (fg) from Figma | tinted bg/border (rgba of the hues, per-mode alpha) |
| Interaction states | — | active surfaces, focus ring (Design-SDK stacked box-shadow: 2px surface gap + 3px translucent brand ring, uniform system-wide), disabled opacity 0.5 (shadcn), overlay scrim; background-modifier-hover/-active alpha fills (pattern from the falcon-auth-redesign reference, mapped to our palette) |
| Icon sizes | — | 12/14/16/20/24px per shadcn conventions (size-3…size-6, default size-4) |
| Z-index | — | dropdown 1000 → tooltip 1600 |
| Motion | — | The reference's duration/delay/easing scale verbatim, consumed directly; per-component animations |
| Fonts | — | self-hosted via @fontsource-variable/inter + @fontsource-variable/geist-mono (src/fonts.ts) |
