From c561207128210f674e5d28901795202cd118b4b3 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Mon, 29 Dec 2025 12:21:58 -0800 Subject: [PATCH] FORGE --- .gitignore | 34 ++ CLAUDE.md | 105 ++++ README.md | 92 ++++ bun.lock | 31 ++ examples/button.tsx | 123 +++++ examples/form.tsx | 252 ++++++++++ examples/navigation.tsx | 516 ++++++++++++++++++++ examples/profile.tsx | 238 +++++++++ examples/spa/app.tsx | 202 ++++++++ examples/spa/index.html | 18 + examples/spa/index.tsx | 19 + examples/ssr/helpers.tsx | 109 +++++ examples/ssr/landing.tsx | 119 +++++ examples/ssr/pages.tsx | 111 +++++ package.json | 19 + server.tsx | 40 ++ src/index.tsx | 235 +++++++++ src/tests/index.test.tsx | 983 ++++++++++++++++++++++++++++++++++++++ src/tests/test_helpers.ts | 32 ++ src/types.ts | 305 ++++++++++++ tsconfig.json | 30 ++ 21 files changed, 3613 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 examples/button.tsx create mode 100644 examples/form.tsx create mode 100644 examples/navigation.tsx create mode 100644 examples/profile.tsx create mode 100644 examples/spa/app.tsx create mode 100644 examples/spa/index.html create mode 100644 examples/spa/index.tsx create mode 100644 examples/ssr/helpers.tsx create mode 100644 examples/ssr/landing.tsx create mode 100644 examples/ssr/pages.tsx create mode 100644 package.json create mode 100644 server.tsx create mode 100644 src/index.tsx create mode 100644 src/tests/index.test.tsx create mode 100644 src/tests/test_helpers.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..645017e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# Forge - Structured CSS Authoring + +A typed, local, variant-driven way to author CSS. Compiles to real CSS but removes the chaos: no global conflicts, no selector gymnastics, no inline styles. Built for Hono JSX with SSR support. + +## The Problem + +CSS is hostile to humans at scale: global namespace, no markup-to-definition link, requires inline styles for per-instance variance, silent conflicts, selector complexity. + +## The Solution + +- **Local styles** - Attached via generated class names, not strings +- **Parts** - Named sub-components replace selectors (no `.Button > .Icon` nonsense) +- **Variants** - Typed parameters replace inline styles (no `style={{ color: x }}`) +- **Deterministic** - Known merge order, dev warnings for conflicts +- **Compiles to CSS** - Not a new language, not runtime CSS-in-JS, just organized CSS generation + +## Core Concepts + +**`define(name?, def)`** - Creates a styled component. Returns a component function. +- Accepts CSS properties in camelCase (auto-converts to kebab-case) +- Numbers auto-converted to `px` (except unitless props like `opacity`, `zIndex`) +- Generates CSS classes and registers styles globally + +**Parts** - Sub-components within a component (e.g., Header, Body, Footer) +- Defined via `parts: { PartName: { ...styles } }` +- Accessible in render as `parts.PartName` +- Generate classes like `ComponentName_PartName` + +**Variants** - Conditional styling based on props +- Boolean: `variants: { active: { color: 'blue' } }` → `` +- Keyed: `variants: { size: { small: {...}, large: {...} } }` → `` +- Work on both root and parts +- Generate classes like `ComponentName.variant-key` + +**States** - Pseudo-selectors like hover, focus +- `states: { hover: { background: 'blue' } }` → `.Class:hover { ... }` + +**Custom Render** - Override default rendering +- `render({ props, parts }) { return ... }` +- Compose parts manually, pass props through + +## File Structure + +- `src/index.tsx` - Main implementation (`define`, `Styles`, CSS generation) +- `src/types.ts` - `TagDef` type with all CSS properties, helper sets +- **`examples/`** - **REFERENCE THESE** for real-world usage patterns: + - `helpers.tsx` - Layout wrapper, reusable components (Body, Header, ThemeToggle) + - `index.tsx` - Landing page with grid, cards, parts, and custom render + - `button.tsx` - Button variants (intent, size, disabled) + - `profile.tsx` - Complex component with multiple parts and variants + - `navigation.tsx` - Tabs, pills, breadcrumbs, vertical nav patterns +- `src/tests/` - Comprehensive test suite with test helpers + +## Implementation Details + +- **Static CSS generation** - CSS created at component definition time, not runtime +- **Global styles registry** - `styles` object stores all CSS as plain objects +- **`Styles` component** - Renders ` + + +
+ + + diff --git a/examples/spa/index.tsx b/examples/spa/index.tsx new file mode 100644 index 0000000..ffa4855 --- /dev/null +++ b/examples/spa/index.tsx @@ -0,0 +1,19 @@ +import { render } from 'hono/jsx/dom' +import { App } from './app' + +const root = document.getElementById('root') + +// Initial render +if (root) { + render(, root) +} + +// On route change, re-render the whole app to update nav state +function updateApp() { + if (root) { + render(, root) + } +} + +window.addEventListener('routechange', updateApp) +window.addEventListener('popstate', updateApp) diff --git a/examples/ssr/helpers.tsx b/examples/ssr/helpers.tsx new file mode 100644 index 0000000..e69088c --- /dev/null +++ b/examples/ssr/helpers.tsx @@ -0,0 +1,109 @@ +import { define, Styles } from '../../src' + +export const Body = define('Body', { + base: 'body', + + margin: 0, + padding: '40px 20px', + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + background: '#f3f4f6', +}) + +const Container = define('Container', { + maxWidth: 1200, + margin: '0 auto' +}) + +export const Header = define('Header', { + base: 'h1', + + marginBottom: 40, + color: '#111827' +}) + +export const ExampleSection = define('ExampleSection', { + marginBottom: 40, + + parts: { + Header: { + base: 'h2', + + marginBottom: 16, + color: '#374151', + fontSize: 18 + } + }, + render({ props, parts: { Root, Header } }) { + return ( + +
{props.title}
+ {props.children} +
+ ) + } +}) + +const Nav = define('SSR_Nav', { + base: 'nav', + + display: 'flex', + gap: 20, + marginBottom: 40, + padding: 20, + background: 'white', + borderRadius: 8, + boxShadow: '0 1px 3px rgba(0,0,0,0.1)' +}) + +const NavLink = define('SSR_NavLink', { + base: 'a', + + color: '#3b82f6', + textDecoration: 'none', + fontWeight: 500, + + states: { + hover: { + textDecoration: 'underline' + } + }, + + selectors: { + '&[aria-current]': { + color: '#1e40af', + fontWeight: 600, + textDecoration: 'underline' + } + } +}) + +export const Layout = define({ + render({ props }) { + const path = props.path || '' + + return ( + + + + + {props.title} + + + + + +
{props.title}
+ {props.children} +
+ + + ) + } +}) diff --git a/examples/ssr/landing.tsx b/examples/ssr/landing.tsx new file mode 100644 index 0000000..e10a343 --- /dev/null +++ b/examples/ssr/landing.tsx @@ -0,0 +1,119 @@ +import { createScope, Styles } from '../../src' + +const { define } = createScope('Landing') + +const Page = define('Page', { + base: 'body', + + margin: 0, + padding: 0, + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', +}) + +const Container = define('Container', { + textAlign: 'center', + color: 'white', +}) + +const Title = define('Title', { + base: 'h1', + + fontSize: 48, + fontWeight: 700, + marginBottom: 50, + color: 'white', +}) + +const Subtitle = define('Subtitle', { + base: 'p', + + fontSize: 20, + marginBottom: 48, + color: 'rgba(255, 255, 255, 0.9)', +}) + +const ButtonGroup = define('ButtonGroup', { + display: 'flex', + gap: 50, + justifyContent: 'center', + flexWrap: 'wrap', +}) + +const ChoiceCard = define('ChoiceCard', { + base: 'a', + + display: 'block', + padding: 40, + background: 'white', + borderRadius: 16, + textDecoration: 'none', + color: '#111827', + boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)', + transition: 'all 0.3s ease', + minWidth: 250, + + states: { + ':hover': { + transform: 'translateY(-8px)', + boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)', + } + }, + + parts: { + Icon: { + fontSize: 48, + marginBottom: 16, + }, + Title: { + base: 'h2', + fontSize: 24, + fontWeight: 600, + marginBottom: 8, + color: '#111827', + } + }, + + render({ props, parts: { Root, Icon, Title, Description } }) { + return ( + + {props.icon} + {props.title} + + ) + } +}) + +export const LandingPage = () => ( + + + + + Forge - Choose Your Rendering Mode + + + + + Welcome to Forge + + + + + + + + + +) diff --git a/examples/ssr/pages.tsx b/examples/ssr/pages.tsx new file mode 100644 index 0000000..bf3b175 --- /dev/null +++ b/examples/ssr/pages.tsx @@ -0,0 +1,111 @@ +import { define } from '../../src' +import { Layout } from './helpers' +import { ButtonExamplesContent } from '../button' +import { ProfileExamplesContent } from '../profile' +import { NavigationExamplesContent } from '../navigation' +import { FormExamplesContent } from '../form' + +const P = define('SSR_P', { + color: '#6b7280', + fontSize: 18, + marginBottom: 48, +}) + +const ExamplesGrid = define('SSR_ExamplesGrid', { + display: 'grid', + gap: 20, + gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))' +}) + +const ExampleCard = define('SSR_ExampleCard', { + base: 'a', + + background: 'white', + padding: 24, + borderRadius: 12, + boxShadow: '0 1px 3px rgba(0,0,0,0.1)', + textDecoration: 'none', + transition: 'all 0.2s ease', + display: 'block', + + states: { + hover: { + transform: 'translateY(-2px)', + boxShadow: '0 4px 12px rgba(0,0,0,0.15)' + } + }, + + parts: { + H2: { + color: '#111827', + margin: '0 0 8px 0', + fontSize: 20, + }, + P: { + color: '#6b7280', + margin: 0, + fontSize: 14, + } + }, + + render({ props: { title, desc, ...rest }, parts: { Root, H2, P } }) { + return ( + +

{title}

+

{desc}

+
+ ) + } +}) + +export const IndexPage = ({ path }: any) => ( + +

Explore component examples built with Forge

+ + + + + + + + + + +
+) + +export const ButtonExamplesPage = ({ path }: any) => ( + + + +) + +export const ProfileExamplesPage = ({ path }: any) => ( + + + +) + +export const NavigationExamplesPage = ({ path }: any) => ( + + + +) + +export const FormExamplesPage = ({ path }: any) => ( + + + +) diff --git a/package.json b/package.json new file mode 100644 index 0000000..0104871 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "forge", + "module": "src/index.ts", + "type": "module", + "scripts": { + "dev": "bun build:spa && bun run --hot server.tsx", + "test": "bun test", + "build:spa": "bun build examples/spa/index.tsx --outfile dist/spa.js --target browser" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "hono": "^4.11.3" + } +} diff --git a/server.tsx b/server.tsx new file mode 100644 index 0000000..90f9d17 --- /dev/null +++ b/server.tsx @@ -0,0 +1,40 @@ +import { Hono } from 'hono' +import { IndexPage, ProfileExamplesPage, ButtonExamplesPage, NavigationExamplesPage, FormExamplesPage } from './examples/ssr/pages' +import { LandingPage } from './examples/ssr/landing' +import { stylesToCSS } from './src' + +const app = new Hono() + +app.get('/', c => c.html()) + +app.get('/main.css', c => c.text(stylesToCSS(), 200, { + 'Content-Type': 'text/css; charset=utf-8', +})) + +app.get('/ssr', c => c.html()) + +app.get('/ssr/profile', c => c.html()) + +app.get('/ssr/buttons', c => c.html()) + +app.get('/ssr/navigation', c => c.html()) + +app.get('/ssr/form', c => c.html()) + +app.get('/styles', c => c.text(stylesToCSS())) + +app.get('/spa/*', async c => c.html(await Bun.file('./examples/spa/index.html').text())) + +app.get('/spa.js', async c => { + const file = Bun.file('./dist/spa.js') + return new Response(file, { + headers: { + 'Content-Type': 'application/javascript', + }, + }) +}) + +export default { + port: 3300, + fetch: app.fetch, +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..e7f7e45 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,235 @@ +import type { JSX } from 'hono/jsx' +import { type TagDef, UnitlessProps, NonStyleKeys } from './types' + +export const styles: Record> = {} + +// All CSS styles inside ') + }) + + test('Styles includes CSS for all registered components', () => { + define('StylesComp2', { width: 100 }) + define('StylesComp3', { height: 200 }) + + const css = getStylesCSS() + expect(css).toContain('.StylesComp2') + expect(css).toContain('width: 100px') + expect(css).toContain('.StylesComp3') + expect(css).toContain('height: 200px') + }) + + test('Styles includes variant CSS', () => { + define('StylesVariant', { + variants: { + size: { + small: { padding: 8 }, + large: { padding: 24 } + } + } + }) + + const css = getStylesCSS() + expect(css).toContain('.StylesVariant.size-small') + expect(css).toContain('padding: 8px') + expect(css).toContain('.StylesVariant.size-large') + expect(css).toContain('padding: 24px') + }) + + test('Styles includes part CSS', () => { + define('StylesPart', { + parts: { + Header: { color: 'red' }, + Body: { color: 'blue' } + } + }) + + const css = getStylesCSS() + expect(css).toContain('.StylesPart_Header') + expect(css).toContain('color: red') + expect(css).toContain('.StylesPart_Body') + expect(css).toContain('color: blue') + }) +}) + +describe('variants on parts via props', () => { + test('applies boolean variant to part when passed as prop', () => { + const Component = define('PartVariantBool', { + parts: { + Tab: { base: 'button', color: 'gray' } + }, + variants: { + active: { + parts: { + Tab: { color: 'blue' } + } + } + }, + render: ({ props, parts }) => { + return ( + + Active Tab + Inactive Tab + + ) + } + }) + + const html = renderToString(Component({})) + // Should have one tab with active class, one without + expect(html).toContain('PartVariantBool_Tab active') + // Count occurrences - should have 2 tabs total + const tabCount = (html.match(/PartVariantBool_Tab/g) || []).length + expect(tabCount).toBe(2) + }) + + test('applies string variant to part when passed as prop', () => { + const Component = define('PartVariantString', { + parts: { + Pill: { base: 'button' } + }, + variants: { + size: { + small: { parts: { Pill: { padding: 8 } } }, + large: { parts: { Pill: { padding: 24 } } } + } + }, + render: ({ props, parts }) => { + return ( + + Small + Large + + ) + } + }) + + const html = renderToString(Component({})) + expect(html).toContain('PartVariantString_Pill size-small') + expect(html).toContain('PartVariantString_Pill size-large') + }) + + test('does not pass variant props through to HTML', () => { + const Component = define('NoVariantLeakage', { + parts: { + Item: { base: 'div' } + }, + variants: { + active: { + parts: { Item: { color: 'blue' } } + } + }, + render: ({ props, parts }) => { + return ( + + Item + + ) + } + }) + + const html = renderToString(Component({})) + // Should have the class, but not the attribute + expect(html).toContain('class="NoVariantLeakage_Item active"') + expect(html).not.toContain('active="true"') + expect(html).not.toContain('active="false"') + }) + + test('combines root and part level variants', () => { + const Component = define('CombinedVariants', { + parts: { + NavItem: { base: 'a' } + }, + variants: { + theme: { + dark: { + backgroundColor: 'black', + parts: { NavItem: { color: 'white' } } + } + }, + active: { + parts: { NavItem: { fontWeight: 700 } } + } + }, + render: ({ props, parts }) => { + return ( + + Active Link + Inactive Link + + ) + } + }) + + const html = renderToString(Component({ theme: 'dark' })) + // Root should have theme variant + expect(html).toContain('CombinedVariants theme-dark') + // Active NavItem should have both theme and active classes + expect(html).toContain('CombinedVariants_NavItem theme-dark active') + // Inactive NavItem should have only theme class + expect(html).toMatch(/CombinedVariants_NavItem theme-dark"[^>]*>Inactive/) + }) +}) + +describe('base with attributes', () => { + test('extracts element name from base with attributes', () => { + const Component = define('InputRadio', { + base: 'input[type=radio]', + display: 'block' + }) + + const html = renderToString(Component({})) + expect(html).toContain(' { + const Component = define('InputCheckbox', { + base: 'input[type=checkbox]' + }) + + const html = renderToString(Component({})) + expect(html).toContain('type="checkbox"') + }) + + test('works without attributes', () => { + const Component = define('PlainInput', { + base: 'input', + padding: 10 + }) + + const html = renderToString(Component({})) + expect(html).toContain(' { + const Component = define('OverridableInput', { + base: 'input[type=text]' + }) + + const html = renderToString(Component({ type: 'email' })) + expect(html).toContain('type="email"') + }) +}) + +describe('selectors with @ and &', () => { + test('generates CSS for selectors with @PartName', () => { + define('SelectorTest', { + parts: { + Input: { base: 'input[type=checkbox]', display: 'none' }, + Label: { + base: 'label', + color: '#666', + selectors: { + '@Input:checked + &': { + color: 'blue' + } + } + } + } + }) + + const css = getStylesCSS() + expect(css).toContain('.SelectorTest_Input:checked + .SelectorTest_Label') + expect(css).toContain('color: blue') + }) + + test('selectors support general sibling combinator ~', () => { + define('SiblingTest', { + parts: { + Trigger: { base: 'input[type=checkbox]' }, + Content: { + display: 'none', + selectors: { + '@Trigger:checked ~ &': { + display: 'block' + } + } + } + } + }) + + const css = getStylesCSS() + expect(css).toContain('.SiblingTest_Trigger:checked ~ .SiblingTest_Content') + expect(css).toContain('display: block') + }) + + test('selectors can include pseudo-classes on &', () => { + define('PseudoTest', { + parts: { + Radio: { base: 'input[type=radio]', display: 'none' }, + Label: { + base: 'label', + selectors: { + '@Radio:checked + &:hover': { + background: 'lightblue' + } + } + } + } + }) + + const css = getStylesCSS() + expect(css).toContain('.PseudoTest_Radio:checked + .PseudoTest_Label:hover') + expect(css).toContain('background: lightblue') + }) + + test('selectors work on root level', () => { + define('RootSelectorTest', { + color: 'black', + selectors: { + '&:hover': { + color: 'blue' + } + } + }) + + const css = getStylesCSS() + expect(css).toContain('.RootSelectorTest:hover') + expect(css).toContain('color: blue') + }) + + test('supports multiple selectors', () => { + define('MultiSelectorTest', { + parts: { + Input: { base: 'input[type=checkbox]' }, + Label: { + selectors: { + '@Input:checked + &': { color: 'green' }, + '@Input:disabled + &': { opacity: 0.5 } + } + } + } + }) + + const css = getStylesCSS() + expect(css).toContain('.MultiSelectorTest_Input:checked + .MultiSelectorTest_Label') + expect(css).toContain('color: green') + expect(css).toContain('.MultiSelectorTest_Input:disabled + .MultiSelectorTest_Label') + expect(css).toContain('opacity: 0.5') + }) +}) + +describe('edge cases', () => { + test('handles empty definition', () => { + const Component = define({}) + const html = renderToString(Component({})) + + expect(html).toBeDefined() + expect(html).toMatch(/class="Def\d+"/) + }) + + test('handles definition with only parts', () => { + const Component = define({ + parts: { + Header: { base: 'header' } + } + }) + + const result = Component({}) + expect(result).toBeDefined() + }) + + test('handles definition with only variants', () => { + const Component = define({ + variants: { + size: { + small: { padding: 8 } + } + } + }) + + const html = renderToString(Component({ size: 'small' })) + expect(html).toContain('size-small') + }) + + test('throws error when defining duplicate component names', () => { + define('NoDuplicateTest', { width: 100 }) + + expect(() => { + define('NoDuplicateTest', { width: 200 }) + }).toThrow('NoDuplicateTest is already defined! Must use unique names.') + }) + + test('handles complex nested structures', () => { + define('ComplexNested', { + display: 'grid', + parts: { + Container: { padding: 16 }, + Item: { fontSize: 14 } + }, + variants: { + theme: { + dark: { + backgroundColor: 'black', + parts: { + Container: { backgroundColor: '#222' }, + Item: { color: 'white' } + } + } + } + } + }) + + const styles = parseCSS(getStylesCSS()) + expect(styles['ComplexNested']?.['display']).toBe('grid') + expect(styles['ComplexNested_Container']?.['padding']).toBe('16px') + expect(styles['ComplexNested_Item']?.['font-size']).toBe('14px') + expect(styles['ComplexNested.theme-dark']?.['background-color']).toBe('black') + expect(styles['ComplexNested_Container.theme-dark']?.['background-color']).toBe('#222') + expect(styles['ComplexNested_Item.theme-dark']?.['color']).toBe('white') + }) + + test('handles JSX children correctly', () => { + const Component = define('ChildrenTest', {}) + + const html = renderToString(Component({ + children: Nested Element + })) + expect(html).toContain('Nested Element') + }) + + test('handles multiple children', () => { + const Component = define('MultiChildren', { + render: ({ props, parts }) => { + return {props.children} + } + }) + + const html = renderToString(Component({ + children: [ +
First
, +
Second
+ ] + })) + expect(html).toContain('First') + expect(html).toContain('Second') + }) +}) diff --git a/src/tests/test_helpers.ts b/src/tests/test_helpers.ts new file mode 100644 index 0000000..d615394 --- /dev/null +++ b/src/tests/test_helpers.ts @@ -0,0 +1,32 @@ +import { define } from '../index' + +export function renderToString(jsx: any): string { + return jsx.toString() +} + +export function getStylesCSS(): string { + const StylesComponent = define.Styles + const result = StylesComponent() as any + return result.props.dangerouslySetInnerHTML.__html as string +} + +export function parseCSS(css: string): Record> { + const styles: Record> = {} + const classMatches = Array.from(css.matchAll(/\.([^\s{]+)\s*\{([^}]+)\}/g)) + + for (const match of classMatches) { + if (!match[1] || !match[2]) continue + + const className = match[1] + const cssText = match[2] + 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() + } + } + + return styles +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6f8b817 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,305 @@ +export type TagDef = { + className?: string + base?: string + states?: Record, + selectors?: Record, + parts?: Record + variants?: Record> + render?: (obj: any) => any + alignContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'stretch' | 'start' | 'end' | 'baseline' + alignItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' + alignSelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' + aspectRatio?: number | string + + bottom?: number | string + left?: number | string + right?: number | string + top?: number | string + inset?: number | string + + // logical positioning / sizing + insetBlock?: number | string + insetInline?: number | string + insetBlockStart?: number | string + insetBlockEnd?: number | string + insetInlineStart?: number | string + insetInlineEnd?: number | string + + boxSizing?: 'content-box' | 'border-box' + + columnGap?: number | string + rowGap?: number | string + gap?: number | string + + contain?: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint' + + display?: 'block' | 'inline' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'inline-grid' | 'flow-root' | 'none' | 'contents' | 'table' | 'table-row' | 'table-cell' + + // float layout + float?: 'left' | 'right' | 'inline-start' | 'inline-end' | 'none' + clear?: 'left' | 'right' | 'both' | 'inline-start' | 'inline-end' | 'none' + + flex?: number | string + flexBasis?: number | string + flexDirection?: 'row' | 'row-reverse' | 'column' | 'column-reverse' + flexGrow?: number + flexShrink?: number + flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse' + flexFlow?: string + + gridAutoFlow?: 'row' | 'column' | 'dense' | 'row dense' | 'column dense' + gridAutoColumns?: string + gridAutoRows?: string + gridColumn?: string + gridColumnStart?: string | number + gridColumnEnd?: string | number + gridRow?: string + gridRowStart?: string | number + gridRowEnd?: string | number + gridArea?: string + gridGap?: number | string + gridTemplateColumns?: string + gridTemplateRows?: string + gridTemplateAreas?: string + + height?: number | string + width?: number | string + maxHeight?: number | string + maxWidth?: number | string + minHeight?: number | string + minWidth?: number | string + + // logical sizes + blockSize?: number | string + inlineSize?: number | string + minBlockSize?: number | string + maxBlockSize?: number | string + minInlineSize?: number | string + maxInlineSize?: number | string + + margin?: number | string + marginBottom?: number | string + marginLeft?: number | string + marginRight?: number | string + marginTop?: number | string + + padding?: number | string + paddingBottom?: number | string + paddingLeft?: number | string + paddingRight?: number | string + paddingTop?: number | string + + order?: number + + overflow?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip' + overflowX?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip' + overflowY?: 'visible' | 'hidden' | 'scroll' | 'auto' | 'clip' + overflowWrap?: 'normal' | 'break-word' | 'anywhere' + + // overscroll / snap / scrolling ergonomics + overscrollBehavior?: 'auto' | 'contain' | 'none' + overscrollBehaviorX?: 'auto' | 'contain' | 'none' + overscrollBehaviorY?: 'auto' | 'contain' | 'none' + scrollBehavior?: 'auto' | 'smooth' + scrollSnapType?: 'none' | 'x' | 'y' | 'block' | 'inline' | 'both' | string + scrollSnapAlign?: 'none' | 'start' | 'end' | 'center' + scrollSnapStop?: 'normal' | 'always' + scrollMargin?: number | string + scrollMarginTop?: number | string + scrollMarginRight?: number | string + scrollMarginBottom?: number | string + scrollMarginLeft?: number | string + scrollPadding?: number | string + scrollPaddingTop?: number | string + scrollPaddingRight?: number | string + scrollPaddingBottom?: number | string + scrollPaddingLeft?: number | string + scrollbarWidth?: 'auto' | 'thin' | 'none' + scrollbarColor?: string + + placeContent?: string + placeItems?: string + placeSelf?: string + + position?: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky' + + justifyContent?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'start' | 'end' | 'left' | 'right' | 'stretch' + justifyItems?: 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right' + justifySelf?: 'auto' | 'normal' | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline' | 'start' | 'end' | 'self-start' | 'self-end' | 'left' | 'right' + + verticalAlign?: 'baseline' | 'top' | 'middle' | 'bottom' | 'text-top' | 'text-bottom' | 'sub' | 'super' + + zIndex?: number + + // visual/theme-related + animation?: string + appearance?: 'none' | 'auto' | 'button' | 'textfield' | 'searchfield' | 'textarea' | 'checkbox' | 'radio' + backdropFilter?: string + + background?: string + backgroundAttachment?: 'scroll' | 'fixed' | 'local' + backgroundClip?: 'border-box' | 'padding-box' | 'content-box' | 'text' + backgroundColor?: string + backgroundImage?: string + backgroundPosition?: string + backgroundRepeat?: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | 'space' | 'round' + backgroundSize?: 'auto' | 'cover' | 'contain' + + border?: string + borderBottom?: string + borderBottomColor?: string + borderBottomLeftRadius?: number | string + borderBottomRightRadius?: number | string + borderBottomStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden' + borderBottomWidth?: number | string + borderColor?: string + borderLeft?: string + borderLeftColor?: string + borderLeftStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden' + borderLeftWidth?: number | string + borderRadius?: number | string + borderRight?: string + borderRightColor?: string + borderRightStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden' + borderRightWidth?: number | string + borderStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden' + borderTop?: string + borderTopColor?: string + borderTopLeftRadius?: number | string + borderTopRightRadius?: number | string + borderTopStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' | 'hidden' + borderTopWidth?: number | string + borderWidth?: number | string + + // table-ish + borderCollapse?: 'collapse' | 'separate' + borderSpacing?: number | string + captionSide?: 'top' | 'bottom' + emptyCells?: 'show' | 'hide' + tableLayout?: 'auto' | 'fixed' + + boxShadow?: string + clipPath?: string + + color?: string + content?: string + cursor?: 'auto' | 'default' | 'none' | 'context-menu' | 'help' | 'pointer' | 'progress' | 'wait' | 'cell' | 'crosshair' | 'text' | 'vertical-text' | 'alias' | 'copy' | 'move' | 'no-drop' | 'not-allowed' | 'grab' | 'grabbing' | 'e-resize' | 'n-resize' | 'ne-resize' | 'nw-resize' | 's-resize' | 'se-resize' | 'sw-resize' | 'w-resize' | 'ew-resize' | 'ns-resize' | 'nesw-resize' | 'nwse-resize' | 'col-resize' | 'row-resize' | 'all-scroll' | 'zoom-in' | 'zoom-out' + + filter?: string + + font?: string + fontFamily?: string + fontSize?: number | string + fontStyle?: 'normal' | 'italic' | 'oblique' + fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 'normal' | 'bold' | 'bolder' | 'lighter' | number + fontStretch?: string + fontVariant?: string + fontKerning?: 'auto' | 'normal' | 'none' + + isolation?: 'auto' | 'isolate' + letterSpacing?: number | string + lineHeight?: number | string + + listStyle?: string + listStyleImage?: string + listStylePosition?: 'inside' | 'outside' + listStyleType?: 'none' | 'disc' | 'circle' | 'square' | 'decimal' | 'decimal-leading-zero' | 'lower-roman' | 'upper-roman' | 'lower-alpha' | 'upper-alpha' | 'lower-greek' | 'lower-latin' | 'upper-latin' + + 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' + + opacity?: number + + outline?: string + outlineColor?: string + outlineOffset?: number | string + outlineStyle?: 'none' | 'solid' | 'dashed' | 'dotted' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset' + outlineWidth?: number | string + + // form / selection / interaction + caretColor?: string + accentColor?: string + pointerEvents?: 'auto' | 'none' | 'visiblePainted' | 'visibleFill' | 'visibleStroke' | 'visible' | 'painted' | 'fill' | 'stroke' | 'all' + resize?: 'none' | 'both' | 'horizontal' | 'vertical' | 'block' | 'inline' + touchAction?: 'auto' | 'none' | 'pan-x' | 'pan-y' | 'manipulation' | string + userSelect?: 'auto' | 'none' | 'text' | 'contain' | 'all' + + // writing / bidi / hyphenation + direction?: 'ltr' | 'rtl' + writingMode?: 'horizontal-tb' | 'vertical-rl' | 'vertical-lr' | string + unicodeBidi?: 'normal' | 'embed' | 'bidi-override' | 'isolate' | 'isolate-override' | 'plaintext' + hyphens?: 'none' | 'manual' | 'auto' + tabSize?: number | string + + textAlign?: 'left' | 'right' | 'center' | 'justify' | 'start' | 'end' + textDecoration?: string + textDecorationColor?: string + textDecorationLine?: 'none' | 'underline' | 'overline' | 'line-through' | 'blink' + textDecorationStyle?: 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy' + textDecorationThickness?: number | string + textUnderlineOffset?: number | string + textIndent?: number | string + textOverflow?: 'clip' | 'ellipsis' | string + textShadow?: string + textTransform?: 'none' | 'capitalize' | 'uppercase' | 'lowercase' | 'full-width' | 'full-size-kana' + whiteSpace?: 'normal' | 'nowrap' | 'pre' | 'pre-wrap' | 'pre-line' | 'break-spaces' + wordBreak?: 'normal' | 'break-all' | 'keep-all' | 'break-word' + wordSpacing?: number | string + wordWrap?: 'normal' | 'break-word' | 'anywhere' + + transform?: string + transformOrigin?: string + transformStyle?: 'flat' | 'preserve-3d' + perspective?: number | string + perspectiveOrigin?: string + backfaceVisibility?: 'visible' | 'hidden' + + transition?: string + visibility?: 'visible' | 'hidden' | 'collapse' + willChange?: 'auto' | 'scroll-position' | 'contents' + + // masks (if you want modern visual effects) + mask?: string + maskImage?: string + maskSize?: string + maskPosition?: string + maskRepeat?: string + + // svg styling (if you want these supported) + fill?: string + stroke?: string + strokeWidth?: number | string + strokeLinecap?: 'butt' | 'round' | 'square' + strokeLinejoin?: 'miter' | 'round' | 'bevel' + strokeDasharray?: number | string + strokeDashoffset?: number | string +} + +export const NonStyleKeys = new Set([ + 'className', + 'base', + 'states', + 'css', + 'parts', + 'variants', + 'render', + 'styles', + 'selectors', +]) + +export const UnitlessProps = new Set([ + 'animationIterationCount', + 'aspectRatio', + 'columnCount', + 'flex', + 'flexGrow', + 'flexShrink', + 'fontWeight', + 'lineHeight', + 'opacity', + 'order', + 'orphans', + 'widows', + 'zIndex' +]) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..16bb5e0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}