diff --git a/README.md b/README.md
index 8f74083..3a74802 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,8 @@
## 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
@@ -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".
- "Variants" replace inline styles with typed, declarative parameters.
- Style composition is deterministic.
-- Errors and feedback.
+- Themes are easy.
+- Errors and feedback are provided.
## examples
diff --git a/examples/button.tsx b/examples/button.tsx
index 5300a29..f2ca113 100644
--- a/examples/button.tsx
+++ b/examples/button.tsx
@@ -1,20 +1,21 @@
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 Button = define('Root', {
base: 'button',
- padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`,
+ padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
- gap: theme.spacing.xs,
- background: theme.colors.accent,
- color: theme.colors.bg,
- border: `1px solid ${theme.colors.accent}`,
- borderRadius: theme.radius.sm,
+ gap: theme('spacing-xs'),
+ background: theme('colors-accent'),
+ color: theme('colors-bg'),
+ border: `1px solid ${theme('colors-accent')}`,
+ borderRadius: theme('radius-sm'),
fontSize: 14,
fontWeight: 400,
cursor: "pointer",
@@ -23,8 +24,8 @@ const Button = define('Root', {
states: {
":not(:disabled):hover": {
- background: theme.colors.accentDim,
- borderColor: theme.colors.accentDim,
+ background: theme('colors-accentDim'),
+ borderColor: theme('colors-accentDim'),
},
":not(:disabled):active": {
transform: 'translateY(1px)',
@@ -34,23 +35,23 @@ const Button = define('Root', {
variants: {
intent: {
primary: {
- background: theme.colors.accent,
- color: theme.colors.bg,
- border: `1px solid ${theme.colors.accent}`,
+ background: theme('colors-accent'),
+ color: theme('colors-bg'),
+ border: `1px solid ${theme('colors-accent')}`,
},
secondary: {
- background: theme.colors.bgElevated,
- color: theme.colors.fg,
- border: `1px solid ${theme.colors.border}`,
+ background: theme('colors-bgElevated'),
+ color: theme('colors-fg'),
+ border: `1px solid ${theme('colors-border')}`,
states: {
":not(:disabled):hover": {
- borderColor: theme.colors.borderActive,
+ borderColor: theme('colors-borderActive'),
}
}
},
danger: {
background: "#ff0000",
- color: theme.colors.bg,
+ color: theme('colors-bg'),
border: "1px solid #ff0000",
states: {
":not(:disabled):hover": {
@@ -61,23 +62,23 @@ const Button = define('Root', {
},
ghost: {
background: "transparent",
- color: theme.colors.fgMuted,
- border: `1px solid ${theme.colors.border}`,
+ color: theme('colors-fgMuted'),
+ border: `1px solid ${theme('colors-border')}`,
states: {
":not(:disabled):hover": {
- color: theme.colors.fg,
- borderColor: theme.colors.borderActive,
+ color: theme('colors-fg'),
+ borderColor: theme('colors-borderActive'),
}
}
},
},
size: {
small: {
- padding: `${theme.spacing.xs}px ${theme.spacing.md}px`,
+ padding: `${theme('spacing-xs')} ${theme('spacing-md')}`,
fontSize: 12,
},
large: {
- padding: `${theme.spacing.md}px ${theme.spacing.xl}px`,
+ padding: `${theme('spacing-md')} ${theme('spacing-xl')}`,
fontSize: 16,
},
},
@@ -90,7 +91,7 @@ const Button = define('Root', {
const ButtonRow = define('Row', {
display: 'flex',
- gap: theme.spacing.md,
+ gap: theme('spacing-md'),
flexWrap: 'wrap',
alignItems: 'center',
})
diff --git a/examples/form.tsx b/examples/form.tsx
index 65f1873..7feb2c7 100644
--- a/examples/form.tsx
+++ b/examples/form.tsx
@@ -1,15 +1,16 @@
import { define } from '../src'
-import { ExampleSection, theme } from './ssr/helpers'
+import { ExampleSection } from './ssr/helpers'
+import { theme } from './ssr/themes'
const Input = define('Input', {
base: 'input',
- padding: `${theme.spacing.sm}px ${theme.spacing.md}px`,
+ padding: `${theme('spacing-sm')} ${theme('spacing-md')}`,
fontSize: 14,
- border: `1px solid ${theme.colors.border}`,
- borderRadius: theme.radius.sm,
- background: theme.colors.bgElevated,
- color: theme.colors.fg,
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-sm'),
+ background: theme('colors-bgElevated'),
+ color: theme('colors-fg'),
transition: 'all 0.2s ease',
width: '100%',
boxSizing: 'border-box',
@@ -17,11 +18,11 @@ const Input = define('Input', {
states: {
':focus': {
outline: 'none',
- borderColor: theme.colors.borderActive,
+ borderColor: theme('colors-borderActive'),
},
':disabled': {
- background: theme.colors.bg,
- color: theme.colors.fgDim,
+ background: theme('colors-bg'),
+ color: theme('colors-fgDim'),
cursor: 'not-allowed'
}
},
@@ -37,10 +38,10 @@ const Input = define('Input', {
}
},
success: {
- borderColor: theme.colors.accent,
+ borderColor: theme('colors-accent'),
states: {
':focus': {
- borderColor: theme.colors.accent,
+ borderColor: theme('colors-accent'),
}
}
}
@@ -51,12 +52,12 @@ const Input = define('Input', {
const Textarea = define('Textarea', {
base: 'textarea',
- padding: `${theme.spacing.sm}px ${theme.spacing.md}px`,
+ padding: `${theme('spacing-sm')} ${theme('spacing-md')}`,
fontSize: 14,
- border: `1px solid ${theme.colors.border}`,
- borderRadius: theme.radius.sm,
- background: theme.colors.bgElevated,
- color: theme.colors.fg,
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-sm'),
+ background: theme('colors-bgElevated'),
+ color: theme('colors-fg'),
transition: 'all 0.2s ease',
width: '100%',
minHeight: 120,
@@ -67,13 +68,13 @@ const Textarea = define('Textarea', {
states: {
':focus': {
outline: 'none',
- borderColor: theme.colors.borderActive,
+ borderColor: theme('colors-borderActive'),
}
}
})
const FormGroup = define('FormGroup', {
- marginBottom: theme.spacing.lg,
+ marginBottom: theme('spacing-lg'),
parts: {
Label: {
@@ -81,12 +82,12 @@ const FormGroup = define('FormGroup', {
display: 'block',
fontSize: 14,
fontWeight: 400,
- color: theme.colors.fg,
- marginBottom: theme.spacing.xs
+ color: theme('colors-fg'),
+ marginBottom: theme('spacing-xs')
},
Helper: {
fontSize: 12,
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
marginTop: 6
},
Error: {
@@ -120,21 +121,21 @@ const Checkbox = define('Checkbox', {
base: 'label',
display: 'flex',
alignItems: 'center',
- gap: theme.spacing.sm,
+ gap: theme('spacing-sm'),
cursor: 'pointer',
fontSize: 14,
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
states: {
':hover': {
- color: theme.colors.fg
+ color: theme('colors-fg')
}
},
selectors: {
'@Input:disabled + &': {
cursor: 'not-allowed',
- color: theme.colors.fgDim
+ color: theme('colors-fgDim')
}
}
}
@@ -160,20 +161,20 @@ const FormExamples = define('FormExamples', {
const Button = define('FormButton', {
base: 'button',
- padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`,
+ padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
fontSize: 14,
fontWeight: 400,
- border: `1px solid ${theme.colors.accent}`,
- borderRadius: theme.radius.sm,
+ border: `1px solid ${theme('colors-accent')}`,
+ borderRadius: theme('radius-sm'),
cursor: 'pointer',
transition: 'all 0.2s ease',
- background: theme.colors.accent,
- color: theme.colors.bg,
+ background: theme('colors-accent'),
+ color: theme('colors-bg'),
states: {
':hover': {
- background: theme.colors.accentDim,
- borderColor: theme.colors.accentDim,
+ background: theme('colors-accentDim'),
+ borderColor: theme('colors-accentDim'),
},
':active': {
transform: 'translateY(1px)'
@@ -183,12 +184,12 @@ const Button = define('FormButton', {
variants: {
variant: {
secondary: {
- background: theme.colors.bgElevated,
- color: theme.colors.fg,
- border: `1px solid ${theme.colors.border}`,
+ background: theme('colors-bgElevated'),
+ color: theme('colors-fg'),
+ border: `1px solid ${theme('colors-border')}`,
states: {
':hover': {
- borderColor: theme.colors.borderActive,
+ borderColor: theme('colors-borderActive'),
}
}
}
@@ -198,8 +199,8 @@ const Button = define('FormButton', {
const ButtonGroup = define('FormButtonGroup', {
display: 'flex',
- gap: theme.spacing.sm,
- marginTop: theme.spacing.lg
+ gap: theme('spacing-sm'),
+ marginTop: theme('spacing-lg')
})
export const FormExamplesContent = () => (
diff --git a/examples/navigation.tsx b/examples/navigation.tsx
index 780bec3..16be375 100644
--- a/examples/navigation.tsx
+++ b/examples/navigation.tsx
@@ -1,5 +1,6 @@
import { define } from '../src'
-import { ExampleSection, theme } from './ssr/helpers'
+import { ExampleSection } from './ssr/helpers'
+import { theme } from './ssr/themes'
const TabSwitcher = define('TabSwitcher', {
parts: {
@@ -10,41 +11,41 @@ const TabSwitcher = define('TabSwitcher', {
TabBar: {
display: 'flex',
gap: 0,
- borderBottom: `1px solid ${theme.colors.border}`,
- marginBottom: theme.spacing.lg,
+ borderBottom: `1px solid ${theme('colors-border')}`,
+ marginBottom: theme('spacing-lg'),
},
TabLabel: {
base: 'label',
- padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`,
+ padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
position: 'relative',
marginBottom: -1,
background: 'transparent',
borderBottom: '1px solid transparent',
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
- color: theme.colors.fg,
+ color: theme('colors-fg'),
}
},
selectors: {
'@Input:checked + &': {
- color: theme.colors.accent,
- borderBottom: `1px solid ${theme.colors.accent}`
+ color: theme('colors-accent'),
+ borderBottom: `1px solid ${theme('colors-accent')}`
}
}
},
Content: {
display: 'none',
- padding: theme.spacing.lg,
- background: theme.colors.bgElevated,
- border: `1px solid ${theme.colors.border}`,
- borderRadius: theme.radius.sm,
+ padding: theme('spacing-lg'),
+ background: theme('colors-bgElevated'),
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-sm'),
selectors: {
'@Input:checked ~ &': {
@@ -91,37 +92,37 @@ const Pills = define('Pills', {
},
PillBar: {
display: 'flex',
- gap: theme.spacing.xs,
+ gap: theme('spacing-xs'),
flexWrap: 'wrap',
},
PillLabel: {
base: 'label',
- padding: `${theme.spacing.xs}px ${theme.spacing.md}px`,
- background: theme.colors.bgElevated,
- border: `1px solid ${theme.colors.border}`,
+ padding: `${theme('spacing-xs')} ${theme('spacing-md')}`,
+ background: theme('colors-bgElevated'),
+ border: `1px solid ${theme('colors-border')}`,
borderRadius: 20,
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
- borderColor: theme.colors.borderActive,
- color: theme.colors.fg,
+ borderColor: theme('colors-borderActive'),
+ color: theme('colors-fg'),
}
},
selectors: {
'@Input:checked + &': {
- background: theme.colors.accent,
- borderColor: theme.colors.accent,
- color: theme.colors.bg
+ background: theme('colors-accent'),
+ borderColor: theme('colors-accent'),
+ color: theme('colors-bg')
},
'@Input:checked + &:hover': {
- background: theme.colors.accentDim,
- borderColor: theme.colors.accentDim,
+ background: theme('colors-accentDim'),
+ borderColor: theme('colors-accentDim'),
}
}
}
@@ -165,36 +166,36 @@ const VerticalNav = define('VerticalNav', {
NavLabel: {
base: 'label',
- padding: `${theme.spacing.sm}px ${theme.spacing.md}px`,
+ padding: `${theme('spacing-sm')} ${theme('spacing-md')}`,
display: 'flex',
alignItems: 'center',
- gap: theme.spacing.sm,
+ gap: theme('spacing-sm'),
background: 'transparent',
border: `1px solid transparent`,
- borderRadius: theme.radius.sm,
- color: theme.colors.fgMuted,
+ borderRadius: theme('radius-sm'),
+ color: theme('colors-fgMuted'),
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
- background: theme.colors.bgElevated,
- borderColor: theme.colors.border,
- color: theme.colors.fg,
+ background: theme('colors-bgElevated'),
+ borderColor: theme('colors-border'),
+ color: theme('colors-fg'),
}
},
selectors: {
'@Input:checked + &': {
- background: theme.colors.bgElevated,
- borderColor: theme.colors.accent,
- color: theme.colors.accent,
+ background: theme('colors-bgElevated'),
+ borderColor: theme('colors-accent'),
+ color: theme('colors-accent'),
},
'@Input:checked + &:hover': {
- borderColor: theme.colors.accentDim,
- color: theme.colors.accentDim
+ borderColor: theme('colors-accentDim'),
+ color: theme('colors-accentDim')
}
}
},
@@ -235,31 +236,31 @@ const VerticalNav = define('VerticalNav', {
const Breadcrumbs = define('Breadcrumbs', {
display: 'flex',
alignItems: 'center',
- gap: theme.spacing.xs,
+ gap: theme('spacing-xs'),
flexWrap: 'wrap',
parts: {
Item: {
base: 'a',
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
fontSize: 14,
textDecoration: 'none',
transition: 'color 0.2s ease',
states: {
':hover': {
- color: theme.colors.accent,
+ color: theme('colors-accent'),
}
}
},
Separator: {
- color: theme.colors.fgDim,
+ color: theme('colors-fgDim'),
fontSize: 14,
userSelect: 'none',
},
Current: {
- color: theme.colors.fg,
+ color: theme('colors-fg'),
fontSize: 14,
}
},
@@ -289,25 +290,25 @@ const Breadcrumbs = define('Breadcrumbs', {
const Tabs = define('Tabs', {
display: 'flex',
gap: 0,
- borderBottom: `1px solid ${theme.colors.border}`,
+ borderBottom: `1px solid ${theme('colors-border')}`,
parts: {
Tab: {
base: 'button',
- padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`,
+ padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
position: 'relative',
marginBottom: -1,
background: 'transparent',
border: 'none',
borderBottom: '1px solid transparent',
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
- color: theme.colors.fg,
+ color: theme('colors-fg'),
}
}
}
@@ -317,8 +318,8 @@ const Tabs = define('Tabs', {
active: {
parts: {
Tab: {
- color: theme.colors.accent,
- borderBottom: `1px solid ${theme.colors.accent}`,
+ color: theme('colors-accent'),
+ borderBottom: `1px solid ${theme('colors-accent')}`,
}
}
}
@@ -339,25 +340,25 @@ const Tabs = define('Tabs', {
const SimplePills = define('SimplePills', {
display: 'flex',
- gap: theme.spacing.xs,
+ gap: theme('spacing-xs'),
flexWrap: 'wrap',
parts: {
Pill: {
base: 'button',
- padding: `${theme.spacing.xs}px ${theme.spacing.md}px`,
- background: theme.colors.bgElevated,
- border: `1px solid ${theme.colors.border}`,
+ padding: `${theme('spacing-xs')} ${theme('spacing-md')}`,
+ background: theme('colors-bgElevated'),
+ border: `1px solid ${theme('colors-border')}`,
borderRadius: 20,
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
- borderColor: theme.colors.borderActive,
- color: theme.colors.fg,
+ borderColor: theme('colors-borderActive'),
+ color: theme('colors-fg'),
}
}
}
@@ -367,13 +368,13 @@ const SimplePills = define('SimplePills', {
active: {
parts: {
Pill: {
- background: theme.colors.accent,
- borderColor: theme.colors.accent,
- color: theme.colors.bg,
+ background: theme('colors-accent'),
+ borderColor: theme('colors-accent'),
+ color: theme('colors-bg'),
states: {
':hover': {
- background: theme.colors.accentDim,
- borderColor: theme.colors.accentDim,
+ background: theme('colors-accentDim'),
+ borderColor: theme('colors-accentDim'),
}
}
}
@@ -403,14 +404,14 @@ const SimpleVerticalNav = define('SimpleVerticalNav', {
parts: {
NavItem: {
base: 'button',
- padding: `${theme.spacing.sm}px ${theme.spacing.md}px`,
+ padding: `${theme('spacing-sm')} ${theme('spacing-md')}`,
display: 'flex',
alignItems: 'center',
- gap: theme.spacing.sm,
+ gap: theme('spacing-sm'),
background: 'transparent',
border: `1px solid transparent`,
- borderRadius: theme.radius.sm,
- color: theme.colors.fgMuted,
+ borderRadius: theme('radius-sm'),
+ color: theme('colors-fgMuted'),
fontSize: 14,
textAlign: 'left',
cursor: 'pointer',
@@ -418,9 +419,9 @@ const SimpleVerticalNav = define('SimpleVerticalNav', {
states: {
':hover': {
- background: theme.colors.bgElevated,
- borderColor: theme.colors.border,
- color: theme.colors.fg,
+ background: theme('colors-bgElevated'),
+ borderColor: theme('colors-border'),
+ color: theme('colors-fg'),
}
}
},
@@ -438,13 +439,13 @@ const SimpleVerticalNav = define('SimpleVerticalNav', {
active: {
parts: {
NavItem: {
- background: theme.colors.bgElevated,
- borderColor: theme.colors.accent,
- color: theme.colors.accent,
+ background: theme('colors-bgElevated'),
+ borderColor: theme('colors-accent'),
+ color: theme('colors-accent'),
states: {
':hover': {
- borderColor: theme.colors.accentDim,
- color: theme.colors.accentDim,
+ borderColor: theme('colors-accentDim'),
+ color: theme('colors-accentDim'),
}
}
}
diff --git a/examples/profile.tsx b/examples/profile.tsx
index 0c8a3e0..68e728c 100644
--- a/examples/profile.tsx
+++ b/examples/profile.tsx
@@ -1,22 +1,23 @@
import { define } from '../src'
-import { ExampleSection, theme } from './ssr/helpers'
+import { ExampleSection } from './ssr/helpers'
+import { theme } from './ssr/themes'
const UserProfile = define('UserProfile', {
base: 'div',
- padding: theme.spacing.lg,
+ padding: theme('spacing-lg'),
maxWidth: 600,
margin: "0 auto",
- background: theme.colors.bgElevated,
- border: `1px solid ${theme.colors.border}`,
- borderRadius: theme.radius.md,
+ background: theme('colors-bgElevated'),
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-md'),
parts: {
Header: {
display: "flex",
alignItems: "center",
- gap: theme.spacing.md,
- marginBottom: theme.spacing.md,
+ gap: theme('spacing-md'),
+ marginBottom: theme('spacing-md'),
},
Avatar: {
base: 'img',
@@ -24,7 +25,7 @@ const UserProfile = define('UserProfile', {
height: 64,
borderRadius: "50%",
objectFit: "cover",
- border: `2px solid ${theme.colors.border}`,
+ border: `2px solid ${theme('colors-border')}`,
},
Info: {
flex: 1,
@@ -33,25 +34,25 @@ const UserProfile = define('UserProfile', {
marginBottom: 4,
fontSize: 18,
fontWeight: 400,
- color: theme.colors.fg,
+ color: theme('colors-fg'),
},
Handle: {
fontSize: 14,
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
},
Bio: {
- marginBottom: theme.spacing.md,
+ marginBottom: theme('spacing-md'),
width: "100%",
fontSize: 14,
lineHeight: 1.6,
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
wordWrap: "break-word",
},
Stats: {
display: "flex",
- gap: theme.spacing.lg,
- paddingTop: theme.spacing.md,
- borderTop: `1px solid ${theme.colors.border}`,
+ gap: theme('spacing-lg'),
+ paddingTop: theme('spacing-md'),
+ borderTop: `1px solid ${theme('colors-border')}`,
},
Stat: {
display: "flex",
@@ -61,11 +62,11 @@ const UserProfile = define('UserProfile', {
StatValue: {
fontSize: 18,
fontWeight: 400,
- color: theme.colors.fg,
+ color: theme('colors-fg'),
},
StatLabel: {
fontSize: 12,
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
textTransform: "uppercase",
},
},
@@ -114,7 +115,7 @@ const UserProfile = define('UserProfile', {
verified: {
parts: {
Avatar: {
- border: `2px solid ${theme.colors.accent}`,
+ border: `2px solid ${theme('colors-accent')}`,
},
},
},
diff --git a/examples/spa/app.tsx b/examples/spa/app.tsx
index bc19758..465c12a 100644
--- a/examples/spa/app.tsx
+++ b/examples/spa/app.tsx
@@ -1,18 +1,68 @@
import { define } from '../../src'
-import { theme } from '../ssr/helpers'
+import { theme } from '../ssr/themes'
import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation'
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 (
+
+
+ Dark
+ Light
+
+
+ )
+ }
+})
+
export const Main = define('SpaMain', {
base: 'div',
minHeight: '100%',
- padding: theme.spacing.xl,
- fontFamily: theme.fonts.mono,
- background: theme.colors.bg,
- color: theme.colors.fg,
+ height: '100%',
+ padding: theme('spacing-xl'),
+ fontFamily: theme('fonts-mono'),
+ background: theme('colors-bg'),
+ color: theme('colors-fg'),
+ boxSizing: 'border-box',
})
export const Container = define('SpaContainer', {
@@ -26,19 +76,19 @@ export const Container = define('SpaContainer', {
const Link = define('Link', {
base: 'a',
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
textDecoration: 'none',
fontSize: 14,
states: {
hover: {
- color: theme.colors.fg,
+ color: theme('colors-fg'),
}
},
selectors: {
'&[aria-current]': {
- color: theme.colors.fg,
+ color: theme('colors-fg'),
textDecoration: 'underline',
}
},
@@ -62,51 +112,51 @@ const Nav = define('Nav', {
base: 'nav',
display: 'flex',
- gap: theme.spacing.lg,
- marginBottom: theme.spacing.xl,
- padding: theme.spacing.lg,
- background: theme.colors.bgElevated,
- border: `1px solid ${theme.colors.border}`,
- borderRadius: theme.radius.sm,
+ gap: theme('spacing-lg'),
+ marginBottom: theme('spacing-xl'),
+ padding: theme('spacing-lg'),
+ background: theme('colors-bgElevated'),
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-sm'),
})
const P = define('P', {
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
fontSize: 16,
- marginBottom: theme.spacing.xxl,
+ marginBottom: theme('spacing-xxl'),
})
const ExamplesGrid = define('ExamplesGrid', {
display: 'grid',
- gap: theme.spacing.lg,
+ gap: theme('spacing-lg'),
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
})
const ExampleCard = define('ExampleCard', {
base: 'a',
- background: theme.colors.bgElevated,
- padding: theme.spacing.lg,
- border: `1px solid ${theme.colors.border}`,
- borderRadius: theme.radius.sm,
+ background: theme('colors-bgElevated'),
+ padding: theme('spacing-lg'),
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-sm'),
textDecoration: 'none',
display: 'block',
states: {
hover: {
- borderColor: theme.colors.borderActive,
+ borderColor: theme('colors-borderActive'),
}
},
parts: {
H2: {
- color: theme.colors.fg,
- margin: `0 0 ${theme.spacing.sm}px 0`,
+ color: theme('colors-fg'),
+ margin: `0 0 ${theme('spacing-sm')} 0`,
fontSize: 18,
fontWeight: 400,
},
P: {
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
margin: 0,
fontSize: 14,
}
@@ -182,13 +232,13 @@ export function route(path: string) {
const HomeLink = define('HomeLink', {
base: 'a',
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
textDecoration: 'none',
fontSize: 14,
states: {
hover: {
- color: theme.colors.fg,
+ color: theme('colors-fg'),
}
}
})
@@ -206,6 +256,7 @@ export function App() {
Buttons
Navigation
Forms
+
{route(path)}
diff --git a/examples/spa/index.html b/examples/spa/index.html
index 3d4dcd9..76805ec 100644
--- a/examples/spa/index.html
+++ b/examples/spa/index.html
@@ -3,16 +3,30 @@
-
Forge SPA Examples
-
+
+
diff --git a/examples/ssr/darkTheme.tsx b/examples/ssr/darkTheme.tsx
index 8bfe4cf..d7f5005 100644
--- a/examples/ssr/darkTheme.tsx
+++ b/examples/ssr/darkTheme.tsx
@@ -1,37 +1,30 @@
export default {
- colors: {
- bg: '#0a0a0a',
- bgElevated: '#111',
- bgHover: '#1a1a1a',
+ 'colors-bg': '#0a0a0a',
+ 'colors-bgElevated': '#111',
+ 'colors-bgHover': '#1a1a1a',
- fg: '#00ff00',
- fgMuted: '#888',
- fgDim: '#444',
+ 'colors-fg': '#00ff00',
+ 'colors-fgMuted': '#888',
+ 'colors-fgDim': '#444',
- border: '#222',
- borderActive: '#00ff00',
+ 'colors-border': '#222',
+ 'colors-borderActive': '#00ff00',
- accent: '#00ff00',
- accentDim: '#008800',
- },
+ 'colors-accent': '#00ff00',
+ 'colors-accentDim': '#008800',
- fonts: {
- mono: "'Monaco', 'Menlo', 'Consolas', monospace",
- sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
- },
+ 'fonts-mono': "'Monaco', 'Menlo', 'Consolas', monospace",
+ 'fonts-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
- spacing: {
- xs: 8,
- sm: 12,
- md: 16,
- lg: 24,
- xl: 32,
- xxl: 48,
- },
+ '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
- radius: {
- sm: 4,
- md: 8,
- lg: 12,
- }
-}
\ No newline at end of file
diff --git a/examples/ssr/helpers.tsx b/examples/ssr/helpers.tsx
index 223dc16..d811452 100644
--- a/examples/ssr/helpers.tsx
+++ b/examples/ssr/helpers.tsx
@@ -1,16 +1,14 @@
-import { define, Styles } from '../../src'
-import darkTheme from './darkTheme'
-
-export const theme = darkTheme
+import { define } from '../../src'
+import { theme } from './themes'
export const Body = define('Body', {
base: 'body',
margin: 0,
- padding: theme.spacing.xl,
- fontFamily: theme.fonts.mono,
- background: theme.colors.bg,
- color: theme.colors.fg,
+ padding: theme('spacing-xl'),
+ fontFamily: theme('fonts-mono'),
+ background: theme('colors-bg'),
+ color: theme('colors-fg'),
})
const Container = define('Container', {
@@ -21,21 +19,21 @@ const Container = define('Container', {
export const Header = define('Header', {
base: 'h1',
- marginBottom: theme.spacing.xl,
- color: theme.colors.fg,
+ marginBottom: theme('spacing-xl'),
+ color: theme('colors-fg'),
fontSize: 28,
fontWeight: 400,
})
export const ExampleSection = define('ExampleSection', {
- marginBottom: theme.spacing.xl,
+ marginBottom: theme('spacing-xl'),
parts: {
Header: {
base: 'h2',
- marginBottom: theme.spacing.md,
- color: theme.colors.fgMuted,
+ marginBottom: theme('spacing-md'),
+ color: theme('colors-fgMuted'),
fontSize: 16,
fontWeight: 400,
}
@@ -54,39 +52,96 @@ const Nav = define({
base: 'nav',
display: 'flex',
- gap: theme.spacing.lg,
- marginBottom: theme.spacing.xl,
- padding: theme.spacing.lg,
- background: theme.colors.bgElevated,
- border: `1px solid ${theme.colors.border}`,
- borderRadius: theme.radius.sm,
+ gap: theme('spacing-lg'),
+ marginBottom: theme('spacing-xl'),
+ padding: theme('spacing-lg'),
+ background: theme('colors-bgElevated'),
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-sm'),
})
const NavLink = define({
base: 'a',
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
textDecoration: 'none',
fontSize: 14,
states: {
hover: {
- color: theme.colors.fg,
+ color: theme('colors-fg'),
}
},
selectors: {
'&[aria-current]': {
- color: theme.colors.fg,
+ color: theme('colors-fg'),
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 (
+
+
+ Dark
+ Light
+
+
+ )
+ }
+})
+
export const Layout = define({
render({ props }) {
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 (
@@ -104,10 +159,12 @@ export const Layout = define({
Buttons
Navigation
Forms
+
{props.children}
+
)
diff --git a/examples/ssr/landing.tsx b/examples/ssr/landing.tsx
index 817b63f..a1bc30a 100644
--- a/examples/ssr/landing.tsx
+++ b/examples/ssr/landing.tsx
@@ -1,5 +1,5 @@
import { createScope, Styles } from '../../src'
-import { theme } from './helpers'
+import { theme } from './themes'
const { define } = createScope('Landing')
@@ -7,11 +7,11 @@ const Page = define('Page', {
base: 'body',
margin: 0,
- padding: theme.spacing.xl,
+ padding: theme('spacing-xl'),
minHeight: '100vh',
- fontFamily: theme.fonts.mono,
- background: theme.colors.bg,
- color: theme.colors.fg,
+ fontFamily: theme('fonts-mono'),
+ background: theme('colors-bg'),
+ color: theme('colors-fg'),
})
const Container = define('Container', {
@@ -24,8 +24,8 @@ const Pre = define('Pre', {
fontSize: 14,
lineHeight: 1.4,
- marginBottom: theme.spacing.xl,
- color: theme.colors.fg,
+ marginBottom: theme('spacing-xl'),
+ color: theme('colors-fg'),
whiteSpace: 'pre',
})
@@ -34,66 +34,82 @@ const P = define('P', {
fontSize: 16,
lineHeight: 1.6,
- marginBottom: theme.spacing.xl,
- color: theme.colors.fgMuted,
+ marginBottom: theme('spacing-xl'),
+ color: theme('colors-fgMuted'),
})
const LinkSection = define('LinkSection', {
- marginTop: theme.spacing.xxl,
- paddingTop: theme.spacing.xl,
- borderTop: `1px solid ${theme.colors.border}`,
+ marginTop: theme('spacing-xxl'),
+ paddingTop: theme('spacing-xl'),
+ borderTop: `1px solid ${theme('colors-border')}`,
})
const Link = define('Link', {
base: 'a',
display: 'inline-block',
- marginRight: theme.spacing.xl,
- padding: `${theme.spacing.sm}px ${theme.spacing.lg}px`,
- background: theme.colors.bgElevated,
- border: `1px solid ${theme.colors.border}`,
- color: theme.colors.fg,
+ marginRight: theme('spacing-xl'),
+ padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
+ background: theme('colors-bgElevated'),
+ border: `1px solid ${theme('colors-border')}`,
+ color: theme('colors-fg'),
textDecoration: 'none',
fontSize: 14,
states: {
':hover': {
- background: theme.colors.bgHover,
- borderColor: theme.colors.borderActive,
+ background: theme('colors-bgHover'),
+ borderColor: theme('colors-borderActive'),
}
}
})
-export const LandingPage = () => (
-
-
-
-
-
forge
-
-
-
-
- {`╔═╝╔═║╔═║╔═╝╔═╝
+export const LandingPage = () => {
+ 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)
+ `
+
+ return (
+
+
+
+
+ forge
+
+
+
+
+ {`╔═╝╔═║╔═║╔═╝╔═╝
╔═╝║ ║╔╔╝║ ║╔═╝
╝ ══╝╝ ╝══╝══╝`}
-
- Typed, local, variant-driven CSS. No globals, no selector hell, no inline styles.
- Built for TSX. Compiles to real CSS.
-
+
+ Typed, local, variant-driven CSS. No globals, no selector hell, no inline styles.
+ Built for TSX. Compiles to real CSS.
+
-
- CSS is hostile to humans at scale. Forge fixes that by making styles local,
- typed, and composable. Parts replace selectors. Variants replace inline styles.
- Everything deterministic.
-
+
+ CSS is hostile to humans at scale. Forge fixes that by making styles local,
+ typed, and composable. Parts replace selectors. Variants replace inline styles.
+ Everything deterministic.
+
-
- SSR demos →
- SPA demos →
-
-
-
-
-)
+
+ SSR demos →
+ SPA demos →
+
+
+
+
+
+ )
+}
diff --git a/examples/ssr/lightTheme.tsx b/examples/ssr/lightTheme.tsx
new file mode 100644
index 0000000..cf46679
--- /dev/null
+++ b/examples/ssr/lightTheme.tsx
@@ -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
\ No newline at end of file
diff --git a/examples/ssr/pages.tsx b/examples/ssr/pages.tsx
index cba68dd..ecfa0c8 100644
--- a/examples/ssr/pages.tsx
+++ b/examples/ssr/pages.tsx
@@ -1,47 +1,48 @@
import { define } from '../../src'
-import { Layout, theme } from './helpers'
+import { Layout } from './helpers'
+import { theme } from './themes'
import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation'
import { FormExamplesContent } from '../form'
const P = define('SSR_P', {
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
fontSize: 16,
- marginBottom: theme.spacing.xxl,
+ marginBottom: theme('spacing-xxl'),
})
const ExamplesGrid = define('SSR_ExamplesGrid', {
display: 'grid',
- gap: theme.spacing.lg,
+ gap: theme('spacing-lg'),
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
})
const ExampleCard = define('SSR_ExampleCard', {
base: 'a',
- background: theme.colors.bgElevated,
- padding: theme.spacing.lg,
- border: `1px solid ${theme.colors.border}`,
- borderRadius: theme.radius.sm,
+ background: theme('colors-bgElevated'),
+ padding: theme('spacing-lg'),
+ border: `1px solid ${theme('colors-border')}`,
+ borderRadius: theme('radius-sm'),
textDecoration: 'none',
display: 'block',
states: {
hover: {
- borderColor: theme.colors.borderActive,
+ borderColor: theme('colors-borderActive'),
}
},
parts: {
H2: {
- color: theme.colors.fg,
- margin: `0 0 ${theme.spacing.sm}px 0`,
+ color: theme('colors-fg'),
+ margin: `0 0 ${theme('spacing-sm')} 0`,
fontSize: 18,
fontWeight: 400,
},
P: {
- color: theme.colors.fgMuted,
+ color: theme('colors-fgMuted'),
margin: 0,
fontSize: 14,
}
diff --git a/examples/ssr/themes.tsx b/examples/ssr/themes.tsx
new file mode 100644
index 0000000..d6aba63
--- /dev/null
+++ b/examples/ssr/themes.tsx
@@ -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 })
\ No newline at end of file
diff --git a/src/index.tsx b/src/index.tsx
index a3149c6..1b3cbce 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -2,6 +2,58 @@ import type { JSX } from 'hono/jsx'
import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
export const styles: Record
> = {}
+const themes: Record> = {}
+
+// Type registry for theme variables (will be auto-populated)
+let registeredThemeKeys: Set = 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>(
+ name: string,
+ values: T
+): T {
+ themes[name] = values as Record
+
+ // 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 extends Record ? keyof T : never
+
+// Create a typed themeVar function from your themes
+export function createThemedVar>(_themes: T) {
+ return function themeVar>(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