Compare commits

...

29 Commits
main ... main

Author SHA1 Message Date
bc2b68d97e 0.0.11 2026-04-08 14:48:24 -07:00
b730f394e6 Fix TypeScript type assertions and null safety 2026-04-08 14:48:20 -07:00
499ab7f769 0.0.10 2026-04-08 14:44:31 -07:00
9301aed3e3 Add ForgeComponent type and define overloads 2026-04-08 14:44:27 -07:00
dcf4f1062b 0.0.9 2026-04-07 22:37:36 -07:00
65904850ac 0.0.8 2026-04-07 22:37:33 -07:00
de7cd6d646 Handle dangerouslySetInnerHTML prop safely 2026-04-07 22:37:33 -07:00
1100f6ad01 Use --watch, not --hot 2026-04-07 20:03:48 -07:00
de20042d14 0.0.7 2026-03-25 20:53:41 -07:00
5eaef0c5de 0.0.6 2026-03-25 20:53:37 -07:00
ad1cfff52c Remove TypeScript peer dependency 2026-03-25 20:51:14 -07:00
8571b544e6 Move parts creation outside component function 2026-03-11 19:16:26 -07:00
1b6a19befe 0.0.5 2026-03-11 14:41:34 -07:00
1e8f37650e Add static Component.Part syntax 2026-03-11 14:41:23 -07:00
22f1abc625 0.0.4 2026-03-11 13:30:28 -07:00
239ad7459e Add global css() function for element/reset styles 2026-03-11 13:15:38 -07:00
8389a41c59 guide 2026-02-18 20:48:46 -08:00
a1578a770a 0.0.3 2026-02-12 07:45:29 -08:00
30db3822d6 don't regenerate root every render 2026-02-12 07:45:15 -08:00
Chris Wanstrath
860ceba320 0.0.2 2026-02-04 16:15:35 -08:00
Chris Wanstrath
939c9c77d6 more css properties 2026-02-04 16:15:25 -08:00
67180bb4f3 @because/forge 2026-01-29 19:37:11 -08:00
Chris Wanstrath
46652243c5 v0.1.1 2026-01-29 18:34:52 -08:00
Chris Wanstrath
50a0e2642c npm 2026-01-29 18:34:20 -08:00
debfd73ab2 fix . insertion 2026-01-27 21:02:15 -08:00
9b6e1e91ec parts can have a custom render() 2026-01-24 10:04:49 -08:00
e772e0e711 remove hono dependency 2026-01-20 16:42:19 -08:00
50aa4c5d07 Merge pull request 'Fix HMR import.meta.hot for Bun static analysis' (#1) from probablycorey/forge:fix/hmr-static-analysis into main
Reviewed-on: defunkt/forge#1
2026-01-21 00:02:47 +00:00
8484553029 Fix HMR import.meta.hot for Bun static analysis
Use direct conditional instead of optional chaining so Bun can
statically analyze and remove the HMR block in production builds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 10:46:15 -08:00
11 changed files with 1661 additions and 54 deletions

1
.npmrc Normal file
View File

@ -0,0 +1 @@
registry=https://npm.nose.space

View File

@ -24,7 +24,9 @@ CSS is hostile to humans at scale: global namespace, no markup-to-definition lin
**Parts** - Sub-components within a component (e.g., Header, Body, Footer)
- Defined via `parts: { PartName: { ...styles } }`
- Accessible in render as `parts.PartName`
- Also accessible as static properties: `Component.PartName` (no custom render needed)
- Generate classes like `ComponentName_PartName`
- Variant part styles generate dual CSS: direct class (`.Part.variant`) and descendant (`.Root.variant .Part`)
**Variants** - Conditional styling based on props
- Boolean: `variants: { active: { color: 'blue' } }``<Component active />`

View File

@ -118,6 +118,43 @@ console.log(<Profile pic={user.pic} bio={user.bio} />)
console.log(<Profile size="small" pic={user.pic} bio={user.bio} />)
```
### parts + `Component.Part`
Parts are also available as static properties on the component, so you
can compose without writing a custom `render()`:
```tsx
const Card = define("Card", {
padding: 20,
background: "#111",
parts: {
Title: { base: "h2", fontSize: 24 },
Body: { color: "gray" },
},
variants: {
dark: {
background: "black",
parts: {
Body: { color: "white" },
},
},
},
})
// Usage
<Card>
<Card.Title>Hello</Card.Title>
<Card.Body>World</Card.Body>
</Card>
<Card dark>
<Card.Title>Dark mode</Card.Title>
<Card.Body>Variant styles reach parts automatically</Card.Body>
</Card>
```
### selectors
Use `selectors` to write custom CSS selectors. Reference the current

View File

@ -1,40 +1,33 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"configVersion": 1,
"workspaces": {
"": {
"name": "forge",
"dependencies": {
"hono": "^4.11.3",
},
"name": "@because/forge",
"devDependencies": {
"@types/bun": "latest",
"@types/prismjs": "^1.26.5",
"hono": "^4.11.3",
"prismjs": "^1.30.0",
"snarkdown": "^2.0.0",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/bun": ["@types/bun@1.3.11", "https://npm.nose.space/@types/bun/-/bun-1.3.11.tgz", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/node": ["@types/node@25.5.0", "https://npm.nose.space/@types/node/-/node-25.5.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
"@types/prismjs": ["@types/prismjs@1.26.6", "https://npm.nose.space/@types/prismjs/-/prismjs-1.26.6.tgz", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bun-types": ["bun-types@1.3.11", "https://npm.nose.space/bun-types/-/bun-types-1.3.11.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
"hono": ["hono@4.12.9", "https://npm.nose.space/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"prismjs": ["prismjs@1.30.0", "https://npm.nose.space/prismjs/-/prismjs-1.30.0.tgz", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"snarkdown": ["snarkdown@2.0.0", "", {}, "sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A=="],
"snarkdown": ["snarkdown@2.0.0", "https://npm.nose.space/snarkdown/-/snarkdown-2.0.0.tgz", {}, "sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

1140
docs/GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -426,6 +426,43 @@ console.log(<Profile pic={user.pic} bio={user.bio} />)
console.log(<Profile size="small" pic={user.pic} bio={user.bio} />)
\`\`\`
### parts + \`Component.Part\`
Parts are also available as static properties on the component, so you
can compose without writing a custom \`render()\`:
\`\`\`tsx
const Card = define("Card", {
padding: 20,
background: "#111",
parts: {
Title: { base: "h2", fontSize: 24 },
Body: { color: "gray" },
},
variants: {
dark: {
background: "black",
parts: {
Body: { color: "white" },
},
},
},
})
// Usage
<Card>
<Card.Title>Hello</Card.Title>
<Card.Body>World</Card.Body>
</Card>
<Card dark>
<Card.Title>Dark mode</Card.Title>
<Card.Body>Variant styles reach parts automatically</Card.Body>
</Card>
\`\`\`
### selectors
Use \`selectors\` to write custom CSS selectors. Reference the current

View File

@ -1,6 +1,6 @@
{
"name": "forge",
"version": "0.1.0",
"name": "@because/forge",
"version": "0.0.11",
"type": "module",
"main": "src/index.tsx",
"module": "src/index.tsx",
@ -11,21 +11,21 @@
"types": "./src/index.tsx"
}
},
"files": [
"src/index.tsx",
"src/types.ts",
"README.md"
],
"scripts": {
"dev": "bun build:spa && bun run --hot server.tsx",
"dev": "bun build:spa && bun --watch run server.tsx",
"test": "bun test",
"build:spa": "bun build examples/spa/index.tsx --outfile dist/spa.js --target browser"
},
"devDependencies": {
"@types/bun": "latest",
"@types/prismjs": "^1.26.5",
"hono": "^4.11.3",
"prismjs": "^1.30.0",
"snarkdown": "^2.0.0"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"hono": "^4.11.3"
}
}

View File

@ -1,5 +1,8 @@
import type { JSX } from 'hono/jsx'
import { type TagDef, UnitlessProps, NonStyleKeys } from './types'
import { type TagDef, type HTMLTag, UnitlessProps, NonStyleKeys } from './types'
type ForgeComponent<Parts = {}> =
((props: Record<string, any>) => any) &
{ [K in keyof Parts]: (props: Record<string, any>) => any }
export const styles: Record<string, Record<string, string>> = {}
const themes: Record<string, Record<string, any>> = {}
@ -9,9 +12,6 @@ export function clearStyles() {
for (const key in themes) delete themes[key]
}
// HMR support - clear styles when module is replaced
import.meta.hot?.dispose(() => clearStyles())
export function createTheme<const T extends Record<string, string | number>>(name: string, values: T): T {
themes[name] = values as Record<string, string | number>
return values
@ -100,7 +100,7 @@ export function stylesToCSS(): string {
for (const [selector, style] of Object.entries(styles)) {
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)))
out.push(` ${name}: ${value};`)
out.push(`}\n`)
@ -109,10 +109,6 @@ export function stylesToCSS(): string {
return out.join('\n')
}
function expandSelector(selector: string): string {
return selector.startsWith('.') ? selector : `.${selector}`
}
// creates a CSS class name
function makeClassName(baseName: string, partName?: string, variantName?: string, variantKey?: string): string {
const cls = partName ? `${baseName}_${partName}` : baseName
@ -153,7 +149,7 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
// Extract element name from base (e.g., 'input[type=radio]' -> 'input')
const tagName = base.split('[')[0]
const Tag = (tagName) as keyof JSX.IntrinsicElements
const Tag = tagName as HTMLTag
// Extract attributes from base (e.g., 'input[type=radio]' -> { type: 'radio' })
const baseAttrs: Record<string, string> = {}
@ -195,7 +191,16 @@ function makeComponent(baseName: string, rootDef: TagDef, rootProps: Record<stri
classNames.push(variantKey === true ? variantName : `${variantName}-${variantKey}`)
}
return <Tag class={classNames.join(' ')} {...baseAttrs} {...props}>{children}</Tag>
const finalProps = { class: classNames.join(' '), ...baseAttrs, ...props, children }
if ((finalProps as any).dangerouslySetInnerHTML) {
const { children: _, ...rest } = finalProps
return <Tag {...rest} />
}
const content = (partName && def.render) ? def.render(finalProps) : children
return <Tag {...finalProps}>{content}</Tag>
}
}
@ -206,7 +211,7 @@ function stateName(state: string): string {
// Register base styles, selectors, and states for a class
function registerClassStyles(name: string, className: string, def: TagDef) {
styles[className] ??= makeStyle(def)
styles[`.${className}`] ??= makeStyle(def)
for (let [selector, selectorDef] of Object.entries(def.selectors ?? {})) {
selector = selector.replace(/@(\w+)/g, (_, partName) => `.${makeClassName(name, partName)}`)
@ -216,7 +221,7 @@ function registerClassStyles(name: string, className: string, def: TagDef) {
}
for (const [state, style] of Object.entries(def.states ?? {}))
styles[`${className}${stateName(state)}`] = makeStyle(style)
styles[`.${className}${stateName(state)}`] = makeStyle(style)
}
// adds CSS styles for tag definition
@ -244,8 +249,12 @@ function registerStyles(name: string, def: TagDef) {
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
const basePartClassName = makeClassName(name, partName)
// Direct class on part (for render({ parts }) path where parts inherit variant props)
const partClassName = `${basePartClassName}.${variantName}`
registerClassStyles(name, partClassName, partDef)
// Descendant selector (for Component.Part path where root has variant class)
const descendantClassName = `${baseClassName}.${variantName} .${basePartClassName}`
registerClassStyles(name, descendantClassName, partDef)
}
} else {
// Keyed variant - iterate over the keys
@ -254,8 +263,13 @@ function registerStyles(name: string, def: TagDef) {
registerClassStyles(name, className, variantDef)
for (const [partName, partDef] of Object.entries(variantDef.parts ?? {})) {
const basePartClassName = makeClassName(name, partName)
// Direct class on part (for render({ parts }) path)
const partClassName = makeClassName(name, partName, variantName, variantKey)
registerClassStyles(name, partClassName, partDef)
// Descendant selector (for Component.Part path)
const descendantClassName = `${className} .${basePartClassName}`
registerClassStyles(name, descendantClassName, partDef)
}
}
}
@ -270,7 +284,7 @@ export function createScope(scope: string) {
return {
define: (nameOrDef: string | TagDef, defIfNamed?: TagDef) => {
if (typeof nameOrDef === 'string')
return define(`${scope}${nameOrDef === 'Root' ? '' : nameOrDef}`, defIfNamed)
return define(`${scope}${nameOrDef === 'Root' ? '' : nameOrDef}`, defIfNamed!)
else
return define(`${scope}${anonName(nameOrDef)}`, nameOrDef as TagDef)
}
@ -278,22 +292,34 @@ export function createScope(scope: string) {
}
// the main event
export function define<const T extends TagDef>(name: string, def: T): ForgeComponent<T['parts']>
export function define<const T extends TagDef>(def: T): ForgeComponent<T['parts']>
export function define(nameOrDef: string | TagDef, defIfNamed?: 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)
return (props: Record<string, any>) => {
const parts: Record<string, Function> = {}
const currentProps: Record<string, any> = {}
const Root = makeComponent(name, def, currentProps)
const parts: Record<string, Function> = { Root }
for (const [part] of Object.entries(def.parts ?? {}))
parts[part] = makeComponent(name, def, props, part)
for (const [part] of Object.entries(def.parts ?? {}))
parts[part] = makeComponent(name, def, currentProps, part)
parts.Root = makeComponent(name, def, props)
return def.render?.({ props, parts }) ?? <parts.Root {...props}>{props.children}</parts.Root>
const component = (props: Record<string, any>) => {
for (const key in currentProps) delete currentProps[key]
Object.assign(currentProps, props)
return def.render?.({ props, parts }) ?? <Root {...props}>{props.children}</Root>
}
// Attach parts as static properties for Component.Part syntax
for (const [part] of Object.entries(def.parts ?? {}))
(component as any)[part] = makeComponent(name, def, {}, part)
return component
}
// automatic names
@ -313,5 +339,18 @@ function tagName(base: string): string {
return capitalized === 'A' ? 'Anchor' : capitalized
}
// global CSS for resets, element defaults, pseudo-elements
export function css(rules: Record<string, TagDef>) {
for (const [selector, def] of Object.entries(rules)) {
styles[selector] = makeStyle(def)
for (const [state, stateDef] of Object.entries(def.states ?? {}))
styles[`${selector}${stateName(state)}`] = makeStyle(stateDef)
}
injectStylesInBrowser()
}
// shortcut so you only have to import one thing, if you want
define.Styles = Styles
define.Styles = Styles
define.css = css

View File

@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test'
import { define } from '../index'
import { define, css } from '../index'
import { renderToString, getStylesCSS, parseCSS } from './test_helpers'
describe('define - basic functionality', () => {
@ -558,6 +558,82 @@ describe('custom render function', () => {
expect(html).toContain('<footer')
expect(html).toContain('Main Content')
})
test('part can have its own render function', () => {
const Component = define('PartRender', {
parts: {
Icon: {
base: 'span',
color: 'blue',
render: (props) => <> {props.children}</>
}
},
render: ({ props, parts }) => (
<parts.Root>
<parts.Icon>star</parts.Icon>
</parts.Root>
)
})
const html = renderToString(Component({}))
expect(html).toContain('★ star')
expect(html).toContain('class="PartRender_Icon"')
expect(html).toContain('<span') // base tag is preserved
})
test('part render receives props including children', () => {
let receivedProps: any
const Component = define('PartRenderProps', {
parts: {
Item: {
base: 'li',
render: (props) => {
receivedProps = props
return <>item: {props.children}</>
}
}
},
render: ({ props, parts }) => (
<parts.Root>
<parts.Item data-test="value">content</parts.Item>
</parts.Root>
)
})
const html = renderToString(Component({}))
expect(receivedProps.class).toBe('PartRenderProps_Item')
expect(receivedProps['data-test']).toBe('value')
expect(receivedProps.children).toBe('content')
expect(html).toContain('<li') // base tag preserved
expect(html).toContain('item: content')
})
test('part render works with variants', () => {
const Component = define('PartRenderVariant', {
variants: {
size: {
small: { fontSize: 12 },
large: { fontSize: 24 }
}
},
parts: {
Label: {
fontWeight: 'bold',
render: (props) => <>Label: {props.children}</>
}
},
render: ({ props, parts }) => (
<parts.Root>
<parts.Label>text</parts.Label>
</parts.Root>
)
})
const html = renderToString(Component({ size: 'large' }))
expect(html).toContain('Label: text')
expect(html).toContain('PartRenderVariant_Label')
})
})
describe('Styles component', () => {
@ -981,3 +1057,199 @@ describe('edge cases', () => {
expect(html).toContain('Second')
})
})
describe('Component.Part static access', () => {
test('parts are accessible as static properties', () => {
const Component = define('StaticParts', {
parts: {
Header: { base: 'header' },
Body: { base: 'main' }
}
})
expect(typeof (Component as any).Header).toBe('function')
expect(typeof (Component as any).Body).toBe('function')
})
test('static parts render with correct className and element', () => {
const Component = define('StaticPartRender', {
parts: {
Logo: { base: 'a', color: 'blue' },
Nav: { base: 'nav', display: 'flex' }
}
}) as any
const logoHtml = renderToString(Component.Logo({ href: '/', children: 'Home' }))
expect(logoHtml).toContain('class="StaticPartRender_Logo"')
expect(logoHtml).toContain('<a')
expect(logoHtml).toContain('href="/"')
expect(logoHtml).toContain('Home')
const navHtml = renderToString(Component.Nav({ children: 'Links' }))
expect(navHtml).toContain('class="StaticPartRender_Nav"')
expect(navHtml).toContain('<nav')
})
test('static parts work as JSX children of parent component', () => {
const Card = define('StaticCard', {
padding: 20,
parts: {
Title: { base: 'h2', fontSize: 24 },
Body: { base: 'div', padding: 10 }
}
}) as any
const html = renderToString(Card({
children: [
Card.Title({ children: 'Hello' }),
Card.Body({ children: 'World' })
]
}))
expect(html).toContain('class="StaticCard"')
expect(html).toContain('class="StaticCard_Title"')
expect(html).toContain('class="StaticCard_Body"')
expect(html).toContain('Hello')
expect(html).toContain('World')
})
test('variant on parent applies to static parts via descendant selector CSS', () => {
define('StaticVariantParts', {
parts: {
Item: { color: 'black' }
},
variants: {
theme: {
dark: {
backgroundColor: 'black',
parts: {
Item: { color: 'white' }
}
}
}
}
})
const css = getStylesCSS()
// Descendant selector for Component.Part usage
expect(css).toContain('.StaticVariantParts.theme-dark .StaticVariantParts_Item')
// Direct class selector still generated for render({ parts }) usage
expect(css).toContain('.StaticVariantParts_Item.theme-dark')
})
test('boolean variant on parent generates descendant selector CSS', () => {
define('StaticBoolVariant', {
parts: {
Icon: { base: 'span', opacity: 0.5 }
},
variants: {
active: {
parts: {
Icon: { opacity: 1 }
}
}
}
})
const css = getStylesCSS()
expect(css).toContain('.StaticBoolVariant.active .StaticBoolVariant_Icon')
expect(css).toContain('.StaticBoolVariant_Icon.active')
})
test('static parts can receive variant props directly', () => {
const Tabs = define('StaticTabs', {
parts: {
Tab: { base: 'button', color: 'gray' }
},
variants: {
active: {
parts: {
Tab: { color: 'blue' }
}
}
}
}) as any
const activeHtml = renderToString(Tabs.Tab({ active: true, children: 'Home' }))
expect(activeHtml).toContain('StaticTabs_Tab active')
expect(activeHtml).toContain('Home')
const inactiveHtml = renderToString(Tabs.Tab({ children: 'Settings' }))
expect(inactiveHtml).toContain('class="StaticTabs_Tab"')
expect(inactiveHtml).not.toContain('active')
})
})
describe('css() - global styles', () => {
test('registers bare element selectors', () => {
css({
'body': { margin: 0, fontFamily: 'system-ui' }
})
const out = getStylesCSS()
expect(out).toContain('body {')
expect(out).toContain('font-family: system-ui')
expect(out).toContain('margin: 0px')
})
test('registers universal selector', () => {
css({
'*': { boxSizing: 'border-box' }
})
const out = getStylesCSS()
expect(out).toContain('* {')
expect(out).toContain('box-sizing: border-box')
})
test('registers compound selectors', () => {
css({
'*, *::before, *::after': { boxSizing: 'border-box' }
})
const out = getStylesCSS()
expect(out).toContain('*, *::before, *::after {')
})
test('registers pseudo-element selectors', () => {
css({
'::-webkit-scrollbar': { width: 8 }
})
const out = getStylesCSS()
expect(out).toContain('::-webkit-scrollbar {')
expect(out).toContain('width: 8px')
})
test('handles states on global selectors', () => {
css({
'a': {
color: 'blue',
states: {
hover: { color: 'darkblue' }
}
}
})
const out = getStylesCSS()
expect(out).toContain('a {')
expect(out).toContain('a:hover {')
expect(out).toContain('color: darkblue')
})
test('registers multiple selectors at once', () => {
css({
'html': { lineHeight: 1.5 },
'h1': { fontSize: 32 },
'p': { margin: 0 }
})
const out = getStylesCSS()
expect(out).toContain('html {')
expect(out).toContain('h1 {')
expect(out).toContain('p {')
})
test('accessible via define.css', () => {
expect(define.css).toBe(css)
})
})

View File

@ -19,12 +19,12 @@ export function parseCSS(css: string): Record<string, Record<string, string>> {
const className = match[1]
const cssText = match[2]
styles[className] = {}
styles[className] ??= {}
const propMatches = Array.from(cssText.matchAll(/\s*([^:]+):\s*([^;]+);/g))
for (const propMatch of propMatches) {
if (!propMatch[1] || !propMatch[2]) continue
styles[className]![propMatch[1].trim()] = propMatch[2].trim()
styles[className]![propMatch[1].trim()] ??= propMatch[2].trim()
}
}

View File

@ -31,7 +31,24 @@ export type TagDef = {
rowGap?: number | string
gap?: number | string
// multi-column layout
columns?: string
columnCount?: number | 'auto'
columnWidth?: number | string
columnRule?: string
columnRuleColor?: string
columnRuleStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
columnRuleWidth?: number | string
columnFill?: 'auto' | 'balance' | 'balance-all'
columnSpan?: 'none' | 'all'
contain?: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint'
contentVisibility?: 'visible' | 'hidden' | 'auto'
// container queries
container?: string
containerType?: 'normal' | 'size' | 'inline-size'
containerName?: string
display?: 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'inline-grid' | 'flow-root' | 'none' | 'contents' | 'table' | 'table-row' | 'table-cell'
@ -133,6 +150,14 @@ export type TagDef = {
// visual/theme-related
animation?: string
animationName?: string
animationDuration?: string
animationTimingFunction?: string
animationDelay?: string
animationIterationCount?: number | 'infinite'
animationDirection?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'
animationFillMode?: 'none' | 'forwards' | 'backwards' | 'both'
animationPlayState?: 'running' | 'paused'
appearance?: 'none' | 'auto' | 'button' | 'textfield' | 'searchfield' | 'textarea' | 'checkbox' | 'radio'
backdropFilter?: string
@ -170,6 +195,12 @@ export type TagDef = {
borderTopStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden'
borderTopWidth?: number | string
borderWidth?: number | string
borderImage?: string
borderImageSource?: string
borderImageSlice?: number | string
borderImageWidth?: number | string
borderImageOutset?: number | string
borderImageRepeat?: 'stretch' | 'repeat' | 'round' | 'space' | string
// table-ish
borderCollapse?: 'collapse' | 'separate'
@ -182,10 +213,14 @@ export type TagDef = {
clipPath?: string
color?: string
colorScheme?: 'normal' | 'light' | 'dark' | 'light dark' | string
content?: string
counterReset?: string
counterIncrement?: 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'
filter?: string
imageRendering?: 'auto' | 'crisp-edges' | 'pixelated'
font?: string
fontFamily?: string
@ -208,6 +243,7 @@ export type TagDef = {
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'
objectPosition?: string
opacity?: number
@ -247,15 +283,37 @@ export type TagDef = {
wordBreak?: 'normal' | 'break-all' | 'keep-all' | 'break-word'
wordSpacing?: number | string
wordWrap?: 'normal' | 'break-word' | 'anywhere'
textRendering?: 'auto' | 'optimizeSpeed' | 'optimizeLegibility' | 'geometricPrecision'
textWrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty' | 'stable'
// CSS shapes
shapeOutside?: string
shapeMargin?: number | string
shapeImageThreshold?: number
// fragmentation / breaks
breakBefore?: 'auto' | 'avoid' | 'always' | 'all' | 'avoid-page' | 'page' | 'left' | 'right' | 'recto' | 'verso' | 'avoid-column' | 'column' | 'avoid-region' | 'region'
breakAfter?: 'auto' | 'avoid' | 'always' | 'all' | 'avoid-page' | 'page' | 'left' | 'right' | 'recto' | 'verso' | 'avoid-column' | 'column' | 'avoid-region' | 'region'
breakInside?: 'auto' | 'avoid' | 'avoid-page' | 'avoid-column' | 'avoid-region'
orphans?: number
widows?: number
transform?: string
transformOrigin?: string
transformStyle?: 'flat' | 'preserve-3d'
transformBox?: 'content-box' | 'border-box' | 'fill-box' | 'stroke-box' | 'view-box'
rotate?: string
scale?: string | number
translate?: string
perspective?: number | string
perspectiveOrigin?: string
backfaceVisibility?: 'visible' | 'hidden'
transition?: string
transitionProperty?: string
transitionDuration?: string
transitionTimingFunction?: string
transitionDelay?: string
visibility?: 'visible' | 'hidden' | 'collapse'
willChange?: 'auto' | 'scroll-position' | 'contents'
@ -300,6 +358,34 @@ export const UnitlessProps = new Set([
'opacity',
'order',
'orphans',
'scale',
'shapeImageThreshold',
'widows',
'zIndex'
])
])
// All standard HTML element tag names
export type HTMLTag =
| 'a' | 'abbr' | 'address' | 'area' | 'article' | 'aside' | 'audio'
| 'b' | 'base' | 'bdi' | 'bdo' | 'blockquote' | 'body' | 'br' | 'button'
| 'canvas' | 'caption' | 'cite' | 'code' | 'col' | 'colgroup'
| 'data' | 'datalist' | 'dd' | 'del' | 'details' | 'dfn' | 'dialog' | 'div' | 'dl' | 'dt'
| 'em' | 'embed'
| 'fieldset' | 'figcaption' | 'figure' | 'footer' | 'form'
| 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'head' | 'header' | 'hgroup' | 'hr' | 'html'
| 'i' | 'iframe' | 'img' | 'input' | 'ins'
| 'kbd'
| 'label' | 'legend' | 'li' | 'link'
| 'main' | 'map' | 'mark' | 'menu' | 'meta' | 'meter'
| 'nav' | 'noscript'
| 'object' | 'ol' | 'optgroup' | 'option' | 'output'
| 'p' | 'picture' | 'pre' | 'progress'
| 'q'
| 'rp' | 'rt' | 'ruby'
| 's' | 'samp' | 'script' | 'search' | 'section' | 'select' | 'slot' | 'small' | 'source' | 'span' | 'strong' | 'style' | 'sub' | 'summary' | 'sup'
| 'table' | 'tbody' | 'td' | 'template' | 'textarea' | 'tfoot' | 'th' | 'thead' | 'time' | 'title' | 'tr' | 'track'
| 'u' | 'ul'
| 'var' | 'video'
| 'wbr'
// SVG elements
| 'svg' | 'path' | 'circle' | 'ellipse' | 'line' | 'polygon' | 'polyline' | 'rect' | 'g' | 'defs' | 'use' | 'text' | 'tspan' | 'image' | 'clipPath' | 'mask' | 'pattern' | 'linearGradient' | 'radialGradient' | 'stop' | 'filter' | 'feBlend' | 'feColorMatrix' | 'feGaussianBlur' | 'symbol'