readme on landing page

This commit is contained in:
Chris Wanstrath 2026-01-05 17:04:08 -08:00
parent 85844c9b44
commit 992bddc769
7 changed files with 565 additions and 123 deletions

View File

@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "forge",
@ -9,6 +8,9 @@
},
"devDependencies": {
"@types/bun": "latest",
"@types/prismjs": "^1.26.5",
"prismjs": "^1.30.0",
"snarkdown": "^2.0.0",
},
"peerDependencies": {
"typescript": "^5",
@ -20,10 +22,16 @@
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"snarkdown": ["snarkdown@2.0.0", "", {}, "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=="],

547
examples/landing.tsx Normal file
View File

@ -0,0 +1,547 @@
import { createScope, Styles } from '../src'
import { theme } from './ssr/themes'
import markdown from 'snarkdown'
import Prism from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-jsx'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-tsx'
const { define } = createScope('Landing')
const Page = define('Page', {
base: 'body',
margin: 0,
padding: theme('spacing-xl'),
minHeight: '100vh',
fontFamily: theme('fonts-mono'),
background: theme('colors-bg'),
color: theme('colors-fg'),
})
const Container = define('Container', {
maxWidth: 800,
margin: '0 auto',
})
const Pre = define('Pre', {
base: 'pre',
fontSize: 14,
lineHeight: 1.4,
marginBottom: theme('spacing-xl'),
color: theme('colors-fg'),
whiteSpace: 'pre',
borderBottom: '1px solid var(--theme-colors-border)',
})
const P = define('P', {
base: 'p',
fontSize: 16,
lineHeight: 1.6,
marginBottom: theme('spacing-xl'),
color: theme('colors-fgMuted'),
})
const LinkSection = define('LinkSection', {
marginBottom: theme('spacing-xl'),
})
const Link = define('Link', {
base: 'a',
display: 'inline-block',
marginRight: theme('spacing-xl'),
padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
background: theme('colors-bgElevated'),
border: `1px solid ${theme('colors-border')}`,
color: theme('colors-fg'),
textDecoration: 'none',
fontSize: 14,
states: {
':hover': {
background: theme('colors-bgHover'),
borderColor: theme('colors-borderActive'),
}
}
})
const ThemeToggle = define('ThemeToggle', {
position: 'fixed',
top: theme('spacing-lg'),
right: theme('spacing-lg'),
padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
background: theme('colors-bgElevated'),
border: `1px solid ${theme('colors-border')}`,
color: theme('colors-fg'),
fontSize: 14,
cursor: 'pointer',
fontFamily: theme('fonts-mono'),
states: {
':hover': {
background: theme('colors-bgHover'),
borderColor: theme('colors-borderActive'),
}
}
})
// Helper to highlight code blocks in markdown HTML
function highlightCodeBlocks(html: string): string {
return html.replace(/<pre class="code[^"]*"><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g, (_, lang, code) => {
// Decode HTML entities
const decoded = code
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
// Map language to Prism grammar (tsx/typescript)
const grammar = lang === 'tsx' ? Prism.languages.tsx : Prism.languages.typescript
const highlighted = Prism.highlight(decoded, grammar!, lang)
return `<pre class="code ${lang}"><code class="language-${lang}">${highlighted}</code></pre>`
})
}
export const LandingPage = () => {
const themeScript = `
function switchTheme(themeName) {
document.body.setAttribute('data-theme', themeName)
localStorage.setItem('theme', themeName)
updateThemeToggle()
}
function updateThemeToggle() {
const currentTheme = document.body.getAttribute('data-theme')
const toggle = document.getElementById('theme-toggle')
if (toggle) {
toggle.textContent = currentTheme === 'dark' ? '☀ light' : '🌙 dark'
}
}
function toggleTheme() {
const currentTheme = document.body.getAttribute('data-theme')
const newTheme = currentTheme === 'dark' ? 'light' : 'dark'
switchTheme(newTheme)
}
window.switchTheme = switchTheme
window.toggleTheme = toggleTheme
// Load saved theme or default to dark
const savedTheme = localStorage.getItem('theme') || 'dark'
document.body.setAttribute('data-theme', savedTheme)
updateThemeToggle()
`
const prismTheme = `
code[class*="language-"],
pre[class*="language-"] {
color: var(--theme-colors-fg);
background: none;
font-family: var(--theme-fonts-mono);
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
tab-size: 4;
hyphens: none;
}
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
background: var(--theme-colors-bgElevated);
border: 1px solid var(--theme-colors-border);
border-radius: 4px;
}
/* Dark theme colors */
[data-theme="dark"] .token.comment,
[data-theme="dark"] .token.prolog,
[data-theme="dark"] .token.doctype,
[data-theme="dark"] .token.cdata {
color: #999999;
}
[data-theme="dark"] .token.punctuation {
color: #808080;
}
[data-theme="dark"] .token.tag {
color: #ff6b6b;
}
[data-theme="dark"] .token.property,
[data-theme="dark"] .token.boolean,
[data-theme="dark"] .token.number,
[data-theme="dark"] .token.constant,
[data-theme="dark"] .token.symbol,
[data-theme="dark"] .token.deleted {
color: #ffd93d;
}
[data-theme="dark"] .token.selector,
[data-theme="dark"] .token.attr-name,
[data-theme="dark"] .token.string,
[data-theme="dark"] .token.char,
[data-theme="dark"] .token.builtin,
[data-theme="dark"] .token.inserted {
color: #ff6b6b;
}
[data-theme="dark"] .token.operator,
[data-theme="dark"] .token.entity,
[data-theme="dark"] .token.url,
[data-theme="dark"] .language-css .token.string,
[data-theme="dark"] .style .token.string {
color: #d4d4d4;
}
[data-theme="dark"] .token.atrule,
[data-theme="dark"] .token.attr-value,
[data-theme="dark"] .token.keyword {
color: #bb86fc;
}
[data-theme="dark"] .token.function,
[data-theme="dark"] .token.class-name {
color: #4dd0e1;
}
[data-theme="dark"] .token.regex,
[data-theme="dark"] .token.important,
[data-theme="dark"] .token.variable {
color: #bb86fc;
}
/* Light theme colors */
[data-theme="light"] .token.comment,
[data-theme="light"] .token.prolog,
[data-theme="light"] .token.doctype,
[data-theme="light"] .token.cdata {
color: #888888;
}
[data-theme="light"] .token.punctuation {
color: #666666;
}
[data-theme="light"] .token.tag {
color: #dc143c;
}
[data-theme="light"] .token.property,
[data-theme="light"] .token.boolean,
[data-theme="light"] .token.number,
[data-theme="light"] .token.constant,
[data-theme="light"] .token.symbol,
[data-theme="light"] .token.deleted {
color: #c9a700;
}
[data-theme="light"] .token.selector,
[data-theme="light"] .token.attr-name,
[data-theme="light"] .token.string,
[data-theme="light"] .token.char,
[data-theme="light"] .token.builtin,
[data-theme="light"] .token.inserted {
color: #dc143c;
}
[data-theme="light"] .token.operator,
[data-theme="light"] .token.entity,
[data-theme="light"] .token.url,
[data-theme="light"] .language-css .token.string,
[data-theme="light"] .style .token.string {
color: #000000;
}
[data-theme="light"] .token.atrule,
[data-theme="light"] .token.attr-value,
[data-theme="light"] .token.keyword {
color: #9333ea;
}
[data-theme="light"] .token.function,
[data-theme="light"] .token.class-name {
color: #0891b2;
}
[data-theme="light"] .token.regex,
[data-theme="light"] .token.important,
[data-theme="light"] .token.variable {
color: #9333ea;
}
`
return (
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>forge</title>
<Styles />
<style dangerouslySetInnerHTML={{ __html: prismTheme }} />
</head>
<Page data-theme="dark">
<ThemeToggle id="theme-toggle" onclick="toggleTheme()">🌙 dark</ThemeToggle>
<Container>
<Pre>{`╔═╝╔═║╔═║╔═╝╔═╝
`}</Pre>
<LinkSection>
<Link href="/ssr">SSR demos </Link>
<Link href="/spa">SPA demos </Link>
</LinkSection>
<div dangerouslySetInnerHTML={{ __html: highlightCodeBlocks(markdown(MARKDOWN_CONTENT)) }}></div>
</Container>
<script dangerouslySetInnerHTML={{ __html: themeScript }}></script>
</Page>
</html>
)
}
const MARKDOWN_CONTENT = `
## overview
Forge is a typed, local, variant-driven way to organize CSS and create
self-contained TSX components out of discrete parts.
## css problems
- Styles are global and open - anything can override anything anywhere.
- No IDE-friendly link between the class name in markup and its definition.
- Organizational techniques are patterns a human must know and follow, not APIs.
- Errors happen silently.
## forge solutions
- All styles are local to your TSX components.
- Styles are 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
### styles
\`\`\`tsx
import { define } from "forge"
export const Container = define("Container", {
width: 600,
margin: '0 auto',
})
export const Button = define({
base: "button",
padding: 20,
background: "blue",
})
// Usage
<Container>
<Button>Click me</Button>
</Container>
\`\`\`
### variants
\`\`\`tsx
import { define } from "forge"
export const Button = define({
base: "button",
padding: 20,
background: "blue",
variants: {
status: {
danger: { background: "red" },
warning: { background: "yellow" },
}
},
})
// Usage
<Button>Click me</Button>
<Button status="danger">Click me carefully</Button>
<Button status="warning">Click me?</Button>
\`\`\`
### parts + \`render()\`
\`\`\`typescript
export const Profile = define("div", {
padding: 50,
background: "red",
parts: {
Header: { display: "flex" },
Avatar: { base: "img", width: 50 },
Bio: { color: "gray" },
},
variants: {
size: {
small: {
parts: { Avatar: { width: 20 } },
},
},
},
render({ props, parts: { Root, Header, Avatar, Bio } }) {
return (
<Root>
<Header>
<Avatar src={props.pic} />
<Bio>{props.bio}</Bio>
</Header>
</Root>
)
},
})
// Usage:
import { Profile } from "./whatever"
console.log(<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", {
// ...
})
\`\`\`
`

View File

@ -108,7 +108,7 @@ const Link = define('Link', {
}
})
const Nav = define('Nav', {
const Nav = define({
base: 'nav',
display: 'flex',

View File

@ -1,116 +0,0 @@
import { createScope, Styles } from '../../src'
import { theme } from './themes'
const { define } = createScope('Landing')
const Page = define('Page', {
base: 'body',
margin: 0,
padding: theme('spacing-xl'),
minHeight: '100vh',
fontFamily: theme('fonts-mono'),
background: theme('colors-bg'),
color: theme('colors-fg'),
})
const Container = define('Container', {
maxWidth: 800,
margin: '0 auto',
})
const Pre = define('Pre', {
base: 'pre',
fontSize: 14,
lineHeight: 1.4,
marginBottom: theme('spacing-xl'),
color: theme('colors-fg'),
whiteSpace: 'pre',
borderBottom: '1px solid var(--theme-colors-border)',
})
const P = define('P', {
base: 'p',
fontSize: 16,
lineHeight: 1.6,
marginBottom: theme('spacing-xl'),
color: theme('colors-fgMuted'),
})
const LinkSection = define('LinkSection', {
marginTop: theme('spacing-xxl'),
paddingTop: theme('spacing-xl'),
borderTop: `1px solid ${theme('colors-border')}`,
})
const Link = define('Link', {
base: 'a',
display: 'inline-block',
marginRight: theme('spacing-xl'),
padding: `${theme('spacing-sm')} ${theme('spacing-lg')}`,
background: theme('colors-bgElevated'),
border: `1px solid ${theme('colors-border')}`,
color: theme('colors-fg'),
textDecoration: 'none',
fontSize: 14,
states: {
':hover': {
background: theme('colors-bgHover'),
borderColor: theme('colors-borderActive'),
}
}
})
export const LandingPage = () => {
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)
`
return (
<html>
<head>
<meta charset="UTF-8" />
<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

@ -8,7 +8,10 @@
"build:spa": "bun build examples/spa/index.tsx --outfile dist/spa.js --target browser"
},
"devDependencies": {
"@types/bun": "latest"
"@types/bun": "latest",
"@types/prismjs": "^1.26.5",
"prismjs": "^1.30.0",
"snarkdown": "^2.0.0"
},
"peerDependencies": {
"typescript": "^5"

View File

@ -1,6 +1,6 @@
import { Hono } from 'hono'
import { IndexPage, ProfileExamplesPage, ButtonExamplesPage, NavigationExamplesPage, FormExamplesPage } from './examples/ssr/pages'
import { LandingPage } from './examples/ssr/landing'
import { LandingPage } from './examples/landing'
import { stylesToCSS } from './src'
const app = new Hono()

View File

@ -38,8 +38,8 @@ describe('define - basic functionality', () => {
const html2 = renderToString(Component2({}))
// Should have different auto-generated names
expect(html1).toMatch(/class="Def\d+"/)
expect(html2).toMatch(/class="Def\d+"/)
expect(html1).toMatch(/class="Div\d*"/)
expect(html2).toMatch(/class="Div\d*"/)
expect(html1).not.toBe(html2)
})
@ -891,7 +891,7 @@ describe('edge cases', () => {
const html = renderToString(Component({}))
expect(html).toBeDefined()
expect(html).toMatch(/class="Def\d+"/)
expect(html).toMatch(/class="Div\d*"/)
})
test('handles definition with only parts', () => {