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
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

View File

@ -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',
})

View File

@ -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 = () => (

View File

@ -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'),
}
}
}

View File

@ -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')}`,
},
},
},

View File

@ -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 (
<Root>
<Select id="theme-select" onchange={handleChange}>
<option value="dark">Dark</option>
<option value="light">Light</option>
</Select>
</Root>
)
}
})
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() {
<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/form" aria-current={path === '/spa/form' ? 'page' : undefined}>Forms</Link>
<ThemePicker />
</Nav>
<div id="content">
{route(path)}

View File

@ -3,16 +3,30 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/main.css"/>
<title>Forge SPA Examples</title>
<style>
html, body {
margin: 0;
height: 100%;
}
#root {
height: 100%;
}
</style>
</head>
<body>
<body data-theme="dark">
<div id="root"></div>
<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>
</html>

View File

@ -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,
}
}

View File

@ -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 (
<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({
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 (
<html>
<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/navigation" aria-current={path === '/ssr/navigation' ? 'page' : undefined}>Navigation</NavLink>
<NavLink href="/ssr/form" aria-current={path === '/ssr/form' ? 'page' : undefined}>Forms</NavLink>
<ThemePicker />
</Nav>
<Header>{props.title}</Header>
{props.children}
</Container>
<script dangerouslySetInnerHTML={{ __html: themeScript }}></script>
</Body>
</html>
)

View File

@ -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,37 +34,51 @@ 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 = () => (
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 (
<html>
<head>
<meta charset="UTF-8" />
@ -72,7 +86,7 @@ export const LandingPage = () => (
<title>forge</title>
<Styles />
</head>
<Page>
<Page data-theme="dark">
<Container>
<Pre>{`╔═╝╔═║╔═║╔═╝╔═╝
@ -94,6 +108,8 @@ export const LandingPage = () => (
<Link href="/spa">SPA demos </Link>
</LinkSection>
</Container>
<script dangerouslySetInnerHTML={{ __html: themeScript }}></script>
</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 { 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,
}

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'
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.
// Use w/ SSR: <Styles/>
@ -29,6 +81,14 @@ function injectStylesInBrowser() {
export function stylesToCSS(): 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)) {
if (Object.keys(style).length === 0) continue