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 ( + + + + ) + } +}) + 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 ( + + + + ) + } +}) + 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.title}
{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