6 Commits

Author SHA1 Message Date
olly 4919bc26e5 visuals, fixes & storybook upd 2026-06-01 01:09:55 +03:00
olly a5d2742c7c fixes 2026-05-31 18:09:55 +03:00
olly 22afa7e1a5 feat: structure 2026-05-31 17:49:29 +03:00
olly 67993ae3ec feat: storybook 2026-05-31 17:11:42 +03:00
olly 2f937e94b1 feat: storybook 2026-05-31 17:11:33 +03:00
olly b6cf54c836 stuff 2026-05-31 16:47:52 +03:00
59 changed files with 8407 additions and 996 deletions
+3
View File
@@ -7,6 +7,9 @@
node_modules
dist/
# Storybook build output (the .storybook config + *.stories.tsx ARE committed)
storybook-static/
# Profile
.rspack-profile-*/
+27
View File
@@ -0,0 +1,27 @@
import type { StorybookConfig } from 'storybook-react-rsbuild';
/* Dev-only playground. Never shipped — package `files` is ["dist"]. */
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs'],
staticDirs: ['../src/assets'],
framework: {
name: 'storybook-react-rsbuild',
options: {},
},
typescript: {
// Prop tables in autodocs come from the components' TS types.
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
// Keep our own props + Radix primitives; drop other node_modules noise.
propFilter: (prop) =>
prop.parent
? !/node_modules/.test(prop.parent.fileName) ||
/node_modules\/radix-ui/.test(prop.parent.fileName)
: true,
},
},
};
export default config;
+4
View File
@@ -0,0 +1,4 @@
import { addons } from 'storybook/manager-api';
import theme from './theme';
addons.setConfig({ theme });
+12
View File
@@ -0,0 +1,12 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
@font-face {
font-family: 'Anta';
src: url('/Anta-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
/* Calm dark canvas. Uses the felt's subtle radial glow (no heavy fixed
grain) so the story sits on-brand without flooding the frame. The
--bg-glow / --ink tokens come from the imported stylesheet and react
to the data-theme toggle automatically. */
.sb-show-main {
background-color: var(--ink);
background-image: var(--bg-glow);
background-size: cover;
background-repeat: no-repeat;
background-attachment: fixed;
}
/* Autodocs preview blocks: same dark surface as the canvas. */
.docs-story,
.sbdocs-preview {
background-color: var(--ink) !important;
background-image: var(--bg-glow);
background-size: cover;
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, type ReactNode } from 'react';
import type { Preview, Decorator } from 'storybook-react-rsbuild';
import { Tooltip } from 'radix-ui';
/* The shipped library surface, exactly as a consumer would load it.
Fonts are loaded via preview-head.html (Google Fonts link + Anta @font-face)
to avoid bundler inlining the @import url() mid-stylesheet. */
import '../src/styles/index.css';
/* Storybook-only canvas styling (background, docs blocks). */
import './preview.css';
import theme from './theme';
/* Toolbar theme switch → drives `data-theme` on <html>, same lever as the
library's ThemeProvider. The frame stays content-sized; the dark canvas
comes from preview.css, so stories never balloon to full-viewport. */
function ThemeFrame({ theme, children }: { theme: string; children: ReactNode }) {
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return <Tooltip.Provider delayDuration={200}>{children}</Tooltip.Provider>;
}
const withModernSk: Decorator = (Story, context) => (
<ThemeFrame theme={(context.globals.theme as string) ?? 'dark'}>
<Story />
</ThemeFrame>
);
const preview: Preview = {
tags: ['autodocs'],
decorators: [withModernSk],
parameters: {
layout: 'centered',
backgrounds: { disable: true },
docs: { theme },
controls: {
matchers: { color: /(background|color)$/i, date: /Date$/i },
},
options: {
storySort: { order: ['Getting Started', 'Inputs', 'Selection', 'Feedback', 'Overlays', 'Data Display', 'Layout'] },
},
},
globalTypes: {
theme: {
description: 'ModernSK theme',
defaultValue: 'dark',
toolbar: {
title: 'Theme',
icon: 'contrast',
items: [
{ value: 'dark', title: 'Dark', icon: 'moon' },
{ value: 'light', title: 'Light', icon: 'sun' },
],
dynamicTitle: true,
},
},
},
};
export default preview;
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'storybook/theming';
/* Dark, lime-accented chrome so the Storybook UI + autodocs match the
components instead of framing them in stock white. */
export default create({
base: 'dark',
brandTitle: 'ModernSK',
colorPrimary: '#bef264',
colorSecondary: '#bef264',
// App
appBg: '#0f100d',
appContentBg: '#0f100d',
appPreviewBg: '#0f100d',
appBorderColor: '#2a2c22',
appBorderRadius: 10,
// Typography
fontBase: "'Onest', system-ui, -apple-system, 'Segoe UI', sans-serif",
fontCode: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace",
// Text
textColor: '#f3f4ee',
textInverseColor: '#0f100d',
textMutedColor: '#9a9c8c',
// Toolbar / sidebar bars
barBg: '#16170f',
barTextColor: '#9a9c8c',
barSelectedColor: '#bef264',
barHoverColor: '#bef264',
// Inputs
inputBg: '#1c1d16',
inputBorder: '#2a2c22',
inputTextColor: '#f3f4ee',
inputBorderRadius: 8,
});
+60
View File
@@ -0,0 +1,60 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
`modern-sk` — a tactile, dark-first React component library built on [Radix](https://www.radix-ui.com/) primitives. Distributed as a git-installable / publishable package; only `dist/` ships. Consumers get built ESM + CJS, `.d.ts` types, and a single `styles.css`.
## Commands
```bash
npm run dev # Rsbuild playground at http://localhost:3000 (renders src/App.tsx — every component on one page)
npm run storybook # Storybook component explorer + autodocs at http://localhost:6006
npm run build # build publishable package: tsup (JS/types) + build:css into dist/
npm run lint # rslint (rslint.config.ts) — covers src/ including stories
npm run format # prettier --write .
```
No test suite exists. `npm run build` runs automatically on install via the `prepare` script — consumers build the package themselves.
The build is two steps that must both run (the `build` script chains them):
- `tsup` bundles `src/index.ts` → ESM/CJS + types. `react`/`react-dom` are externalized (peer deps); `radix-ui` + `@phosphor-icons/react` are bundled.
- `build:css` runs esbuild twice: `src/styles/index.css``dist/styles.css` (fontless core), and `src/styles/fonts.css``dist/fonts.css` with `--loader:.ttf=dataurl` (inlines the self-hosted Anta font). Both are package exports.
## Architecture
Two parallel surfaces share the same source but never mix at publish time:
- **Library (shipped):** `src/index.ts` is the public entry. It re-exports everything from `src/components/ui.tsx`, plus `ThemeProvider`/`useTheme` from `src/components/theme.tsx`, and exposes `TooltipProvider` (Radix's `Tooltip.Provider`). The shipped stylesheet entry is `src/styles/index.css`.
- **Playground (dev-only, never published):** two of them — `src/index.tsx` mounts the `src/App.tsx` kitchen sink (Rsbuild dev target, `src/styles/global.css`), and Storybook (`.storybook/` config + `src/stories/*.stories.tsx`) is the component catalogue with autodocs.
Only `dist/` ships (`files: ["dist"]`), so the playground, stories, and `.storybook/` are excluded from the package automatically — but they ARE committed to git so anyone can run them.
`src/styles/index.css` (shipped) vs `src/styles/global.css` (dev) is a deliberate split: `index.css` imports only `tokens.css` + `components.css` and applies box-sizing at **zero specificity** via `:where([class^='modern-sk-'])` so it never touches consumer elements. `global.css` adds a global reset, the optional `fonts.css`, and kitchen-sink layout helpers — those must stay out of the shipped bundle.
### Fonts (`src/styles/fonts.css`)
Fonts are NOT in the core stylesheet. `tokens.css` defines `--font-display/sans/mono` chains that degrade to `system-ui`; the actual faces (Anta `@font-face` + Onest/Geist Mono Google Fonts `@import`) live in `src/styles/fonts.css`, shipped as the optional `modern-sk/fonts.css` export. Consumers either import it, or override the `--font-*` tokens to remap typefaces. Storybook's `preview.tsx` and the dev `global.css` both import `fonts.css` so the playgrounds stay branded.
### Components (`src/components/ui.tsx`)
All components live in one file. Pattern: Radix provides logic/accessibility, every visual comes from CSS. Components are thin wrappers that attach `modern-sk-*` classes and spread props. The `cx()` helper joins class names (no classnames dependency). Components forward refs where they wrap a DOM element. There is no inline styling and no CSS-in-JS — **all appearance is driven by `modern-sk-*` classes resolving against CSS custom properties.**
### Styling system (`src/styles/`)
- `tokens.css` — single source of truth: color/type CSS custom properties (no font loading — see Fonts above). Every component reads from here.
- `components.css` — the `modern-sk-*` class definitions.
- Dark/light is driven by `data-theme` on `<html>`, set by `ThemeProvider` (persisted to `localStorage` under key `modern-sk-theme`, default `dark`).
When adding or changing a component: add the wrapper in `ui.tsx`, define its `modern-sk-*` styles in `components.css`, and pull any new color/spacing value from a token in `tokens.css` rather than hardcoding.
## Consumer contract
Consumers import `modern-sk/styles.css` once at app root, wrap their tree in `ThemeProvider`, and wrap any tooltip-using subtree in `TooltipProvider`. Keep these provider requirements intact when refactoring exports.
## Conventions
- React 19, React Compiler is enabled (`babel-plugin-react-compiler` in the Rsbuild dev pipeline).
- TypeScript is `noEmit` + `verbatimModuleSyntax`: use `import type` for type-only imports. `noUnusedLocals`/`noUnusedParameters` are on.
- ESM-only package (`"type": "module"`).
+57 -11
View File
@@ -1,14 +1,21 @@
# @modernsk/ui
<div align="center">
<img src="assets/logo.svg" alt="ModernSK" width="380" />
**Tactile, dark-first React component library built on [Radix](https://www.radix-ui.com/) primitives.**
Tactile, dark-first React component library built on [Radix](https://www.radix-ui.com/) primitives.
Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
</div>
---
## Install
Git-hosted (pin to a tag):
Distributed via self-hosted git — install straight from the repo:
```bash
npm install github:YOUR_ORG/modernsk-ui#v0.1.0
npm i git+ssh://git@git.ollyhearn.ru:49239/olly/modern-sk.git
```
`react` and `react-dom` (>=18) are peer dependencies — your app provides them.
@@ -19,8 +26,9 @@ The package builds itself on install via the `prepare` script.
Import the stylesheet once at your app root, then use components anywhere:
```tsx
import '@modernsk/ui/styles.css';
import { ThemeProvider, TooltipProvider, Button, Card } from '@modernsk/ui';
import 'modern-sk/styles.css'; // required — tokens + components
import 'modern-sk/fonts.css'; // optional — branded faces (see Fonts)
import { ThemeProvider, TooltipProvider, Button, Card } from 'modern-sk';
export function App() {
return (
@@ -37,18 +45,56 @@ export function App() {
- `ThemeProvider` manages dark/light via `data-theme` on `<html>` and persists to `localStorage`. Read it with `useTheme()`.
- `TooltipProvider` must wrap any tree that uses `<Tooltip>`.
- All visuals come from CSS custom properties in the shipped stylesheet; the self-hosted display font is inlined, so no extra asset hosting is needed.
- All visuals come from CSS custom properties in the shipped stylesheet.
## Fonts
`modern-sk/styles.css` ships **no fonts**. The type tokens default to a chain
that degrades to `system-ui`, so the library works with zero font loading.
To get the branded ModernSK faces (Anta display + Onest + Geist Mono), import
the optional stylesheet — Anta is self-hosted and inlined, no asset hosting
needed:
```tsx
import 'modern-sk/fonts.css';
```
To use your **own** fonts, skip `fonts.css` and override the tokens anywhere
after `styles.css` — every component re-reads them:
```css
:root {
--font-display: 'Your Display', sans-serif;
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
}
```
## Develop
A live playground (every component on one page) runs via Rsbuild:
```bash
npm run dev # playground at http://localhost:3000
npm run dev # Rsbuild playground (every component on one page) at http://localhost:3000
npm run storybook # Storybook component explorer + docs at http://localhost:6006
npm run build # build the publishable package into dist/
npm run lint
```
**Storybook** is the component catalogue: each component has live controls and an
auto-generated prop table, plus a theme toggle in the toolbar. Stories live in
`src/stories/*.stories.tsx`; config is in `.storybook/`. It is committed to git so
anyone can `npm run storybook` and browse — but it never ships in the package.
## What ships
`npm publish` / git-install exposes only `dist/` — built ESM + CJS, `.d.ts` types, and `styles.css`. The playground (`src/App.tsx`, Rsbuild config) is dev-only and never published.
A git install exposes only `dist/` — built ESM + CJS, `.d.ts` types,
`styles.css`, and `fonts.css`. Everything else (`src/App.tsx`, `.storybook/`,
stories, Rsbuild config) is dev-only and never published.
---
<div align="center">
brought to you by **ollyhearn** & **claude** with &lt;3
</div>
+21
View File
@@ -0,0 +1,21 @@
<svg width="440" height="140" viewBox="0 0 440 140" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ModernSK">
<defs>
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="140" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1b1d15"/>
<stop offset="1" stop-color="#0d0e0a"/>
</linearGradient>
<linearGradient id="sk" x1="300" y1="0" x2="420" y2="140" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#d4ff7a"/>
<stop offset="1" stop-color="#a8e04a"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="438" height="138" rx="22" fill="url(#panel)" stroke="#2a2c22" stroke-width="1.5"/>
<rect x="6" y="6" width="428" height="2" rx="1" fill="#ffffff" opacity="0.05"/>
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle"
font-family="'Anta', 'Onest', system-ui, -apple-system, 'Segoe UI', sans-serif"
font-size="58" font-weight="700" letter-spacing="2">
<tspan fill="#e7e8e0">MODERN</tspan><tspan fill="url(#sk)">SK</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+4863 -2
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -1,5 +1,5 @@
{
"name": "@modernsk/ui",
"name": "modern-sk",
"version": "0.1.0",
"description": "ModernSK — tactile, dark-first React component library built on Radix primitives.",
"license": "MIT",
@@ -16,16 +16,19 @@
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
"./styles.css": "./dist/styles.css",
"./fonts.css": "./dist/fonts.css"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup && npm run build:css",
"build:css": "esbuild src/styles/index.css --bundle --loader:.ttf=dataurl --outfile=dist/styles.css",
"build:css": "esbuild src/styles/index.css --bundle --outfile=dist/styles.css && esbuild src/styles/fonts.css --bundle --loader:.ttf=dataurl --outfile=dist/fonts.css",
"dev": "rsbuild dev --open",
"preview": "rsbuild preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"lint": "rslint",
"format": "prettier --write .",
"prepare": "npm run build"
@@ -43,6 +46,7 @@
"@rsbuild/plugin-babel": "^1.2.0",
"@rsbuild/plugin-react": "^2.0.0",
"@rslint/core": "^0.5.1",
"@storybook/addon-docs": "^10.4.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "^1.0.0",
@@ -50,6 +54,8 @@
"prettier": "^3.8.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"storybook": "^10.4.1",
"storybook-react-rsbuild": "^3.3.4",
"tsup": "^8.5.0",
"typescript": "^6.0.3"
}
+5 -4
View File
@@ -76,7 +76,7 @@ const App = () => {
const [chips, setChips] = useState(['design', '2026', 'invoices']);
return (
<div className="msk-felt">
<div className="modern-sk-felt">
<div className="wrap">
<header>
<div className="topbar">
@@ -87,8 +87,8 @@ const App = () => {
<p className="sub">
Every component, live and interactive, built on Radix Primitives
styled from the global tokens in{' '}
<span className="msk-mono">tokens.css</span> +{' '}
<span className="msk-mono">components.css</span>. Click, toggle,
<span className="modern-sk-mono">tokens.css</span> +{' '}
<span className="modern-sk-mono">components.css</span>. Click, toggle,
focus it all responds.
</p>
</div>
@@ -238,11 +238,12 @@ const App = () => {
<div className="cap">Slider &amp; stepper</div>
<div className="cluster">
<Slider defaultValue={[62]} max={100} step={1} />
<Slider defaultValue={[40]} max={100} step={20} marks />
<Stepper
onDecrement={() => setCount((n) => Math.max(0, n - 1))}
onIncrement={() => setCount((n) => n + 1)}
/>
<span className="msk-mono" style={{ color: 'var(--fg-2)' }}>
<span className="modern-sk-mono" style={{ color: 'var(--fg-2)' }}>
{count}
</span>
</div>
+57
View File
@@ -0,0 +1,57 @@
import { type ReactNode } from 'react';
import { AlertDialog as RAlertDialog } from 'radix-ui';
import { Button } from '../button';
export const AlertDialog = ({
trigger,
title,
description,
cancelLabel = 'Cancel',
actionLabel = 'Confirm',
destructive,
onAction,
open,
defaultOpen,
onOpenChange,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
cancelLabel?: string;
actionLabel?: string;
destructive?: boolean;
onAction?: () => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (o: boolean) => void;
}) => (
<RAlertDialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
{trigger && <RAlertDialog.Trigger asChild>{trigger}</RAlertDialog.Trigger>}
<RAlertDialog.Portal>
<RAlertDialog.Overlay className="modern-sk-overlay" />
<RAlertDialog.Content className="modern-sk-dialog">
<RAlertDialog.Title className="modern-sk-dialog__title">
{title}
</RAlertDialog.Title>
{description && (
<RAlertDialog.Description className="modern-sk-dialog__desc">
{description}
</RAlertDialog.Description>
)}
<div className="modern-sk-dialog__footer">
<RAlertDialog.Cancel asChild>
<Button variant="ghost">{cancelLabel}</Button>
</RAlertDialog.Cancel>
<RAlertDialog.Action asChild>
<Button
variant={destructive ? 'ember' : 'primary'}
onClick={onAction}
>
{actionLabel}
</Button>
</RAlertDialog.Action>
</div>
</RAlertDialog.Content>
</RAlertDialog.Portal>
</RAlertDialog.Root>
);
+44
View File
@@ -0,0 +1,44 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { cx } from '../utils';
type BadgeVariant = 'lime' | 'ember' | 'neutral' | 'outline';
export const Badge = ({
variant = 'neutral',
dot,
className,
children,
...props
}: ComponentPropsWithoutRef<'span'> & {
variant?: BadgeVariant;
dot?: boolean;
}) => (
<span
className={cx(
'modern-sk-badge',
`modern-sk-badge--${variant}`,
dot && 'modern-sk-badge--dot',
className,
)}
{...props}
>
{children}
</span>
);
export const Chip = ({
children,
onRemove,
}: {
children: ReactNode;
onRemove?: () => void;
}) => (
<span className="modern-sk-chip">
{children}
{onRemove && (
<button type="button" className="x" onClick={onRemove} aria-label="Remove">
×
</button>
)}
</span>
);
+27
View File
@@ -0,0 +1,27 @@
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export type BtnVariant = 'key' | 'primary' | 'ember' | 'ghost';
type ButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm';
iconOnly?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'key', size, iconOnly, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'modern-sk-btn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size === 'sm' && 'modern-sk-btn--sm',
iconOnly && 'modern-sk-btn--icon',
className,
)}
{...props}
/>
),
);
Button.displayName = 'Button';
+19
View File
@@ -0,0 +1,19 @@
import { type ReactNode } from 'react';
import { cx } from '../utils';
type CalloutVariant = 'info' | 'success' | 'warning' | 'danger';
export const Callout = ({
variant = 'info',
icon,
children,
}: {
variant?: CalloutVariant;
icon?: ReactNode;
children: ReactNode;
}) => (
<div className={cx('modern-sk-callout', variant !== 'info' && `modern-sk-callout--${variant}`)}>
{icon && <span className="modern-sk-callout__icon">{icon}</span>}
<div className="modern-sk-callout__body">{children}</div>
</div>
);
+6
View File
@@ -0,0 +1,6 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Card = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-card', className)} {...props} />
);
+55
View File
@@ -0,0 +1,55 @@
import { type ReactNode } from 'react';
import { Dialog as RDialog } from 'radix-ui';
import { X } from '@phosphor-icons/react';
import { IconButton } from '../icon-button';
export const Dialog = ({
trigger,
title,
description,
children,
footer,
open,
defaultOpen,
onOpenChange,
modal,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (o: boolean) => void;
modal?: boolean;
}) => (
<RDialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} modal={modal}>
{trigger && <RDialog.Trigger asChild>{trigger}</RDialog.Trigger>}
<RDialog.Portal>
<RDialog.Overlay className="modern-sk-overlay" />
<RDialog.Content className="modern-sk-dialog">
<RDialog.Title className="modern-sk-dialog__title">{title}</RDialog.Title>
{description && (
<RDialog.Description className="modern-sk-dialog__desc">
{description}
</RDialog.Description>
)}
{children && <div className="modern-sk-dialog__body">{children}</div>}
{footer && <div className="modern-sk-dialog__footer">{footer}</div>}
<RDialog.Close asChild>
<IconButton
variant="ghost"
size="sm"
className="modern-sk-dialog__close"
aria-label="Close"
>
<X size={14} weight="bold" />
</IconButton>
</RDialog.Close>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
);
export const DialogClose = RDialog.Close;
+25
View File
@@ -0,0 +1,25 @@
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
import type { BtnVariant } from '../button';
type IconButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm' | 'lg';
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ variant = 'key', size, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'modern-sk-btn',
'modern-sk-iconbtn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size && `modern-sk-iconbtn--${size}`,
className,
)}
{...props}
/>
),
);
IconButton.displayName = 'IconButton';
+17
View File
@@ -0,0 +1,17 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const List = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-list', className)} {...props} />
);
export const Row = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'div'> & { selected?: boolean }) => (
<div
className={cx('modern-sk-row', selected && 'is-selected', className)}
{...props}
/>
);
+56
View File
@@ -0,0 +1,56 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { DropdownMenu as RMenu } from 'radix-ui';
export const Menu = RMenu.Root;
export const MenuTrigger = RMenu.Trigger;
export const MenuContent = ({
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Content>) => (
<RMenu.Portal>
<RMenu.Content className="modern-sk-menu" sideOffset={6} {...props}>
{children}
</RMenu.Content>
</RMenu.Portal>
);
export const MenuItem = ({
icon,
shortcut,
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Item> & {
icon?: ReactNode;
shortcut?: string;
}) => (
<RMenu.Item className="modern-sk-menu-item" {...props}>
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</RMenu.Item>
);
export const MenuSeparator = () => (
<RMenu.Separator className="modern-sk-menu-sep" />
);
export const MenuSurface = ({ children }: { children: ReactNode }) => (
<div className="modern-sk-menu">{children}</div>
);
export const MenuRow = ({
icon,
shortcut,
children,
}: {
icon?: ReactNode;
shortcut?: string;
children: ReactNode;
}) => (
<div className="modern-sk-menu-item">
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</div>
);
+16
View File
@@ -0,0 +1,16 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Progress as RProgress } from 'radix-ui';
import { cx } from '../utils';
export const Progress = ({
value = 0,
className,
...props
}: ComponentPropsWithoutRef<typeof RProgress.Root>) => (
<RProgress.Root className={cx('modern-sk-progress', className)} value={value} {...props}>
<RProgress.Indicator
className="modern-sk-progress__indicator"
style={{ width: `${value}%` }}
/>
</RProgress.Root>
);
+22
View File
@@ -0,0 +1,22 @@
import { type ComponentPropsWithoutRef } from 'react';
import { ScrollArea as RScrollArea } from 'radix-ui';
import { cx } from '../utils';
export const ScrollArea = ({
children,
className,
...props
}: ComponentPropsWithoutRef<typeof RScrollArea.Root>) => (
<RScrollArea.Root className={cx('modern-sk-scroll', className)} {...props}>
<RScrollArea.Viewport className="modern-sk-scroll__viewport">
{children}
</RScrollArea.Viewport>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="vertical">
<RScrollArea.Thumb className="modern-sk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="horizontal">
<RScrollArea.Thumb className="modern-sk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Corner />
</RScrollArea.Root>
);
@@ -0,0 +1,66 @@
import { type ComponentPropsWithoutRef, useEffect, useRef } from 'react';
import { ToggleGroup as RToggleGroup } from 'radix-ui';
import { cx } from '../utils';
type SegProps = Omit<
ComponentPropsWithoutRef<typeof RToggleGroup.Root>,
'type' | 'onValueChange' | 'defaultValue' | 'value'
> & {
value: string;
defaultValue?: string;
onValueChange: (v: string) => void;
items: Array<{ value: string; label: string }>;
};
export const SegmentedControl = ({
value,
onValueChange,
items,
className,
...props
}: SegProps) => {
const rootRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLSpanElement>(null);
const initialized = useRef(false);
useEffect(() => {
const root = rootRef.current;
const thumb = thumbRef.current;
if (!root || !thumb) return;
const selected = root.querySelector<HTMLElement>('[data-state="on"]');
if (!selected) return;
if (!initialized.current) {
thumb.style.transition = 'none';
}
thumb.style.transform = `translateX(${selected.offsetLeft}px)`;
thumb.style.width = `${selected.offsetWidth}px`;
if (!initialized.current) {
thumb.getBoundingClientRect();
thumb.style.transition = '';
initialized.current = true;
}
}, [value]);
return (
<RToggleGroup.Root
ref={rootRef}
type="single"
className={cx('modern-sk-seg', className)}
value={value}
onValueChange={(v) => v && onValueChange(v)}
{...props}
>
<span ref={thumbRef} className="modern-sk-seg__thumb" aria-hidden />
{items.map((it) => (
<RToggleGroup.Item
key={it.value}
value={it.value}
className="modern-sk-seg__item"
>
{it.label}
</RToggleGroup.Item>
))}
</RToggleGroup.Root>
);
};
+42
View File
@@ -0,0 +1,42 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Select as RSelect } from 'radix-ui';
import { Check, CaretDown } from '@phosphor-icons/react';
type SelectProps = ComponentPropsWithoutRef<typeof RSelect.Root> & {
placeholder?: string;
items: Array<{ value: string; label: string }>;
'aria-label'?: string;
};
export const Select = ({ placeholder, items, ...rest }: SelectProps) => (
<RSelect.Root {...rest}>
<RSelect.Trigger className="modern-sk-select" aria-label={rest['aria-label']}>
<RSelect.Value placeholder={placeholder} />
<RSelect.Icon className="modern-sk-select__icon">
<CaretDown size={12} weight="bold" />
</RSelect.Icon>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content
className="modern-sk-select__content"
position="popper"
sideOffset={6}
>
<RSelect.Viewport>
{items.map((it) => (
<RSelect.Item
key={it.value}
value={it.value}
className="modern-sk-select__item"
>
<RSelect.ItemText>{it.label}</RSelect.ItemText>
<RSelect.ItemIndicator className="modern-sk-select__item-indicator">
<Check size={14} weight="bold" />
</RSelect.ItemIndicator>
</RSelect.Item>
))}
</RSelect.Viewport>
</RSelect.Content>
</RSelect.Portal>
</RSelect.Root>
);
+53
View File
@@ -0,0 +1,53 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import {
Switch as RSwitch,
Checkbox as RCheckbox,
RadioGroup as RRadioGroup,
} from 'radix-ui';
export const Switch = (props: ComponentPropsWithoutRef<typeof RSwitch.Root>) => (
<RSwitch.Root className="modern-sk-switch" {...props}>
<RSwitch.Thumb className="modern-sk-switch__thumb" />
</RSwitch.Root>
);
export const Checkbox = (props: ComponentPropsWithoutRef<typeof RCheckbox.Root>) => (
<RCheckbox.Root className="modern-sk-check" {...props}>
<RCheckbox.Indicator className="modern-sk-check__indicator">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--lime-ink)"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12l5 5L20 6" />
</svg>
</RCheckbox.Indicator>
</RCheckbox.Root>
);
export const RadioGroup = RRadioGroup.Root;
export const RadioItem = ({
value,
...props
}: ComponentPropsWithoutRef<typeof RRadioGroup.Item>) => (
<RRadioGroup.Item className="modern-sk-radio" value={value} {...props}>
<RRadioGroup.Indicator className="modern-sk-radio__indicator" />
</RRadioGroup.Item>
);
export const Control = ({
children,
control,
}: {
children: ReactNode;
control: ReactNode;
}) => (
<label className="modern-sk-control">
{control}
{children}
</label>
);
+142
View File
@@ -0,0 +1,142 @@
import { type ComponentPropsWithoutRef, type CSSProperties } from 'react';
import { Slider as RSlider } from 'radix-ui';
type Mark = { value: number; label?: string };
type MarksProp = boolean | Array<number | Mark>;
type NotchPlacement = 'top' | 'bottom' | 'both' | 'none';
type KnobStyle = 'square' | 'round';
type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'className'> & {
/**
* Step marks.
* - `true` — auto-generate one mark per `step` between `min` and `max`.
* - array — explicit marks; numbers or `{ value, label }` for tick labels.
*/
marks?: MarksProp;
/**
* Where to draw the notch ticks relative to the track.
* `'bottom'` (default), `'top'`, `'both'`, or `'none'` to hide ticks
* (labels still render when provided). No effect without `marks`.
*/
notches?: NotchPlacement;
/** Thumb shape. `'square'` (default) has a small border-radius; `'round'` is a full circle. */
knobStyle?: KnobStyle;
className?: string;
};
function resolveMarks(
marks: MarksProp,
min: number,
max: number,
step: number,
): Mark[] {
if (Array.isArray(marks)) {
return marks
.map((m) => (typeof m === 'number' ? { value: m } : m))
.filter((m) => m.value >= min && m.value <= max);
}
if (marks !== true) return [];
if (!(step > 0) || max <= min) return [];
const count = Math.floor((max - min) / step);
// Guard against absurd notch counts (e.g. step=1 over a 01000 range).
if (count < 1 || count > 100) return [];
return Array.from({ length: count + 1 }, (_, i) => ({ value: min + i * step }));
}
const percent = (value: number, min: number, max: number) =>
max === min ? 0 : (value - min) / (max - min);
const NotchLayer = ({
marks,
min,
max,
side,
}: {
marks: Mark[];
min: number;
max: number;
side: 'top' | 'bottom';
}) => (
<div className={`modern-sk-slider__notches modern-sk-slider__notches--${side}`} aria-hidden>
{marks.map((mark) => (
<span
key={mark.value}
className="modern-sk-slider__notch"
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
/>
))}
</div>
);
export const Slider = ({
marks,
notches = 'bottom',
knobStyle = 'square',
min = 0,
max = 100,
step = 1,
className,
...props
}: SliderProps) => {
const resolved = marks != null ? resolveMarks(marks, min, max, step) : [];
const hasMarks = resolved.length > 0;
const hasLabels = resolved.some((m) => m.label != null);
const showTop = hasMarks && (notches === 'top' || notches === 'both');
const showBottom = hasMarks && (notches === 'bottom' || notches === 'both');
const cls = [
'modern-sk-slider',
`modern-sk-slider--knob-${knobStyle}`,
hasMarks && 'modern-sk-slider--has-marks',
hasLabels && 'modern-sk-slider--has-labels',
showTop && 'modern-sk-slider--notch-top',
showBottom && 'modern-sk-slider--notch-bottom',
className,
]
.filter(Boolean)
.join(' ');
return (
<RSlider.Root className={cls} min={min} max={max} step={step} {...props}>
<RSlider.Track className="modern-sk-slider__track">
<RSlider.Range className="modern-sk-slider__range" />
{showTop && <NotchLayer marks={resolved} min={min} max={max} side="top" />}
{showBottom && <NotchLayer marks={resolved} min={min} max={max} side="bottom" />}
{hasLabels && (
<div className="modern-sk-slider__labels" aria-hidden>
{resolved.map((mark) =>
mark.label != null ? (
<span
key={mark.value}
className="modern-sk-slider__label"
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
>
{mark.label}
</span>
) : null,
)}
</div>
)}
</RSlider.Track>
<RSlider.Thumb className="modern-sk-slider__thumb" aria-label="Value" />
</RSlider.Root>
);
};
export const Stepper = ({
onDecrement,
onIncrement,
}: {
onDecrement: () => void;
onIncrement: () => void;
}) => (
<div className="modern-sk-stepper">
<button type="button" onClick={onDecrement} aria-label="Decrease">
</button>
<button type="button" onClick={onIncrement} aria-label="Increase">
+
</button>
</div>
);
+74
View File
@@ -0,0 +1,74 @@
import { useId, type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Spinner = ({
size,
className,
...props
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
const uid = useId();
const grooveId = `modern-sk-groove-${uid}`;
const glowId = `modern-sk-glow-${uid}`;
return (
<span
role="status"
aria-label="Loading"
className={cx('modern-sk-spinner', size && `modern-sk-spinner--${size}`, className)}
{...props}
>
<svg viewBox="0 0 36 36" fill="none">
<defs>
{/* Carved channel: flat ring sunk by a top inner shadow — like the switch well. */}
<filter id={grooveId} x="-30%" y="-30%" width="160%" height="160%">
<feComponentTransfer in="SourceAlpha" result="inv">
<feFuncA type="table" tableValues="1 0" />
</feComponentTransfer>
<feGaussianBlur in="inv" stdDeviation="1" result="blur" />
<feOffset in="blur" dy="1" result="off" />
<feFlood floodColor="#000" floodOpacity="0.7" />
<feComposite in2="off" operator="in" />
<feComposite in2="SourceAlpha" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="SourceGraphic" />
<feMergeNode in="shadow" />
</feMerge>
</filter>
{/* Soft round glow — generous region so it never clips to a square. */}
<filter id={glowId} x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="1.6" />
</filter>
</defs>
<circle
cx="18"
cy="18"
r="14"
stroke="var(--spin-track)"
strokeWidth="5"
filter={`url(#${grooveId})`}
/>
<g className="modern-sk-spinner__arc">
<circle
cx="18"
cy="18"
r="14"
stroke="var(--lime)"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="22 88"
opacity="0.8"
filter={`url(#${glowId})`}
/>
<circle
cx="18"
cy="18"
r="14"
stroke="var(--lime)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="22 88"
/>
</g>
</svg>
</span>
);
};
+24
View File
@@ -0,0 +1,24 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Table = ({ children, ...props }: ComponentPropsWithoutRef<'table'>) => (
<div className="modern-sk-table-wrap">
<table className="modern-sk-table" {...props}>
{children}
</table>
</div>
);
export const THead = (p: ComponentPropsWithoutRef<'thead'>) => <thead {...p} />;
export const TBody = (p: ComponentPropsWithoutRef<'tbody'>) => <tbody {...p} />;
export const Tr = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'tr'> & { selected?: boolean }) => (
<tr className={cx(selected && 'is-selected', className)} {...props} />
);
export const Th = (p: ComponentPropsWithoutRef<'th'>) => <th {...p} />;
export const Td = (p: ComponentPropsWithoutRef<'td'>) => <td {...p} />;
+28
View File
@@ -0,0 +1,28 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Tabs as RTabs } from 'radix-ui';
import { cx } from '../utils';
export const Tabs = RTabs.Root;
export const TabsList = ({
items,
className,
...props
}: { items: Array<{ value: string; label: string }> } & Omit<
ComponentPropsWithoutRef<typeof RTabs.List>,
'children'
>) => (
<RTabs.List className={cx('modern-sk-tabs', className)} {...props}>
{items.map((it) => (
<RTabs.Trigger
key={it.value}
value={it.value}
className="modern-sk-tabs__trigger"
>
{it.label}
</RTabs.Trigger>
))}
</RTabs.List>
);
export const TabsContent = RTabs.Content;
+26
View File
@@ -0,0 +1,26 @@
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { cx } from '../utils';
export const TextField = forwardRef<HTMLInputElement, ComponentPropsWithoutRef<'input'>>(
({ className, ...props }, ref) => (
<input ref={ref} className={cx('modern-sk-field', className)} {...props} />
),
);
TextField.displayName = 'TextField';
export const TextArea = forwardRef<HTMLTextAreaElement, ComponentPropsWithoutRef<'textarea'>>(
({ className, ...props }, ref) => (
<textarea ref={ref} className={cx('modern-sk-field', className)} {...props} />
),
);
TextArea.displayName = 'TextArea';
export const SearchField = ({
icon,
...props
}: ComponentPropsWithoutRef<'input'> & { icon: ReactNode }) => (
<div className="modern-sk-search">
<span className="ph">{icon}</span>
<TextField {...props} />
</div>
);
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from 'react';
type ThemeMode = 'dark' | 'light';
const KEY = 'msk-theme';
const KEY = 'modern-sk-theme';
const ThemeContext = createContext<{
theme: ThemeMode;
+42
View File
@@ -0,0 +1,42 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { Tooltip as RTooltip } from 'radix-ui';
import { cx } from '../utils';
type TooltipProps = {
content: ReactNode;
children: ReactNode;
delayDuration?: number;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (o: boolean) => void;
} & Omit<ComponentPropsWithoutRef<typeof RTooltip.Content>, 'children' | 'content'>;
export const Tooltip = ({
content,
children,
delayDuration,
open,
defaultOpen,
onOpenChange,
sideOffset = 6,
className,
...contentProps
}: TooltipProps) => (
<RTooltip.Root
delayDuration={delayDuration}
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<RTooltip.Trigger asChild>{children}</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content
className={cx('modern-sk-tooltip', className)}
sideOffset={sideOffset}
{...contentProps}
>
{content}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
);
+21 -679
View File
@@ -1,679 +1,21 @@
/* ============================================================
ModernSK UI — Radix Primitives wrapped in the ModernSK look.
Logic/accessibility from Radix; every pixel from the tokens in
styles/tokens.css + styles/components.css.
============================================================ */
import {
forwardRef,
useId,
type ComponentPropsWithoutRef,
type CSSProperties,
type ReactNode,
} from 'react';
import {
Switch as RSwitch,
Checkbox as RCheckbox,
RadioGroup as RRadioGroup,
Tabs as RTabs,
Slider as RSlider,
DropdownMenu as RMenu,
Tooltip as RTooltip,
Progress as RProgress,
Select as RSelect,
ToggleGroup as RToggleGroup,
Dialog as RDialog,
AlertDialog as RAlertDialog,
ScrollArea as RScrollArea,
} from 'radix-ui';
import { Check, CaretDown, X } from '@phosphor-icons/react';
const cx = (...c: Array<string | false | undefined>) =>
c.filter(Boolean).join(' ');
/* ---------- BUTTON ---------- */
type BtnVariant = 'key' | 'primary' | 'ember' | 'ghost';
type ButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm';
iconOnly?: boolean;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'key', size, iconOnly, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'msk-btn',
variant !== 'key' && `msk-btn--${variant}`,
size === 'sm' && 'msk-btn--sm',
iconOnly && 'msk-btn--icon',
className,
)}
{...props}
/>
),
);
Button.displayName = 'Button';
/* ---------- TEXT FIELD ---------- */
export const TextField = forwardRef<
HTMLInputElement,
ComponentPropsWithoutRef<'input'>
>(({ className, ...props }, ref) => (
<input ref={ref} className={cx('msk-field', className)} {...props} />
));
TextField.displayName = 'TextField';
export const TextArea = forwardRef<
HTMLTextAreaElement,
ComponentPropsWithoutRef<'textarea'>
>(({ className, ...props }, ref) => (
<textarea ref={ref} className={cx('msk-field', className)} {...props} />
));
TextArea.displayName = 'TextArea';
export const SearchField = ({
icon,
...props
}: ComponentPropsWithoutRef<'input'> & { icon: ReactNode }) => (
<div className="msk-search">
<span className="ph">{icon}</span>
<TextField {...props} />
</div>
);
/* ---------- SELECT (Radix) ---------- */
type SelectProps = {
value?: string;
defaultValue?: string;
onValueChange?: (v: string) => void;
placeholder?: string;
items: Array<{ value: string; label: string }>;
'aria-label'?: string;
};
export const Select = ({
value,
defaultValue,
onValueChange,
placeholder,
items,
...rest
}: SelectProps) => (
<RSelect.Root
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
>
<RSelect.Trigger className="msk-select" aria-label={rest['aria-label']}>
<RSelect.Value placeholder={placeholder} />
<RSelect.Icon className="msk-select__icon">
<CaretDown size={12} weight="bold" />
</RSelect.Icon>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content
className="msk-select__content"
position="popper"
sideOffset={6}
>
<RSelect.Viewport>
{items.map((it) => (
<RSelect.Item
key={it.value}
value={it.value}
className="msk-select__item"
>
<RSelect.ItemText>{it.label}</RSelect.ItemText>
<RSelect.ItemIndicator className="msk-select__item-indicator">
<Check size={14} weight="bold" />
</RSelect.ItemIndicator>
</RSelect.Item>
))}
</RSelect.Viewport>
</RSelect.Content>
</RSelect.Portal>
</RSelect.Root>
);
/* ---------- SWITCH ---------- */
export const Switch = (
props: ComponentPropsWithoutRef<typeof RSwitch.Root>,
) => (
<RSwitch.Root className="msk-switch" {...props}>
<RSwitch.Thumb className="msk-switch__thumb" />
</RSwitch.Root>
);
/* ---------- CHECKBOX ---------- */
export const Checkbox = (
props: ComponentPropsWithoutRef<typeof RCheckbox.Root>,
) => (
<RCheckbox.Root className="msk-check" {...props}>
<RCheckbox.Indicator className="msk-check__indicator">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="var(--lime-ink)"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12l5 5L20 6" />
</svg>
</RCheckbox.Indicator>
</RCheckbox.Root>
);
/* ---------- RADIO GROUP ---------- */
export const RadioGroup = RRadioGroup.Root;
export const RadioItem = ({
value,
...props
}: ComponentPropsWithoutRef<typeof RRadioGroup.Item>) => (
<RRadioGroup.Item className="msk-radio" value={value} {...props}>
<RRadioGroup.Indicator className="msk-radio__indicator" />
</RRadioGroup.Item>
);
/* control + label row helper */
export const Control = ({
children,
control,
}: {
children: ReactNode;
control: ReactNode;
}) => (
<label className="msk-control">
{control}
{children}
</label>
);
/* ---------- SEGMENTED CONTROL (ToggleGroup single) ---------- */
type SegProps = {
value: string;
onValueChange: (v: string) => void;
items: Array<{ value: string; label: string }>;
};
export const SegmentedControl = ({
value,
onValueChange,
items,
}: SegProps) => (
<RToggleGroup.Root
type="single"
className="msk-seg"
value={value}
onValueChange={(v) => v && onValueChange(v)}
>
{items.map((it) => (
<RToggleGroup.Item
key={it.value}
value={it.value}
className="msk-seg__item"
>
{it.label}
</RToggleGroup.Item>
))}
</RToggleGroup.Root>
);
/* ---------- SLIDER ---------- */
export const Slider = (
props: ComponentPropsWithoutRef<typeof RSlider.Root>,
) => (
<RSlider.Root className="msk-slider" {...props}>
<RSlider.Track className="msk-slider__track">
<RSlider.Range className="msk-slider__range" />
</RSlider.Track>
<RSlider.Thumb className="msk-slider__thumb" aria-label="Value" />
</RSlider.Root>
);
/* ---------- STEPPER ---------- */
export const Stepper = ({
onDecrement,
onIncrement,
}: {
onDecrement: () => void;
onIncrement: () => void;
}) => (
<div className="msk-stepper">
<button type="button" onClick={onDecrement} aria-label="Decrease">
</button>
<button type="button" onClick={onIncrement} aria-label="Increase">
+
</button>
</div>
);
/* ---------- TABS ---------- */
export const Tabs = RTabs.Root;
export const TabsList = ({
items,
}: {
items: Array<{ value: string; label: string }>;
}) => (
<RTabs.List className="msk-tabs">
{items.map((it) => (
<RTabs.Trigger
key={it.value}
value={it.value}
className="msk-tabs__trigger"
>
{it.label}
</RTabs.Trigger>
))}
</RTabs.List>
);
export const TabsContent = RTabs.Content;
/* ---------- PROGRESS ---------- */
export const Progress = ({ value = 0 }: { value?: number }) => (
<RProgress.Root className="msk-progress" value={value}>
<RProgress.Indicator
className="msk-progress__indicator"
style={{ width: `${value}%` }}
/>
</RProgress.Root>
);
/* ---------- BADGE ---------- */
type BadgeVariant = 'lime' | 'ember' | 'neutral' | 'outline';
export const Badge = ({
variant = 'neutral',
dot,
className,
children,
...props
}: ComponentPropsWithoutRef<'span'> & {
variant?: BadgeVariant;
dot?: boolean;
}) => (
<span
className={cx(
'msk-badge',
`msk-badge--${variant}`,
dot && 'msk-badge--dot',
className,
)}
{...props}
>
{children}
</span>
);
/* ---------- CHIP ---------- */
export const Chip = ({
children,
onRemove,
}: {
children: ReactNode;
onRemove?: () => void;
}) => (
<span className="msk-chip">
{children}
{onRemove && (
<button type="button" className="x" onClick={onRemove} aria-label="Remove">
×
</button>
)}
</span>
);
/* ---------- CARD ---------- */
export const Card = ({
className,
...props
}: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('msk-card', className)} {...props} />
);
/* ---------- LIST ---------- */
export const List = ({
className,
...props
}: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('msk-list', className)} {...props} />
);
export const Row = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'div'> & { selected?: boolean }) => (
<div
className={cx('msk-row', selected && 'is-selected', className)}
{...props}
/>
);
/* ---------- DROPDOWN MENU (Radix) ---------- */
export const Menu = RMenu.Root;
export const MenuTrigger = RMenu.Trigger;
export const MenuContent = ({
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Content>) => (
<RMenu.Portal>
<RMenu.Content className="msk-menu" sideOffset={6} {...props}>
{children}
</RMenu.Content>
</RMenu.Portal>
);
export const MenuItem = ({
icon,
shortcut,
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Item> & {
icon?: ReactNode;
shortcut?: string;
}) => (
<RMenu.Item className="msk-menu-item" {...props}>
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</RMenu.Item>
);
export const MenuSeparator = () => (
<RMenu.Separator className="msk-menu-sep" />
);
/* Static menu surface — for showcasing the menu without a trigger. */
export const MenuSurface = ({ children }: { children: ReactNode }) => (
<div className="msk-menu">{children}</div>
);
export const MenuRow = ({
icon,
shortcut,
children,
}: {
icon?: ReactNode;
shortcut?: string;
children: ReactNode;
}) => (
<div className="msk-menu-item">
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</div>
);
/* ---------- TOOLTIP (Radix) ---------- */
export const Tooltip = ({
content,
children,
}: {
content: ReactNode;
children: ReactNode;
}) => (
<RTooltip.Root>
<RTooltip.Trigger asChild>{children}</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content className="msk-tooltip" sideOffset={6}>
{content}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
);
/* ---------- ICON BUTTON ---------- */
type IconButtonProps = ComponentPropsWithoutRef<'button'> & {
variant?: BtnVariant;
size?: 'sm' | 'lg';
};
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ variant = 'key', size, className, ...props }, ref) => (
<button
ref={ref}
className={cx(
'msk-btn',
'msk-iconbtn',
variant !== 'key' && `msk-btn--${variant}`,
size && `msk-iconbtn--${size}`,
className,
)}
{...props}
/>
),
);
IconButton.displayName = 'IconButton';
/* ---------- SPINNER ----------
Carved donut groove (sunk like the switch well, dark rim at top →
light catch at the bottom) with a glossy lime arc spinning inside it. */
export const Spinner = ({
size,
className,
...props
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
const gid = `msk-groove-${useId()}`;
return (
<span
role="status"
aria-label="Loading"
className={cx('msk-spinner', size && `msk-spinner--${size}`, className)}
{...props}
>
<svg viewBox="0 0 36 36" fill="none">
<defs>
<linearGradient
id={gid}
x1="18"
y1="4"
x2="18"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="var(--spin-groove-1)" />
<stop offset="1" stopColor="var(--spin-groove-2)" />
</linearGradient>
</defs>
{/* carved channel */}
<circle cx="18" cy="18" r="14" stroke={`url(#${gid})`} strokeWidth="5" />
{/* glossy lime arc, nested inside the groove with rounded ends */}
<circle
className="msk-spinner__arc"
cx="18"
cy="18"
r="14"
stroke="var(--lime)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="22 88"
/>
</svg>
</span>
);
};
/* ---------- CALLOUT ---------- */
type CalloutVariant = 'info' | 'success' | 'warning' | 'danger';
export const Callout = ({
variant = 'info',
icon,
children,
}: {
variant?: CalloutVariant;
icon?: ReactNode;
children: ReactNode;
}) => (
<div className={cx('msk-callout', variant !== 'info' && `msk-callout--${variant}`)}>
{icon && <span className="msk-callout__icon">{icon}</span>}
<div className="msk-callout__body">{children}</div>
</div>
);
/* ---------- TABLE ---------- */
export const Table = ({
children,
...props
}: ComponentPropsWithoutRef<'table'>) => (
<div className="msk-table-wrap">
<table className="msk-table" {...props}>
{children}
</table>
</div>
);
export const THead = (p: ComponentPropsWithoutRef<'thead'>) => <thead {...p} />;
export const TBody = (p: ComponentPropsWithoutRef<'tbody'>) => <tbody {...p} />;
export const Tr = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'tr'> & { selected?: boolean }) => (
<tr className={cx(selected && 'is-selected', className)} {...props} />
);
export const Th = (p: ComponentPropsWithoutRef<'th'>) => <th {...p} />;
export const Td = (p: ComponentPropsWithoutRef<'td'>) => <td {...p} />;
/* ---------- SCROLL AREA (Radix) ---------- */
export const ScrollArea = ({
children,
className,
style,
}: {
children: ReactNode;
className?: string;
style?: CSSProperties;
}) => (
<RScrollArea.Root className={cx('msk-scroll', className)} style={style}>
<RScrollArea.Viewport className="msk-scroll__viewport">
{children}
</RScrollArea.Viewport>
<RScrollArea.Scrollbar className="msk-scroll__bar" orientation="vertical">
<RScrollArea.Thumb className="msk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Scrollbar className="msk-scroll__bar" orientation="horizontal">
<RScrollArea.Thumb className="msk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Corner />
</RScrollArea.Root>
);
/* ---------- DIALOG / MODAL (Radix Dialog) ---------- */
export const Dialog = ({
trigger,
title,
description,
children,
footer,
open,
onOpenChange,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
open?: boolean;
onOpenChange?: (o: boolean) => void;
}) => (
<RDialog.Root open={open} onOpenChange={onOpenChange}>
{trigger && <RDialog.Trigger asChild>{trigger}</RDialog.Trigger>}
<RDialog.Portal>
<RDialog.Overlay className="msk-overlay" />
<RDialog.Content className="msk-dialog">
<RDialog.Title className="msk-dialog__title">{title}</RDialog.Title>
{description && (
<RDialog.Description className="msk-dialog__desc">
{description}
</RDialog.Description>
)}
{children && <div className="msk-dialog__body">{children}</div>}
{footer && <div className="msk-dialog__footer">{footer}</div>}
<RDialog.Close asChild>
<IconButton
variant="ghost"
size="sm"
className="msk-dialog__close"
aria-label="Close"
>
<X size={14} weight="bold" />
</IconButton>
</RDialog.Close>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
);
/* Low-level Dialog parts (for custom compositions). */
export const DialogClose = RDialog.Close;
/* ---------- ALERT DIALOG (Radix AlertDialog) ---------- */
export const AlertDialog = ({
trigger,
title,
description,
cancelLabel = 'Cancel',
actionLabel = 'Confirm',
destructive,
onAction,
open,
onOpenChange,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
cancelLabel?: string;
actionLabel?: string;
destructive?: boolean;
onAction?: () => void;
open?: boolean;
onOpenChange?: (o: boolean) => void;
}) => (
<RAlertDialog.Root open={open} onOpenChange={onOpenChange}>
{trigger && <RAlertDialog.Trigger asChild>{trigger}</RAlertDialog.Trigger>}
<RAlertDialog.Portal>
<RAlertDialog.Overlay className="msk-overlay" />
<RAlertDialog.Content className="msk-dialog">
<RAlertDialog.Title className="msk-dialog__title">
{title}
</RAlertDialog.Title>
{description && (
<RAlertDialog.Description className="msk-dialog__desc">
{description}
</RAlertDialog.Description>
)}
<div className="msk-dialog__footer">
<RAlertDialog.Cancel asChild>
<Button variant="ghost">{cancelLabel}</Button>
</RAlertDialog.Cancel>
<RAlertDialog.Action asChild>
<Button
variant={destructive ? 'ember' : 'primary'}
onClick={onAction}
>
{actionLabel}
</Button>
</RAlertDialog.Action>
</div>
</RAlertDialog.Content>
</RAlertDialog.Portal>
</RAlertDialog.Root>
);
/* ---------- WINDOW CHROME ---------- */
export const Window = ({
title,
badge,
children,
...props
}: ComponentPropsWithoutRef<'div'> & {
title: string;
badge?: ReactNode;
}) => (
<div className="msk-window" {...props}>
<div className="msk-titlebar">
<span className="msk-traffic r" />
<span className="msk-traffic y" />
<span className="msk-traffic g" />
<span className="ttl">{title}</span>
{badge && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{badge}
</div>
)}
</div>
{children}
</div>
);
export * from './button';
export * from './icon-button';
export * from './text-field';
export * from './select';
export * from './selection';
export * from './segmented-control';
export * from './slider';
export * from './tabs';
export * from './progress';
export * from './badge';
export * from './card';
export * from './list';
export * from './menu';
export * from './tooltip';
export * from './spinner';
export * from './callout';
export * from './table';
export * from './scroll-area';
export * from './dialog';
export * from './alert-dialog';
export * from './window';
+2
View File
@@ -0,0 +1,2 @@
export const cx = (...c: Array<string | false | undefined>) =>
c.filter(Boolean).join(' ');
+26
View File
@@ -0,0 +1,26 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
export const Window = ({
title,
badge,
children,
...props
}: ComponentPropsWithoutRef<'div'> & {
title: string;
badge?: ReactNode;
}) => (
<div className="modern-sk-window" {...props}>
<div className="modern-sk-titlebar">
<span className="modern-sk-traffic r" />
<span className="modern-sk-traffic y" />
<span className="modern-sk-traffic g" />
<span className="ttl">{title}</span>
{badge && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{badge}
</div>
)}
</div>
{children}
</div>
);
+3 -2
View File
@@ -1,7 +1,8 @@
/* ============================================================
ModernSK UI — public package entry.
ModernSK — public package entry.
Import components from here; import the stylesheet once at your
app root: import '@modernsk/ui/styles.css';
app root: import 'modern-sk/styles.css';
Optionally add the branded fonts: import 'modern-sk/fonts.css';
============================================================ */
import { Tooltip } from 'radix-ui';
+64
View File
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { AlertDialog, Button } from '../components/ui';
const meta = {
title: 'Overlays/AlertDialog',
component: AlertDialog,
parameters: {
docs: {
description: {
component:
'Confirmation dialog built on Radix AlertDialog. Use for destructive or irreversible actions — focus is trapped and the cancel button is always reachable. Set `destructive` to switch the action button to the ember (red) variant.',
},
},
},
argTypes: {
title: { control: 'text' },
description: { control: 'text' },
cancelLabel: { control: 'text' },
actionLabel: { control: 'text' },
destructive: { control: 'boolean' },
trigger: { control: false },
},
args: {
title: 'Delete this project?',
description: 'This action cannot be undone.',
actionLabel: 'Delete',
cancelLabel: 'Cancel',
destructive: true,
trigger: <Button variant="ember">Delete</Button>,
onAction: () => {},
},
} satisfies Meta<typeof AlertDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Destructive: Story = {
render: () => (
<AlertDialog
trigger={<Button variant="ember">Delete account</Button>}
title="Delete your account?"
description="All data will be permanently removed. This cannot be undone."
actionLabel="Delete account"
cancelLabel="Keep account"
destructive
onAction={() => {}}
/>
),
};
export const Confirm: Story = {
render: () => (
<AlertDialog
trigger={<Button variant="primary">Publish</Button>}
title="Publish changes?"
description="This will make your changes visible to all users."
actionLabel="Publish"
cancelLabel="Cancel"
onAction={() => {}}
/>
),
};
+63
View File
@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Button } from '../components/ui';
const meta = {
title: 'Inputs/Button',
component: Button,
parameters: {
docs: {
description: {
component:
'Tactile push button. Four variants and an optional small size; everything else is a native `<button>`, so all standard button props pass through.',
},
},
},
argTypes: {
variant: {
control: 'inline-radio',
options: ['key', 'primary', 'ember', 'ghost'],
description: 'Visual emphasis. `key` is the default neutral button.',
},
size: {
control: 'inline-radio',
options: [undefined, 'sm'],
description: 'Omit for default; `sm` for the compact size.',
},
iconOnly: { control: 'boolean', description: 'Square padding for a single glyph.' },
disabled: { control: 'boolean' },
children: { control: 'text' },
className: { control: 'text' },
},
args: { children: 'Button', variant: 'key' },
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Variants: Story = {
render: () => (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Button variant="key">Key</Button>
<Button variant="primary">Primary</Button>
<Button variant="ember">Ember</Button>
<Button variant="ghost">Ghost</Button>
</div>
),
};
export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Button variant="primary">Default</Button>
<Button variant="primary" size="sm">
Small
</Button>
</div>
),
};
export const Disabled: Story = {
args: { disabled: true, variant: 'primary', children: 'Disabled' },
};
+101
View File
@@ -0,0 +1,101 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import {
Card,
List,
Row,
Table,
THead,
TBody,
Tr,
Th,
Td,
Badge,
Chip,
} from '../components/ui';
const meta = {
title: 'Data Display/Surfaces',
component: Card,
parameters: {
docs: {
description: {
component: 'Cards, selectable lists/rows, badges, chips, and the bordered table.',
},
},
},
argTypes: {
className: { control: 'text' },
},
} satisfies Meta<typeof Card>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CardPlayground: Story = {
name: 'Card',
args: { children: 'Card content' },
render: (args) => (
<Card {...args} style={{ maxWidth: 320, padding: 20 }}>
<h3 className="modern-sk-h3">Storage</h3>
<p className="modern-sk-body">128 GB of 256 GB used.</p>
</Card>
),
};
export const ListRows: Story = {
render: () => (
<List style={{ width: 320 }}>
<Row selected>General</Row>
<Row>Appearance</Row>
<Row>Notifications</Row>
<Row>Privacy</Row>
</List>
),
};
export const BadgeShowcase: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<Badge variant="lime">Lime</Badge>
<Badge variant="ember">Ember</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="lime" dot>Online</Badge>
</div>
),
};
export const Chips: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Chip>Design</Chip>
<Chip onRemove={() => {}}>Removable</Chip>
</div>
),
};
export const DataTable: Story = {
render: () => (
<Table>
<THead>
<Tr>
<Th>Device</Th>
<Th>Status</Th>
<Th>Battery</Th>
</Tr>
</THead>
<TBody>
<Tr selected>
<Td>MacBook Pro</Td>
<Td><Badge variant="lime" dot>Online</Badge></Td>
<Td>82%</Td>
</Tr>
<Tr>
<Td>iPhone 16</Td>
<Td><Badge variant="neutral">Idle</Badge></Td>
<Td>54%</Td>
</Tr>
</TBody>
</Table>
),
};
+54
View File
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Dialog, Button, TextField } from '../components/ui';
const meta = {
title: 'Overlays/Dialog',
component: Dialog,
parameters: {
docs: {
description: {
component:
'Modal dialog built on Radix Dialog. Pass a `trigger` to wire open/close automatically, or control it with `open` / `onOpenChange`. Title is required; description, body, and footer are optional slots.',
},
},
},
argTypes: {
title: { control: 'text' },
description: { control: 'text' },
trigger: { control: false },
children: { control: false },
footer: { control: false },
},
args: {
title: 'Rename project',
description: 'Choose a new name for this project.',
trigger: <Button variant="primary">Open dialog</Button>,
},
} satisfies Meta<typeof Dialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const WithBody: Story = {
name: 'With body',
render: () => (
<Dialog
trigger={<Button variant="primary">Open dialog</Button>}
title="Rename project"
description="Choose a new name for this project."
footer={<Button variant="primary">Save</Button>}
>
<TextField placeholder="Project name" defaultValue="My project" />
</Dialog>
),
};
export const NoDescription: Story = {
name: 'No description',
args: {
description: undefined,
title: 'Confirm action',
},
};
+90
View File
@@ -0,0 +1,90 @@
import { useState } from 'react';
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Info, CheckCircle, Warning, XCircle } from '@phosphor-icons/react';
import { Progress, Spinner, Callout, Badge, Chip, Button } from '../components/ui';
const meta = {
title: 'Feedback/Status',
component: Progress,
parameters: {
docs: {
description: {
component:
'Progress bar, spinner, callouts, badges and chips — the status + signalling family.',
},
},
},
argTypes: {
value: { control: { type: 'range', min: 0, max: 100, step: 1 } },
className: { control: 'text' },
},
args: { value: 40 },
} satisfies Meta<typeof Progress>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div style={{ width: 320 }}>
<Progress {...args} />
</div>
),
};
function ProgressDemo() {
const [v, setV] = useState(40);
return (
<div style={{ width: 320, display: 'flex', flexDirection: 'column', gap: 12 }}>
<Progress value={v} />
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" onClick={() => setV((x) => Math.max(0, x - 10))}>10</Button>
<Button size="sm" variant="primary" onClick={() => setV((x) => Math.min(100, x + 10))}>+10</Button>
</div>
</div>
);
}
export const ProgressBar: Story = { render: () => <ProgressDemo /> };
export const Spinners: Story = {
render: () => (
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<Spinner size="sm" />
<Spinner />
<Spinner size="lg" />
</div>
),
};
export const Callouts: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 460 }}>
<Callout variant="info" icon={<Info size={18} />}>Sync runs in the background.</Callout>
<Callout variant="success" icon={<CheckCircle size={18} />}>All changes saved.</Callout>
<Callout variant="warning" icon={<Warning size={18} />}>Storage is almost full.</Callout>
<Callout variant="danger" icon={<XCircle size={18} />}>Failed to reach the server.</Callout>
</div>
),
};
export const Badges: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<Badge variant="lime">Lime</Badge>
<Badge variant="ember">Ember</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="lime" dot>Online</Badge>
</div>
),
};
export const Chips: Story = {
render: () => (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<Chip>Design</Chip>
<Chip onRemove={() => {}}>Removable</Chip>
</div>
),
};
+57
View File
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Gear, Plus, Trash } from '@phosphor-icons/react';
import { IconButton } from '../components/ui';
const meta = {
title: 'Inputs/IconButton',
component: IconButton,
parameters: {
docs: {
description: {
component:
'Square button for a single icon. Shares the Button variants; sizes are `sm` / default / `lg`.',
},
},
},
argTypes: {
variant: {
control: 'inline-radio',
options: ['key', 'primary', 'ember', 'ghost'],
description: 'Visual emphasis. `key` is the default neutral button.',
},
size: {
control: 'inline-radio',
options: ['sm', undefined, 'lg'],
description: 'Button size: `sm` compact, default regular, `lg` large.',
},
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof IconButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
args: { variant: 'key', children: <Gear size={18} /> },
};
export const Variants: Story = {
render: () => (
<div style={{ display: 'flex', gap: 12 }}>
<IconButton variant="key" aria-label="settings"><Gear size={18} /></IconButton>
<IconButton variant="primary" aria-label="add"><Plus size={18} weight="bold" /></IconButton>
<IconButton variant="ember" aria-label="delete"><Trash size={18} /></IconButton>
<IconButton variant="ghost" aria-label="settings"><Gear size={18} /></IconButton>
</div>
),
};
export const Sizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<IconButton size="sm" variant="primary" aria-label="add"><Plus size={14} weight="bold" /></IconButton>
<IconButton variant="primary" aria-label="add"><Plus size={18} weight="bold" /></IconButton>
<IconButton size="lg" variant="primary" aria-label="add"><Plus size={22} weight="bold" /></IconButton>
</div>
),
};
+42
View File
@@ -0,0 +1,42 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Getting Started/Introduction" />
# ModernSK
Tactile, dark-first React components built on [Radix](https://www.radix-ui.com/) primitives.
Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
This Storybook is the **development playground** — it is never published with the
package. Use it to browse every component, read its prop table (the **Docs** tab on
each story), and try props live in the **Controls** panel.
## Using the library in an app
```tsx
import 'modern-sk/styles.css'; // required — tokens + components
import 'modern-sk/fonts.css'; // optional — branded faces
import { ThemeProvider, TooltipProvider, Button } from 'modern-sk';
export function App() {
return (
<ThemeProvider>
<TooltipProvider delayDuration={200}>
<Button variant="primary">Click</Button>
</TooltipProvider>
</ThemeProvider>
);
}
```
## Theme
Use the **Theme** toggle in the toolbar above to flip every story between dark and
light. In an app the same lever is `data-theme` on `<html>`, managed by
`ThemeProvider` / `useTheme()`.
## Fonts
Components read the `--font-display`, `--font-sans` and `--font-mono` tokens. Import
`modern-sk/fonts.css` for the branded faces, or override those tokens to map the
components onto fonts your app already loads.
+102
View File
@@ -0,0 +1,102 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Copy, Scissors, Trash, ArrowCounterClockwise } from '@phosphor-icons/react';
import {
Tooltip,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
MenuSeparator,
Dialog,
AlertDialog,
Button,
} from '../components/ui';
const meta = {
title: 'Overlays/Floating',
component: Tooltip,
parameters: {
docs: {
description: {
component:
'Floating surfaces — Tooltip, dropdown Menu, Dialog and AlertDialog. All are Radix-backed and portal-rendered.',
},
},
},
argTypes: {
children: { control: false },
content: { control: 'text' },
delayDuration: { control: 'number' },
open: { control: 'boolean' },
defaultOpen: { control: 'boolean' },
onOpenChange: { action: 'open changed' },
sideOffset: { control: 'number' },
},
args: {
content: 'Tooltip text',
children: null,
delayDuration: 0,
},
} satisfies Meta<typeof Tooltip>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
name: 'Tooltip Playground',
render: (args) => (
<Tooltip {...args}>
<Button variant="ghost">Hover me</Button>
</Tooltip>
),
};
export const TooltipStory: Story = {
name: 'Tooltip',
render: () => (
<Tooltip content="Saved to iCloud">
<Button variant="ghost">Hover me</Button>
</Tooltip>
),
};
export const DropdownMenu: Story = {
render: () => (
<Menu>
<MenuTrigger asChild>
<Button>Open menu</Button>
</MenuTrigger>
<MenuContent>
<MenuItem icon={<Copy size={16} />} shortcut="⌘C">Copy</MenuItem>
<MenuItem icon={<Scissors size={16} />} shortcut="⌘X">Cut</MenuItem>
<MenuItem icon={<ArrowCounterClockwise size={16} />} shortcut="⌘Z">Undo</MenuItem>
<MenuSeparator />
<MenuItem icon={<Trash size={16} />}>Delete</MenuItem>
</MenuContent>
</Menu>
),
};
export const ModalDialog: Story = {
render: () => (
<Dialog
trigger={<Button variant="primary">Open dialog</Button>}
title="Rename project"
description="Choose a new name for this project."
footer={<Button variant="primary">Save</Button>}
/>
),
};
export const Confirm: Story = {
render: () => (
<AlertDialog
trigger={<Button variant="ember">Delete</Button>}
title="Delete this project?"
description="This action cannot be undone."
actionLabel="Delete"
destructive
onAction={() => {}}
/>
),
};
+73
View File
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { ScrollArea } from '../components/ui';
const meta = {
title: 'Layout/ScrollArea',
component: ScrollArea,
parameters: {
docs: {
description: {
component:
'Radix ScrollArea with custom styled scrollbars. Wraps content in a viewport with vertical and horizontal scrollbars.',
},
},
},
argTypes: {
children: { control: false },
className: { control: 'text' },
},
} satisfies Meta<typeof ScrollArea>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
<ScrollArea style={{ width: 300, height: 200, border: '1px solid var(--neutral-border)' }}>
<div style={{ padding: 16 }}>
<h4 className="modern-sk-h4" style={{ marginBottom: 8 }}>Scrollable content</h4>
{Array.from({ length: 20 }).map((_, i) => (
<p key={i} className="modern-sk-body" style={{ marginBottom: 12 }}>
Item {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
))}
</div>
</ScrollArea>
),
};
export const Vertical: Story = {
render: () => (
<ScrollArea style={{ width: 280, height: 150, border: '1px solid var(--neutral-border)' }}>
<div style={{ padding: 12 }}>
{Array.from({ length: 30 }).map((_, i) => (
<div key={i} style={{ padding: 8, borderBottom: '1px solid var(--neutral-border)' }}>
Row {i + 1}
</div>
))}
</div>
</ScrollArea>
),
};
export const Horizontal: Story = {
render: () => (
<ScrollArea style={{ width: 320, height: 80, border: '1px solid var(--neutral-border)' }}>
<div style={{ display: 'flex', gap: 8, padding: 12, width: 'fit-content' }}>
{Array.from({ length: 20 }).map((_, i) => (
<div
key={i}
style={{
padding: 8,
minWidth: 100,
background: 'var(--neutral-surface)',
borderRadius: 4,
}}
>
Item {i + 1}
</div>
))}
</div>
</ScrollArea>
),
};
+68
View File
@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { SegmentedControl } from '../components/ui';
const meta = {
title: 'Selection/SegmentedControl',
component: SegmentedControl,
parameters: {
docs: {
description: {
component:
'Single-select button group with animated thumb. Pass `items` array of `{ value, label }` objects, plus `value` and `onValueChange` for control.',
},
},
},
argTypes: {
value: { control: 'text' },
onValueChange: { action: 'value changed' },
items: { control: false },
className: { control: 'text' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof SegmentedControl>;
export default meta;
type Story = StoryObj<typeof meta>;
function SegmentedDemo() {
const [v, setV] = useState('day');
return (
<SegmentedControl
value={v}
onValueChange={setV}
items={[
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]}
/>
);
}
export const Playground: Story = {
render: () => <SegmentedDemo />,
};
export const TimeRange: Story = {
render: () => <SegmentedDemo />,
};
function OptionsDemo() {
const [v, setV] = useState('draft');
return (
<SegmentedControl
value={v}
onValueChange={setV}
items={[
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
{ value: 'archived', label: 'Archived' },
]}
/>
);
}
export const Options: Story = {
render: () => <OptionsDemo />,
};
+39
View File
@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Select } from '../components/ui';
const items = [
{ value: 'sequoia', label: 'Sequoia' },
{ value: 'sonoma', label: 'Sonoma' },
{ value: 'ventura', label: 'Ventura' },
{ value: 'monterey', label: 'Monterey' },
];
const meta = {
title: 'Inputs/Select',
component: Select,
parameters: {
docs: {
description: {
component:
'Radix Select in the ModernSK skin. Pass `items` plus an optional `placeholder`; control it with `value` / `onValueChange` or leave it uncontrolled with `defaultValue`.',
},
},
},
argTypes: {
items: { control: false },
placeholder: { control: 'text' },
disabled: { control: 'boolean' },
defaultValue: { control: 'text' },
value: { control: 'text' },
onValueChange: { action: 'value changed' },
'aria-label': { control: 'text' },
},
args: { items, placeholder: 'Pick a release…', 'aria-label': 'macOS release' },
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const WithDefault: Story = { args: { defaultValue: 'sonoma' } };
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react';
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import {
Switch,
Checkbox,
RadioGroup,
RadioItem,
SegmentedControl,
Control,
} from '../components/ui';
/* Grouped selection controls. Anchored on Switch for the docgen table;
the stories below showcase each control. */
const meta = {
title: 'Selection/Controls',
component: Switch,
parameters: {
docs: {
description: {
component:
'Toggle, checkbox, radio group and segmented control. The `Control` helper pairs any of them with a clickable label.',
},
},
},
argTypes: {
defaultChecked: { control: 'boolean' },
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
onCheckedChange: { action: 'checked changed' },
},
} satisfies Meta<typeof Switch>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
args: { defaultChecked: false },
};
export const Switches: Story = {
render: () => (
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
<Switch defaultChecked />
<Switch />
<Control control={<Switch defaultChecked />}>Wi-Fi</Control>
</div>
),
};
export const Checkboxes: Story = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Control control={<Checkbox defaultChecked />}>Sync to iCloud</Control>
<Control control={<Checkbox />}>Share analytics</Control>
</div>
),
};
export const Radios: Story = {
render: () => (
<RadioGroup defaultValue="comfortable" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Control control={<RadioItem value="compact" />}>Compact</Control>
<Control control={<RadioItem value="comfortable" />}>Comfortable</Control>
<Control control={<RadioItem value="spacious" />}>Spacious</Control>
</RadioGroup>
),
};
function SegmentedDemo() {
const [v, setV] = useState('day');
return (
<SegmentedControl
value={v}
onValueChange={setV}
items={[
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]}
/>
);
}
export const Segmented: Story = { render: () => <SegmentedDemo /> };
+74
View File
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Slider } from '../components/ui';
const meta = {
title: 'Inputs/Slider',
component: Slider,
parameters: {
docs: {
description: {
component:
'Radix Slider in the carved-track skin. All Radix Slider props pass through (`defaultValue`, `min`, `max`, `step`, `onValueChange`). Set `marks` to carve step notches into the track — `marks` snaps to the Radix `step`.',
},
},
},
args: { defaultValue: [60], min: 0, max: 100, step: 1 },
argTypes: {
defaultValue: { control: 'object', description: 'Uncontrolled starting value(s).' },
min: { control: 'number' },
max: { control: 'number' },
step: { control: 'number', description: 'Snap increment (also drives auto `marks`).' },
disabled: { control: 'boolean' },
marks: {
control: 'boolean',
description: 'Step marks. `true` derives one per `step`; or pass an array for custom/labelled marks.',
},
notches: {
control: 'inline-radio',
options: ['top', 'bottom', 'both', 'none'],
description: 'Notch tick placement relative to the track (labels still render when `none`).',
},
knobStyle: {
control: 'inline-radio',
options: ['square', 'round'],
description: 'Knob shape: `square` (default) or `round`.',
},
className: { control: 'text' },
},
decorators: [(Story) => <div style={{ width: 280 }}><Story /></div>],
} satisfies Meta<typeof Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
/** `marks` auto-derives one notch per `step`. */
export const Stepped: Story = {
args: { defaultValue: [40], step: 10, marks: true },
};
/** `notches='both'` carves ticks above and below the bar. */
export const NotchesBoth: Story = {
args: { defaultValue: [60], step: 20, marks: true, notches: 'both' },
};
/** Pass an array of `{ value, label }` for labelled ticks. */
export const LabelledMarks: Story = {
args: {
defaultValue: [50],
step: 25,
notches: 'bottom',
marks: [
{ value: 0, label: 'Off' },
{ value: 25, label: 'Low' },
{ value: 50, label: 'Mid' },
{ value: 75, label: 'High' },
{ value: 100, label: 'Max' },
],
},
};
export const Disabled: Story = {
args: { defaultValue: [30], step: 10, marks: true, disabled: true },
};
+47
View File
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Tabs, TabsList, TabsContent } from '../components/ui';
const meta = {
title: 'Navigation/Tabs',
component: Tabs,
parameters: {
docs: {
description: {
component:
'Radix Tabs. `Tabs` is the root, `TabsList` takes `items`, and `TabsContent` matches each `value`.',
},
},
},
argTypes: {
defaultValue: { control: 'text' },
value: { control: 'text' },
onValueChange: { action: 'value changed' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Tabs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
<Tabs defaultValue="overview" style={{ width: 420 }}>
<TabsList
items={[
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings' },
]}
/>
<TabsContent value="overview" style={{ paddingTop: 16 }} className="modern-sk-body">
Project at a glance.
</TabsContent>
<TabsContent value="activity" style={{ paddingTop: 16 }} className="modern-sk-body">
Recent activity feed.
</TabsContent>
<TabsContent value="settings" style={{ paddingTop: 16 }} className="modern-sk-body">
Preferences and configuration.
</TabsContent>
</Tabs>
),
};
+48
View File
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { MagnifyingGlass } from '@phosphor-icons/react';
import { TextField, TextArea, SearchField } from '../components/ui';
const meta = {
title: 'Inputs/TextField',
component: TextField,
parameters: {
docs: {
description: {
component:
'Sunken text input. `TextField`, `TextArea`, and `SearchField` (icon + input) share the `modern-sk-field` look and forward all native props.',
},
},
},
argTypes: {
placeholder: { control: 'text' },
value: { control: 'text' },
defaultValue: { control: 'text' },
disabled: { control: 'boolean' },
readOnly: { control: 'boolean' },
required: { control: 'boolean' },
type: { control: 'text' },
onChange: { action: 'changed' },
},
args: { placeholder: 'Type here…' },
} satisfies Meta<typeof TextField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Disabled: Story = { args: { disabled: true, value: 'Read only' } };
export const Multiline: Story = {
render: () => <TextArea rows={4} placeholder="Multiple lines…" style={{ width: 320 }} />,
};
export const Search: Story = {
render: () => (
<SearchField
icon={<MagnifyingGlass size={16} />}
placeholder="Search…"
style={{ width: 280 }}
/>
),
};
+36
View File
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Window, Badge, List, Row } from '../components/ui';
const meta = {
title: 'Layout/Window',
component: Window,
parameters: {
docs: {
description: {
component:
'macOS-style window chrome: traffic lights, a title, an optional `badge` slot, and arbitrary children for the body.',
},
},
},
argTypes: {
title: { control: 'text' },
badge: { control: false },
children: { control: false },
},
args: { title: 'Finder' },
} satisfies Meta<typeof Window>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<Window {...args} style={{ width: 380 }} badge={<Badge variant="lime" dot>Synced</Badge>}>
<List style={{ padding: 12 }}>
<Row selected>Documents</Row>
<Row>Downloads</Row>
<Row>Pictures</Row>
</List>
</Window>
),
};
+1217 -245
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
/* ============================================================
ModernSK — optional branded webfonts.
Import this ONLY if you want the default ModernSK typefaces:
import 'modern-sk/styles.css'; // required — tokens + components
import 'modern-sk/fonts.css'; // optional — branded faces
Skip it and the --font-* tokens degrade to system-ui, or
override --font-display / --font-sans / --font-mono in your own
CSS to map components onto fonts your app already loads.
------------------------------------------------------------
Onest + Geist Mono come from Google Fonts; Anta (the squared
display face) is self-hosted and inlined at build time.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap');
@font-face {
font-family: 'Anta';
src: url('../assets/Anta-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
+1
View File
@@ -1,3 +1,4 @@
@import './fonts.css';
@import './tokens.css';
@import './components.css';
+11 -4
View File
@@ -1,12 +1,19 @@
/* ============================================================
ModernSK UI — shippable stylesheet.
ModernSK — shippable stylesheet.
Tokens + component styles only. No demo/page layout, no global
reset of consumer elements. Font is inlined at build time.
reset of consumer elements. No fonts: import 'modern-sk/fonts.css'
for the branded faces, or override the --font-* tokens.
============================================================ */
@import './tokens.css';
@import './components.css';
/* Non-invasive box-sizing for our own components only (zero specificity). */
:where([class^='msk-'], [class*=' msk-']) {
/* Non-invasive box-sizing + branded font for our own components only
(zero specificity, so consumer elements are never touched and the
--font-mono / --font-display classes still win by class specificity).
This is what carries the typeface onto portalled content (tooltips,
menus, dialogs) and bare text nodes (control labels) that never set
their own font-family. */
:where([class^='modern-sk-'], [class*=' modern-sk-']) {
box-sizing: border-box;
font-family: var(--font-sans);
}
+22 -29
View File
@@ -4,19 +4,14 @@
Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
------------------------------------------------------------
Single source of truth. Every component reads from here.
------------------------------------------------------------
Fonts are NOT loaded here. The branded faces (Anta / Onest /
Geist Mono) live in the optional `modern-sk/fonts.css`. The
--font-* chains below degrade to system-ui when that file is
not imported, and an app can override the tokens to remap any
typeface without touching this file.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap');
/* Anta — squared geometric display face (self-hosted from /public/fonts). */
@font-face {
font-family: 'Anta';
src: url('../assets/Anta-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root {
/* ---------- BRAND ---------- */
--lime: #bef264;
@@ -183,16 +178,15 @@
--grain-opacity: 0.45;
/* ---------- SPINNER GROOVE ----------
Carved donut channel — dark at the top rim, catching light at the
bottom, exactly like the sunk wells (switch / field). */
--spin-groove-1: #090a07; /* top — in shadow */
--spin-groove-2: #34352b; /* bottom — catches light */
Solid carved channel — flat base felt, sunk by an SVG inner shadow,
exactly like the switch well (no gradient). */
--spin-track: #23241c;
}
/* ============================================================
THE FELT — the one global background.
============================================================ */
.msk-felt {
.modern-sk-felt {
position: relative;
background-color: var(--ink);
background-image: var(--bg-glow);
@@ -201,7 +195,7 @@
background-attachment: fixed;
color: var(--fg-1);
}
.msk-felt::before {
.modern-sk-felt::before {
content: "";
position: fixed;
inset: 0;
@@ -269,48 +263,47 @@
radial-gradient(90% 70% at 85% 110%, rgba(233,87,43,0.08), transparent 60%),
radial-gradient(100% 100% at 50% 50%, #f2f2ea 0%, #ecece3 60%, #e2e2d6 100%);
/* carved groove on warm paper — top grey shadow, bottom near-white */
--spin-groove-1: #c2c3b6;
--spin-groove-2: #ffffff;
/* carved groove on warm paper — flat base, sunk by inner shadow */
--spin-track: #dcdcd2;
}
/* ============================================================
SEMANTIC TYPE CLASSES
============================================================ */
.msk-display {
.modern-sk-display {
font-family: var(--font-display); font-weight: 400;
font-size: var(--text-5xl); line-height: var(--lh-tight);
letter-spacing: -0.01em; color: var(--fg-1);
}
.msk-h1 {
.modern-sk-h1 {
font-family: var(--font-sans); font-weight: var(--w-bold);
font-size: var(--text-3xl); line-height: var(--lh-snug);
letter-spacing: var(--track-snug); color: var(--fg-1);
}
.msk-h2 {
.modern-sk-h2 {
font-family: var(--font-sans); font-weight: var(--w-semibold);
font-size: var(--text-2xl); line-height: var(--lh-snug);
letter-spacing: var(--track-snug); color: var(--fg-1);
}
.msk-h3 {
.modern-sk-h3 {
font-family: var(--font-sans); font-weight: var(--w-semibold);
font-size: var(--text-xl); line-height: var(--lh-snug); color: var(--fg-1);
}
.msk-body {
.modern-sk-body {
font-family: var(--font-sans); font-weight: var(--w-regular);
font-size: var(--text-base); line-height: var(--lh-normal); color: var(--fg-2);
}
.msk-body-strong { font-weight: var(--w-medium); color: var(--fg-1); }
.msk-caption {
.modern-sk-body-strong { font-weight: var(--w-medium); color: var(--fg-1); }
.modern-sk-caption {
font-family: var(--font-sans); font-weight: var(--w-medium);
font-size: var(--text-sm); color: var(--fg-3);
}
.msk-label {
.modern-sk-label {
font-family: var(--font-sans); font-weight: var(--w-semibold);
font-size: 11px; text-transform: uppercase;
letter-spacing: var(--track-caps); color: var(--fg-3);
}
.msk-mono {
.modern-sk-mono {
font-family: var(--font-mono); font-weight: var(--w-regular);
font-size: var(--text-sm); color: var(--fg-2);
}