forge/docs/GUIDE.md
2026-02-18 20:48:46 -08:00

1068 lines
22 KiB
Markdown

# Forge Guide
A complete guide to writing components with Forge.
## Table of Contents
- [Getting Started](#getting-started)
- [Basic Components](#basic-components)
- [HTML Tags](#html-tags)
- [CSS Properties](#css-properties)
- [States](#states)
- [Variants](#variants)
- [Parts](#parts)
- [Custom Render](#custom-render)
- [Selectors](#selectors)
- [Scopes](#scopes)
- [Themes](#themes)
- [SSR and SPA](#ssr-and-spa)
---
## Getting Started
Import `define` and start creating components. Each call to `define` generates real CSS classes and returns a JSX component.
```tsx
import { define } from 'forge'
const Box = define('Box', {
padding: 20,
background: '#111',
})
// Renders: <div class="Box">...</div>
<Box>Hello</Box>
```
Include `<Styles />` in your HTML head to render the generated CSS:
```tsx
import { define, Styles } from 'forge'
// or use define.Styles — same thing
<html>
<head>
<Styles />
</head>
<body>...</body>
</html>
```
---
## Basic Components
### Named components
Pass a name as the first argument. The name becomes the CSS class.
```tsx
const Card = define('Card', {
padding: 20,
background: '#111',
borderRadius: 8,
})
// <div class="Card">...</div>
```
Names must be unique — defining the same name twice throws an error.
### Anonymous components
Omit the name and Forge generates one from the base tag: `Div`, `Button2`, `Anchor3`, etc.
```tsx
const Box = define({ display: 'flex', gap: 16 })
// class="Div"
const Link = define({ base: 'a', color: 'blue' })
// class="Anchor"
```
---
## HTML Tags
By default, components render as `<div>`. Use `base` to change the tag:
```tsx
const Button = define('Button', {
base: 'button',
padding: 20,
cursor: 'pointer',
})
// <button class="Button">...</button>
const Heading = define('Heading', {
base: 'h1',
fontSize: 28,
})
// <h1 class="Heading">...</h1>
```
### Attribute shorthand
Set default attributes right in the base string:
```tsx
const Radio = define('Radio', {
base: 'input[type=radio]',
})
// <input type="radio" class="Radio" />
const Checkbox = define('Checkbox', {
base: 'input[type=checkbox]',
})
// <input type="checkbox" class="Checkbox" />
```
Props passed at usage time override base attributes.
---
## CSS Properties
Write CSS properties in camelCase. They compile to real CSS at definition time.
```tsx
const Card = define('Card', {
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: 20,
backgroundColor: '#111',
borderRadius: 8,
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
})
```
Generated CSS:
```css
.Card {
background-color: #111;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
}
```
### Numeric values
Numbers are auto-converted to `px`:
```tsx
{ padding: 20 } // padding: 20px
{ margin: 0 } // margin: 0px
{ fontSize: 14 } // font-size: 14px
{ borderRadius: 8 } // border-radius: 8px
```
Except for unitless properties, which stay as plain numbers:
```tsx
{ opacity: 0.5 } // opacity: 0.5
{ zIndex: 10 } // z-index: 10
{ flex: 1 } // flex: 1
{ flexGrow: 2 } // flex-grow: 2
{ flexShrink: 0 } // flex-shrink: 0
{ fontWeight: 400 } // font-weight: 400
{ lineHeight: 1.6 } // line-height: 1.6
{ order: 3 } // order: 3
```
Strings are passed through as-is:
```tsx
{ padding: '12px 24px' } // padding: 12px 24px
{ border: '1px solid #222' } // border: 1px solid #222
{ margin: '0 auto' } // margin: 0 auto
```
### All supported properties
Every standard CSS property is supported in camelCase form: layout (`display`, `position`, `flexDirection`, `gridTemplateColumns`, etc.), box model (`margin`, `padding`, `width`, `height`, etc.), typography (`fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, etc.), visual (`background`, `border`, `boxShadow`, `opacity`, etc.), animation (`transition`, `animation`, `transform`, etc.), and SVG (`fill`, `stroke`, `strokeWidth`, etc.).
---
## States
Pseudo-selectors like `:hover`, `:focus`, `:active`, and `:disabled`.
```tsx
const Button = define('Button', {
base: 'button',
padding: 20,
background: 'blue',
cursor: 'pointer',
states: {
':hover': { background: 'darkblue' },
':active': { transform: 'translateY(1px)' },
':focus': { outline: '2px solid white' },
':disabled': { opacity: 0.3, cursor: 'not-allowed' },
},
})
```
The colon is optional — `hover` and `:hover` are equivalent:
```tsx
states: {
hover: { background: 'darkblue' }, // same as ':hover'
}
```
### Complex pseudo-selectors
Use the full string for compound selectors:
```tsx
states: {
':not(:disabled):hover': { background: 'darkblue' },
':not(:disabled):active': { transform: 'translateY(1px)' },
}
```
Generated CSS:
```css
.Button:not(:disabled):hover { background: darkblue; }
.Button:not(:disabled):active { transform: translateY(1px); }
```
---
## Variants
Variants are typed props that apply conditional CSS classes. They replace inline styles with a clean, declarative API.
### Boolean variants
A variant whose value is a style object. Activated by passing `true`.
```tsx
const Button = define('Button', {
base: 'button',
padding: 20,
background: 'blue',
variants: {
disabled: {
opacity: 0.3,
cursor: 'not-allowed',
},
rounded: {
borderRadius: 999,
},
},
})
```
```tsx
<Button>Normal</Button>
<Button disabled>Disabled</Button>
<Button rounded>Pill</Button>
<Button disabled rounded>Both</Button>
```
Generated CSS:
```css
.Button { padding: 20px; background: blue; }
.Button.disabled { cursor: not-allowed; opacity: 0.3; }
.Button.rounded { border-radius: 999px; }
```
HTML output for `<Button disabled rounded>`:
```html
<button class="Button disabled rounded">Both</button>
```
Variant props are consumed by Forge and **not** passed to the HTML element.
### Keyed variants
A variant whose value is an object of named options. Activated by passing a string.
```tsx
const Button = define('Button', {
base: 'button',
padding: 16,
variants: {
intent: {
primary: { background: 'blue', color: 'white' },
secondary: { background: '#333', color: '#ccc' },
danger: { background: 'red', color: 'white' },
ghost: { background: 'transparent', color: 'gray' },
},
size: {
small: { padding: '8px 16px', fontSize: 12 },
large: { padding: '16px 32px', fontSize: 16 },
},
},
})
```
```tsx
<Button intent="primary">Save</Button>
<Button intent="danger" size="large">Delete Account</Button>
<Button intent="ghost" size="small">Cancel</Button>
```
Generated CSS:
```css
.Button.intent-primary { background: blue; color: white; }
.Button.intent-danger { background: red; color: white; }
.Button.size-small { font-size: 12px; padding: 8px 16px; }
.Button.size-large { font-size: 16px; padding: 16px 32px; }
```
### Variants with states
Variants can include their own pseudo-selectors:
```tsx
variants: {
intent: {
danger: {
background: 'red',
states: {
':not(:disabled):hover': { background: '#cc0000' },
},
},
secondary: {
background: '#333',
states: {
':not(:disabled):hover': { borderColor: 'green' },
},
},
},
}
```
### Combining multiple variants
Multiple keyed and boolean variants can be used together freely:
```tsx
<Button intent="primary" size="small">Small Primary</Button>
<Button intent="danger" size="large" disabled>Large Danger Disabled</Button>
```
---
## Parts
Parts are named sub-components within a component. They get their own CSS classes and can have their own base tags, states, and selectors.
```tsx
const Card = define('Card', {
padding: 20,
background: '#111',
parts: {
Header: {
base: 'h2',
fontSize: 24,
marginBottom: 12,
},
Body: {
fontSize: 14,
lineHeight: 1.6,
color: '#888',
},
Footer: {
base: 'footer',
marginTop: 16,
paddingTop: 12,
borderTop: '1px solid #333',
fontSize: 12,
},
},
render({ props, parts: { Root, Header, Body, Footer } }) {
return (
<Root>
<Header>{props.title}</Header>
<Body>{props.children}</Body>
<Footer>{props.footer}</Footer>
</Root>
)
},
})
```
```tsx
<Card title="Welcome" footer="Last updated today">
This is the card body content.
</Card>
```
Generated CSS classes:
```css
.Card { padding: 20px; background: #111; }
.Card_Header { font-size: 24px; margin-bottom: 12px; }
.Card_Body { color: #888; font-size: 14px; line-height: 1.6; }
.Card_Footer { border-top: 1px solid #333; font-size: 12px; margin-top: 16px; padding-top: 12px; }
```
### Parts with states
Parts can have their own pseudo-selectors:
```tsx
parts: {
Tab: {
base: 'button',
color: '#888',
borderBottom: '1px solid transparent',
states: {
':hover': { color: '#fff' },
},
},
}
```
### Variants that affect parts
Variants can override styles on specific parts:
```tsx
const UserProfile = define('UserProfile', {
padding: 24,
parts: {
Avatar: { base: 'img', width: 64, height: 64, borderRadius: '50%' },
Name: { fontSize: 18 },
Bio: { fontSize: 14, color: '#888' },
},
variants: {
size: {
compact: {
padding: 16, // override root
parts: {
Avatar: { width: 48, height: 48 }, // override part
Name: { fontSize: 16 },
},
},
large: {
padding: 32,
parts: {
Avatar: { width: 96, height: 96 },
Name: { fontSize: 24 },
},
},
},
verified: {
parts: {
Avatar: { border: '2px solid gold' },
},
},
},
render({ props, parts: { Root, Avatar, Name, Bio } }) {
return (
<Root>
<Avatar src={props.avatarUrl} alt={props.name} />
<Name>{props.name}{props.verified && ' ✓'}</Name>
<Bio>{props.bio}</Bio>
</Root>
)
},
})
```
```tsx
<UserProfile size="compact" name="Alex" ... />
<UserProfile size="large" verified name="Jordan" ... />
```
Generated CSS:
```css
.UserProfile { padding: 24px; }
.UserProfile_Avatar { border-radius: 50%; height: 64px; width: 64px; }
.UserProfile.size-compact { padding: 16px; }
.UserProfile_Avatar.size-compact { height: 48px; width: 48px; }
.UserProfile_Avatar.verified { border: 2px solid gold; }
```
### Applying variants to parts in render
Inside `render()`, you can pass variant props directly to part components:
```tsx
const Tabs = define('Tabs', {
display: 'flex',
parts: {
Tab: {
base: 'button',
color: '#888',
borderBottom: '1px solid transparent',
},
},
variants: {
active: {
parts: {
Tab: {
color: 'green',
borderBottom: '1px solid green',
},
},
},
},
render({ props, parts: { Root, Tab } }) {
return (
<Root>
{props.items?.map((item: any) => (
<Tab active={item.active}>{item.label}</Tab>
))}
</Root>
)
},
})
```
The `active` prop on `<Tab>` adds the variant class to that specific tab instance. It is not passed through to the HTML.
---
## Custom Render
Override the default rendering with a `render` function. It receives `props` (everything passed to the component) and `parts` (component functions for Root and all named parts).
```tsx
const FormGroup = define('FormGroup', {
marginBottom: 24,
parts: {
Label: { base: 'label', display: 'block', fontSize: 14, marginBottom: 8 },
Helper: { fontSize: 12, color: '#888', marginTop: 6 },
Error: { fontSize: 12, color: 'red', 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>
)
},
})
```
```tsx
<FormGroup label="Email" helper="We'll never share your email">
<Input type="email" placeholder="you@example.com" />
</FormGroup>
<FormGroup label="Username" error="Username is already taken">
<Input status="error" value="admin" />
</FormGroup>
```
### Destructuring props
Destructure to separate custom props from HTML passthrough props:
```tsx
render({ props: { title, subtitle, ...rest }, parts: { Root, H2, P } }) {
return (
<Root {...rest}>
<H2>{title}</H2>
<P>{subtitle}</P>
</Root>
)
}
```
### Without render
If no `render` is provided, the component renders its children into the root tag:
```tsx
const Box = define('Box', { padding: 20 })
// Equivalent to:
// render({ props, parts: { Root } }) {
// return <Root {...props}>{props.children}</Root>
// }
```
---
## Selectors
The `selectors` key lets you write custom CSS selectors. Use `&` for the current element and `@PartName` to reference other parts.
### Basic selectors
```tsx
const NavLink = define('NavLink', {
base: 'a',
color: '#888',
textDecoration: 'none',
selectors: {
'&:hover': { color: '#fff' },
'&[aria-current]': { color: '#fff', textDecoration: 'underline' },
},
})
```
```tsx
<NavLink href="/home" aria-current="page">Home</NavLink>
<NavLink href="/about">About</NavLink>
```
### Cross-part selectors
Reference other parts with `@PartName`. This is the mechanism that enables CSS-only interactive components.
```tsx
const Checkbox = define('Checkbox', {
parts: {
Input: {
base: 'input[type=checkbox]',
display: 'none',
},
Label: {
base: 'label',
padding: 10,
cursor: 'pointer',
color: 'gray',
selectors: {
// When the Input is checked, style this Label
'@Input:checked + &': {
color: 'green',
fontWeight: 'bold',
},
// When the Input is disabled, style this Label
'@Input:disabled + &': {
opacity: 0.5,
cursor: 'not-allowed',
},
},
},
},
render({ props, parts: { Root, Input, Label } }) {
return (
<Root>
<Input id={props.id} checked={props.checked} disabled={props.disabled} />
<Label for={props.id}>{props.label}</Label>
</Root>
)
},
})
```
The `@Input` in `@Input:checked + &` is replaced with `.Checkbox_Input`, and `&` is replaced with `.Checkbox_Label`, producing:
```css
.Checkbox_Input:checked + .Checkbox_Label {
color: green;
font-weight: bold;
}
```
### CSS-only tab switcher
Hidden radio inputs + sibling selectors = tabs without JavaScript:
```tsx
const TabSwitcher = define('TabSwitcher', {
parts: {
Input: {
base: 'input[type=radio]',
display: 'none',
},
TabBar: {
display: 'flex',
borderBottom: '1px solid #333',
},
TabLabel: {
base: 'label',
padding: '12px 24px',
color: '#888',
cursor: 'pointer',
states: {
':hover': { color: '#fff' },
},
selectors: {
'@Input:checked + &': {
color: 'green',
borderBottom: '1px solid green',
},
},
},
Content: {
display: 'none',
padding: 24,
selectors: {
// General sibling combinator: when a radio is checked,
// show the corresponding content panel
'@Input:checked ~ &': { display: 'block' },
},
},
},
render({ props, parts: { Root, Input, TabBar, TabLabel, Content } }) {
return (
<Root>
<TabBar>
{props.tabs?.map((tab: any, i: number) => (
<>
<Input id={`${props.name}-${tab.id}`} name={props.name} checked={i === 0} />
<TabLabel for={`${props.name}-${tab.id}`}>{tab.label}</TabLabel>
</>
))}
</TabBar>
{props.tabs?.map((tab: any) => (
<Content>{tab.content}</Content>
))}
</Root>
)
},
})
```
```tsx
<TabSwitcher
name="demo"
tabs={[
{ id: 'one', label: 'Tab 1', content: <p>First panel</p> },
{ id: 'two', label: 'Tab 2', content: <p>Second panel</p> },
{ id: 'three', label: 'Tab 3', content: <p>Third panel</p> },
]}
/>
```
### Selector reference
| Selector | Meaning |
|---|---|
| `&:hover` | This element on hover |
| `&[aria-current]` | This element when attribute is present |
| `@Input:checked + &` | This element when adjacent Input is checked |
| `@Input:checked ~ &` | This element when any preceding Input sibling is checked |
| `@Input:disabled + &` | This element when adjacent Input is disabled |
| `@Input:checked + &:hover` | This element on hover, but only when Input is checked |
---
## Scopes
When building a family of related components, `createScope` prefixes all names automatically:
```tsx
import { createScope } from 'forge'
const { define } = createScope('Button')
const Button = define('Root', { // CSS class: "Button" (Root is special)
base: 'button',
padding: 20,
})
const ButtonRow = define('Row', { // CSS class: "ButtonRow"
display: 'flex',
gap: 16,
})
const ButtonIcon = define('Icon', { // CSS class: "ButtonIcon"
width: 20,
height: 20,
})
```
`Root` is the special case — `define('Root', ...)` with scope `'Button'` produces class `Button`, not `ButtonRoot`.
---
## Themes
Built-in CSS custom properties with type safety.
### Define themes
Create a theme file with your tokens:
```tsx
// darkTheme.tsx
export default {
'colors-bg': '#0a0a0a',
'colors-bgElevated': '#111',
'colors-fg': '#00ff00',
'colors-fgMuted': '#888',
'colors-border': '#222',
'colors-accent': '#00ff00',
'colors-accentDim': '#008800',
'fonts-mono': "'Monaco', 'Menlo', monospace",
'spacing-sm': '12px',
'spacing-md': '16px',
'spacing-lg': '24px',
'spacing-xl': '32px',
'radius-sm': '4px',
'radius-md': '8px',
} as const
```
```tsx
// lightTheme.tsx
export default {
'colors-bg': '#f5f5f0',
'colors-bgElevated': '#fff',
'colors-fg': '#0a0a0a',
'colors-fgMuted': '#666',
'colors-border': '#ddd',
'colors-accent': '#0066cc',
'colors-accentDim': '#004499',
'fonts-mono': "'Monaco', 'Menlo', monospace",
'spacing-sm': '12px',
'spacing-md': '16px',
'spacing-lg': '24px',
'spacing-xl': '32px',
'radius-sm': '4px',
'radius-md': '8px',
} as const
```
### Register themes
```tsx
// themes.tsx
import { createThemes } from 'forge'
import darkTheme from './darkTheme'
import lightTheme from './lightTheme'
export const theme = createThemes({
dark: darkTheme,
light: lightTheme,
})
```
`createThemes` returns a typed function. It only accepts keys that exist in your theme objects.
### Use theme values
`theme('key')` returns `var(--theme-key)`:
```tsx
import { theme } from './themes'
const Card = define('Card', {
padding: theme('spacing-lg'), // var(--theme-spacing-lg)
background: theme('colors-bgElevated'), // var(--theme-colors-bgElevated)
color: theme('colors-fg'), // var(--theme-colors-fg)
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-md'),
})
```
### Switch themes
Themes are controlled by the `data-theme` attribute:
```tsx
// Set initial theme
<body data-theme="dark">
// Switch at runtime
document.body.setAttribute('data-theme', 'light')
```
Generated CSS:
```css
[data-theme="dark"] {
--theme-colors-bg: #0a0a0a;
--theme-colors-fg: #00ff00;
/* ... */
}
[data-theme="light"] {
--theme-colors-bg: #f5f5f0;
--theme-colors-fg: #0a0a0a;
/* ... */
}
```
### Other theme APIs
```tsx
// Register a single theme
import { createTheme } from 'forge'
createTheme('dark', { bgColor: '#000', fgColor: '#fff' })
// Extend existing themes with new tokens
import { extendThemes } from 'forge'
extendThemes({ dark: { 'colors-success': '#0f0' } })
// Untyped fallback for dynamic theme keys
import { themeVar } from 'forge'
themeVar('colors-bg') // 'var(--theme-colors-bg)'
```
---
## SSR and SPA
### Server-side rendering
Include `<Styles />` in the `<head>` to inline all CSS:
```tsx
import { define, Styles } from 'forge'
const Page = () => (
<html>
<head>
<Styles />
</head>
<body>...</body>
</html>
)
```
Or write CSS to a file using `stylesToCSS()`:
```tsx
import { stylesToCSS } from 'forge'
// Write to a .css file and serve it
const css = stylesToCSS()
Bun.write('public/main.css', css)
// Then link it
<link rel="stylesheet" href="/main.css" />
```
### Single-page apps
In browser environments, Forge automatically injects a `<style id="forge-styles">` tag into `document.head` after every `define()` call. No manual setup needed.
### HMR
Forge automatically clears and re-registers styles when modules are hot-reloaded via `import.meta.hot.dispose`.
---
## Full Example
Putting it all together — a button component with scoping, themes, multiple variant types, states, and combined usage:
```tsx
import { createScope } from 'forge'
import { theme } from './themes'
const { define } = createScope('Button')
const Button = define('Root', {
base: 'button',
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'),
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':not(:disabled):hover': {
background: theme('colors-accentDim'),
},
':not(:disabled):active': {
transform: 'translateY(1px)',
},
},
variants: {
intent: {
primary: {
background: theme('colors-accent'),
color: theme('colors-bg'),
},
secondary: {
background: theme('colors-bgElevated'),
color: theme('colors-fg'),
border: `1px solid ${theme('colors-border')}`,
states: {
':not(:disabled):hover': {
borderColor: theme('colors-borderActive'),
},
},
},
danger: {
background: '#ff0000',
states: {
':not(:disabled):hover': { background: '#cc0000' },
},
},
ghost: {
background: 'transparent',
color: theme('colors-fgMuted'),
},
},
size: {
small: { padding: '8px 16px', fontSize: 12 },
large: { padding: '16px 32px', fontSize: 16 },
},
disabled: {
opacity: 0.3,
cursor: 'not-allowed',
},
},
})
const ButtonRow = define('Row', {
display: 'flex',
gap: theme('spacing-md'),
flexWrap: 'wrap',
alignItems: 'center',
})
```
```tsx
<ButtonRow>
<Button intent="primary">Save</Button>
<Button intent="secondary">Cancel</Button>
<Button intent="danger" size="large">Delete</Button>
<Button intent="ghost" size="small">Skip</Button>
<Button disabled>Disabled</Button>
</ButtonRow>
```