This commit is contained in:
Chris Wanstrath 2025-12-29 14:18:30 -08:00
parent f66a47d0bb
commit 855112ac82
14 changed files with 530 additions and 293 deletions

View File

@ -8,7 +8,8 @@
## overview ## overview
Forge is a typed, local, variant-driven way to organize CSS, built around TSX. Forge is a typed, local, variant-driven way to organize CSS and create
self-contained TSX components out of discrete parts.
## css problems ## css problems
@ -24,7 +25,8 @@ Forge is a typed, local, variant-driven way to organize CSS, built around TSX.
- Component styles are made up of independently styled "Parts". - Component styles are made up of independently styled "Parts".
- "Variants" replace inline styles with typed, declarative parameters. - "Variants" replace inline styles with typed, declarative parameters.
- Style composition is deterministic. - Style composition is deterministic.
- Errors and feedback. - Themes are easy.
- Errors and feedback are provided.
## examples ## examples

View File

@ -1,20 +1,21 @@
import { createScope } from '../src' import { createScope } from '../src'
import { ExampleSection, theme } from './ssr/helpers' import { ExampleSection } from './ssr/helpers'
import { theme } from './ssr/themes'
const { define } = createScope('Button') const { define } = createScope('Button')
const Button = define('Root', { const Button = define('Root', {
base: 'button', base: 'button',
padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`, padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
gap: theme.spacing.xs, gap: theme('spacing-xs'),
background: theme.colors.accent, background: theme('colors-accent'),
color: theme.colors.bg, color: theme('colors-bg'),
border: `1px solid ${theme.colors.accent}`, border: `1px solid ${theme('colors-accent')}`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
cursor: "pointer", cursor: "pointer",
@ -23,8 +24,8 @@ const Button = define('Root', {
states: { states: {
":not(:disabled):hover": { ":not(:disabled):hover": {
background: theme.colors.accentDim, background: theme('colors-accentDim'),
borderColor: theme.colors.accentDim, borderColor: theme('colors-accentDim'),
}, },
":not(:disabled):active": { ":not(:disabled):active": {
transform: 'translateY(1px)', transform: 'translateY(1px)',
@ -34,23 +35,23 @@ const Button = define('Root', {
variants: { variants: {
intent: { intent: {
primary: { primary: {
background: theme.colors.accent, background: theme('colors-accent'),
color: theme.colors.bg, color: theme('colors-bg'),
border: `1px solid ${theme.colors.accent}`, border: `1px solid ${theme('colors-accent')}`,
}, },
secondary: { secondary: {
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
color: theme.colors.fg, color: theme('colors-fg'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
states: { states: {
":not(:disabled):hover": { ":not(:disabled):hover": {
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
} }
} }
}, },
danger: { danger: {
background: "#ff0000", background: "#ff0000",
color: theme.colors.bg, color: theme('colors-bg'),
border: "1px solid #ff0000", border: "1px solid #ff0000",
states: { states: {
":not(:disabled):hover": { ":not(:disabled):hover": {
@ -61,23 +62,23 @@ const Button = define('Root', {
}, },
ghost: { ghost: {
background: "transparent", background: "transparent",
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
states: { states: {
":not(:disabled):hover": { ":not(:disabled):hover": {
color: theme.colors.fg, color: theme('colors-fg'),
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
} }
} }
}, },
}, },
size: { size: {
small: { small: {
padding: `${theme.spacing.xs}px ${theme.spacing.md}px`, padding: `${theme('spacing-xs')} ${theme('spacing-md')}`,
fontSize: 12, fontSize: 12,
}, },
large: { large: {
padding: `${theme.spacing.md}px ${theme.spacing.xl}px`, padding: `${theme('spacing-md')} ${theme('spacing-xl')}`,
fontSize: 16, fontSize: 16,
}, },
}, },
@ -90,7 +91,7 @@ const Button = define('Root', {
const ButtonRow = define('Row', { const ButtonRow = define('Row', {
display: 'flex', display: 'flex',
gap: theme.spacing.md, gap: theme('spacing-md'),
flexWrap: 'wrap', flexWrap: 'wrap',
alignItems: 'center', alignItems: 'center',
}) })

View File

@ -1,15 +1,16 @@
import { define } from '../src' import { define } from '../src'
import { ExampleSection, theme } from './ssr/helpers' import { ExampleSection } from './ssr/helpers'
import { theme } from './ssr/themes'
const Input = define('Input', { const Input = define('Input', {
base: 'input', base: 'input',
padding: `${theme.spacing.sm}px ${theme.spacing.md}px`, padding: `${theme('spacing-sm')} ${theme('spacing-md')}`,
fontSize: 14, fontSize: 14,
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
color: theme.colors.fg, color: theme('colors-fg'),
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
width: '100%', width: '100%',
boxSizing: 'border-box', boxSizing: 'border-box',
@ -17,11 +18,11 @@ const Input = define('Input', {
states: { states: {
':focus': { ':focus': {
outline: 'none', outline: 'none',
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
}, },
':disabled': { ':disabled': {
background: theme.colors.bg, background: theme('colors-bg'),
color: theme.colors.fgDim, color: theme('colors-fgDim'),
cursor: 'not-allowed' cursor: 'not-allowed'
} }
}, },
@ -37,10 +38,10 @@ const Input = define('Input', {
} }
}, },
success: { success: {
borderColor: theme.colors.accent, borderColor: theme('colors-accent'),
states: { states: {
':focus': { ':focus': {
borderColor: theme.colors.accent, borderColor: theme('colors-accent'),
} }
} }
} }
@ -51,12 +52,12 @@ const Input = define('Input', {
const Textarea = define('Textarea', { const Textarea = define('Textarea', {
base: 'textarea', base: 'textarea',
padding: `${theme.spacing.sm}px ${theme.spacing.md}px`, padding: `${theme('spacing-sm')} ${theme('spacing-md')}`,
fontSize: 14, fontSize: 14,
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
color: theme.colors.fg, color: theme('colors-fg'),
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
width: '100%', width: '100%',
minHeight: 120, minHeight: 120,
@ -67,13 +68,13 @@ const Textarea = define('Textarea', {
states: { states: {
':focus': { ':focus': {
outline: 'none', outline: 'none',
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
} }
} }
}) })
const FormGroup = define('FormGroup', { const FormGroup = define('FormGroup', {
marginBottom: theme.spacing.lg, marginBottom: theme('spacing-lg'),
parts: { parts: {
Label: { Label: {
@ -81,12 +82,12 @@ const FormGroup = define('FormGroup', {
display: 'block', display: 'block',
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
color: theme.colors.fg, color: theme('colors-fg'),
marginBottom: theme.spacing.xs marginBottom: theme('spacing-xs')
}, },
Helper: { Helper: {
fontSize: 12, fontSize: 12,
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
marginTop: 6 marginTop: 6
}, },
Error: { Error: {
@ -120,21 +121,21 @@ const Checkbox = define('Checkbox', {
base: 'label', base: 'label',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: theme.spacing.sm, gap: theme('spacing-sm'),
cursor: 'pointer', cursor: 'pointer',
fontSize: 14, fontSize: 14,
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
states: { states: {
':hover': { ':hover': {
color: theme.colors.fg color: theme('colors-fg')
} }
}, },
selectors: { selectors: {
'@Input:disabled + &': { '@Input:disabled + &': {
cursor: 'not-allowed', cursor: 'not-allowed',
color: theme.colors.fgDim color: theme('colors-fgDim')
} }
} }
} }
@ -160,20 +161,20 @@ const FormExamples = define('FormExamples', {
const Button = define('FormButton', { const Button = define('FormButton', {
base: 'button', base: 'button',
padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`, padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
border: `1px solid ${theme.colors.accent}`, border: `1px solid ${theme('colors-accent')}`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
background: theme.colors.accent, background: theme('colors-accent'),
color: theme.colors.bg, color: theme('colors-bg'),
states: { states: {
':hover': { ':hover': {
background: theme.colors.accentDim, background: theme('colors-accentDim'),
borderColor: theme.colors.accentDim, borderColor: theme('colors-accentDim'),
}, },
':active': { ':active': {
transform: 'translateY(1px)' transform: 'translateY(1px)'
@ -183,12 +184,12 @@ const Button = define('FormButton', {
variants: { variants: {
variant: { variant: {
secondary: { secondary: {
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
color: theme.colors.fg, color: theme('colors-fg'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
states: { states: {
':hover': { ':hover': {
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
} }
} }
} }
@ -198,8 +199,8 @@ const Button = define('FormButton', {
const ButtonGroup = define('FormButtonGroup', { const ButtonGroup = define('FormButtonGroup', {
display: 'flex', display: 'flex',
gap: theme.spacing.sm, gap: theme('spacing-sm'),
marginTop: theme.spacing.lg marginTop: theme('spacing-lg')
}) })
export const FormExamplesContent = () => ( export const FormExamplesContent = () => (

View File

@ -1,5 +1,6 @@
import { define } from '../src' import { define } from '../src'
import { ExampleSection, theme } from './ssr/helpers' import { ExampleSection } from './ssr/helpers'
import { theme } from './ssr/themes'
const TabSwitcher = define('TabSwitcher', { const TabSwitcher = define('TabSwitcher', {
parts: { parts: {
@ -10,41 +11,41 @@ const TabSwitcher = define('TabSwitcher', {
TabBar: { TabBar: {
display: 'flex', display: 'flex',
gap: 0, gap: 0,
borderBottom: `1px solid ${theme.colors.border}`, borderBottom: `1px solid ${theme('colors-border')}`,
marginBottom: theme.spacing.lg, marginBottom: theme('spacing-lg'),
}, },
TabLabel: { TabLabel: {
base: 'label', base: 'label',
padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`, padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
position: 'relative', position: 'relative',
marginBottom: -1, marginBottom: -1,
background: 'transparent', background: 'transparent',
borderBottom: '1px solid transparent', borderBottom: '1px solid transparent',
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 14, fontSize: 14,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
color: theme.colors.fg, color: theme('colors-fg'),
} }
}, },
selectors: { selectors: {
'@Input:checked + &': { '@Input:checked + &': {
color: theme.colors.accent, color: theme('colors-accent'),
borderBottom: `1px solid ${theme.colors.accent}` borderBottom: `1px solid ${theme('colors-accent')}`
} }
} }
}, },
Content: { Content: {
display: 'none', display: 'none',
padding: theme.spacing.lg, padding: theme('spacing-lg'),
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
selectors: { selectors: {
'@Input:checked ~ &': { '@Input:checked ~ &': {
@ -91,37 +92,37 @@ const Pills = define('Pills', {
}, },
PillBar: { PillBar: {
display: 'flex', display: 'flex',
gap: theme.spacing.xs, gap: theme('spacing-xs'),
flexWrap: 'wrap', flexWrap: 'wrap',
}, },
PillLabel: { PillLabel: {
base: 'label', base: 'label',
padding: `${theme.spacing.xs}px ${theme.spacing.md}px`, padding: `${theme('spacing-xs')} ${theme('spacing-md')}`,
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: 20, borderRadius: 20,
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 14, fontSize: 14,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
color: theme.colors.fg, color: theme('colors-fg'),
} }
}, },
selectors: { selectors: {
'@Input:checked + &': { '@Input:checked + &': {
background: theme.colors.accent, background: theme('colors-accent'),
borderColor: theme.colors.accent, borderColor: theme('colors-accent'),
color: theme.colors.bg color: theme('colors-bg')
}, },
'@Input:checked + &:hover': { '@Input:checked + &:hover': {
background: theme.colors.accentDim, background: theme('colors-accentDim'),
borderColor: theme.colors.accentDim, borderColor: theme('colors-accentDim'),
} }
} }
} }
@ -165,36 +166,36 @@ const VerticalNav = define('VerticalNav', {
NavLabel: { NavLabel: {
base: 'label', base: 'label',
padding: `${theme.spacing.sm}px ${theme.spacing.md}px`, padding: `${theme('spacing-sm')} ${theme('spacing-md')}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: theme.spacing.sm, gap: theme('spacing-sm'),
background: 'transparent', background: 'transparent',
border: `1px solid transparent`, border: `1px solid transparent`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 14, fontSize: 14,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
borderColor: theme.colors.border, borderColor: theme('colors-border'),
color: theme.colors.fg, color: theme('colors-fg'),
} }
}, },
selectors: { selectors: {
'@Input:checked + &': { '@Input:checked + &': {
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
borderColor: theme.colors.accent, borderColor: theme('colors-accent'),
color: theme.colors.accent, color: theme('colors-accent'),
}, },
'@Input:checked + &:hover': { '@Input:checked + &:hover': {
borderColor: theme.colors.accentDim, borderColor: theme('colors-accentDim'),
color: theme.colors.accentDim color: theme('colors-accentDim')
} }
} }
}, },
@ -235,31 +236,31 @@ const VerticalNav = define('VerticalNav', {
const Breadcrumbs = define('Breadcrumbs', { const Breadcrumbs = define('Breadcrumbs', {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: theme.spacing.xs, gap: theme('spacing-xs'),
flexWrap: 'wrap', flexWrap: 'wrap',
parts: { parts: {
Item: { Item: {
base: 'a', base: 'a',
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 14, fontSize: 14,
textDecoration: 'none', textDecoration: 'none',
transition: 'color 0.2s ease', transition: 'color 0.2s ease',
states: { states: {
':hover': { ':hover': {
color: theme.colors.accent, color: theme('colors-accent'),
} }
} }
}, },
Separator: { Separator: {
color: theme.colors.fgDim, color: theme('colors-fgDim'),
fontSize: 14, fontSize: 14,
userSelect: 'none', userSelect: 'none',
}, },
Current: { Current: {
color: theme.colors.fg, color: theme('colors-fg'),
fontSize: 14, fontSize: 14,
} }
}, },
@ -289,25 +290,25 @@ const Breadcrumbs = define('Breadcrumbs', {
const Tabs = define('Tabs', { const Tabs = define('Tabs', {
display: 'flex', display: 'flex',
gap: 0, gap: 0,
borderBottom: `1px solid ${theme.colors.border}`, borderBottom: `1px solid ${theme('colors-border')}`,
parts: { parts: {
Tab: { Tab: {
base: 'button', base: 'button',
padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`, padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
position: 'relative', position: 'relative',
marginBottom: -1, marginBottom: -1,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderBottom: '1px solid transparent', borderBottom: '1px solid transparent',
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 14, fontSize: 14,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
color: theme.colors.fg, color: theme('colors-fg'),
} }
} }
} }
@ -317,8 +318,8 @@ const Tabs = define('Tabs', {
active: { active: {
parts: { parts: {
Tab: { Tab: {
color: theme.colors.accent, color: theme('colors-accent'),
borderBottom: `1px solid ${theme.colors.accent}`, borderBottom: `1px solid ${theme('colors-accent')}`,
} }
} }
} }
@ -339,25 +340,25 @@ const Tabs = define('Tabs', {
const SimplePills = define('SimplePills', { const SimplePills = define('SimplePills', {
display: 'flex', display: 'flex',
gap: theme.spacing.xs, gap: theme('spacing-xs'),
flexWrap: 'wrap', flexWrap: 'wrap',
parts: { parts: {
Pill: { Pill: {
base: 'button', base: 'button',
padding: `${theme.spacing.xs}px ${theme.spacing.md}px`, padding: `${theme('spacing-xs')} ${theme('spacing-md')}`,
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: 20, borderRadius: 20,
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 14, fontSize: 14,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
color: theme.colors.fg, color: theme('colors-fg'),
} }
} }
} }
@ -367,13 +368,13 @@ const SimplePills = define('SimplePills', {
active: { active: {
parts: { parts: {
Pill: { Pill: {
background: theme.colors.accent, background: theme('colors-accent'),
borderColor: theme.colors.accent, borderColor: theme('colors-accent'),
color: theme.colors.bg, color: theme('colors-bg'),
states: { states: {
':hover': { ':hover': {
background: theme.colors.accentDim, background: theme('colors-accentDim'),
borderColor: theme.colors.accentDim, borderColor: theme('colors-accentDim'),
} }
} }
} }
@ -403,14 +404,14 @@ const SimpleVerticalNav = define('SimpleVerticalNav', {
parts: { parts: {
NavItem: { NavItem: {
base: 'button', base: 'button',
padding: `${theme.spacing.sm}px ${theme.spacing.md}px`, padding: `${theme('spacing-sm')} ${theme('spacing-md')}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: theme.spacing.sm, gap: theme('spacing-sm'),
background: 'transparent', background: 'transparent',
border: `1px solid transparent`, border: `1px solid transparent`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 14, fontSize: 14,
textAlign: 'left', textAlign: 'left',
cursor: 'pointer', cursor: 'pointer',
@ -418,9 +419,9 @@ const SimpleVerticalNav = define('SimpleVerticalNav', {
states: { states: {
':hover': { ':hover': {
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
borderColor: theme.colors.border, borderColor: theme('colors-border'),
color: theme.colors.fg, color: theme('colors-fg'),
} }
} }
}, },
@ -438,13 +439,13 @@ const SimpleVerticalNav = define('SimpleVerticalNav', {
active: { active: {
parts: { parts: {
NavItem: { NavItem: {
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
borderColor: theme.colors.accent, borderColor: theme('colors-accent'),
color: theme.colors.accent, color: theme('colors-accent'),
states: { states: {
':hover': { ':hover': {
borderColor: theme.colors.accentDim, borderColor: theme('colors-accentDim'),
color: theme.colors.accentDim, color: theme('colors-accentDim'),
} }
} }
} }

View File

@ -1,22 +1,23 @@
import { define } from '../src' import { define } from '../src'
import { ExampleSection, theme } from './ssr/helpers' import { ExampleSection } from './ssr/helpers'
import { theme } from './ssr/themes'
const UserProfile = define('UserProfile', { const UserProfile = define('UserProfile', {
base: 'div', base: 'div',
padding: theme.spacing.lg, padding: theme('spacing-lg'),
maxWidth: 600, maxWidth: 600,
margin: "0 auto", margin: "0 auto",
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: theme.radius.md, borderRadius: theme('radius-md'),
parts: { parts: {
Header: { Header: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: theme.spacing.md, gap: theme('spacing-md'),
marginBottom: theme.spacing.md, marginBottom: theme('spacing-md'),
}, },
Avatar: { Avatar: {
base: 'img', base: 'img',
@ -24,7 +25,7 @@ const UserProfile = define('UserProfile', {
height: 64, height: 64,
borderRadius: "50%", borderRadius: "50%",
objectFit: "cover", objectFit: "cover",
border: `2px solid ${theme.colors.border}`, border: `2px solid ${theme('colors-border')}`,
}, },
Info: { Info: {
flex: 1, flex: 1,
@ -33,25 +34,25 @@ const UserProfile = define('UserProfile', {
marginBottom: 4, marginBottom: 4,
fontSize: 18, fontSize: 18,
fontWeight: 400, fontWeight: 400,
color: theme.colors.fg, color: theme('colors-fg'),
}, },
Handle: { Handle: {
fontSize: 14, fontSize: 14,
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
}, },
Bio: { Bio: {
marginBottom: theme.spacing.md, marginBottom: theme('spacing-md'),
width: "100%", width: "100%",
fontSize: 14, fontSize: 14,
lineHeight: 1.6, lineHeight: 1.6,
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
wordWrap: "break-word", wordWrap: "break-word",
}, },
Stats: { Stats: {
display: "flex", display: "flex",
gap: theme.spacing.lg, gap: theme('spacing-lg'),
paddingTop: theme.spacing.md, paddingTop: theme('spacing-md'),
borderTop: `1px solid ${theme.colors.border}`, borderTop: `1px solid ${theme('colors-border')}`,
}, },
Stat: { Stat: {
display: "flex", display: "flex",
@ -61,11 +62,11 @@ const UserProfile = define('UserProfile', {
StatValue: { StatValue: {
fontSize: 18, fontSize: 18,
fontWeight: 400, fontWeight: 400,
color: theme.colors.fg, color: theme('colors-fg'),
}, },
StatLabel: { StatLabel: {
fontSize: 12, fontSize: 12,
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
textTransform: "uppercase", textTransform: "uppercase",
}, },
}, },
@ -114,7 +115,7 @@ const UserProfile = define('UserProfile', {
verified: { verified: {
parts: { parts: {
Avatar: { Avatar: {
border: `2px solid ${theme.colors.accent}`, border: `2px solid ${theme('colors-accent')}`,
}, },
}, },
}, },

View File

@ -1,18 +1,68 @@
import { define } from '../../src' import { define } from '../../src'
import { theme } from '../ssr/helpers' import { theme } from '../ssr/themes'
import { ButtonExamplesContent } from '../button' import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile' import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation' import { NavigationExamplesContent } from '../navigation'
import { FormExamplesContent } from '../form' import { FormExamplesContent } from '../form'
// ThemePicker component
const ThemePicker = define('SpaThemePicker', {
marginLeft: 'auto',
parts: {
Select: {
base: 'select',
padding: `${theme('spacing-xs')} ${theme('spacing-md')}`,
background: theme('colors-bgElevated'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-sm'),
color: theme('colors-fg'),
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
borderColor: theme('colors-borderActive'),
},
':focus': {
outline: 'none',
borderColor: theme('colors-borderActive'),
}
}
}
},
render({ parts: { Root, Select } }) {
const handleChange = (e: Event) => {
const target = e.target as HTMLSelectElement
const themeName = target.value
document.body.setAttribute('data-theme', themeName)
localStorage.setItem('theme', themeName)
}
return (
<Root>
<Select id="theme-select" onchange={handleChange}>
<option value="dark">Dark</option>
<option value="light">Light</option>
</Select>
</Root>
)
}
})
export const Main = define('SpaMain', { export const Main = define('SpaMain', {
base: 'div', base: 'div',
minHeight: '100%', minHeight: '100%',
padding: theme.spacing.xl, height: '100%',
fontFamily: theme.fonts.mono, padding: theme('spacing-xl'),
background: theme.colors.bg, fontFamily: theme('fonts-mono'),
color: theme.colors.fg, background: theme('colors-bg'),
color: theme('colors-fg'),
boxSizing: 'border-box',
}) })
export const Container = define('SpaContainer', { export const Container = define('SpaContainer', {
@ -26,19 +76,19 @@ export const Container = define('SpaContainer', {
const Link = define('Link', { const Link = define('Link', {
base: 'a', base: 'a',
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
textDecoration: 'none', textDecoration: 'none',
fontSize: 14, fontSize: 14,
states: { states: {
hover: { hover: {
color: theme.colors.fg, color: theme('colors-fg'),
} }
}, },
selectors: { selectors: {
'&[aria-current]': { '&[aria-current]': {
color: theme.colors.fg, color: theme('colors-fg'),
textDecoration: 'underline', textDecoration: 'underline',
} }
}, },
@ -62,51 +112,51 @@ const Nav = define('Nav', {
base: 'nav', base: 'nav',
display: 'flex', display: 'flex',
gap: theme.spacing.lg, gap: theme('spacing-lg'),
marginBottom: theme.spacing.xl, marginBottom: theme('spacing-xl'),
padding: theme.spacing.lg, padding: theme('spacing-lg'),
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
}) })
const P = define('P', { const P = define('P', {
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 16, fontSize: 16,
marginBottom: theme.spacing.xxl, marginBottom: theme('spacing-xxl'),
}) })
const ExamplesGrid = define('ExamplesGrid', { const ExamplesGrid = define('ExamplesGrid', {
display: 'grid', display: 'grid',
gap: theme.spacing.lg, gap: theme('spacing-lg'),
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
}) })
const ExampleCard = define('ExampleCard', { const ExampleCard = define('ExampleCard', {
base: 'a', base: 'a',
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
padding: theme.spacing.lg, padding: theme('spacing-lg'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
textDecoration: 'none', textDecoration: 'none',
display: 'block', display: 'block',
states: { states: {
hover: { hover: {
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
} }
}, },
parts: { parts: {
H2: { H2: {
color: theme.colors.fg, color: theme('colors-fg'),
margin: `0 0 ${theme.spacing.sm}px 0`, margin: `0 0 ${theme('spacing-sm')} 0`,
fontSize: 18, fontSize: 18,
fontWeight: 400, fontWeight: 400,
}, },
P: { P: {
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
margin: 0, margin: 0,
fontSize: 14, fontSize: 14,
} }
@ -182,13 +232,13 @@ export function route(path: string) {
const HomeLink = define('HomeLink', { const HomeLink = define('HomeLink', {
base: 'a', base: 'a',
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
textDecoration: 'none', textDecoration: 'none',
fontSize: 14, fontSize: 14,
states: { states: {
hover: { hover: {
color: theme.colors.fg, color: theme('colors-fg'),
} }
} }
}) })
@ -206,6 +256,7 @@ export function App() {
<Link href="/spa/buttons" aria-current={path === '/spa/buttons' ? 'page' : undefined}>Buttons</Link> <Link href="/spa/buttons" aria-current={path === '/spa/buttons' ? 'page' : undefined}>Buttons</Link>
<Link href="/spa/navigation" aria-current={path === '/spa/navigation' ? 'page' : undefined}>Navigation</Link> <Link href="/spa/navigation" aria-current={path === '/spa/navigation' ? 'page' : undefined}>Navigation</Link>
<Link href="/spa/form" aria-current={path === '/spa/form' ? 'page' : undefined}>Forms</Link> <Link href="/spa/form" aria-current={path === '/spa/form' ? 'page' : undefined}>Forms</Link>
<ThemePicker />
</Nav> </Nav>
<div id="content"> <div id="content">
{route(path)} {route(path)}

View File

@ -3,16 +3,30 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/main.css"/>
<title>Forge SPA Examples</title> <title>Forge SPA Examples</title>
<style> <style>
html, body { html, body {
margin: 0; margin: 0;
height: 100%;
}
#root {
height: 100%;
} }
</style> </style>
</head> </head>
<body> <body data-theme="dark">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/spa.js"></script> <script type="module" src="/spa.js"></script>
<script>
// Load saved theme and apply it
const savedTheme = localStorage.getItem('theme') || 'dark'
document.body.setAttribute('data-theme', savedTheme)
// Set initial select value after page loads
window.addEventListener('load', () => {
const select = document.getElementById('theme-select')
if (select) select.value = savedTheme
})
</script>
</body> </body>
</html> </html>

View File

@ -1,37 +1,30 @@
export default { export default {
colors: { 'colors-bg': '#0a0a0a',
bg: '#0a0a0a', 'colors-bgElevated': '#111',
bgElevated: '#111', 'colors-bgHover': '#1a1a1a',
bgHover: '#1a1a1a',
fg: '#00ff00', 'colors-fg': '#00ff00',
fgMuted: '#888', 'colors-fgMuted': '#888',
fgDim: '#444', 'colors-fgDim': '#444',
border: '#222', 'colors-border': '#222',
borderActive: '#00ff00', 'colors-borderActive': '#00ff00',
accent: '#00ff00', 'colors-accent': '#00ff00',
accentDim: '#008800', 'colors-accentDim': '#008800',
},
fonts: { 'fonts-mono': "'Monaco', 'Menlo', 'Consolas', monospace",
mono: "'Monaco', 'Menlo', 'Consolas', monospace", 'fonts-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
spacing: { 'spacing-xs': '8px',
xs: 8, 'spacing-sm': '12px',
sm: 12, 'spacing-md': '16px',
md: 16, 'spacing-lg': '24px',
lg: 24, 'spacing-xl': '32px',
xl: 32, 'spacing-xxl': '48px',
xxl: 48,
}, 'radius-sm': '4px',
'radius-md': '8px',
'radius-lg': '12px',
} as const
radius: {
sm: 4,
md: 8,
lg: 12,
}
}

View File

@ -1,16 +1,14 @@
import { define, Styles } from '../../src' import { define } from '../../src'
import darkTheme from './darkTheme' import { theme } from './themes'
export const theme = darkTheme
export const Body = define('Body', { export const Body = define('Body', {
base: 'body', base: 'body',
margin: 0, margin: 0,
padding: theme.spacing.xl, padding: theme('spacing-xl'),
fontFamily: theme.fonts.mono, fontFamily: theme('fonts-mono'),
background: theme.colors.bg, background: theme('colors-bg'),
color: theme.colors.fg, color: theme('colors-fg'),
}) })
const Container = define('Container', { const Container = define('Container', {
@ -21,21 +19,21 @@ const Container = define('Container', {
export const Header = define('Header', { export const Header = define('Header', {
base: 'h1', base: 'h1',
marginBottom: theme.spacing.xl, marginBottom: theme('spacing-xl'),
color: theme.colors.fg, color: theme('colors-fg'),
fontSize: 28, fontSize: 28,
fontWeight: 400, fontWeight: 400,
}) })
export const ExampleSection = define('ExampleSection', { export const ExampleSection = define('ExampleSection', {
marginBottom: theme.spacing.xl, marginBottom: theme('spacing-xl'),
parts: { parts: {
Header: { Header: {
base: 'h2', base: 'h2',
marginBottom: theme.spacing.md, marginBottom: theme('spacing-md'),
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 16, fontSize: 16,
fontWeight: 400, fontWeight: 400,
} }
@ -54,39 +52,96 @@ const Nav = define({
base: 'nav', base: 'nav',
display: 'flex', display: 'flex',
gap: theme.spacing.lg, gap: theme('spacing-lg'),
marginBottom: theme.spacing.xl, marginBottom: theme('spacing-xl'),
padding: theme.spacing.lg, padding: theme('spacing-lg'),
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
}) })
const NavLink = define({ const NavLink = define({
base: 'a', base: 'a',
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
textDecoration: 'none', textDecoration: 'none',
fontSize: 14, fontSize: 14,
states: { states: {
hover: { hover: {
color: theme.colors.fg, color: theme('colors-fg'),
} }
}, },
selectors: { selectors: {
'&[aria-current]': { '&[aria-current]': {
color: theme.colors.fg, color: theme('colors-fg'),
textDecoration: 'underline', textDecoration: 'underline',
} }
} }
}) })
const ThemePicker = define('ThemePicker', {
marginLeft: 'auto',
parts: {
Select: {
base: 'select',
padding: `${theme('spacing-xs')} ${theme('spacing-md')}`,
background: theme('colors-bgElevated'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-sm'),
color: theme('colors-fg'),
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
borderColor: theme('colors-borderActive'),
},
':focus': {
outline: 'none',
borderColor: theme('colors-borderActive'),
}
}
}
},
render({ parts: { Root, Select } }) {
return (
<Root>
<Select id="theme-select" onchange="window.switchTheme(this.value)">
<option value="dark">Dark</option>
<option value="light">Light</option>
</Select>
</Root>
)
}
})
export const Layout = define({ export const Layout = define({
render({ props }) { render({ props }) {
const path = props.path || '' const path = props.path || ''
const themeScript = `
function switchTheme(themeName) {
document.body.setAttribute('data-theme', themeName)
localStorage.setItem('theme', themeName)
}
window.switchTheme = switchTheme
// Load saved theme or default to dark
const savedTheme = localStorage.getItem('theme') || 'dark'
document.body.setAttribute('data-theme', savedTheme)
// Set initial select value
const select = document.getElementById('theme-select')
if (select) select.value = savedTheme
`
return ( return (
<html> <html>
<head> <head>
@ -104,10 +159,12 @@ export const Layout = define({
<NavLink href="/ssr/buttons" aria-current={path === '/ssr/buttons' ? 'page' : undefined}>Buttons</NavLink> <NavLink href="/ssr/buttons" aria-current={path === '/ssr/buttons' ? 'page' : undefined}>Buttons</NavLink>
<NavLink href="/ssr/navigation" aria-current={path === '/ssr/navigation' ? 'page' : undefined}>Navigation</NavLink> <NavLink href="/ssr/navigation" aria-current={path === '/ssr/navigation' ? 'page' : undefined}>Navigation</NavLink>
<NavLink href="/ssr/form" aria-current={path === '/ssr/form' ? 'page' : undefined}>Forms</NavLink> <NavLink href="/ssr/form" aria-current={path === '/ssr/form' ? 'page' : undefined}>Forms</NavLink>
<ThemePicker />
</Nav> </Nav>
<Header>{props.title}</Header> <Header>{props.title}</Header>
{props.children} {props.children}
</Container> </Container>
<script dangerouslySetInnerHTML={{ __html: themeScript }}></script>
</Body> </Body>
</html> </html>
) )

View File

@ -1,5 +1,5 @@
import { createScope, Styles } from '../../src' import { createScope, Styles } from '../../src'
import { theme } from './helpers' import { theme } from './themes'
const { define } = createScope('Landing') const { define } = createScope('Landing')
@ -7,11 +7,11 @@ const Page = define('Page', {
base: 'body', base: 'body',
margin: 0, margin: 0,
padding: theme.spacing.xl, padding: theme('spacing-xl'),
minHeight: '100vh', minHeight: '100vh',
fontFamily: theme.fonts.mono, fontFamily: theme('fonts-mono'),
background: theme.colors.bg, background: theme('colors-bg'),
color: theme.colors.fg, color: theme('colors-fg'),
}) })
const Container = define('Container', { const Container = define('Container', {
@ -24,8 +24,8 @@ const Pre = define('Pre', {
fontSize: 14, fontSize: 14,
lineHeight: 1.4, lineHeight: 1.4,
marginBottom: theme.spacing.xl, marginBottom: theme('spacing-xl'),
color: theme.colors.fg, color: theme('colors-fg'),
whiteSpace: 'pre', whiteSpace: 'pre',
}) })
@ -34,66 +34,82 @@ const P = define('P', {
fontSize: 16, fontSize: 16,
lineHeight: 1.6, lineHeight: 1.6,
marginBottom: theme.spacing.xl, marginBottom: theme('spacing-xl'),
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
}) })
const LinkSection = define('LinkSection', { const LinkSection = define('LinkSection', {
marginTop: theme.spacing.xxl, marginTop: theme('spacing-xxl'),
paddingTop: theme.spacing.xl, paddingTop: theme('spacing-xl'),
borderTop: `1px solid ${theme.colors.border}`, borderTop: `1px solid ${theme('colors-border')}`,
}) })
const Link = define('Link', { const Link = define('Link', {
base: 'a', base: 'a',
display: 'inline-block', display: 'inline-block',
marginRight: theme.spacing.xl, marginRight: theme('spacing-xl'),
padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`, padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
color: theme.colors.fg, color: theme('colors-fg'),
textDecoration: 'none', textDecoration: 'none',
fontSize: 14, fontSize: 14,
states: { states: {
':hover': { ':hover': {
background: theme.colors.bgHover, background: theme('colors-bgHover'),
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
} }
} }
}) })
export const LandingPage = () => ( export const LandingPage = () => {
<html> const themeScript = `
<head> function switchTheme(themeName) {
<meta charset="UTF-8" /> document.body.setAttribute('data-theme', themeName)
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> localStorage.setItem('theme', themeName)
<title>forge</title> }
<Styles />
</head> window.switchTheme = switchTheme
<Page>
<Container> // Load saved theme or default to dark
<Pre>{`╔═╝╔═║╔═║╔═╝╔═╝ const savedTheme = localStorage.getItem('theme') || 'dark'
document.body.setAttribute('data-theme', savedTheme)
`
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>forge</title>
<Styles />
</head>
<Page data-theme="dark">
<Container>
<Pre>{`╔═╝╔═║╔═║╔═╝╔═╝
`}</Pre> `}</Pre>
<P> <P>
Typed, local, variant-driven CSS. No globals, no selector hell, no inline styles. Typed, local, variant-driven CSS. No globals, no selector hell, no inline styles.
Built for TSX. Compiles to real CSS. Built for TSX. Compiles to real CSS.
</P> </P>
<P> <P>
CSS is hostile to humans at scale. Forge fixes that by making styles local, CSS is hostile to humans at scale. Forge fixes that by making styles local,
typed, and composable. Parts replace selectors. Variants replace inline styles. typed, and composable. Parts replace selectors. Variants replace inline styles.
Everything deterministic. Everything deterministic.
</P> </P>
<LinkSection> <LinkSection>
<Link href="/ssr">SSR demos </Link> <Link href="/ssr">SSR demos </Link>
<Link href="/spa">SPA demos </Link> <Link href="/spa">SPA demos </Link>
</LinkSection> </LinkSection>
</Container> </Container>
</Page> <script dangerouslySetInnerHTML={{ __html: themeScript }}></script>
</html> </Page>
) </html>
)
}

View File

@ -0,0 +1,29 @@
export default {
'colors-bg': '#f5f5f0',
'colors-bgElevated': '#fff',
'colors-bgHover': '#e8e8e0',
'colors-fg': '#0a0a0a',
'colors-fgMuted': '#666',
'colors-fgDim': '#999',
'colors-border': '#ddd',
'colors-borderActive': '#008800',
'colors-accent': '#008800',
'colors-accentDim': '#00aa00',
'fonts-mono': "'Monaco', 'Menlo', 'Consolas', monospace",
'fonts-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
'spacing-xs': '8px',
'spacing-sm': '12px',
'spacing-md': '16px',
'spacing-lg': '24px',
'spacing-xl': '32px',
'spacing-xxl': '48px',
'radius-sm': '4px',
'radius-md': '8px',
'radius-lg': '12px',
} as const

View File

@ -1,47 +1,48 @@
import { define } from '../../src' import { define } from '../../src'
import { Layout, theme } from './helpers' import { Layout } from './helpers'
import { theme } from './themes'
import { ButtonExamplesContent } from '../button' import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile' import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation' import { NavigationExamplesContent } from '../navigation'
import { FormExamplesContent } from '../form' import { FormExamplesContent } from '../form'
const P = define('SSR_P', { const P = define('SSR_P', {
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
fontSize: 16, fontSize: 16,
marginBottom: theme.spacing.xxl, marginBottom: theme('spacing-xxl'),
}) })
const ExamplesGrid = define('SSR_ExamplesGrid', { const ExamplesGrid = define('SSR_ExamplesGrid', {
display: 'grid', display: 'grid',
gap: theme.spacing.lg, gap: theme('spacing-lg'),
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
}) })
const ExampleCard = define('SSR_ExampleCard', { const ExampleCard = define('SSR_ExampleCard', {
base: 'a', base: 'a',
background: theme.colors.bgElevated, background: theme('colors-bgElevated'),
padding: theme.spacing.lg, padding: theme('spacing-lg'),
border: `1px solid ${theme.colors.border}`, border: `1px solid ${theme('colors-border')}`,
borderRadius: theme.radius.sm, borderRadius: theme('radius-sm'),
textDecoration: 'none', textDecoration: 'none',
display: 'block', display: 'block',
states: { states: {
hover: { hover: {
borderColor: theme.colors.borderActive, borderColor: theme('colors-borderActive'),
} }
}, },
parts: { parts: {
H2: { H2: {
color: theme.colors.fg, color: theme('colors-fg'),
margin: `0 0 ${theme.spacing.sm}px 0`, margin: `0 0 ${theme('spacing-sm')} 0`,
fontSize: 18, fontSize: 18,
fontWeight: 400, fontWeight: 400,
}, },
P: { P: {
color: theme.colors.fgMuted, color: theme('colors-fgMuted'),
margin: 0, margin: 0,
fontSize: 14, fontSize: 14,
} }

10
examples/ssr/themes.tsx Normal file
View File

@ -0,0 +1,10 @@
import { createTheme, createThemedVar } from '../../src'
import darkTheme from './darkTheme'
import lightTheme from './lightTheme'
// Register themes and get typed keys back
const dark = createTheme('dark', darkTheme)
const light = createTheme('light', lightTheme)
// Create a typed themeVar function
export const theme = createThemedVar({ dark, light })

View File

@ -2,6 +2,58 @@ import type { JSX } from 'hono/jsx'
import { type TagDef, UnitlessProps, NonStyleKeys } from './types' import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
export const styles: Record<string, Record<string, string>> = {} export const styles: Record<string, Record<string, string>> = {}
const themes: Record<string, Record<string, any>> = {}
// Type registry for theme variables (will be auto-populated)
let registeredThemeKeys: Set<string> = new Set()
// Clear all registered styles
export function clearStyles() {
for (const key in styles) delete styles[key]
}
// Register a theme with CSS custom properties
export function createTheme<const T extends Record<string, string | number>>(
name: string,
values: T
): T {
themes[name] = values as Record<string, string>
// track for runtime validation
Object.keys(values).forEach(key => registeredThemeKeys.add(key))
return values
}
// Generate CSS for all registered themes
export function themesToCSS(): string {
let out: string[] = []
for (const [name, vars] of Object.entries(themes)) {
out.push(`[data-theme="${name}"] {`)
for (const [key, value] of Object.entries(vars)) {
out.push(` --theme-${key}: ${value};`)
}
out.push(`}\n`)
}
return out.join('\n')
}
// Helper type to extract theme keys from multiple theme objects
type ThemeKeys<T> = T extends Record<string, any> ? keyof T : never
// Create a typed themeVar function from your themes
export function createThemedVar<T extends Record<string, any>>(_themes: T) {
return function themeVar<K extends ThemeKeys<T[keyof T]>>(name: K): string {
return `var(--theme-${name as string})`
}
}
// Generic themeVar (untyped fallback)
export function themeVar(name: string): string {
return `var(--theme-${name as string})`
}
// All CSS styles inside <style></style. // All CSS styles inside <style></style.
// Use w/ SSR: <Styles/> // Use w/ SSR: <Styles/>
@ -29,6 +81,14 @@ function injectStylesInBrowser() {
export function stylesToCSS(): string { export function stylesToCSS(): string {
let out: string[] = [] let out: string[] = []
// Include theme CSS first
const themeCSS = themesToCSS()
if (themeCSS) {
out.push(themeCSS)
out.push('\n')
}
// Then component styles
for (const [selector, style] of Object.entries(styles)) { for (const [selector, style] of Object.entries(styles)) {
if (Object.keys(style).length === 0) continue if (Object.keys(style).length === 0) continue