Compare commits

..

No commits in common. "3dba9e6ba6659a27412b77a04e5e9fd8e5ba592e" and "6b68c401f744662f965c48e33ab968f8a2533c95" have entirely different histories.

20 changed files with 360 additions and 1727 deletions

105
CLAUDE.md
View File

@ -1,105 +0,0 @@
# Forge - Structured CSS Authoring
A typed, local, variant-driven way to author CSS. Compiles to real CSS but removes the chaos: no global conflicts, no selector gymnastics, no inline styles. Built for Hono JSX with SSR support.
## The Problem
CSS is hostile to humans at scale: global namespace, no markup-to-definition link, requires inline styles for per-instance variance, silent conflicts, selector complexity.
## The Solution
- **Local styles** - Attached via generated class names, not strings
- **Parts** - Named sub-components replace selectors (no `.Button > .Icon` nonsense)
- **Variants** - Typed parameters replace inline styles (no `style={{ color: x }}`)
- **Deterministic** - Known merge order, dev warnings for conflicts
- **Compiles to CSS** - Not a new language, not runtime CSS-in-JS, just organized CSS generation
## Core Concepts
**`define(name?, def)`** - Creates a styled component. Returns a component function.
- Accepts CSS properties in camelCase (auto-converts to kebab-case)
- Numbers auto-converted to `px` (except unitless props like `opacity`, `zIndex`)
- Generates CSS classes and registers styles globally
**Parts** - Sub-components within a component (e.g., Header, Body, Footer)
- Defined via `parts: { PartName: { ...styles } }`
- Accessible in render as `parts.PartName`
- Generate classes like `ComponentName_PartName`
**Variants** - Conditional styling based on props
- Boolean: `variants: { active: { color: 'blue' } }``<Component active />`
- Keyed: `variants: { size: { small: {...}, large: {...} } }``<Component size="small" />`
- Work on both root and parts
- Generate classes like `ComponentName.variant-key`
**States** - Pseudo-selectors like hover, focus
- `states: { hover: { background: 'blue' } }``.Class:hover { ... }`
**Custom Render** - Override default rendering
- `render({ props, parts }) { return <parts.Root>...</parts.Root> }`
- Compose parts manually, pass props through
## File Structure
- `src/index.tsx` - Main implementation (`define`, `Styles`, CSS generation)
- `src/types.ts` - `TagDef` type with all CSS properties, helper sets
- **`examples/`** - **REFERENCE THESE** for real-world usage patterns:
- `helpers.tsx` - Layout wrapper, reusable components (Body, Header, ThemeToggle)
- `index.tsx` - Landing page with grid, cards, parts, and custom render
- `button.tsx` - Button variants (intent, size, disabled)
- `profile.tsx` - Complex component with multiple parts and variants
- `navigation.tsx` - Tabs, pills, breadcrumbs, vertical nav patterns
- `src/tests/` - Comprehensive test suite with test helpers
## Implementation Details
- **Static CSS generation** - CSS created at component definition time, not runtime
- **Global styles registry** - `styles` object stores all CSS as plain objects
- **`Styles` component** - Renders `<style>` tag with all registered CSS (include in HTML `<head>`)
- **Real CSS classes** - Generates actual CSS selectors, not inline styles or CSS-in-JS
- **Class naming**:
- Root: `ComponentName`
- Parts: `ComponentName_PartName`
- Variants: `ComponentName.variant-key` or just `variant` for boolean
- States: `.ClassName:state`
- **No duplicate names** - Throws if same name registered twice
- **Anonymous components** - Auto-named `Def1`, `Def2`, etc. when name omitted
## Usage Pattern
**IMPORTANT: Check `examples/` for real-world patterns before writing new components.**
```tsx
import { define } from 'forge'
export const Button = define('Button', {
base: 'button', // HTML tag (default: 'div')
padding: 20,
background: 'blue',
states: {
hover: { background: 'darkblue' }
},
variants: {
danger: { background: 'red' }, // boolean
size: { // keyed
small: { padding: 10 },
large: { padding: 30 }
}
}
})
// <Button>Click</Button>
// <Button danger>Danger!</Button>
// <Button size="large">Big</Button>
```
For complex patterns (parts with variants, custom render, states within variants), see:
- `examples/index.tsx` - ExampleCard with parts, custom render, nested variants
- `examples/profile.tsx` - Multi-part component with size/theme variants
- `examples/navigation.tsx` - Multiple component patterns (tabs, pills, breadcrumbs)
## Testing
Tests use Bun's test runner. Helpers in `src/tests/test_helpers.ts` for rendering JSX to HTML strings and parsing CSS.

View File

@ -1,35 +1,4 @@
# Forge # forge
## Why Forge?
CSS is powerful, but hostile.
### Problems with CSS
- Styles are **global and open** — anything can override anything.
- Theres **no link** between a class in markup and its definition.
- Inline styles exist because theres **no structured way to vary styles per instance**.
- Overrides are silent — conflicts happen without feedback.
- Complex components require selector gymnastics and reach-in styling.
### What Forge Does Instead
- Styles are **local to components** and attached by generated handles, not strings.
- **Parts** give components named sub-targets without selectors.
- **Variants** replace inline styles with typed, declarative parameters.
- Style composition is **deterministic** (known merge order, last-wins).
- Overlapping changes are **warned about in dev**, not silently ignored.
### What Forge Is
- A typed, local, variant-driven way to author CSS.
- A system that optimizes for **people typing at a keyboard**, not selectors in a cascade.
### What Forge Is Not
- Not a new component model.
- Not a new language.
- Not a CSS replacement — it compiles _to_ CSS, but removes the chaos.
Example: Example:

View File

@ -1,9 +1,7 @@
import { createScope } from '../src' import { define } from '../src'
import { ExampleSection } from './ssr/helpers' import { Layout, ExampleSection } from './helpers'
const { define } = createScope('Button') const Button = define('Button', {
const Button = define('Root', {
base: 'button', base: 'button',
padding: "12px 24px", padding: "12px 24px",
@ -25,12 +23,12 @@ const Button = define('Root', {
states: { states: {
":not(:disabled):hover": { ":not(:disabled):hover": {
transform: 'translateY(-2px)', transform: 'translateY(-2px) !important',
filter: 'brightness(1.05)' filter: 'brightness(1.05)'
}, },
":not(:disabled):active": { ":not(:disabled):active": {
transform: 'translateY(1px)', transform: 'translateY(1px) !important',
boxShadow: '0 2px 3px rgba(0, 0, 0, 0.2)' boxShadow: '0 2px 3px rgba(0, 0, 0, 0.2) !important'
}, },
}, },
@ -75,15 +73,15 @@ const Button = define('Root', {
}, },
}) })
const ButtonRow = define('Row', { const ButtonRow = define('ButtonRow', {
display: 'flex', display: 'flex',
gap: 16, gap: 16,
flexWrap: 'wrap', flexWrap: 'wrap',
alignItems: 'center', alignItems: 'center',
}) })
export const ButtonExamplesContent = () => ( export const ButtonExamplesPage = () => (
<> <Layout title="Forge Button Component Examples">
<ExampleSection title="Intents"> <ExampleSection title="Intents">
<ButtonRow> <ButtonRow>
<Button intent="primary">Primary</Button> <Button intent="primary">Primary</Button>
@ -119,5 +117,5 @@ export const ButtonExamplesContent = () => (
<Button intent="ghost" disabled>Ghost Disabled</Button> <Button intent="ghost" disabled>Ghost Disabled</Button>
</ButtonRow> </ButtonRow>
</ExampleSection> </ExampleSection>
</> </Layout >
) )

View File

@ -1,252 +0,0 @@
import { define } from '../src'
import { ExampleSection } from './ssr/helpers'
const Input = define('Input', {
base: 'input',
padding: '12px 16px',
fontSize: 16,
border: '2px solid #e5e7eb',
borderRadius: 8,
background: 'white',
color: '#111827',
transition: 'all 0.2s ease',
width: '100%',
boxSizing: 'border-box',
states: {
':focus': {
outline: 'none',
borderColor: '#3b82f6',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)'
},
':disabled': {
background: '#f3f4f6',
color: '#9ca3af',
cursor: 'not-allowed'
}
},
variants: {
status: {
error: {
borderColor: '#ef4444',
states: {
':focus': {
borderColor: '#ef4444',
boxShadow: '0 0 0 3px rgba(239, 68, 68, 0.1)'
}
}
},
success: {
borderColor: '#10b981',
states: {
':focus': {
borderColor: '#10b981',
boxShadow: '0 0 0 3px rgba(16, 185, 129, 0.1)'
}
}
}
}
}
})
const Textarea = define('Textarea', {
base: 'textarea',
padding: '12px 16px',
fontSize: 16,
border: '2px solid #e5e7eb',
borderRadius: 8,
background: 'white',
color: '#111827',
transition: 'all 0.2s ease',
width: '100%',
minHeight: 120,
boxSizing: 'border-box',
fontFamily: 'inherit',
resize: 'vertical',
states: {
':focus': {
outline: 'none',
borderColor: '#3b82f6',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)'
}
}
})
const FormGroup = define('FormGroup', {
marginBottom: 24,
parts: {
Label: {
base: 'label',
display: 'block',
fontSize: 14,
fontWeight: 600,
color: '#374151',
marginBottom: 8
},
Helper: {
fontSize: 13,
color: '#6b7280',
marginTop: 6
},
Error: {
fontSize: 13,
color: '#ef4444',
marginTop: 6
}
},
render({ props, parts: { Root, Label, Helper, Error } }) {
return (
<Root>
{props.label && <Label>{props.label}</Label>}
{props.children}
{props.helper && <Helper>{props.helper}</Helper>}
{props.error && <Error>{props.error}</Error>}
</Root>
)
}
})
const Checkbox = define('Checkbox', {
parts: {
Input: {
base: 'input[type=checkbox]',
width: 20,
height: 20,
cursor: 'pointer'
},
Label: {
base: 'label',
display: 'flex',
alignItems: 'center',
gap: 12,
cursor: 'pointer',
fontSize: 16,
color: '#374151',
states: {
':hover': {
color: '#111827'
}
},
selectors: {
'@Input:disabled + &': {
cursor: 'not-allowed',
color: '#9ca3af'
}
}
}
},
render({ props, parts: { Root, Input, Label } }) {
return (
<Root>
<Label>
<Input id={props.id} checked={props.checked} disabled={props.disabled} />
{props.label}
</Label>
</Root>
)
}
})
const FormExamples = define('FormExamples', {
maxWidth: 600,
margin: '0 auto'
})
const Button = define('FormButton', {
base: 'button',
padding: '12px 24px',
fontSize: 16,
fontWeight: 600,
border: 'none',
borderRadius: 8,
cursor: 'pointer',
transition: 'all 0.2s ease',
background: '#3b82f6',
color: 'white',
states: {
':hover': {
background: '#2563eb'
},
':active': {
transform: 'translateY(1px)'
}
},
variants: {
variant: {
secondary: {
background: '#6b7280',
color: 'white',
states: {
':hover': {
background: '#4b5563'
}
}
}
}
}
})
const ButtonGroup = define('FormButtonGroup', {
display: 'flex',
gap: 12,
marginTop: 24
})
export const FormExamplesContent = () => (
<FormExamples>
<ExampleSection title="Text Inputs">
<FormGroup label="Email" helper="We'll never share your email">
<Input type="email" placeholder="you@example.com" />
</FormGroup>
<FormGroup label="Password">
<Input type="password" placeholder="Enter your password" />
</FormGroup>
<FormGroup label="Disabled Input">
<Input value="This field is disabled" disabled />
</FormGroup>
</ExampleSection>
<ExampleSection title="Validation States">
<FormGroup label="Valid Email" helper="Looks good!">
<Input status="success" type="email" value="user@example.com" />
</FormGroup>
<FormGroup label="Invalid Email" error="Please enter a valid email address">
<Input status="error" type="email" value="not-an-email" />
</FormGroup>
</ExampleSection>
<ExampleSection title="Textarea">
<FormGroup label="Bio" helper="Tell us about yourself">
<Textarea placeholder="Write something interesting..." />
</FormGroup>
</ExampleSection>
<ExampleSection title="Checkboxes">
<FormGroup>
<Checkbox id="cb1" label="I agree to the terms and conditions" checked />
<Checkbox id="cb2" label="Subscribe to newsletter" />
<Checkbox id="cb3" label="This option is disabled" disabled />
</FormGroup>
</ExampleSection>
<ButtonGroup>
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary">Reset</Button>
</ButtonGroup>
</FormExamples>
)

65
examples/helpers.tsx Normal file
View File

@ -0,0 +1,65 @@
import { define, Styles } from '../src'
export const Body = define('Body', {
base: 'body',
margin: 0,
padding: '40px 20px',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
background: '#f3f4f6',
})
const Container = define('Container', {
maxWidth: 1200,
margin: '0 auto'
})
export const Header = define('Header', {
base: 'h1',
marginBottom: 40,
color: '#111827'
})
export const ExampleSection = define('ExampleSection', {
marginBottom: 40,
parts: {
Header: {
base: 'h2',
marginBottom: 16,
color: '#374151',
fontSize: 18
}
},
render({ props, parts: { Root, Header } }) {
return (
<Root>
<Header>{props.title}</Header>
{props.children}
</Root>
)
}
})
export const Layout = define({
render({ props }) {
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{props.title}</title>
<Styles />
</head>
<Body>
<Container>
<Header>{props.title}</Header>
{props.children}
</Container>
</Body>
</html>
)
}
})

82
examples/index.tsx Normal file
View File

@ -0,0 +1,82 @@
export const IndexPage = () => (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forge Examples</title>
<style dangerouslySetInnerHTML={{
__html: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 40px 20px;
background: #f9fafb;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
color: #111827;
margin-bottom: 16px;
}
p {
color: #6b7280;
font-size: 18px;
margin-bottom: 48px;
}
.examples-grid {
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.example-card {
background: white;
padding: 24px;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
text-decoration: none;
transition: all 0.2s ease;
display: block;
}
.example-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.example-card h2 {
color: #111827;
margin: 0 0 8px 0;
font-size: 20px;
}
.example-card p {
color: #6b7280;
margin: 0;
font-size: 14px;
}
`}} />
</head>
<body>
<div class="container">
<h1>Forge Examples</h1>
<p>Explore component examples built with Forge</p>
<div class="examples-grid">
<a href="/profile" class="example-card">
<h2>Profile Card</h2>
<p>User profile component with variants for size, theme, and verified status</p>
</a>
<a href="/buttons" class="example-card">
<h2>Buttons</h2>
<p>Button component with intent, size, and disabled variants</p>
</a>
<a href="/navigation" class="example-card">
<h2>Navigation</h2>
<p>Navigation patterns including tabs, pills, vertical nav, and breadcrumbs</p>
</a>
</div>
</div>
</body>
</html>
)

View File

@ -1,25 +1,20 @@
import { define } from '../src' import { define } from '../src'
import { ExampleSection } from './ssr/helpers' import { Layout, ExampleSection } from './helpers'
const TabSwitcher = define('TabSwitcher', { const Tabs = define('Tabs', {
parts: {
Input: {
base: 'input[type=radio]',
display: 'none',
},
TabBar: {
display: 'flex', display: 'flex',
gap: 0, gap: 0,
borderBottom: '2px solid #e5e7eb', borderBottom: '2px solid #e5e7eb',
marginBottom: 24,
}, parts: {
TabLabel: { Tab: {
base: 'label', base: 'button',
padding: '12px 24px', padding: '12px 24px',
position: 'relative', position: 'relative',
marginBottom: -2, marginBottom: -2,
background: 'transparent', background: 'transparent',
border: 'none',
borderBottom: '2px solid transparent', borderBottom: '2px solid transparent',
color: '#6b7280', color: '#6b7280',
fontSize: 14, fontSize: 14,
@ -31,52 +26,37 @@ const TabSwitcher = define('TabSwitcher', {
':hover': { ':hover': {
color: '#111827', color: '#111827',
} }
}
}
}, },
selectors: { variants: {
'@Input:checked + &': { active: {
parts: {
Tab: {
color: '#3b82f6', color: '#3b82f6',
borderBottom: '2px solid #3b82f6' borderBottom: '2px solid #3b82f6',
}
}
},
Content: {
display: 'none',
padding: 20,
background: '#f9fafb',
borderRadius: 8,
selectors: {
'@Input:checked ~ &': {
display: 'block'
} }
} }
} }
}, },
render({ props, parts: { Root, Input, TabBar, TabLabel, Content } }) { render({ props, parts: { Root, Tab } }) {
return ( return (
<Root> <Root>
<TabBar> <script dangerouslySetInnerHTML={{
{props.tabs?.map((tab: any, index: number) => ( __html: `
<> const ClickTab = (label) => console.log('Tab clicked:', label)
<Input `}} />
key={`input-${tab.id}`}
id={`${props.name}-${tab.id}`}
name={props.name || 'tabs'}
checked={index === 0}
/>
<TabLabel key={`label-${tab.id}`} for={`${props.name}-${tab.id}`}>
{tab.label}
</TabLabel>
</>
))}
</TabBar>
{props.tabs?.map((tab: any) => ( {props.items?.map((item: any) => (
<Content key={`content-${tab.id}`}> <Tab
{tab.content} key={item.id}
</Content> active={item.active}
onClick={`ClickTab("${item.label}")`}
>
{item.label}
</Tab>
))} ))}
</Root> </Root>
) )
@ -84,18 +64,13 @@ const TabSwitcher = define('TabSwitcher', {
}) })
const Pills = define('Pills', { const Pills = define('Pills', {
parts: {
Input: {
base: 'input[type=radio]',
display: 'none',
},
PillBar: {
display: 'flex', display: 'flex',
gap: 8, gap: 8,
flexWrap: 'wrap', flexWrap: 'wrap',
},
PillLabel: { parts: {
base: 'label', Pill: {
base: 'button',
padding: '8px 16px', padding: '8px 16px',
background: '#f3f4f6', background: '#f3f4f6',
@ -112,57 +87,53 @@ const Pills = define('Pills', {
background: '#e5e7eb', background: '#e5e7eb',
color: '#111827', color: '#111827',
} }
}
}
}, },
selectors: { variants: {
'@Input:checked + &': { active: {
parts: {
Pill: {
background: '#3b82f6', background: '#3b82f6',
color: 'white' color: 'white',
}, states: {
'@Input:checked + &:hover': { ':hover': {
background: '#2563eb' background: '#2563eb',
color: 'white',
}
}
} }
} }
} }
}, },
render({ props, parts: { Root, Input, PillBar, PillLabel } }) { render({ props, parts: { Root, Pill } }) {
return ( return (
<Root> <Root>
<PillBar> {props.items?.map((item: any) => (
{props.items?.map((item: any, index: number) => ( <Pill
<> key={item.id}
<Input active={item.active}
key={`input-${item.id}`} onclick={() => console.log('Pill clicked:', item.label)}
id={`${props.name}-${item.id}`} >
name={props.name || 'pills'}
checked={index === 0}
/>
<PillLabel key={`label-${item.id}`} for={`${props.name}-${item.id}`}>
{item.label} {item.label}
</PillLabel> </Pill>
</>
))} ))}
</PillBar>
</Root> </Root>
) )
} }
}) })
const VerticalNav = define('VerticalNav', { const VerticalNav = define('VerticalNav', {
parts: {
Input: {
base: 'input[type=radio]',
display: 'none',
},
NavBar: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, gap: 4,
width: 240, width: 240,
},
NavLabel: { parts: {
base: 'label', NavItem: {
base: 'a',
padding: '12px 16px', padding: '12px 16px',
display: 'flex', display: 'flex',
@ -174,6 +145,7 @@ const VerticalNav = define('VerticalNav', {
color: '#6b7280', color: '#6b7280',
fontSize: 14, fontSize: 14,
fontWeight: 500, fontWeight: 500,
textDecoration: 'none',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
@ -182,17 +154,6 @@ const VerticalNav = define('VerticalNav', {
background: '#f3f4f6', background: '#f3f4f6',
color: '#111827', color: '#111827',
} }
},
selectors: {
'@Input:checked + &': {
background: '#eff6ff',
color: '#3b82f6',
},
'@Input:checked + &:hover': {
background: '#dbeafe',
color: '#2563eb'
}
} }
}, },
Icon: { Icon: {
@ -205,25 +166,36 @@ const VerticalNav = define('VerticalNav', {
} }
}, },
render({ props, parts: { Root, Input, NavBar, NavLabel, Icon } }) { variants: {
active: {
parts: {
NavItem: {
background: '#eff6ff',
color: '#3b82f6',
states: {
':hover': {
background: '#dbeafe',
color: '#2563eb',
}
}
}
}
}
},
render({ props, parts: { Root, NavItem, Icon } }) {
return ( return (
<Root> <Root>
<NavBar> {props.items?.map((item: any) => (
{props.items?.map((item: any, index: number) => ( <NavItem
<> key={item.id}
<Input active={item.active}
key={`input-${item.id}`} href={item.href || '#'}
id={`${props.name}-${item.id}`} >
name={props.name || 'nav'}
checked={index === 0}
/>
<NavLabel key={`label-${item.id}`} for={`${props.name}-${item.id}`}>
{item.icon && <Icon>{item.icon}</Icon>} {item.icon && <Icon>{item.icon}</Icon>}
{item.label} {item.label}
</NavLabel> </NavItem>
</>
))} ))}
</NavBar>
</Root> </Root>
) )
} }
@ -284,224 +256,35 @@ const Breadcrumbs = define('Breadcrumbs', {
} }
}) })
const Tabs = define('Tabs', { export const NavigationExamplesPage = () => (
display: 'flex', <Layout title="Forge Navigation Examples">
gap: 0,
borderBottom: '2px solid #e5e7eb',
parts: {
Tab: {
base: 'button',
padding: '12px 24px',
position: 'relative',
marginBottom: -2,
background: 'transparent',
border: 'none',
borderBottom: '2px solid transparent',
color: '#6b7280',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
color: '#111827',
}
}
}
},
variants: {
active: {
parts: {
Tab: {
color: '#3b82f6',
borderBottom: '2px solid #3b82f6',
}
}
}
},
render({ props, parts: { Root, Tab } }) {
return (
<Root>
{props.items?.map((item: any) => (
<Tab key={item.id} active={item.active}>
{item.label}
</Tab>
))}
</Root>
)
}
})
const SimplePills = define('SimplePills', {
display: 'flex',
gap: 8,
flexWrap: 'wrap',
parts: {
Pill: {
base: 'button',
padding: '8px 16px',
background: '#f3f4f6',
border: 'none',
borderRadius: 20,
color: '#6b7280',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
background: '#e5e7eb',
color: '#111827',
}
}
}
},
variants: {
active: {
parts: {
Pill: {
background: '#3b82f6',
color: 'white',
states: {
':hover': {
background: '#2563eb',
color: 'white',
}
}
}
}
}
},
render({ props, parts: { Root, Pill } }) {
return (
<Root>
{props.items?.map((item: any) => (
<Pill key={item.id} active={item.active}>
{item.label}
</Pill>
))}
</Root>
)
}
})
const SimpleVerticalNav = define('SimpleVerticalNav', {
display: 'flex',
flexDirection: 'column',
gap: 4,
width: 240,
parts: {
NavItem: {
base: 'button',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
gap: 12,
background: 'transparent',
border: 'none',
borderRadius: 8,
color: '#6b7280',
fontSize: 14,
fontWeight: 500,
textAlign: 'left',
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
background: '#f3f4f6',
color: '#111827',
}
}
},
Icon: {
width: 20,
height: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 18,
}
},
variants: {
active: {
parts: {
NavItem: {
background: '#eff6ff',
color: '#3b82f6',
states: {
':hover': {
background: '#dbeafe',
color: '#2563eb',
}
}
}
}
}
},
render({ props, parts: { Root, NavItem, Icon } }) {
return (
<Root>
{props.items?.map((item: any) => (
<NavItem key={item.id} active={item.active}>
{item.icon && <Icon>{item.icon}</Icon>}
{item.label}
</NavItem>
))}
</Root>
)
}
})
export const NavigationExamplesContent = () => (
<>
<ExampleSection title="Tabs"> <ExampleSection title="Tabs">
<TabSwitcher <Tabs items={[
name="demo-tabs" { id: 1, label: 'Overview', active: true },
tabs={[ { id: 2, label: 'Analytics', active: false },
{ id: 'overview', label: 'Overview', content: <p>Overview content</p> }, { id: 3, label: 'Reports', active: false },
{ id: 'analytics', label: 'Analytics', content: <p>Analytics content</p> }, { id: 4, label: 'Settings', active: false },
{ id: 'reports', label: 'Reports', content: <p>Reports content</p> }, ]} />
{ id: 'settings', label: 'Settings', content: <p>Settings content</p> },
]}
/>
</ExampleSection> </ExampleSection>
<ExampleSection title="Pills"> <ExampleSection title="Pills">
<Pills <Pills items={[
name="demo-pills" { id: 1, label: 'All', active: true },
items={[ { id: 2, label: 'Active', active: false },
{ id: 'all', label: 'All' }, { id: 3, label: 'Pending', active: false },
{ id: 'active', label: 'Active' }, { id: 4, label: 'Archived', active: false },
{ id: 'pending', label: 'Pending' }, ]} />
{ id: 'archived', label: 'Archived' },
]}
/>
</ExampleSection> </ExampleSection>
<ExampleSection title="Vertical Navigation"> <ExampleSection title="Vertical Navigation">
<VerticalNav <VerticalNav items={[
name="demo-nav" { id: 1, label: 'Dashboard', icon: '📊', active: true, href: '#' },
items={[ { id: 2, label: 'Projects', icon: '📁', active: false, href: '#' },
{ id: 'dashboard', label: 'Dashboard', icon: '📊' }, { id: 3, label: 'Team', icon: '👥', active: false, href: '#' },
{ id: 'projects', label: 'Projects', icon: '📁' }, { id: 4, label: 'Calendar', icon: '📅', active: false, href: '#' },
{ id: 'team', label: 'Team', icon: '👥' }, { id: 5, label: 'Documents', icon: '📄', active: false, href: '#' },
{ id: 'calendar', label: 'Calendar', icon: '📅' }, { id: 6, label: 'Settings', icon: '⚙️', active: false, href: '#' },
{ id: 'documents', label: 'Documents', icon: '📄' }, ]} />
{ id: 'settings', label: 'Settings', icon: '⚙️' },
]}
/>
</ExampleSection> </ExampleSection>
<ExampleSection title="Breadcrumbs"> <ExampleSection title="Breadcrumbs">
@ -512,5 +295,5 @@ export const NavigationExamplesContent = () => (
{ id: 4, label: 'Design Assets' }, { id: 4, label: 'Design Assets' },
]} /> ]} />
</ExampleSection> </ExampleSection>
</> </Layout>
) )

View File

@ -1,5 +1,5 @@
import { define } from '../src' import { define } from '../src'
import { ExampleSection } from './ssr/helpers' import { Layout, ExampleSection } from './helpers'
const UserProfile = define('UserProfile', { const UserProfile = define('UserProfile', {
base: 'div', base: 'div',
@ -167,8 +167,9 @@ const UserProfile = define('UserProfile', {
}, },
}) })
export const ProfileExamplesContent = () => ( // Export the full example page
<> export const ProfileExamplesPage = () => (
<Layout title="Forge Profile Examples">
<ExampleSection title="Default Profile"> <ExampleSection title="Default Profile">
<UserProfile <UserProfile
name="Sarah Chen" name="Sarah Chen"
@ -234,5 +235,5 @@ export const ProfileExamplesContent = () => (
posts={567} posts={567}
/> />
</ExampleSection> </ExampleSection>
</> </Layout>
) )

View File

@ -1,202 +0,0 @@
import { define } from '../../src'
import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation'
import { FormExamplesContent } from '../form'
export const Main = define('SpaMain', {
base: 'div',
minHeight: '100%',
padding: '40px 20px',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
background: '#f3f4f6',
})
export const Container = define('SpaContainer', {
base: 'div',
maxWidth: 1200,
margin: '0 auto'
})
// Simple client-side router
const Link = define('Link', {
base: 'a',
color: '#3b82f6',
textDecoration: 'none',
fontWeight: 500,
states: {
hover: {
textDecoration: 'underline'
}
},
selectors: {
'&[aria-current]': {
color: '#1e40af',
fontWeight: 600,
textDecoration: 'underline'
}
},
render({ props, parts: { Root } }) {
const handleClick = (e: Event) => {
e.preventDefault()
window.history.pushState({}, '', props.href)
window.dispatchEvent(new Event('routechange'))
}
return (
<Root {...props} onclick={handleClick}>
{props.children}
</Root>
)
}
})
const Nav = define('Nav', {
base: 'nav',
display: 'flex',
gap: 20,
marginBottom: 40,
padding: 20,
background: 'white',
borderRadius: 8,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
})
const P = define('P', {
color: '#6b7280',
fontSize: 18,
marginBottom: 48,
})
const ExamplesGrid = define('ExamplesGrid', {
display: 'grid',
gap: 20,
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
})
const ExampleCard = define('ExampleCard', {
base: 'a',
background: 'white',
padding: 24,
borderRadius: 12,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
textDecoration: 'none',
transition: 'all 0.2s ease',
display: 'block',
states: {
hover: {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
}
},
parts: {
H2: {
color: '#111827',
margin: '0 0 8px 0',
fontSize: 20,
},
P: {
color: '#6b7280',
margin: 0,
fontSize: 14,
}
},
render({ props: { title, desc, ...props }, parts: { Root, H2, P } }) {
const handleClick = (e: Event) => {
e.preventDefault()
window.history.pushState({}, '', props.href)
window.dispatchEvent(new Event('routechange'))
}
return (
<Root {...props} onclick={handleClick}>
<H2>{title}</H2>
<P>{desc}</P>
</Root>
)
}
})
const HomePage = () => (
<>
<P>Explore component examples built with Forge - Client-side SPA version</P>
<ExamplesGrid>
<ExampleCard href="/spa/profile"
title="Profile Card"
desc="User profile component with variants for size, theme, and verified status"
/>
<ExampleCard href="/spa/buttons"
title="Buttons"
desc="Button component with intent, size, and disabled variants"
/>
<ExampleCard href="/spa/navigation"
title="Navigation"
desc="Navigation patterns including tabs, pills, vertical nav, and breadcrumbs"
/>
<ExampleCard href="/spa/form"
title="Forms"
desc="Form inputs with validation states, checkboxes, textareas, and buttons"
/>
</ExamplesGrid>
</>
)
const ProfilePage = () => <ProfileExamplesContent />
const ButtonsPage = () => <ButtonExamplesContent />
const NavigationPage = () => <NavigationExamplesContent />
const FormPage = () => <FormExamplesContent />
export function route(path: string) {
switch (path) {
case '/spa':
case '/spa/':
return <HomePage />
case '/spa/profile':
return <ProfilePage />
case '/spa/buttons':
return <ButtonsPage />
case '/spa/navigation':
return <NavigationPage />
case '/spa/form':
return <FormPage />
default:
return <P>404 Not Found</P>
}
}
export function App() {
const path = window.location.pathname
return (
<Main>
<Container>
<Nav>
<a href="/" style="color: #3b82f6; text-decoration: none; font-weight: 500;">Home</a>
<Link href="/spa" aria-current={path === '/spa' || path === '/spa/' ? 'page' : undefined}>SPA Examples</Link>
<Link href="/spa/profile" aria-current={path === '/spa/profile' ? 'page' : undefined}>Profile</Link>
<Link href="/spa/buttons" aria-current={path === '/spa/buttons' ? 'page' : undefined}>Buttons</Link>
<Link href="/spa/navigation" aria-current={path === '/spa/navigation' ? 'page' : undefined}>Navigation</Link>
<Link href="/spa/form" aria-current={path === '/spa/form' ? 'page' : undefined}>Forms</Link>
</Nav>
<div id="content">
{route(path)}
</div>
</Container>
</Main>
)
}

View File

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forge SPA Examples</title>
<style>
html, body {
height: 100%;
margin: 0;
}
#root {
min-height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/spa.js"></script>
</body>
</html>

View File

@ -1,19 +0,0 @@
import { render } from 'hono/jsx/dom'
import { App, route } from './app'
const root = document.getElementById('root')
// Initial render
if (root) {
render(<App />, root)
}
// On route change, re-render the whole app to update nav state
function updateApp() {
if (root) {
render(<App />, root)
}
}
window.addEventListener('routechange', updateApp)
window.addEventListener('popstate', updateApp)

View File

@ -1,109 +0,0 @@
import { define, Styles } from '../../src'
export const Body = define('Body', {
base: 'body',
margin: 0,
padding: '40px 20px',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
background: '#f3f4f6',
})
const Container = define('Container', {
maxWidth: 1200,
margin: '0 auto'
})
export const Header = define('Header', {
base: 'h1',
marginBottom: 40,
color: '#111827'
})
export const ExampleSection = define('ExampleSection', {
marginBottom: 40,
parts: {
Header: {
base: 'h2',
marginBottom: 16,
color: '#374151',
fontSize: 18
}
},
render({ props, parts: { Root, Header } }) {
return (
<Root>
<Header>{props.title}</Header>
{props.children}
</Root>
)
}
})
const Nav = define('SSR_Nav', {
base: 'nav',
display: 'flex',
gap: 20,
marginBottom: 40,
padding: 20,
background: 'white',
borderRadius: 8,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
})
const NavLink = define('SSR_NavLink', {
base: 'a',
color: '#3b82f6',
textDecoration: 'none',
fontWeight: 500,
states: {
hover: {
textDecoration: 'underline'
}
},
selectors: {
'&[aria-current]': {
color: '#1e40af',
fontWeight: 600,
textDecoration: 'underline'
}
}
})
export const Layout = define({
render({ props }) {
const path = props.path || ''
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{props.title}</title>
<Styles />
</head>
<Body>
<Container>
<Nav>
<NavLink href="/" aria-current={path === '/' ? 'page' : undefined}>Home</NavLink>
<NavLink href="/ssr" aria-current={path.startsWith('/ssr') && path !== '/ssr/profile' && path !== '/ssr/buttons' && path !== '/ssr/navigation' ? 'page' : undefined}>SSR Examples</NavLink>
<NavLink href="/ssr/profile" aria-current={path === '/ssr/profile' ? 'page' : undefined}>Profile</NavLink>
<NavLink href="/ssr/buttons" aria-current={path === '/ssr/buttons' ? 'page' : undefined}>Buttons</NavLink>
<NavLink href="/ssr/navigation" aria-current={path === '/ssr/navigation' ? 'page' : undefined}>Navigation</NavLink>
<NavLink href="/ssr/form" aria-current={path === '/ssr/form' ? 'page' : undefined}>Forms</NavLink>
</Nav>
<Header>{props.title}</Header>
{props.children}
</Container>
</Body>
</html>
)
}
})

View File

@ -1,119 +0,0 @@
import { createScope, Styles } from '../../src'
const { define } = createScope('Landing')
const Page = define('Page', {
base: 'body',
margin: 0,
padding: 0,
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
})
const Container = define('Container', {
textAlign: 'center',
color: 'white',
})
const Title = define('Title', {
base: 'h1',
fontSize: 48,
fontWeight: 700,
marginBottom: 50,
color: 'white',
})
const Subtitle = define('Subtitle', {
base: 'p',
fontSize: 20,
marginBottom: 48,
color: 'rgba(255, 255, 255, 0.9)',
})
const ButtonGroup = define('ButtonGroup', {
display: 'flex',
gap: 50,
justifyContent: 'center',
flexWrap: 'wrap',
})
const ChoiceCard = define('ChoiceCard', {
base: 'a',
display: 'block',
padding: 40,
background: 'white',
borderRadius: 16,
textDecoration: 'none',
color: '#111827',
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
transition: 'all 0.3s ease',
minWidth: 250,
states: {
':hover': {
transform: 'translateY(-8px)',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
}
},
parts: {
Icon: {
fontSize: 48,
marginBottom: 16,
},
Title: {
base: 'h2',
fontSize: 24,
fontWeight: 600,
marginBottom: 8,
color: '#111827',
}
},
render({ props, parts: { Root, Icon, Title, Description } }) {
return (
<Root href={props.href}>
<Icon>{props.icon}</Icon>
<Title>{props.title}</Title>
</Root>
)
}
})
export const LandingPage = () => (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forge - Choose Your Rendering Mode</title>
<Styles />
</head>
<Page>
<Container>
<Title>Welcome to Forge</Title>
<ButtonGroup>
<ChoiceCard
href="/ssr"
icon="🖥️"
title="SSR Examples"
/>
<ChoiceCard
href="/spa"
icon="⚡"
title="SPA Examples"
/>
</ButtonGroup>
</Container>
</Page>
</html>
)

View File

@ -1,111 +0,0 @@
import { define } from '../../src'
import { Layout } from './helpers'
import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation'
import { FormExamplesContent } from '../form'
const P = define('SSR_P', {
color: '#6b7280',
fontSize: 18,
marginBottom: 48,
})
const ExamplesGrid = define('SSR_ExamplesGrid', {
display: 'grid',
gap: 20,
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
})
const ExampleCard = define('SSR_ExampleCard', {
base: 'a',
background: 'white',
padding: 24,
borderRadius: 12,
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
textDecoration: 'none',
transition: 'all 0.2s ease',
display: 'block',
states: {
hover: {
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
}
},
parts: {
H2: {
color: '#111827',
margin: '0 0 8px 0',
fontSize: 20,
},
P: {
color: '#6b7280',
margin: 0,
fontSize: 14,
}
},
render({ props: { title, desc, ...rest }, parts: { Root, H2, P } }) {
return (
<Root {...rest}>
<H2>{title}</H2>
<P>{desc}</P>
</Root>
)
}
})
export const IndexPage = ({ path }: any) => (
<Layout title="Forge Examples" path={path}>
<P>Explore component examples built with Forge</P>
<ExamplesGrid>
<ExampleCard href="/ssr/profile"
title="Profile Card"
desc="User profile component with variants for size, theme, and verified status"
/>
<ExampleCard href="/ssr/buttons"
title="Buttons"
desc="Button component with intent, size, and disabled variants"
/>
<ExampleCard href="/ssr/navigation"
title="Navigation"
desc="Navigation patterns including tabs, pills, vertical nav, and breadcrumbs"
/>
<ExampleCard href="/ssr/form"
title="Forms"
desc="Form inputs with validation states, checkboxes, textareas, and buttons"
/>
</ExamplesGrid>
</Layout>
)
export const ButtonExamplesPage = ({ path }: any) => (
<Layout title="Forge Button Component Examples" path={path}>
<ButtonExamplesContent />
</Layout>
)
export const ProfileExamplesPage = ({ path }: any) => (
<Layout title="Forge Profile Examples" path={path}>
<ProfileExamplesContent />
</Layout>
)
export const NavigationExamplesPage = ({ path }: any) => (
<Layout title="Forge Navigation Examples" path={path}>
<NavigationExamplesContent />
</Layout>
)
export const FormExamplesPage = ({ path }: any) => (
<Layout title="Forge Form Examples" path={path}>
<FormExamplesContent />
</Layout>
)

View File

@ -3,9 +3,8 @@
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun build:spa && bun run --hot server.tsx", "dev": "bun run --hot server.tsx",
"test": "bun test", "test": "bun test"
"build:spa": "bun build examples/spa/index.tsx --outfile dist/spa.js --target browser"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"

View File

@ -1,33 +1,30 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { IndexPage, ProfileExamplesPage, ButtonExamplesPage, NavigationExamplesPage, FormExamplesPage } from './examples/ssr/pages' import { IndexPage } from './examples/index'
import { LandingPage } from './examples/ssr/landing' import { ProfileExamplesPage } from './examples/profile'
import { ButtonExamplesPage } from './examples/button'
import { NavigationExamplesPage } from './examples/navigation'
import { styles, stylesToCSS } from './src' import { styles, stylesToCSS } from './src'
const app = new Hono() const app = new Hono()
app.get('/', c => c.html(<LandingPage />)) app.get('/', c => {
return c.html(<IndexPage />)
})
app.get('/ssr', c => c.html(<IndexPage path="/ssr" />)) app.get('/profile', c => {
return c.html(<ProfileExamplesPage />)
})
app.get('/ssr/profile', c => c.html(<ProfileExamplesPage path="/ssr/profile" />)) app.get('/buttons', c => {
return c.html(<ButtonExamplesPage />)
})
app.get('/ssr/buttons', c => c.html(<ButtonExamplesPage path="/ssr/buttons" />)) app.get('/navigation', c => {
return c.html(<NavigationExamplesPage />)
})
app.get('/ssr/navigation', c => c.html(<NavigationExamplesPage path="/ssr/navigation" />)) app.get('/styles', c => {
return c.text(stylesToCSS(styles))
app.get('/ssr/form', c => c.html(<FormExamplesPage path="/ssr/form" />))
app.get('/styles', c => c.text(stylesToCSS(styles)))
app.get('/spa/*', async c => c.html(await Bun.file('./examples/spa/index.html').text()))
app.get('/spa.js', async c => {
const file = Bun.file('./dist/spa.js')
return new Response(file, {
headers: {
'Content-Type': 'application/javascript',
},
})
}) })
export default { export default {

View File

@ -2,27 +2,8 @@ import type { JSX } from 'hono/jsx'
import { type TagDef, UnitlessProps, NonStyleKeys } from './types' import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
export const styles: Record<string, Record<string, string>> = {} export const styles: Record<string, Record<string, string>> = {}
// Use w/ SSR: <Styles/>
export const Styles = () => <style dangerouslySetInnerHTML={{ __html: stylesToCSS(styles) }} /> export const Styles = () => <style dangerouslySetInnerHTML={{ __html: stylesToCSS(styles) }} />
const isBrowser = typeof document !== 'undefined'
let styleElement: HTMLStyleElement | null = null
// automatically inject <style> tag into browser for SPA
function injectStylesInBrowser() {
if (!isBrowser) return
styleElement ??= document.getElementById('forge-styles') as HTMLStyleElement
if (!styleElement) {
styleElement = document.createElement('style')
styleElement.id = 'forge-styles'
document.head.appendChild(styleElement)
}
styleElement.textContent = stylesToCSS(styles)
}
// turns style object into string CSS definition // turns style object into string CSS definition
export function stylesToCSS(styles: Record<string, Record<string, string>>): string { export function stylesToCSS(styles: Record<string, Record<string, string>>): string {
let out: string[] = [] let out: string[] = []
@ -30,7 +11,7 @@ export function stylesToCSS(styles: Record<string, Record<string, string>>): str
for (const [selector, style] of Object.entries(styles)) { for (const [selector, style] of Object.entries(styles)) {
if (Object.keys(style).length === 0) continue if (Object.keys(style).length === 0) continue
out.push(`${expandSelector(selector)} { `) out.push(`.${selector} {`)
for (const [name, value] of Object.entries(style).sort(([a], [b]) => a.localeCompare(b))) for (const [name, value] of Object.entries(style).sort(([a], [b]) => a.localeCompare(b)))
out.push(` ${name}: ${value};`) out.push(` ${name}: ${value};`)
out.push(`}\n`) out.push(`}\n`)
@ -39,10 +20,6 @@ export function stylesToCSS(styles: Record<string, Record<string, string>>): str
return out.join('\n') return out.join('\n')
} }
function expandSelector(selector: string): string {
return selector.startsWith('.') ? selector : `.${selector}`
}
// creates a CSS class name // creates a CSS class name
function makeClassName(baseName: string, partName?: string, variantName?: string, variantKey?: string): string { function makeClassName(baseName: string, partName?: string, variantName?: string, variantKey?: string): string {
const cls = partName ? `${baseName}_${partName}` : baseName const cls = partName ? `${baseName}_${partName}` : baseName
@ -80,21 +57,7 @@ function makeStyle(def: TagDef) {
function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<string, any>, partName?: string) { function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<string, any>, partName?: string) {
const def = partName ? rootDef.parts?.[partName]! : rootDef const def = partName ? rootDef.parts?.[partName]! : rootDef
const base = def.base ?? 'div' const base = def.base ?? 'div'
const Tag = (base) as keyof JSX.IntrinsicElements
// Extract element name from base (e.g., 'input[type=radio]' -> 'input')
const tagName = base.split('[')[0]
const Tag = (tagName) as keyof JSX.IntrinsicElements
// Extract attributes from base (e.g., 'input[type=radio]' -> { type: 'radio' })
const baseAttrs: Record<string, string> = {}
const attrMatch = base.match(/\[([^\]]+)\]/)
if (attrMatch && attrMatch[1]) {
const attrStr = attrMatch[1]
const [attrName, attrValue] = attrStr.split('=')
if (attrName && attrValue) {
baseAttrs[attrName] = attrValue
}
}
return ({ children, ...props }: { children: any, [key: string]: any }) => { return ({ children, ...props }: { children: any, [key: string]: any }) => {
const classNames = [makeClassName(baseName, partName)] const classNames = [makeClassName(baseName, partName)]
@ -125,7 +88,7 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
classNames.push(variantKey === true ? variantName : `${variantName}-${variantKey}`) classNames.push(variantKey === true ? variantName : `${variantName}-${variantKey}`)
} }
return <Tag class={classNames.join(' ')} {...baseAttrs} {...props}>{children}</Tag> return <Tag class={classNames.join(' ')} {...props}>{children}</Tag>
} }
} }
@ -134,29 +97,20 @@ function stateName(state: string): string {
return state.startsWith(':') ? state : `:${state}` return state.startsWith(':') ? state : `:${state}`
} }
// Register base styles, selectors, and states for a class
function registerClassStyles(name: string, className: string, def: TagDef) {
styles[className] ??= makeStyle(def)
for (let [selector, selectorDef] of Object.entries(def.selectors ?? {})) {
selector = selector.replace(/@(\w+)/g, (_, partName) => `.${makeClassName(name, partName)}`)
selector = selector.replace('&', `.${className}`)
if (styles[selector]) throw `${selector} already defined!`
styles[selector] = makeStyle(selectorDef)
}
for (const [state, style] of Object.entries(def.states ?? {}))
styles[`${className}${stateName(state)}`] = makeStyle(style)
}
// adds CSS styles for tag definition // adds CSS styles for tag definition
function registerStyles(name: string, def: TagDef) { function registerStyles(name: string, def: TagDef) {
const rootClassName = makeClassName(name) const rootClassName = makeClassName(name)
registerClassStyles(name, rootClassName, def) styles[rootClassName] ??= makeStyle(def)
for (const [state, style] of Object.entries(def.states ?? {}))
styles[`${rootClassName}${stateName(state)}`] = makeStyle(style)
for (const [partName, partDef] of Object.entries(def.parts ?? {})) { for (const [partName, partDef] of Object.entries(def.parts ?? {})) {
const partClassName = makeClassName(name, partName) const partClassName = makeClassName(name, partName)
registerClassStyles(name, partClassName, partDef) styles[partClassName] ??= makeStyle(partDef)
for (const [state, style] of Object.entries(partDef.states ?? {}))
styles[`${partClassName}${stateName(state)}`] = makeStyle(style)
} }
for (const [variantName, variantConfig] of Object.entries(def.variants ?? {})) { for (const [variantName, variantConfig] of Object.entries(def.variants ?? {})) {
@ -170,52 +124,43 @@ function registerStyles(name: string, def: TagDef) {
const variantDef = variantConfig as TagDef const variantDef = variantConfig as TagDef
const baseClassName = makeClassName(name) const baseClassName = makeClassName(name)
const className = `${baseClassName}.${variantName}` const className = `${baseClassName}.${variantName}`
registerClassStyles(name, className, variantDef) styles[className] ??= makeStyle(variantDef)
for (const [state, style] of Object.entries(variantDef.states ?? {}))
styles[`${className}${stateName(state)}`] = makeStyle(style)
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) { for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
const basePartClassName = makeClassName(name, partName) const basePartClassName = makeClassName(name, partName)
const partClassName = `${basePartClassName}.${variantName}` const partClassName = `${basePartClassName}.${variantName}`
registerClassStyles(name, partClassName, partDef) styles[partClassName] ??= makeStyle(partDef)
for (const [state, style] of Object.entries(partDef.states ?? {}))
styles[`${partClassName}${stateName(state)}`] = makeStyle(style)
} }
} else { } else {
// Keyed variant - iterate over the keys // Keyed variant - iterate over the keys
for (const [variantKey, variantDef] of Object.entries(variantConfig as Record<string, TagDef>)) { for (const [variantKey, variantDef] of Object.entries(variantConfig as Record<string, TagDef>)) {
const className = makeClassName(name, undefined, variantName, variantKey) const className = makeClassName(name, undefined, variantName, variantKey)
registerClassStyles(name, className, variantDef) styles[className] ??= makeStyle(variantDef)
for (const [state, style] of Object.entries(variantDef.states ?? {}))
styles[`${className}${stateName(state)}`] = makeStyle(style)
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) { for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
const partClassName = makeClassName(name, partName, variantName, variantKey) const partClassName = makeClassName(name, partName, variantName, variantKey)
registerClassStyles(name, partClassName, partDef) styles[partClassName] ??= makeStyle(partDef)
for (const [state, style] of Object.entries(partDef.states ?? {}))
styles[`${partClassName}${stateName(state)}`] = makeStyle(style)
} }
} }
} }
} }
// In browser, inject styles into DOM immediately
injectStylesInBrowser()
} }
// automatic names
let anonComponents = 1 let anonComponents = 1
// module-level scoping
export function createScope(scope: string) {
return {
define: (nameOrDef: string | TagDef, defIfNamed?: TagDef) => {
if (typeof nameOrDef === 'string')
return define(`${scope}${nameOrDef === 'Root' ? '' : nameOrDef}`, defIfNamed)
else
return define(`${scope}Def${anonComponents++}`, nameOrDef as TagDef)
}
}
}
// the main event // the main event
export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) { export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
const name = defIfNamed ? (nameOrDef as string) : `Def${anonComponents++}` const name = defIfNamed ? (nameOrDef as string) : `Def${anonComponents++}`
const def = defIfNamed ?? nameOrDef as TagDef const def = defIfNamed ?? nameOrDef as TagDef
if (styles[name]) throw `${name} is already defined! Must use unique names.`
registerStyles(name, def) registerStyles(name, def)
return (props: Record<string, any>) => { return (props: Record<string, any>) => {
@ -229,5 +174,4 @@ export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
} }
} }
// shortcut so you only have to import one thing, if you want
define.Styles = Styles define.Styles = Styles

View File

@ -744,147 +744,6 @@ describe('variants on parts via props', () => {
}) })
}) })
describe('base with attributes', () => {
test('extracts element name from base with attributes', () => {
const Component = define('InputRadio', {
base: 'input[type=radio]',
display: 'block'
})
const html = renderToString(Component({}))
expect(html).toContain('<input')
expect(html).toContain('type="radio"')
expect(html).toContain('class="InputRadio"')
})
test('extracts multiple attribute formats', () => {
const Component = define('InputCheckbox', {
base: 'input[type=checkbox]'
})
const html = renderToString(Component({}))
expect(html).toContain('type="checkbox"')
})
test('works without attributes', () => {
const Component = define('PlainInput', {
base: 'input',
padding: 10
})
const html = renderToString(Component({}))
expect(html).toContain('<input')
expect(html).not.toContain('type=')
})
test('base attributes can be overridden by props', () => {
const Component = define('OverridableInput', {
base: 'input[type=text]'
})
const html = renderToString(Component({ type: 'email' }))
expect(html).toContain('type="email"')
})
})
describe('selectors with @ and &', () => {
test('generates CSS for selectors with @PartName', () => {
define('SelectorTest', {
parts: {
Input: { base: 'input[type=checkbox]', display: 'none' },
Label: {
base: 'label',
color: '#666',
selectors: {
'@Input:checked + &': {
color: 'blue'
}
}
}
}
})
const css = getStylesCSS()
expect(css).toContain('.SelectorTest_Input:checked + .SelectorTest_Label')
expect(css).toContain('color: blue')
})
test('selectors support general sibling combinator ~', () => {
define('SiblingTest', {
parts: {
Trigger: { base: 'input[type=checkbox]' },
Content: {
display: 'none',
selectors: {
'@Trigger:checked ~ &': {
display: 'block'
}
}
}
}
})
const css = getStylesCSS()
expect(css).toContain('.SiblingTest_Trigger:checked ~ .SiblingTest_Content')
expect(css).toContain('display: block')
})
test('selectors can include pseudo-classes on &', () => {
define('PseudoTest', {
parts: {
Radio: { base: 'input[type=radio]', display: 'none' },
Label: {
base: 'label',
selectors: {
'@Radio:checked + &:hover': {
background: 'lightblue'
}
}
}
}
})
const css = getStylesCSS()
expect(css).toContain('.PseudoTest_Radio:checked + .PseudoTest_Label:hover')
expect(css).toContain('background: lightblue')
})
test('selectors work on root level', () => {
define('RootSelectorTest', {
color: 'black',
selectors: {
'&:hover': {
color: 'blue'
}
}
})
const css = getStylesCSS()
expect(css).toContain('.RootSelectorTest:hover')
expect(css).toContain('color: blue')
})
test('supports multiple selectors', () => {
define('MultiSelectorTest', {
parts: {
Input: { base: 'input[type=checkbox]' },
Label: {
selectors: {
'@Input:checked + &': { color: 'green' },
'@Input:disabled + &': { opacity: 0.5 }
}
}
}
})
const css = getStylesCSS()
expect(css).toContain('.MultiSelectorTest_Input:checked + .MultiSelectorTest_Label')
expect(css).toContain('color: green')
expect(css).toContain('.MultiSelectorTest_Input:disabled + .MultiSelectorTest_Label')
expect(css).toContain('opacity: 0.5')
})
})
describe('edge cases', () => { describe('edge cases', () => {
test('handles empty definition', () => { test('handles empty definition', () => {
const Component = define({}) const Component = define({})
@ -918,12 +777,13 @@ describe('edge cases', () => {
expect(html).toContain('size-small') expect(html).toContain('size-small')
}) })
test('throws error when defining duplicate component names', () => { test('does not duplicate styles when registered multiple times', () => {
define('NoDuplicateTest', { width: 100 }) define('NoDuplicate', { width: 100 })
define('NoDuplicate', { width: 200 })
expect(() => { const styles = parseCSS(getStylesCSS())
define('NoDuplicateTest', { width: 200 }) // Should keep first value (??= operator)
}).toThrow('NoDuplicateTest is already defined! Must use unique names.') expect(styles['NoDuplicate']?.['width']).toBe('100px')
}) })
test('handles complex nested structures', () => { test('handles complex nested structures', () => {

View File

@ -2,140 +2,72 @@ export type TagDef = {
className?: string className?: string
base?: string base?: string
states?: Record<string, TagDef>, states?: Record<string, TagDef>,
selectors?: Record<string, TagDef>,
parts?: Record<string, TagDef> parts?: Record<string, TagDef>
variants?: Record<string, TagDef | Record<string, TagDef>> variants?: Record<string, TagDef | Record<string, TagDef>>
render?: (obj: any) => any render?: (obj: any) => any
// layout-related
alignContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'stretch' | 'start' | 'end' | 'baseline' alignContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'stretch' | 'start' | 'end' | 'baseline'
alignItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' alignItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end'
alignSelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' alignSelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end'
aspectRatio?: number | string aspectRatio?: number | string
bottom?: number | string bottom?: number | string
left?: number | string
right?: number | string
top?: number | string
inset?: number | string
// logical positioning / sizing
insetBlock?: number | string
insetInline?: number | string
insetBlockStart?: number | string
insetBlockEnd?: number | string
insetInlineStart?: number | string
insetInlineEnd?: number | string
boxSizing?: 'content-box' | 'border-box' boxSizing?: 'content-box' | 'border-box'
columnGap?: number | string columnGap?: number | string
rowGap?: number | string
gap?: number | string
contain?: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint' contain?: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint'
display?: 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'inline-grid' | 'flow-root' | 'none' | 'contents' | 'table' | 'table-row' | 'table-cell' display?: 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'inline-grid' | 'flow-root' | 'none' | 'contents' | 'table' | 'table-row' | 'table-cell'
// float layout
float?: 'left' | 'right' | 'inline-start' | 'inline-end' | 'none'
clear?: 'left' | 'right' | 'both' | 'inline-start' | 'inline-end' | 'none'
flex?: number | string flex?: number | string
flexBasis?: number | string flexBasis?: number | string
flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse' flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse'
flexGrow?: number flexGrow?: number
flexShrink?: number flexShrink?: number
flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse' flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'
flexFlow?: string gap?: number | string
gridAutoFlow?: 'row' | 'column' | 'dense' | 'row dense' | 'column dense' gridAutoFlow?: 'row' | 'column' | 'dense' | 'row dense' | 'column dense'
gridAutoColumns?: string
gridAutoRows?: string
gridColumn?: string gridColumn?: string
gridColumnStart?: string | number
gridColumnEnd?: string | number
gridRow?: string
gridRowStart?: string | number
gridRowEnd?: string | number
gridArea?: string
gridGap?: number | string gridGap?: number | string
gridRow?: string
gridTemplateColumns?: string gridTemplateColumns?: string
gridTemplateRows?: string gridTemplateRows?: string
gridTemplateAreas?: string
height?: number | string height?: number | string
width?: number | string inset?: number | string
maxHeight?: number | string justifyContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'start' | 'end' | 'left' | 'right' | 'stretch'
maxWidth?: number | string justifyItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right'
minHeight?: number | string justifySelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right'
minWidth?: number | string left?: number | string
// logical sizes
blockSize?: number | string
inlineSize?: number | string
minBlockSize?: number | string
maxBlockSize?: number | string
minInlineSize?: number | string
maxInlineSize?: number | string
margin?: number | string margin?: number | string
marginBottom?: number | string marginBottom?: number | string
marginLeft?: number | string marginLeft?: number | string
marginRight?: number | string marginRight?: number | string
marginTop?: number | string marginTop?: number | string
maxHeight?: number | string
maxWidth?: number | string
minHeight?: number | string
minWidth?: number | string
order?: number
overflow?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
overflowX?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
overflowY?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
padding?: number | string padding?: number | string
paddingBottom?: number | string paddingBottom?: number | string
paddingLeft?: number | string paddingLeft?: number | string
paddingRight?: number | string paddingRight?: number | string
paddingTop?: number | string paddingTop?: number | string
order?: number
overflow?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
overflowX?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
overflowY?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip'
overflowWrap?: 'normal' | 'break-word' | 'anywhere'
// overscroll / snap / scrolling ergonomics
overscrollBehavior?: 'auto' | 'contain' | 'none'
overscrollBehaviorX?: 'auto' | 'contain' | 'none'
overscrollBehaviorY?: 'auto' | 'contain' | 'none'
scrollBehavior?: 'auto' | 'smooth'
scrollSnapType?: 'none' | 'x' | 'y' | 'block' | 'inline' | 'both' | string
scrollSnapAlign?: 'none' | 'start' | 'end' | 'center'
scrollSnapStop?: 'normal' | 'always'
scrollMargin?: number | string
scrollMarginTop?: number | string
scrollMarginRight?: number | string
scrollMarginBottom?: number | string
scrollMarginLeft?: number | string
scrollPadding?: number | string
scrollPaddingTop?: number | string
scrollPaddingRight?: number | string
scrollPaddingBottom?: number | string
scrollPaddingLeft?: number | string
scrollbarWidth?: 'auto' | 'thin' | 'none'
scrollbarColor?: string
placeContent?: string placeContent?: string
placeItems?: string placeItems?: string
placeSelf?: string placeSelf?: string
position?: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky' position?: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'
right?: number | string
justifyContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'start' | 'end' | 'left' | 'right' | 'stretch' rowGap?: number | string
justifyItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right' top?: number | string
justifySelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right'
verticalAlign?: 'baseline' | 'top' | 'middle' | 'bottom' | 'text-top' | 'text-bottom' | 'sub' | 'super' verticalAlign?: 'baseline' | 'top' | 'middle' | 'bottom' | 'text-top' | 'text-bottom' | 'sub' | 'super'
width?: number | string
zIndex?: number zIndex?: number
// visual/theme-related // visual/theme-related
animation?: string animation?: string
appearance?: 'none' | 'auto' | 'button' | 'textfield' | 'searchfield' | 'textarea' | 'checkbox' | 'radio' appearance?: 'none' | 'auto' | 'button' | 'textfield' | 'searchfield' | 'textarea' | 'checkbox' | 'radio'
backdropFilter?: string backdropFilter?: string
background?: string background?: string
backgroundAttachment?: 'scroll' | 'fixed' | 'local' backgroundAttachment?: 'scroll' | 'fixed' | 'local'
backgroundClip?: 'border-box' | 'padding-box' | 'content-box' | 'text' backgroundClip?: 'border-box' | 'padding-box' | 'content-box' | 'text'
@ -144,7 +76,6 @@ export type TagDef = {
backgroundPosition?: string backgroundPosition?: string
backgroundRepeat?: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | 'space' | 'round' backgroundRepeat?: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | 'space' | 'round'
backgroundSize?: 'auto' | 'cover' | 'contain' backgroundSize?: 'auto' | 'cover' | 'contain'
border?: string border?: string
borderBottom?: string borderBottom?: string
borderBottomColor?: string borderBottomColor?: string
@ -170,122 +101,64 @@ export type TagDef = {
borderTopStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden' borderTopStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
borderTopWidth?: number | string borderTopWidth?: number | string
borderWidth?: number | string borderWidth?: number | string
// table-ish
borderCollapse?: 'collapse' | 'separate'
borderSpacing?: number | string
captionSide?: 'top' | 'bottom'
emptyCells?: 'show' | 'hide'
tableLayout?: 'auto' | 'fixed'
boxShadow?: string boxShadow?: string
clipPath?: string clipPath?: string
color?: string color?: string
content?: string content?: string
cursor?: 'auto' | 'default' | 'none' | 'context-menu' | 'help' | 'pointer' | 'progress' | 'wait' | 'cell' | 'crosshair' | 'text' | 'vertical-text' | 'alias' | 'copy' | 'move' | 'no-drop' | 'not-allowed' | 'grab' | 'grabbing' | 'e-resize' | 'n-resize' | 'ne-resize' | 'nw-resize' | 's-resize' | 'se-resize' | 'sw-resize' | 'w-resize' | 'ew-resize' | 'ns-resize' | 'nesw-resize' | 'nwse-resize' | 'col-resize' | 'row-resize' | 'all-scroll' | 'zoom-in' | 'zoom-out' cursor?: 'auto' | 'default' | 'none' | 'context-menu' | 'help' | 'pointer' | 'progress' | 'wait' | 'cell' | 'crosshair' | 'text' | 'vertical-text' | 'alias' | 'copy' | 'move' | 'no-drop' | 'not-allowed' | 'grab' | 'grabbing' | 'e-resize' | 'n-resize' | 'ne-resize' | 'nw-resize' | 's-resize' | 'se-resize' | 'sw-resize' | 'w-resize' | 'ew-resize' | 'ns-resize' | 'nesw-resize' | 'nwse-resize' | 'col-resize' | 'row-resize' | 'all-scroll' | 'zoom-in' | 'zoom-out'
filter?: string filter?: string
font?: string
fontFamily?: string fontFamily?: string
fontSize?: number | string fontSize?: number | string
fontStyle?: 'normal' | 'italic' | 'oblique' fontStyle?: 'normal' | 'italic' | 'oblique'
fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 'normal' | 'bold' | 'bolder' | 'lighter' | number fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 'normal' | 'bold' | 'bolder' | 'lighter' | number
fontStretch?: string
fontVariant?: string
fontKerning?: 'auto' | 'normal' | 'none'
isolation?: 'auto' | 'isolate' isolation?: 'auto' | 'isolate'
letterSpacing?: number | string letterSpacing?: number | string
lineHeight?: number | string lineHeight?: number | string
listStyle?: string listStyle?: string
listStyleImage?: string listStyleImage?: string
listStylePosition?: 'inside' | 'outside' listStylePosition?: 'inside' | 'outside'
listStyleType?: 'none' | 'disc' | 'circle' | 'square' | 'decimal' | 'decimal-leading-zero' | 'lower-roman' | 'upper-roman' | 'lower-alpha' | 'upper-alpha' | 'lower-greek' | 'lower-latin' | 'upper-latin' listStyleType?: 'none' | 'disc' | 'circle' | 'square' | 'decimal' | 'decimal-leading-zero' | 'lower-roman' | 'upper-roman' | 'lower-alpha' | 'upper-alpha' | 'lower-greek' | 'lower-latin' | 'upper-latin'
mixBlendMode?: 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten' | 'color-dodge' | 'color-burn' | 'hard-light' | 'soft-light' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity' mixBlendMode?: 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten' | 'color-dodge' | 'color-burn' | 'hard-light' | 'soft-light' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity'
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down' objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
opacity?: number opacity?: number
outline?: string outline?: string
outlineColor?: string outlineColor?: string
outlineOffset?: number | string outlineOffset?: number | string
outlineStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' outlineStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset'
outlineWidth?: number | string outlineWidth?: number | string
// form / selection / interaction
caretColor?: string
accentColor?: string
pointerEvents?: 'auto' | 'none' | 'visiblePainted' | 'visibleFill' | 'visibleStroke' | 'visible' | 'painted' | 'fill' | 'stroke' | 'all' pointerEvents?: 'auto' | 'none' | 'visiblePainted' | 'visibleFill' | 'visibleStroke' | 'visible' | 'painted' | 'fill' | 'stroke' | 'all'
resize?: 'none' | 'both' | 'horizontal' | 'vertical' | 'block' | 'inline' resize?: 'none' | 'both' | 'horizontal' | 'vertical' | 'block' | 'inline'
touchAction?: 'auto' | 'none' | 'pan-x' | 'pan-y' | 'manipulation' | string scrollBehavior?: 'auto' | 'smooth'
userSelect?: 'auto' | 'none' | 'text' | 'contain' | 'all'
// writing / bidi / hyphenation
direction?: 'ltr' | 'rtl'
writingMode?: 'horizontal-tb' | 'vertical-rl' | 'vertical-lr' | string
unicodeBidi?: 'normal' | 'embed' | 'bidi-override' | 'isolate' | 'isolate-override' | 'plaintext'
hyphens?: 'none' | 'manual' | 'auto'
tabSize?: number | string
textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end' textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end'
textDecoration?: string textDecoration?: string
textDecorationColor?: string textDecorationColor?: string
textDecorationLine?: 'none' | 'underline' | 'overline' | 'line-through' | 'blink' textDecorationLine?: 'none' | 'underline' | 'overline' | 'line-through' | 'blink'
textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy' textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy'
textDecorationThickness?: number | string textDecorationThickness?: number | string
textUnderlineOffset?: number | string
textIndent?: number | string textIndent?: number | string
textOverflow?: 'clip' | 'ellipsis' | string textOverflow?: 'clip' | 'ellipsis' | string
textShadow?: string textShadow?: string
textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase' | 'full-width' | 'full-size-kana' textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase' | 'full-width' | 'full-size-kana'
transform?: string
transition?: string
userSelect?: 'auto' | 'none' | 'text' | 'contain' | 'all'
visibility?: 'visible' | 'hidden' | 'collapse'
whiteSpace?: 'normal' | 'nowrap' | 'pre' | 'pre-wrap' | 'pre-line' | 'break-spaces' whiteSpace?: 'normal' | 'nowrap' | 'pre' | 'pre-wrap' | 'pre-line' | 'break-spaces'
willChange?: 'auto' | 'scroll-position' | 'contents'
wordBreak?: 'normal' | 'break-all' | 'keep-all' | 'break-word' wordBreak?: 'normal' | 'break-all' | 'keep-all' | 'break-word'
wordSpacing?: number | string wordSpacing?: number | string
wordWrap?: 'normal' | 'break-word' | 'anywhere' wordWrap?: 'normal' | 'break-word' | 'anywhere'
overflowWrap?: 'normal' | 'break-word' | 'anywhere'
transform?: string
transformOrigin?: string
transformStyle?: 'flat' | 'preserve-3d'
perspective?: number | string
perspectiveOrigin?: string
backfaceVisibility?: 'visible' | 'hidden'
transition?: string
visibility?: 'visible' | 'hidden' | 'collapse'
willChange?: 'auto' | 'scroll-position' | 'contents'
// masks (if you want modern visual effects)
mask?: string
maskImage?: string
maskSize?: string
maskPosition?: string
maskRepeat?: string
// svg styling (if you want these supported)
fill?: string
stroke?: string
strokeWidth?: number | string
strokeLinecap?: 'butt' | 'round' | 'square'
strokeLinejoin?: 'miter' | 'round' | 'bevel'
strokeDasharray?: number | string
strokeDashoffset?: number | string
} }
export const NonStyleKeys = new Set([ export const NonStyleKeys = new Set([
'className', 'className',
'base', 'base',
'states', 'states',
'css',
'parts', 'parts',
'variants', 'variants',
'render', 'render',
'styles', 'styles',
'selectors',
]) ])
export const UnitlessProps = new Set([ export const UnitlessProps = new Set([

View File

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext", "DOM"], "lib": ["ESNext"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"moduleDetection": "force", "moduleDetection": "force",