Compare commits

..

No commits in common. "718dc3b73d1f7c85cfdb38ad1d35f589bcddfdcb" and "c561207128210f674e5d28901795202cd118b4b3" have entirely different histories.

14 changed files with 396 additions and 843 deletions

214
README.md
View File

@ -1,52 +1,37 @@
# ⚒️ forge # Forge
``` ## Why Forge?
╔═╝╔═║╔═║╔═╝╔═╝
╔═╝║ ║╔╔╝║ ║╔═╝
╝ ══╝╝ ╝══╝══╝
```
## overview CSS is powerful, but hostile.
Forge is a typed, local, variant-driven way to organize CSS and create ### Problems with CSS
self-contained TSX components out of discrete parts.
## css problems - 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.
- Styles are global and open - anything can override anything anywhere. ### What Forge Does Instead
- No IDE-friendly link between the class name in markup and its definition.
- All techniques are patterns a human must know and follow, not APIs.
- Errors happen silently.
## forge solutions - 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.
- All styles are local to your TSX components. ### What Forge Is
- Styles defined using TS typing.
- Component styles are made up of independently styled "Parts".
- "Variants" replace inline styles with typed, declarative parameters.
- Style composition is deterministic.
- Themes are easy.
- Errors and feedback are provided.
## examples - A typed, local, variant-driven way to author CSS.
- A system that optimizes for **people typing at a keyboard**, not selectors in a cascade.
### styles ### What Forge Is Not
```tsx - Not a new component model.
import { define } from "forge" - Not a new language.
- Not a CSS replacement — it compiles _to_ CSS, but removes the chaos.
export const Button = define("button", { Example:
base: "button",
padding: 20,
background: "blue",
})
// Usage
<Button>Click me</Button>
```
### variants
```tsx ```tsx
import { define } from "forge" import { define } from "forge"
@ -58,7 +43,7 @@ export const Button = define("button", {
background: "blue", background: "blue",
variants: { variants: {
status: { kind: {
danger: { background: "red" }, danger: { background: "red" },
warning: { background: "yellow" }, warning: { background: "yellow" },
} }
@ -67,29 +52,25 @@ export const Button = define("button", {
// Usage // Usage
<Button>Click me</Button> <Button>Click me</Button>
<Button status="danger">Click me carefully</Button> <Button kind="danger">Click me carefully</Button>
<Button status="warning">Click me?</Button> <Button kind="warning">Click me?</Button>
```
### parts + `render()`
```typescript
export const Profile = define("div", { export const Profile = define("div", {
padding: 50, padding: 50,
background: "red", background: "red",
parts: { parts: {
Header: { display: "flex" }, Header: { display: "flex" },
Avatar: { base: "img", width: 50 }, Avatar: { base: 'img', width: 50 },
Bio: { color: "gray" }, Bio: { color: "gray" },
}, },
variants: { variants: {
size: { size: {
small: { small: {
parts: { Avatar: { width: 20 } }, parts: { Avatar: { width: 20 }}
}, }
}, }
}, },
render({ props, parts: { Root, Header, Avatar, Bio } }) { render({ props, parts: { Root, Header, Avatar, Bio } }) {
@ -105,138 +86,7 @@ export const Profile = define("div", {
}) })
// Usage: // Usage:
import { Profile } from "./whatever" import { Profile } from './whatever'
console.log(<Profile pic={user.pic} bio={user.bio} />) <Profile pic={user.pic} bio={user.bio} />
console.log(<Profile size="small" pic={user.pic} bio={user.bio} />)
```
### selectors
Use `selectors` to write custom CSS selectors. Reference the current
element with `&` and other parts with `@PartName`:
```tsx
const Checkbox = define("Checkbox", {
parts: {
Input: {
base: "input[type=checkbox]",
display: "none",
},
Label: {
base: "label",
padding: 10,
cursor: "pointer",
color: "gray",
selectors: {
// style Label when Input is checked
"@Input:checked + &": {
color: "green",
fontWeight: "bold",
},
// style Label when Input is disabled
"@Input:disabled + &": {
opacity: 0.5,
cursor: "not-allowed",
},
},
},
},
render({ props, parts: { Root, Input, Label } }) {
return (
<Root>
<Label>
<Input checked={props.checked} />
{props.label}
</Label>
</Root>
)
},
})
// Usage
<Checkbox label="Agree to terms" checked />
```
## themes
built-in support for CSS variables with full type safety:
```tsx
// themes.tsx - Define your themes
import { createThemes } from "forge"
export const theme = createThemes({
dark: {
bgColor: "#0a0a0a",
fgColor: "#00ff00",
sm: 12,
lg: 24,
},
light: {
bgColor: "#f5f5f0",
fgColor: "#0a0a0a",
sm: 12,
lg: 24,
},
})
// Use theme() in your components
import { define } from "forge"
import { theme } from "./themes"
const Button = define("Button", {
padding: theme("spacing-sm"),
background: theme("colors-bg"),
color: theme("colors-fg"),
})
```
Theme switching is done via the `data-theme` attribute:
```tsx
// Toggle between themes
document.body.setAttribute("data-theme", "dark")
document.body.setAttribute("data-theme", "light")
```
The `theme()` function is fully typed based on your theme keys, giving
you autocomplete and type checking throughout your codebase.
## scopes
Sometimes you want your parts named things like ButtonRow, ButtonCell,
ButtonTable, etc, but all those Button's are repetitive:
```typescript
const { define } = createScope("Button")
// css class becomes "Button"
const Button = define("Root", {
// becomes "Button"
// ...
})
// css class becomes "ButtonRow"
const ButtonRow = define("Row", {
// ...
})
// css class becomes "ButtonContainer"
const ButtonContainer = define("Container", {
// ...
})
```
## see it
Check out the `examples/` dir and view them at http://localhost:3300 by
cloning this repo and running the local web server:
```
bun install
bun dev
open http://localhost:3300
``` ```

View File

@ -1,89 +1,75 @@
import { createScope } from '../src' import { createScope } from '../src'
import { ExampleSection } from './ssr/helpers' import { ExampleSection } from './ssr/helpers'
import { theme } from './ssr/themes'
const { define } = createScope('Button') const { define } = createScope('Button')
const Button = define('Root', { const Button = define('Root', {
base: 'button', base: 'button',
padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`, padding: "12px 24px",
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
gap: theme('spacing-xs'), gap: 8,
background: theme('colors-accent'), background: "#3b82f6",
color: theme('colors-bg'), color: "white",
border: `1px solid ${theme('colors-accent')}`, border: "none",
borderRadius: theme('radius-sm'), borderRadius: 8,
fontSize: 14, fontSize: 16,
fontWeight: 400, fontWeight: 600,
cursor: "pointer", cursor: "pointer",
transition: "all 0.2s ease", transition: "all 0.2s ease",
userSelect: "none", userSelect: "none",
boxShadow: "0 4px 6px rgba(59, 130, 246, 0.4), 0 2px 4px rgba(0, 0, 0, 0.1)",
transform: "translateY(0)",
states: { states: {
":not(:disabled):hover": { ":not(:disabled):hover": {
background: theme('colors-accentDim'), transform: 'translateY(-2px)',
borderColor: theme('colors-accentDim'), filter: 'brightness(1.05)'
}, },
":not(:disabled):active": { ":not(:disabled):active": {
transform: 'translateY(1px)', transform: 'translateY(1px)',
boxShadow: '0 2px 3px rgba(0, 0, 0, 0.2)'
}, },
}, },
variants: { variants: {
intent: { intent: {
primary: { primary: {
background: theme('colors-accent'), background: "#3b82f6",
color: theme('colors-bg'), color: "white",
border: `1px solid ${theme('colors-accent')}`, boxShadow: "0 4px 6px rgba(59, 130, 246, 0.4), 0 2px 4px rgba(0, 0, 0, 0.1)",
}, },
secondary: { secondary: {
background: theme('colors-bgElevated'), background: "#f3f4f6",
color: theme('colors-fg'), color: "#374151",
border: `1px solid ${theme('colors-border')}`, boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06)",
states: {
":not(:disabled):hover": {
borderColor: theme('colors-borderActive'),
}
}
}, },
danger: { danger: {
background: "#ff0000", background: "#ef4444",
color: theme('colors-bg'), color: "white",
border: "1px solid #ff0000", boxShadow: "0 4px 6px rgba(239, 68, 68, 0.4), 0 2px 4px rgba(0, 0, 0, 0.1)",
states: {
":not(:disabled):hover": {
background: "#cc0000",
borderColor: "#cc0000",
}
}
}, },
ghost: { ghost: {
background: "transparent", background: "transparent",
color: theme('colors-fgMuted'), color: "#aaa",
border: `1px solid ${theme('colors-border')}`, boxShadow: "0 4px 6px rgba(0, 0, 0, 0.2), 0 2px 4px rgba(0, 0, 0, 0.1)",
states: { border: "1px solid #eee",
":not(:disabled):hover": {
color: theme('colors-fg'),
borderColor: theme('colors-borderActive'),
}
}
}, },
}, },
size: { size: {
small: { small: {
padding: `${theme('spacing-xs')} ${theme('spacing-md')}`, padding: "8px 16px",
fontSize: 12, fontSize: 14,
}, },
large: { large: {
padding: `${theme('spacing-md')} ${theme('spacing-xl')}`, padding: "16px 32px",
fontSize: 16, fontSize: 18,
}, },
}, },
disabled: { disabled: {
opacity: 0.3, opacity: 0.5,
cursor: "not-allowed", cursor: "not-allowed",
}, },
}, },
@ -91,7 +77,7 @@ const Button = define('Root', {
const ButtonRow = define('Row', { const ButtonRow = define('Row', {
display: 'flex', display: 'flex',
gap: theme('spacing-md'), gap: 16,
flexWrap: 'wrap', flexWrap: 'wrap',
alignItems: 'center', alignItems: 'center',
}) })

View File

@ -1,16 +1,15 @@
import { define } from '../src' import { define } from '../src'
import { ExampleSection } from './ssr/helpers' import { ExampleSection } from './ssr/helpers'
import { theme } from './ssr/themes'
const Input = define('Input', { const Input = define('Input', {
base: 'input', base: 'input',
padding: `${theme('spacing-sm')} ${theme('spacing-md')}`, padding: '12px 16px',
fontSize: 14, fontSize: 16,
border: `1px solid ${theme('colors-border')}`, border: '2px solid #e5e7eb',
borderRadius: theme('radius-sm'), borderRadius: 8,
background: theme('colors-bgElevated'), background: 'white',
color: theme('colors-fg'), color: '#111827',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
width: '100%', width: '100%',
boxSizing: 'border-box', boxSizing: 'border-box',
@ -18,11 +17,12 @@ const Input = define('Input', {
states: { states: {
':focus': { ':focus': {
outline: 'none', outline: 'none',
borderColor: theme('colors-borderActive'), borderColor: '#3b82f6',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)'
}, },
':disabled': { ':disabled': {
background: theme('colors-bg'), background: '#f3f4f6',
color: theme('colors-fgDim'), color: '#9ca3af',
cursor: 'not-allowed' cursor: 'not-allowed'
} }
}, },
@ -30,18 +30,20 @@ const Input = define('Input', {
variants: { variants: {
status: { status: {
error: { error: {
borderColor: '#ff0000', borderColor: '#ef4444',
states: { states: {
':focus': { ':focus': {
borderColor: '#ff0000', borderColor: '#ef4444',
boxShadow: '0 0 0 3px rgba(239, 68, 68, 0.1)'
} }
} }
}, },
success: { success: {
borderColor: theme('colors-accent'), borderColor: '#10b981',
states: { states: {
':focus': { ':focus': {
borderColor: theme('colors-accent'), borderColor: '#10b981',
boxShadow: '0 0 0 3px rgba(16, 185, 129, 0.1)'
} }
} }
} }
@ -52,12 +54,12 @@ const Input = define('Input', {
const Textarea = define('Textarea', { const Textarea = define('Textarea', {
base: 'textarea', base: 'textarea',
padding: `${theme('spacing-sm')} ${theme('spacing-md')}`, padding: '12px 16px',
fontSize: 14, fontSize: 16,
border: `1px solid ${theme('colors-border')}`, border: '2px solid #e5e7eb',
borderRadius: theme('radius-sm'), borderRadius: 8,
background: theme('colors-bgElevated'), background: 'white',
color: theme('colors-fg'), color: '#111827',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
width: '100%', width: '100%',
minHeight: 120, minHeight: 120,
@ -68,31 +70,32 @@ const Textarea = define('Textarea', {
states: { states: {
':focus': { ':focus': {
outline: 'none', outline: 'none',
borderColor: theme('colors-borderActive'), borderColor: '#3b82f6',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)'
} }
} }
}) })
const FormGroup = define('FormGroup', { const FormGroup = define('FormGroup', {
marginBottom: theme('spacing-lg'), marginBottom: 24,
parts: { parts: {
Label: { Label: {
base: 'label', base: 'label',
display: 'block', display: 'block',
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 600,
color: theme('colors-fg'), color: '#374151',
marginBottom: theme('spacing-xs') marginBottom: 8
}, },
Helper: { Helper: {
fontSize: 12, fontSize: 13,
color: theme('colors-fgMuted'), color: '#6b7280',
marginTop: 6 marginTop: 6
}, },
Error: { Error: {
fontSize: 12, fontSize: 13,
color: '#ff0000', color: '#ef4444',
marginTop: 6 marginTop: 6
} }
}, },
@ -113,29 +116,29 @@ const Checkbox = define('Checkbox', {
parts: { parts: {
Input: { Input: {
base: 'input[type=checkbox]', base: 'input[type=checkbox]',
width: 18, width: 20,
height: 18, height: 20,
cursor: 'pointer' cursor: 'pointer'
}, },
Label: { Label: {
base: 'label', base: 'label',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: theme('spacing-sm'), gap: 12,
cursor: 'pointer', cursor: 'pointer',
fontSize: 14, fontSize: 16,
color: theme('colors-fgMuted'), color: '#374151',
states: { states: {
':hover': { ':hover': {
color: theme('colors-fg') color: '#111827'
} }
}, },
selectors: { selectors: {
'@Input:disabled + &': { '@Input:disabled + &': {
cursor: 'not-allowed', cursor: 'not-allowed',
color: theme('colors-fgDim') color: '#9ca3af'
} }
} }
} }
@ -161,20 +164,19 @@ const FormExamples = define('FormExamples', {
const Button = define('FormButton', { const Button = define('FormButton', {
base: 'button', base: 'button',
padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`, padding: '12px 24px',
fontSize: 14, fontSize: 16,
fontWeight: 400, fontWeight: 600,
border: `1px solid ${theme('colors-accent')}`, border: 'none',
borderRadius: theme('radius-sm'), borderRadius: 8,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
background: theme('colors-accent'), background: '#3b82f6',
color: theme('colors-bg'), color: 'white',
states: { states: {
':hover': { ':hover': {
background: theme('colors-accentDim'), background: '#2563eb'
borderColor: theme('colors-accentDim'),
}, },
':active': { ':active': {
transform: 'translateY(1px)' transform: 'translateY(1px)'
@ -184,12 +186,11 @@ const Button = define('FormButton', {
variants: { variants: {
variant: { variant: {
secondary: { secondary: {
background: theme('colors-bgElevated'), background: '#6b7280',
color: theme('colors-fg'), color: 'white',
border: `1px solid ${theme('colors-border')}`,
states: { states: {
':hover': { ':hover': {
borderColor: theme('colors-borderActive'), background: '#4b5563'
} }
} }
} }
@ -199,8 +200,8 @@ const Button = define('FormButton', {
const ButtonGroup = define('FormButtonGroup', { const ButtonGroup = define('FormButtonGroup', {
display: 'flex', display: 'flex',
gap: theme('spacing-sm'), gap: 12,
marginTop: theme('spacing-lg') marginTop: 24
}) })
export const FormExamplesContent = () => ( export const FormExamplesContent = () => (

View File

@ -1,6 +1,5 @@
import { define } from '../src' import { define } from '../src'
import { ExampleSection } from './ssr/helpers' import { ExampleSection } from './ssr/helpers'
import { theme } from './ssr/themes'
const TabSwitcher = define('TabSwitcher', { const TabSwitcher = define('TabSwitcher', {
parts: { parts: {
@ -11,41 +10,41 @@ const TabSwitcher = define('TabSwitcher', {
TabBar: { TabBar: {
display: 'flex', display: 'flex',
gap: 0, gap: 0,
borderBottom: `1px solid ${theme('colors-border')}`, borderBottom: '2px solid #e5e7eb',
marginBottom: theme('spacing-lg'), marginBottom: 24,
}, },
TabLabel: { TabLabel: {
base: 'label', base: 'label',
padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`, padding: '12px 24px',
position: 'relative', position: 'relative',
marginBottom: -1, marginBottom: -2,
background: 'transparent', background: 'transparent',
borderBottom: '1px solid transparent', borderBottom: '2px solid transparent',
color: theme('colors-fgMuted'), color: '#6b7280',
fontSize: 14, fontSize: 14,
fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
color: theme('colors-fg'), color: '#111827',
} }
}, },
selectors: { selectors: {
'@Input:checked + &': { '@Input:checked + &': {
color: theme('colors-accent'), color: '#3b82f6',
borderBottom: `1px solid ${theme('colors-accent')}` borderBottom: '2px solid #3b82f6'
} }
} }
}, },
Content: { Content: {
display: 'none', display: 'none',
padding: theme('spacing-lg'), padding: 20,
background: theme('colors-bgElevated'), background: '#f9fafb',
border: `1px solid ${theme('colors-border')}`, borderRadius: 8,
borderRadius: theme('radius-sm'),
selectors: { selectors: {
'@Input:checked ~ &': { '@Input:checked ~ &': {
@ -92,37 +91,36 @@ const Pills = define('Pills', {
}, },
PillBar: { PillBar: {
display: 'flex', display: 'flex',
gap: theme('spacing-xs'), gap: 8,
flexWrap: 'wrap', flexWrap: 'wrap',
}, },
PillLabel: { PillLabel: {
base: 'label', base: 'label',
padding: `${theme('spacing-xs')} ${theme('spacing-md')}`, padding: '8px 16px',
background: theme('colors-bgElevated'), background: '#f3f4f6',
border: `1px solid ${theme('colors-border')}`, border: 'none',
borderRadius: 20, borderRadius: 20,
color: theme('colors-fgMuted'), color: '#6b7280',
fontSize: 14, fontSize: 14,
fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
borderColor: theme('colors-borderActive'), background: '#e5e7eb',
color: theme('colors-fg'), color: '#111827',
} }
}, },
selectors: { selectors: {
'@Input:checked + &': { '@Input:checked + &': {
background: theme('colors-accent'), background: '#3b82f6',
borderColor: theme('colors-accent'), color: 'white'
color: theme('colors-bg')
}, },
'@Input:checked + &:hover': { '@Input:checked + &:hover': {
background: theme('colors-accentDim'), background: '#2563eb'
borderColor: theme('colors-accentDim'),
} }
} }
} }
@ -166,36 +164,34 @@ const VerticalNav = define('VerticalNav', {
NavLabel: { NavLabel: {
base: 'label', base: 'label',
padding: `${theme('spacing-sm')} ${theme('spacing-md')}`, padding: '12px 16px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: theme('spacing-sm'), gap: 12,
background: 'transparent', background: 'transparent',
border: `1px solid transparent`, borderRadius: 8,
borderRadius: theme('radius-sm'), color: '#6b7280',
color: theme('colors-fgMuted'),
fontSize: 14, fontSize: 14,
fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
background: theme('colors-bgElevated'), background: '#f3f4f6',
borderColor: theme('colors-border'), color: '#111827',
color: theme('colors-fg'),
} }
}, },
selectors: { selectors: {
'@Input:checked + &': { '@Input:checked + &': {
background: theme('colors-bgElevated'), background: '#eff6ff',
borderColor: theme('colors-accent'), color: '#3b82f6',
color: theme('colors-accent'),
}, },
'@Input:checked + &:hover': { '@Input:checked + &:hover': {
borderColor: theme('colors-accentDim'), background: '#dbeafe',
color: theme('colors-accentDim') color: '#2563eb'
} }
} }
}, },
@ -205,7 +201,7 @@ const VerticalNav = define('VerticalNav', {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: 16, fontSize: 18,
} }
}, },
@ -236,32 +232,33 @@ const VerticalNav = define('VerticalNav', {
const Breadcrumbs = define('Breadcrumbs', { const Breadcrumbs = define('Breadcrumbs', {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: theme('spacing-xs'), gap: 8,
flexWrap: 'wrap', flexWrap: 'wrap',
parts: { parts: {
Item: { Item: {
base: 'a', base: 'a',
color: theme('colors-fgMuted'), color: '#6b7280',
fontSize: 14, fontSize: 14,
textDecoration: 'none', textDecoration: 'none',
transition: 'color 0.2s ease', transition: 'color 0.2s ease',
states: { states: {
':hover': { ':hover': {
color: theme('colors-accent'), color: '#3b82f6',
} }
} }
}, },
Separator: { Separator: {
color: theme('colors-fgDim'), color: '#d1d5db',
fontSize: 14, fontSize: 14,
userSelect: 'none', userSelect: 'none',
}, },
Current: { Current: {
color: theme('colors-fg'), color: '#111827',
fontSize: 14, fontSize: 14,
fontWeight: 500,
} }
}, },
@ -290,25 +287,26 @@ const Breadcrumbs = define('Breadcrumbs', {
const Tabs = define('Tabs', { const Tabs = define('Tabs', {
display: 'flex', display: 'flex',
gap: 0, gap: 0,
borderBottom: `1px solid ${theme('colors-border')}`, borderBottom: '2px solid #e5e7eb',
parts: { parts: {
Tab: { Tab: {
base: 'button', base: 'button',
padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`, padding: '12px 24px',
position: 'relative', position: 'relative',
marginBottom: -1, marginBottom: -2,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
borderBottom: '1px solid transparent', borderBottom: '2px solid transparent',
color: theme('colors-fgMuted'), color: '#6b7280',
fontSize: 14, fontSize: 14,
fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
color: theme('colors-fg'), color: '#111827',
} }
} }
} }
@ -318,8 +316,8 @@ const Tabs = define('Tabs', {
active: { active: {
parts: { parts: {
Tab: { Tab: {
color: theme('colors-accent'), color: '#3b82f6',
borderBottom: `1px solid ${theme('colors-accent')}`, borderBottom: '2px solid #3b82f6',
} }
} }
} }
@ -340,25 +338,26 @@ const Tabs = define('Tabs', {
const SimplePills = define('SimplePills', { const SimplePills = define('SimplePills', {
display: 'flex', display: 'flex',
gap: theme('spacing-xs'), gap: 8,
flexWrap: 'wrap', flexWrap: 'wrap',
parts: { parts: {
Pill: { Pill: {
base: 'button', base: 'button',
padding: `${theme('spacing-xs')} ${theme('spacing-md')}`, padding: '8px 16px',
background: theme('colors-bgElevated'), background: '#f3f4f6',
border: `1px solid ${theme('colors-border')}`, border: 'none',
borderRadius: 20, borderRadius: 20,
color: theme('colors-fgMuted'), color: '#6b7280',
fontSize: 14, fontSize: 14,
fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
borderColor: theme('colors-borderActive'), background: '#e5e7eb',
color: theme('colors-fg'), color: '#111827',
} }
} }
} }
@ -368,13 +367,12 @@ const SimplePills = define('SimplePills', {
active: { active: {
parts: { parts: {
Pill: { Pill: {
background: theme('colors-accent'), background: '#3b82f6',
borderColor: theme('colors-accent'), color: 'white',
color: theme('colors-bg'),
states: { states: {
':hover': { ':hover': {
background: theme('colors-accentDim'), background: '#2563eb',
borderColor: theme('colors-accentDim'), color: 'white',
} }
} }
} }
@ -404,24 +402,24 @@ const SimpleVerticalNav = define('SimpleVerticalNav', {
parts: { parts: {
NavItem: { NavItem: {
base: 'button', base: 'button',
padding: `${theme('spacing-sm')} ${theme('spacing-md')}`, padding: '12px 16px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: theme('spacing-sm'), gap: 12,
background: 'transparent', background: 'transparent',
border: `1px solid transparent`, border: 'none',
borderRadius: theme('radius-sm'), borderRadius: 8,
color: theme('colors-fgMuted'), color: '#6b7280',
fontSize: 14, fontSize: 14,
fontWeight: 500,
textAlign: 'left', textAlign: 'left',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
states: { states: {
':hover': { ':hover': {
background: theme('colors-bgElevated'), background: '#f3f4f6',
borderColor: theme('colors-border'), color: '#111827',
color: theme('colors-fg'),
} }
} }
}, },
@ -431,7 +429,7 @@ const SimpleVerticalNav = define('SimpleVerticalNav', {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: 16, fontSize: 18,
} }
}, },
@ -439,13 +437,12 @@ const SimpleVerticalNav = define('SimpleVerticalNav', {
active: { active: {
parts: { parts: {
NavItem: { NavItem: {
background: theme('colors-bgElevated'), background: '#eff6ff',
borderColor: theme('colors-accent'), color: '#3b82f6',
color: theme('colors-accent'),
states: { states: {
':hover': { ':hover': {
borderColor: theme('colors-accentDim'), background: '#dbeafe',
color: theme('colors-accentDim'), color: '#2563eb',
} }
} }
} }

View File

@ -1,23 +1,22 @@
import { define } from '../src' import { define } from '../src'
import { ExampleSection } from './ssr/helpers' import { ExampleSection } from './ssr/helpers'
import { theme } from './ssr/themes'
const UserProfile = define('UserProfile', { const UserProfile = define('UserProfile', {
base: 'div', base: 'div',
padding: theme('spacing-lg'), padding: 24,
maxWidth: 600, maxWidth: 600,
margin: "0 auto", margin: "0 auto",
background: theme('colors-bgElevated'), background: "white",
borderRadius: theme('radius-md'), borderRadius: 12,
border: theme('colors-accent'), boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
parts: { parts: {
Header: { Header: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: theme('spacing-md'), gap: 16,
marginBottom: theme('spacing-md'), marginBottom: 16,
}, },
Avatar: { Avatar: {
base: 'img', base: 'img',
@ -25,34 +24,34 @@ const UserProfile = define('UserProfile', {
height: 64, height: 64,
borderRadius: "50%", borderRadius: "50%",
objectFit: "cover", objectFit: "cover",
border: `2px solid ${theme('colors-border')}`, border: "3px solid #e5e7eb",
}, },
Info: { Info: {
flex: 1, flex: 1,
}, },
Name: { Name: {
marginBottom: 4, marginBottom: 4,
fontSize: 18, fontSize: 20,
fontWeight: 400, fontWeight: 600,
color: theme('colors-fg'), color: "#111827",
}, },
Handle: { Handle: {
fontSize: 14, fontSize: 14,
color: theme('colors-fgMuted'), color: "#6b7280",
}, },
Bio: { Bio: {
marginBottom: theme('spacing-md'), marginBottom: 16,
width: "100%", width: "100%",
fontSize: 14, fontSize: 14,
lineHeight: 1.6, lineHeight: 1.6,
color: theme('colors-fgMuted'), color: "#374151",
wordWrap: "break-word", wordWrap: "break-word",
}, },
Stats: { Stats: {
display: "flex", display: "flex",
gap: theme('spacing-lg'), gap: 24,
paddingTop: theme('spacing-md'), paddingTop: 16,
borderTop: `1px solid ${theme('colors-border')}`, borderTop: "1px solid #e5e7eb",
}, },
Stat: { Stat: {
display: "flex", display: "flex",
@ -61,12 +60,12 @@ const UserProfile = define('UserProfile', {
}, },
StatValue: { StatValue: {
fontSize: 18, fontSize: 18,
fontWeight: 400, fontWeight: 600,
color: theme('colors-fg'), color: "#111827",
}, },
StatLabel: { StatLabel: {
fontSize: 12, fontSize: 12,
color: theme('colors-fgMuted'), color: "#6b7280",
textTransform: "uppercase", textTransform: "uppercase",
}, },
}, },
@ -115,7 +114,19 @@ const UserProfile = define('UserProfile', {
verified: { verified: {
parts: { parts: {
Avatar: { Avatar: {
border: `2px solid ${theme('colors-accent')}`, border: "3px solid #3b82f6",
},
},
},
theme: {
dark: {
background: "#1f2937",
parts: {
Name: { color: "#f9fafb" },
Handle: { color: "#9ca3af" },
Bio: { color: "#d1d5db" },
Stats: { borderTop: "1px solid #374151" },
StatValue: { color: "#f9fafb" },
}, },
}, },
}, },
@ -196,9 +207,10 @@ export const ProfileExamplesContent = () => (
/> />
</ExampleSection> </ExampleSection>
<ExampleSection title="Verified User"> <ExampleSection title="Verified User (Dark Theme)">
<UserProfile <UserProfile
verified={true} verified={true}
theme="dark"
name="Jordan Smith" name="Jordan Smith"
username="jordansmith" username="jordansmith"
avatarUrl="https://i.pravatar.cc/150?img=8" avatarUrl="https://i.pravatar.cc/150?img=8"

View File

@ -1,68 +1,16 @@
import { define } from '../../src' import { define } from '../../src'
import { theme } from '../ssr/themes'
import { ButtonExamplesContent } from '../button' import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile' import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation' import { NavigationExamplesContent } from '../navigation'
import { FormExamplesContent } from '../form' 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 } }) {
return (
<Root>
<Select id="theme-select" onChange={themeChanged}>
<option value="dark">Dark</option>
<option value="light">Light</option>
</Select>
</Root>
)
}
})
function themeChanged(e: Event) {
const target = e.target as HTMLSelectElement
const themeName = target.value
document.body.setAttribute('data-theme', themeName)
localStorage.setItem('theme', themeName)
}
export const Main = define('SpaMain', { export const Main = define('SpaMain', {
base: 'div', base: 'div',
minHeight: '100%', minHeight: '100%',
height: '100%', padding: '40px 20px',
padding: theme('spacing-xl'), fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontFamily: theme('fonts-mono'), background: '#f3f4f6',
background: theme('colors-bg'),
color: theme('colors-fg'),
boxSizing: 'border-box',
}) })
export const Container = define('SpaContainer', { export const Container = define('SpaContainer', {
@ -76,20 +24,21 @@ export const Container = define('SpaContainer', {
const Link = define('Link', { const Link = define('Link', {
base: 'a', base: 'a',
color: theme('colors-fgMuted'), color: '#3b82f6',
textDecoration: 'none', textDecoration: 'none',
fontSize: 14, fontWeight: 500,
states: { states: {
hover: { hover: {
color: theme('colors-fg'), textDecoration: 'underline'
} }
}, },
selectors: { selectors: {
'&[aria-current]': { '&[aria-current]': {
color: theme('colors-fg'), color: '#1e40af',
textDecoration: 'underline', fontWeight: 600,
textDecoration: 'underline'
} }
}, },
@ -112,51 +61,52 @@ const Nav = define('Nav', {
base: 'nav', base: 'nav',
display: 'flex', display: 'flex',
gap: theme('spacing-lg'), gap: 20,
marginBottom: theme('spacing-xl'), marginBottom: 40,
padding: theme('spacing-lg'), padding: 20,
background: theme('colors-bgElevated'), background: 'white',
border: `1px solid ${theme('colors-border')}`, borderRadius: 8,
borderRadius: theme('radius-sm'), boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}) })
const P = define('P', { const P = define('P', {
color: theme('colors-fgMuted'), color: '#6b7280',
fontSize: 16, fontSize: 18,
marginBottom: theme('spacing-xxl'), marginBottom: 48,
}) })
const ExamplesGrid = define('ExamplesGrid', { const ExamplesGrid = define('ExamplesGrid', {
display: 'grid', display: 'grid',
gap: theme('spacing-lg'), gap: 20,
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
}) })
const ExampleCard = define('ExampleCard', { const ExampleCard = define('ExampleCard', {
base: 'a', base: 'a',
background: theme('colors-bgElevated'), background: 'white',
padding: theme('spacing-lg'), padding: 24,
border: `1px solid ${theme('colors-border')}`, borderRadius: 12,
borderRadius: theme('radius-sm'), boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
textDecoration: 'none', textDecoration: 'none',
transition: 'all 0.2s ease',
display: 'block', display: 'block',
states: { states: {
hover: { hover: {
borderColor: theme('colors-borderActive'), transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
} }
}, },
parts: { parts: {
H2: { H2: {
color: theme('colors-fg'), color: '#111827',
margin: `0 0 ${theme('spacing-sm')} 0`, margin: '0 0 8px 0',
fontSize: 18, fontSize: 20,
fontWeight: 400,
}, },
P: { P: {
color: theme('colors-fgMuted'), color: '#6b7280',
margin: 0, margin: 0,
fontSize: 14, fontSize: 14,
} }
@ -180,27 +130,27 @@ const ExampleCard = define('ExampleCard', {
const HomePage = () => ( const HomePage = () => (
<> <>
<P>Client-side rendered examples. Click around, check the source.</P> <P>Explore component examples built with Forge - Client-side SPA version</P>
<ExamplesGrid> <ExamplesGrid>
<ExampleCard href="/spa/profile" <ExampleCard href="/spa/profile"
title="Profile Card" title="Profile Card"
desc="Parts, variants, custom render. Size/theme switching." desc="User profile component with variants for size, theme, and verified status"
/> />
<ExampleCard href="/spa/buttons" <ExampleCard href="/spa/buttons"
title="Buttons" title="Buttons"
desc="Intent, size, disabled states. Basic variant patterns." desc="Button component with intent, size, and disabled variants"
/> />
<ExampleCard href="/spa/navigation" <ExampleCard href="/spa/navigation"
title="Navigation" title="Navigation"
desc="Tabs, pills, breadcrumbs, vertical nav. No router required." desc="Navigation patterns including tabs, pills, vertical nav, and breadcrumbs"
/> />
<ExampleCard href="/spa/form" <ExampleCard href="/spa/form"
title="Forms" title="Forms"
desc="Inputs, validation states, checkboxes, textareas." desc="Form inputs with validation states, checkboxes, textareas, and buttons"
/> />
</ExamplesGrid> </ExamplesGrid>
</> </>
@ -229,20 +179,6 @@ export function route(path: string) {
} }
} }
const HomeLink = define('HomeLink', {
base: 'a',
color: theme('colors-fgMuted'),
textDecoration: 'none',
fontSize: 14,
states: {
hover: {
color: theme('colors-fg'),
}
}
})
export function App() { export function App() {
const path = window.location.pathname const path = window.location.pathname
@ -250,13 +186,12 @@ export function App() {
<Main> <Main>
<Container> <Container>
<Nav> <Nav>
<HomeLink href="/">Home</HomeLink> <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" 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/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/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/navigation" aria-current={path === '/spa/navigation' ? 'page' : undefined}>Navigation</Link>
<Link href="/spa/form" aria-current={path === '/spa/form' ? 'page' : undefined}>Forms</Link> <Link href="/spa/form" aria-current={path === '/spa/form' ? 'page' : undefined}>Forms</Link>
<ThemePicker />
</Nav> </Nav>
<div id="content"> <div id="content">
{route(path)} {route(path)}

View File

@ -3,30 +3,16 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/main.css"/>
<title>Forge SPA Examples</title> <title>Forge SPA Examples</title>
<style> <style>
html, body { html, body {
margin: 0; margin: 0;
height: 100%;
}
#root {
height: 100%;
} }
</style> </style>
</head> </head>
<body data-theme="dark"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/spa.js"></script> <script type="module" src="/spa.js"></script>
<script>
// Load saved theme and apply it
const savedTheme = localStorage.getItem('theme') || 'dark'
document.body.setAttribute('data-theme', savedTheme)
// Set initial select value after page loads
window.addEventListener('load', () => {
const select = document.getElementById('theme-select')
if (select) select.value = savedTheme
})
</script>
</body> </body>
</html> </html>

View File

@ -1,30 +0,0 @@
export default {
'colors-bg': '#0a0a0a',
'colors-bgElevated': '#111',
'colors-bgHover': '#1a1a1a',
'colors-fg': '#00ff00',
'colors-fgMuted': '#888',
'colors-fgDim': '#444',
'colors-border': '#222',
'colors-borderActive': '#00ff00',
'colors-accent': '#00ff00',
'colors-accentDim': '#008800',
'fonts-mono': "'Monaco', 'Menlo', 'Consolas', monospace",
'fonts-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
'spacing-xs': '8px',
'spacing-sm': '12px',
'spacing-md': '16px',
'spacing-lg': '24px',
'spacing-xl': '32px',
'spacing-xxl': '48px',
'radius-sm': '4px',
'radius-md': '8px',
'radius-lg': '12px',
} as const

View File

@ -1,14 +1,12 @@
import { define } from '../../src' import { define, Styles } from '../../src'
import { theme } from './themes'
export const Body = define('Body', { export const Body = define('Body', {
base: 'body', base: 'body',
margin: 0, margin: 0,
padding: theme('spacing-xl'), padding: '40px 20px',
fontFamily: theme('fonts-mono'), fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
background: theme('colors-bg'), background: '#f3f4f6',
color: theme('colors-fg'),
}) })
const Container = define('Container', { const Container = define('Container', {
@ -19,23 +17,20 @@ const Container = define('Container', {
export const Header = define('Header', { export const Header = define('Header', {
base: 'h1', base: 'h1',
marginBottom: theme('spacing-xl'), marginBottom: 40,
color: theme('colors-fg'), color: '#111827'
fontSize: 28,
fontWeight: 400,
}) })
export const ExampleSection = define('ExampleSection', { export const ExampleSection = define('ExampleSection', {
marginBottom: theme('spacing-xl'), marginBottom: 40,
parts: { parts: {
Header: { Header: {
base: 'h2', base: 'h2',
marginBottom: theme('spacing-md'), marginBottom: 16,
color: theme('colors-fgMuted'), color: '#374151',
fontSize: 16, fontSize: 18
fontWeight: 400,
} }
}, },
render({ props, parts: { Root, Header } }) { render({ props, parts: { Root, Header } }) {
@ -48,100 +43,44 @@ export const ExampleSection = define('ExampleSection', {
} }
}) })
const Nav = define({ const Nav = define('SSR_Nav', {
base: 'nav', base: 'nav',
display: 'flex', display: 'flex',
gap: theme('spacing-lg'), gap: 20,
marginBottom: theme('spacing-xl'), marginBottom: 40,
padding: theme('spacing-lg'), padding: 20,
background: theme('colors-bgElevated'), background: 'white',
border: `1px solid ${theme('colors-border')}`, borderRadius: 8,
borderRadius: theme('radius-sm'), boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}) })
const NavLink = define({ const NavLink = define('SSR_NavLink', {
base: 'a', base: 'a',
color: theme('colors-fgMuted'), color: '#3b82f6',
textDecoration: 'none', textDecoration: 'none',
fontSize: 14, fontWeight: 500,
states: { states: {
hover: { hover: {
color: theme('colors-fg'), textDecoration: 'underline'
} }
}, },
selectors: { selectors: {
'&[aria-current]': { '&[aria-current]': {
color: theme('colors-fg'), color: '#1e40af',
textDecoration: 'underline', fontWeight: 600,
textDecoration: 'underline'
} }
} }
}) })
const ThemePicker = define('ThemePicker', {
marginLeft: 'auto',
parts: {
Select: {
base: 'select',
padding: `${theme('spacing-xs')} ${theme('spacing-md')}`,
background: theme('colors-bgElevated'),
border: `1px solid ${theme('colors-border')}`,
borderRadius: theme('radius-sm'),
color: theme('colors-fg'),
fontSize: 14,
cursor: 'pointer',
transition: 'all 0.2s ease',
states: {
':hover': {
borderColor: theme('colors-borderActive'),
},
':focus': {
outline: 'none',
borderColor: theme('colors-borderActive'),
}
}
}
},
render({ parts: { Root, Select } }) {
return (
<Root>
<Select id="theme-select" onchange="window.switchTheme(this.value)">
<option value="dark">Dark</option>
<option value="light">Light</option>
</Select>
</Root>
)
}
})
export const Layout = define({ export const Layout = define({
render({ props }) { render({ props }) {
const path = props.path || '' 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 ( return (
<html> <html>
<head> <head>
@ -159,12 +98,10 @@ export const Layout = define({
<NavLink href="/ssr/buttons" aria-current={path === '/ssr/buttons' ? 'page' : undefined}>Buttons</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/navigation" aria-current={path === '/ssr/navigation' ? 'page' : undefined}>Navigation</NavLink>
<NavLink href="/ssr/form" aria-current={path === '/ssr/form' ? 'page' : undefined}>Forms</NavLink> <NavLink href="/ssr/form" aria-current={path === '/ssr/form' ? 'page' : undefined}>Forms</NavLink>
<ThemePicker />
</Nav> </Nav>
<Header>{props.title}</Header> <Header>{props.title}</Header>
{props.children} {props.children}
</Container> </Container>
<script dangerouslySetInnerHTML={{ __html: themeScript }}></script>
</Body> </Body>
</html> </html>
) )

View File

@ -1,5 +1,4 @@
import { createScope, Styles } from '../../src' import { createScope, Styles } from '../../src'
import { theme } from './themes'
const { define } = createScope('Landing') const { define } = createScope('Landing')
@ -7,110 +6,114 @@ const Page = define('Page', {
base: 'body', base: 'body',
margin: 0, margin: 0,
padding: theme('spacing-xl'), padding: 0,
minHeight: '100vh', minHeight: '100vh',
fontFamily: theme('fonts-mono'), display: 'flex',
background: theme('colors-bg'), alignItems: 'center',
color: theme('colors-fg'), justifyContent: 'center',
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}) })
const Container = define('Container', { const Container = define('Container', {
maxWidth: 800, textAlign: 'center',
margin: '0 auto', color: 'white',
}) })
const Pre = define('Pre', { const Title = define('Title', {
base: 'pre', base: 'h1',
fontSize: 14, fontSize: 48,
lineHeight: 1.4, fontWeight: 700,
marginBottom: theme('spacing-xl'), marginBottom: 50,
color: theme('colors-fg'), color: 'white',
whiteSpace: 'pre',
borderBottom: '1px solid var(--theme-colors-border)',
}) })
const P = define('P', { const Subtitle = define('Subtitle', {
base: 'p', base: 'p',
fontSize: 16, fontSize: 20,
lineHeight: 1.6, marginBottom: 48,
marginBottom: theme('spacing-xl'), color: 'rgba(255, 255, 255, 0.9)',
color: theme('colors-fgMuted'),
}) })
const LinkSection = define('LinkSection', { const ButtonGroup = define('ButtonGroup', {
marginTop: theme('spacing-xxl'), display: 'flex',
paddingTop: theme('spacing-xl'), gap: 50,
borderTop: `1px solid ${theme('colors-border')}`, justifyContent: 'center',
flexWrap: 'wrap',
}) })
const Link = define('Link', { const ChoiceCard = define('ChoiceCard', {
base: 'a', base: 'a',
display: 'inline-block', display: 'block',
marginRight: theme('spacing-xl'), padding: 40,
padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`, background: 'white',
background: theme('colors-bgElevated'), borderRadius: 16,
border: `1px solid ${theme('colors-border')}`,
color: theme('colors-fg'),
textDecoration: 'none', textDecoration: 'none',
fontSize: 14, color: '#111827',
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
transition: 'all 0.3s ease',
minWidth: 250,
states: { states: {
':hover': { ':hover': {
background: theme('colors-bgHover'), transform: 'translateY(-8px)',
borderColor: theme('colors-borderActive'), 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 = () => { export const LandingPage = () => (
const themeScript = ` <html>
function switchTheme(themeName) { <head>
document.body.setAttribute('data-theme', themeName) <meta charset="UTF-8" />
localStorage.setItem('theme', themeName) <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>
window.switchTheme = switchTheme <ButtonGroup>
<ChoiceCard
href="/ssr"
icon="🖥️"
title="SSR Examples"
/>
// Load saved theme or default to dark <ChoiceCard
const savedTheme = localStorage.getItem('theme') || 'dark' href="/spa"
document.body.setAttribute('data-theme', savedTheme) icon="⚡"
` title="SPA Examples"
/>
return ( </ButtonGroup>
<html> </Container>
<head> </Page>
<meta charset="UTF-8" /> </html>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> )
<title>forge</title>
<Styles />
</head>
<Page data-theme="dark">
<Container>
<Pre>{`╔═╝╔═║╔═║╔═╝╔═╝
`}</Pre>
<P>
Typed, local, variant-driven CSS. No globals, no selector hell, no inline styles.
Built for TSX. Compiles to real CSS.
</P>
<P>
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.
</P>
<LinkSection>
<Link href="/ssr">SSR demos </Link>
<Link href="/spa">SPA demos </Link>
</LinkSection>
</Container>
<script dangerouslySetInnerHTML={{ __html: themeScript }}></script>
</Page>
</html>
)
}

View File

@ -1,29 +0,0 @@
export default {
'colors-bg': '#f5f5f0',
'colors-bgElevated': '#fff',
'colors-bgHover': '#e8e8e0',
'colors-fg': '#0a0a0a',
'colors-fgMuted': '#666',
'colors-fgDim': '#999',
'colors-border': '#ddd',
'colors-borderActive': '#008800',
'colors-accent': '#008800',
'colors-accentDim': '#00aa00',
'fonts-mono': "'Monaco', 'Menlo', 'Consolas', monospace",
'fonts-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
'spacing-xs': '8px',
'spacing-sm': '12px',
'spacing-md': '16px',
'spacing-lg': '24px',
'spacing-xl': '32px',
'spacing-xxl': '48px',
'radius-sm': '4px',
'radius-md': '8px',
'radius-lg': '12px',
} as const

View File

@ -1,48 +1,48 @@
import { define } from '../../src' import { define } from '../../src'
import { Layout } from './helpers' import { Layout } from './helpers'
import { theme } from './themes'
import { ButtonExamplesContent } from '../button' import { ButtonExamplesContent } from '../button'
import { ProfileExamplesContent } from '../profile' import { ProfileExamplesContent } from '../profile'
import { NavigationExamplesContent } from '../navigation' import { NavigationExamplesContent } from '../navigation'
import { FormExamplesContent } from '../form' import { FormExamplesContent } from '../form'
const P = define('SSR_P', { const P = define('SSR_P', {
color: theme('colors-fgMuted'), color: '#6b7280',
fontSize: 16, fontSize: 18,
marginBottom: theme('spacing-xxl'), marginBottom: 48,
}) })
const ExamplesGrid = define('SSR_ExamplesGrid', { const ExamplesGrid = define('SSR_ExamplesGrid', {
display: 'grid', display: 'grid',
gap: theme('spacing-lg'), gap: 20,
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
}) })
const ExampleCard = define('SSR_ExampleCard', { const ExampleCard = define('SSR_ExampleCard', {
base: 'a', base: 'a',
background: theme('colors-bgElevated'), background: 'white',
padding: theme('spacing-lg'), padding: 24,
border: `1px solid ${theme('colors-border')}`, borderRadius: 12,
borderRadius: theme('radius-sm'), boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
textDecoration: 'none', textDecoration: 'none',
transition: 'all 0.2s ease',
display: 'block', display: 'block',
states: { states: {
hover: { hover: {
borderColor: theme('colors-borderActive'), transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
} }
}, },
parts: { parts: {
H2: { H2: {
color: theme('colors-fg'), color: '#111827',
margin: `0 0 ${theme('spacing-sm')} 0`, margin: '0 0 8px 0',
fontSize: 18, fontSize: 20,
fontWeight: 400,
}, },
P: { P: {
color: theme('colors-fgMuted'), color: '#6b7280',
margin: 0, margin: 0,
fontSize: 14, fontSize: 14,
} }
@ -60,27 +60,27 @@ const ExampleCard = define('SSR_ExampleCard', {
export const IndexPage = ({ path }: any) => ( export const IndexPage = ({ path }: any) => (
<Layout title="Forge Examples" path={path}> <Layout title="Forge Examples" path={path}>
<P>Server-rendered demos. View source to see the static CSS.</P> <P>Explore component examples built with Forge</P>
<ExamplesGrid> <ExamplesGrid>
<ExampleCard href="/ssr/profile" <ExampleCard href="/ssr/profile"
title="Profile Card" title="Profile Card"
desc="Parts, variants, custom render. Size/theme switching." desc="User profile component with variants for size, theme, and verified status"
/> />
<ExampleCard href="/ssr/buttons" <ExampleCard href="/ssr/buttons"
title="Buttons" title="Buttons"
desc="Intent, size, disabled states. Basic variant patterns." desc="Button component with intent, size, and disabled variants"
/> />
<ExampleCard href="/ssr/navigation" <ExampleCard href="/ssr/navigation"
title="Navigation" title="Navigation"
desc="Tabs, pills, breadcrumbs, vertical nav. No router required." desc="Navigation patterns including tabs, pills, vertical nav, and breadcrumbs"
/> />
<ExampleCard href="/ssr/form" <ExampleCard href="/ssr/form"
title="Forms" title="Forms"
desc="Inputs, validation states, checkboxes, textareas." desc="Form inputs with validation states, checkboxes, textareas, and buttons"
/> />
</ExamplesGrid> </ExamplesGrid>
</Layout> </Layout>

View File

@ -1,9 +0,0 @@
import { createThemes } from '../../src'
import darkTheme from './darkTheme'
import lightTheme from './lightTheme'
// Register themes and get a typed theme function in one step
export const theme = createThemes({
dark: darkTheme,
light: lightTheme
})

View File

@ -2,70 +2,6 @@ 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>> = {}
const themes: Record<string, Record<string, any>> = {}
// Type registry for theme variables (will be auto-populated)
let registeredThemeKeys: Set<string> = new Set()
// Clear all registered styles
export function clearStyles() {
for (const key in styles) delete styles[key]
}
// Register a theme with CSS custom properties
export function createTheme<const T extends Record<string, string | number>>(
name: string,
values: T
): T {
themes[name] = values as Record<string, string>
// track for runtime validation
Object.keys(values).forEach(key => registeredThemeKeys.add(key))
return values
}
// Generate CSS for all registered themes
export function themesToCSS(): string {
let out: string[] = []
for (const [name, vars] of Object.entries(themes)) {
out.push(`[data-theme="${name}"] {`)
for (const [key, value] of Object.entries(vars)) {
out.push(` --theme-${key}: ${value};`)
}
out.push(`}\n`)
}
return out.join('\n')
}
// Helper type to extract theme keys from multiple theme objects
type ThemeKeys<T> = T extends Record<string, any> ? keyof T : never
// Create a typed themeVar function from your themes
export function createThemedVar<T extends Record<string, any>>(_themes: T) {
return function themeVar<K extends ThemeKeys<T[keyof T]>>(name: K): string {
return `var(--theme-${name as string})`
}
}
// Simplified API: register multiple themes and get typed themeVar in one call
type Theme = Record<string, string | number>
export function createThemes<T extends Record<string, Theme>>(themes: T) {
const registeredThemes = {} as T
for (const [name, values] of Object.entries(themes)) {
(registeredThemes as any)[name] = createTheme(name, values)
}
return createThemedVar(registeredThemes)
}
// Generic themeVar (untyped fallback)
export function themeVar(name: string): string {
return `var(--theme-${name as string})`
}
// All CSS styles inside <style></style. // All CSS styles inside <style></style.
// Use w/ SSR: <Styles/> // Use w/ SSR: <Styles/>
@ -93,14 +29,6 @@ function injectStylesInBrowser() {
export function stylesToCSS(): string { export function stylesToCSS(): string {
let out: string[] = [] let out: string[] = []
// Include theme CSS first
const themeCSS = themesToCSS()
if (themeCSS) {
out.push(themeCSS)
out.push('\n')
}
// Then component styles
for (const [selector, style] of Object.entries(styles)) { for (const [selector, style] of Object.entries(styles)) {
if (Object.keys(style).length === 0) continue if (Object.keys(style).length === 0) continue
@ -269,6 +197,9 @@ function registerStyles(name: string, def: TagDef) {
injectStylesInBrowser() injectStylesInBrowser()
} }
// automatic names
let anonComponents = 1
// module-level scoping // module-level scoping
export function createScope(scope: string) { export function createScope(scope: string) {
return { return {
@ -276,15 +207,15 @@ export function createScope(scope: string) {
if (typeof nameOrDef === 'string') if (typeof nameOrDef === 'string')
return define(`${scope}${nameOrDef === 'Root' ? '' : nameOrDef}`, defIfNamed) return define(`${scope}${nameOrDef === 'Root' ? '' : nameOrDef}`, defIfNamed)
else else
return define(`${scope}${anonName(nameOrDef)}`, nameOrDef as TagDef) 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 def = defIfNamed ?? nameOrDef as TagDef const def = defIfNamed ?? nameOrDef as TagDef
const name = defIfNamed ? (nameOrDef as string) : anonName(def)
if (styles[name]) throw `${name} is already defined! Must use unique names.` if (styles[name]) throw `${name} is already defined! Must use unique names.`
registerStyles(name, def) registerStyles(name, def)
@ -300,22 +231,5 @@ export function define(nameOrDef: string | TagDef, defIfNamed?: TagDef) {
} }
} }
// automatic names
const anonComponents: Record<string, number> = {}
// div tag -> Div1
function anonName(def: TagDef): string {
const base = (def.base ?? 'div')
const count = (anonComponents[base] ??= 1)
anonComponents[base] += 1
return tagName(base) + String(count)
}
// a => Anchor, nav => Nav
function tagName(base: string): string {
const capitalized = base.slice(0, 1).toUpperCase() + base.slice(1)
return capitalized === 'A' ? 'Anchor' : capitalized
}
// shortcut so you only have to import one thing, if you want // shortcut so you only have to import one thing, if you want
define.Styles = Styles define.Styles = Styles