@faclon-labs/fds
v0.4.0
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 (Blade/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).
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 Blade 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: Blade (Razorpay) 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 Razorpay Blade'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.
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 |
| @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/modifiers | optional | drag/sortable; nothing imports these yet |
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 | — | Blade'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) |
