This commit is contained in:
Chris Wanstrath 2025-11-29 13:50:17 -08:00
commit 67fae70c83
19 changed files with 2087 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@ -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

111
CLAUDE.md Normal file
View File

@ -0,0 +1,111 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
// import .css files directly and it works
import './index.css';
import { createRoot } from "react-dom/client";
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.

8
README.md Normal file
View File

@ -0,0 +1,8 @@
# 🐺 howl
Howl is a fork of `werewolf-ui`, without any Tailwind.
```bash
bun install
bun dev
```

33
bun.lock Normal file
View File

@ -0,0 +1,33 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "howl",
"dependencies": {
"hono": "^4.10.7",
"lucide-static": "^0.555.0",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
"lucide-static": ["lucide-static@0.555.0", "", {}, "sha512-FMMaYYsEYsUA6xlEzIMoKEV3oGnxIIvAN+AtLmYXvlTJptJTveJjVBQwvtA/zZLrD6KLEu89G95dQYlhivw5jQ=="],
"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=="],
}
}

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "howl",
"module": "index.tsx",
"type": "module",
"scripts": {
"dev": "bun run --hot test/server.tsx"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"hono": "^4.10.7",
"lucide-static": "^0.555.0"
}
}

117
src/avatar.tsx Normal file
View File

@ -0,0 +1,117 @@
import "hono/jsx"
import type { FC, JSX } from "hono/jsx"
import { VStack, HStack } from "./stack"
export type AvatarProps = {
src: string
alt?: string
size?: number
rounded?: boolean
style?: JSX.CSSProperties
}
export const Avatar: FC<AvatarProps> = (props) => {
const { src, size = 32, rounded, style, alt = "" } = props
const avatarStyle: JSX.CSSProperties = {
width: `${size}px`,
height: `${size}px`,
borderRadius: rounded ? "9999px" : undefined,
...style,
}
return <img src={src} alt={alt} style={avatarStyle} />
}
export const Test = () => {
const sampleImages = [
"https://picsum.photos/seed/3/200/200",
"https://picsum.photos/seed/2/200/200",
"https://picsum.photos/seed/8/200/200",
"https://picsum.photos/seed/9/200/200",
]
return (
<VStack gap={8} style={{ padding: "24px" }}>
{/* Size variations */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Size Variations</h2>
<HStack gap={4}>
{[24, 32, 48, 64, 96].map((size) => (
<VStack key={size} h="center" gap={2}>
<Avatar src={sampleImages[0]!} size={size} alt="Sample" />
<p style={{ fontSize: "14px" }}>
{size}x{size}
</p>
</VStack>
))}
</HStack>
</VStack>
{/* Rounded vs Square */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Rounded vs Square</h2>
<HStack gap={6}>
<VStack h="center" gap={2}>
<Avatar src={sampleImages[0]!} size={64} alt="Sample" />
<p style={{ fontSize: "14px" }}>Square</p>
</VStack>
<VStack h="center" gap={2}>
<Avatar src={sampleImages[0]!} size={64} rounded alt="Sample" />
<p style={{ fontSize: "14px" }}>Rounded</p>
</VStack>
</HStack>
</VStack>
{/* Different images */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Different Images</h2>
<HStack gap={4}>
{sampleImages.map((src, index) => (
<VStack key={src} h="center" gap={2}>
<Avatar src={src} size={64} rounded alt={`Sample ${index + 1}`} />
<p style={{ fontSize: "14px" }}>Image {index + 1}</p>
</VStack>
))}
</HStack>
</VStack>
{/* Custom styles */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>With Custom Styles</h2>
<HStack gap={6}>
<VStack h="center" gap={2}>
<Avatar
src={sampleImages[0]!}
size={64}
rounded
style={{ border: "4px solid #3b82f6" }}
alt="With border"
/>
<p style={{ fontSize: "14px" }}>With Border</p>
</VStack>
<VStack h="center" gap={2}>
<Avatar
src={sampleImages[1]!}
size={64}
rounded
style={{ boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)" }}
alt="With shadow"
/>
<p style={{ fontSize: "14px" }}>With Shadow</p>
</VStack>
<VStack h="center" gap={2}>
<Avatar
src={sampleImages[2]!}
size={64}
rounded
style={{ border: "4px solid #22c55e", boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)" }}
alt="Border + shadow"
/>
<p style={{ fontSize: "14px" }}>Border + Shadow</p>
</VStack>
</HStack>
</VStack>
</VStack>
)
}

134
src/button.tsx Normal file
View File

@ -0,0 +1,134 @@
import "hono/jsx"
import type { JSX, FC } from "hono/jsx"
import { VStack, HStack } from "./stack"
export type ButtonProps = JSX.IntrinsicElements["button"] & {
variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive"
size?: "sm" | "md" | "lg"
}
export const Button: FC<ButtonProps> = (props) => {
const { variant = "primary", size = "md", style, ...buttonProps } = props
const baseStyles: JSX.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontWeight: "500",
transition: "all 0.2s",
outline: "none",
cursor: "pointer",
borderRadius: "4px",
border: "1px solid transparent",
}
const variantStyles: Record<string, JSX.CSSProperties> = {
primary: {
backgroundColor: "#3b82f6",
color: "#ffffff",
},
secondary: {
backgroundColor: "#64748b",
color: "#ffffff",
},
outline: {
backgroundColor: "transparent",
color: "#000000",
borderColor: "#d1d5db",
},
ghost: {
backgroundColor: "transparent",
color: "#000000",
},
destructive: {
backgroundColor: "#ef4444",
color: "#ffffff",
},
}
const sizeStyles: Record<string, JSX.CSSProperties> = {
sm: {
height: "32px",
padding: "0 12px",
fontSize: "14px",
},
md: {
height: "40px",
padding: "0 16px",
fontSize: "14px",
},
lg: {
height: "48px",
padding: "0 24px",
fontSize: "16px",
},
}
const combinedStyles: JSX.CSSProperties = {
...baseStyles,
...variantStyles[variant],
...sizeStyles[size],
...(style || {}),
}
return <button {...buttonProps} style={combinedStyles} />
}
export const Test = () => {
return (
<VStack gap={8} style={{ padding: "24px" }}>
{/* Variants */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Button Variants</h2>
<HStack gap={4}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
</HStack>
</VStack>
{/* Sizes */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Button Sizes</h2>
<HStack gap={4} v="end">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</HStack>
</VStack>
{/* With custom content */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Custom Content</h2>
<HStack gap={4}>
<Button variant="primary">
<span>🚀</span>
<span style={{ marginLeft: "8px" }}>Launch</span>
</Button>
<Button variant="outline" style={{ flexDirection: "column", height: "80px", width: "96px" }}>
<span style={{ fontSize: "24px" }}>💳</span>
<span style={{ fontSize: "12px", marginTop: "4px" }}>Card</span>
</Button>
</HStack>
</VStack>
{/* Native attributes work */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Native Attributes</h2>
<HStack gap={4}>
<Button onClick={() => alert("Clicked!")} variant="primary">
Click Me
</Button>
<Button disabled variant="secondary" style={{ opacity: 0.5, pointerEvents: "none" }}>
Disabled
</Button>
<Button type="submit" variant="outline">
Submit
</Button>
</HStack>
</VStack>
</VStack>
)
}

61
src/divider.tsx Normal file
View File

@ -0,0 +1,61 @@
import "hono/jsx"
import type { FC, PropsWithChildren, JSX } from "hono/jsx"
import { VStack } from "./stack"
type DividerProps = PropsWithChildren & {
style?: JSX.CSSProperties
}
export const Divider: FC<DividerProps> = ({ children, style }) => {
const containerStyle: JSX.CSSProperties = {
display: "flex",
alignItems: "center",
margin: "16px 0",
...style,
}
const lineStyle: JSX.CSSProperties = {
flex: 1,
borderTop: "1px solid #d1d5db",
}
const textStyle: JSX.CSSProperties = {
padding: "0 12px",
fontSize: "14px",
color: "#6b7280",
backgroundColor: "white",
}
return (
<div style={containerStyle}>
<div style={lineStyle}></div>
{children && (
<>
<span style={textStyle}>{children}</span>
<div style={lineStyle}></div>
</>
)}
</div>
)
}
export const Test = () => {
return (
<VStack gap={4} style={{ padding: "16px", maxWidth: "448px" }}>
<h2 style={{ fontSize: "18px", fontWeight: "bold" }}>Divider Examples</h2>
<VStack gap={0}>
<p>Would you like to continue</p>
<Divider>OR WOULD YOU LIKE TO</Divider>
<p>Submit to certain death</p>
</VStack>
{/* Just a line */}
<VStack gap={0}>
<p>Look a line 👇</p>
<Divider />
<p>So cool, so straight!</p>
</VStack>
</VStack>
)
}

158
src/grid.tsx Normal file
View File

@ -0,0 +1,158 @@
import type { TailwindSize } from "./types"
import "hono/jsx"
import type { FC, PropsWithChildren, JSX } from "hono/jsx"
import { VStack } from "./stack"
import { Button } from "./button"
type GridProps = PropsWithChildren & {
cols?: GridCols
gap?: TailwindSize
v?: keyof typeof alignItemsMap
h?: keyof typeof justifyItemsMap
style?: JSX.CSSProperties
}
type GridCols = number | { sm?: number; md?: number; lg?: number; xl?: number }
export const Grid: FC<GridProps> = (props) => {
const { cols = 2, gap = 4, v, h, style, children } = props
const gapPx = gap * 4
const baseStyles: JSX.CSSProperties = {
display: "grid",
gridTemplateColumns: getColumnsValue(cols),
gap: `${gapPx}px`,
}
if (v) {
baseStyles.alignItems = alignItemsMap[v]
}
if (h) {
baseStyles.justifyItems = justifyItemsMap[h]
}
const combinedStyles = {
...baseStyles,
...style,
}
return <div style={combinedStyles}>{children}</div>
}
function getColumnsValue(cols: GridCols): string {
if (typeof cols === "number") {
return `repeat(${cols}, minmax(0, 1fr))`
}
// For responsive grids, we'll use the largest value
// In a real implementation, you'd want media queries which require CSS
// For now, let's use the largest value specified
const largestCols = cols.xl || cols.lg || cols.md || cols.sm || 1
return `repeat(${largestCols}, minmax(0, 1fr))`
}
const alignItemsMap = {
start: "start",
center: "center",
end: "end",
stretch: "stretch",
} as const
const justifyItemsMap = {
start: "start",
center: "center",
end: "end",
stretch: "stretch",
} as const
export const Test = () => {
return (
<VStack gap={4} style={{ padding: "16px" }}>
<VStack gap={6}>
<h2 style={{ fontSize: "18px", fontWeight: "bold" }}>Grid Examples</h2>
{/* Simple 3-column grid */}
<VStack gap={2}>
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>Simple 3 columns: cols=3</h3>
<Grid cols={3} gap={4}>
<div style={{ backgroundColor: "#fecaca", padding: "16px", textAlign: "center" }}>Item 1</div>
<div style={{ backgroundColor: "#bbf7d0", padding: "16px", textAlign: "center" }}>Item 2</div>
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>Item 3</div>
<div style={{ backgroundColor: "#fef08a", padding: "16px", textAlign: "center" }}>Item 4</div>
<div style={{ backgroundColor: "#e9d5ff", padding: "16px", textAlign: "center" }}>Item 5</div>
<div style={{ backgroundColor: "#fbcfe8", padding: "16px", textAlign: "center" }}>Item 6</div>
</Grid>
</VStack>
{/* Responsive grid */}
<VStack gap={2}>
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>
Responsive: cols=&#123;sm: 1, md: 2, lg: 3&#125;
</h3>
<Grid cols={{ sm: 1, md: 2, lg: 3 }} gap={4}>
<div style={{ backgroundColor: "#fecaca", padding: "16px", textAlign: "center" }}>Card 1</div>
<div style={{ backgroundColor: "#bbf7d0", padding: "16px", textAlign: "center" }}>Card 2</div>
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>Card 3</div>
<div style={{ backgroundColor: "#fef08a", padding: "16px", textAlign: "center" }}>Card 4</div>
</Grid>
</VStack>
{/* More responsive examples */}
<VStack gap={2}>
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>
More responsive: cols=&#123;sm: 2, lg: 4, xl: 6&#125;
</h3>
<Grid cols={{ sm: 2, lg: 4, xl: 6 }} gap={4}>
<div style={{ backgroundColor: "#fecaca", padding: "16px", textAlign: "center" }}>Item A</div>
<div style={{ backgroundColor: "#bbf7d0", padding: "16px", textAlign: "center" }}>Item B</div>
<div style={{ backgroundColor: "#bfdbfe", padding: "16px", textAlign: "center" }}>Item C</div>
<div style={{ backgroundColor: "#fef08a", padding: "16px", textAlign: "center" }}>Item D</div>
<div style={{ backgroundColor: "#e9d5ff", padding: "16px", textAlign: "center" }}>Item E</div>
<div style={{ backgroundColor: "#fbcfe8", padding: "16px", textAlign: "center" }}>Item F</div>
</Grid>
</VStack>
{/* Payment method example */}
<VStack gap={2}>
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>Payment buttons example</h3>
<Grid cols={3} gap={4}>
<Button variant="outline" style={{ height: "80px", flexDirection: "column" }}>
<div style={{ fontSize: "24px" }}>💳</div>
<span style={{ fontSize: "12px" }}>Card</span>
</Button>
<Button variant="outline" style={{ height: "80px", flexDirection: "column" }}>
<div style={{ fontSize: "24px" }}>🍎</div>
<span style={{ fontSize: "12px" }}>Apple</span>
</Button>
<Button variant="outline" style={{ height: "80px", flexDirection: "column" }}>
<div style={{ fontSize: "24px" }}>💰</div>
<span style={{ fontSize: "12px" }}>PayPal</span>
</Button>
</Grid>
</VStack>
{/* Alignment examples */}
<VStack gap={2}>
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>
Alignment: v="center" h="center"
</h3>
<Grid cols={3} gap={4} v="center" h="center" style={{ height: "128px", backgroundColor: "#f3f4f6" }}>
<div style={{ backgroundColor: "#fecaca", padding: "8px" }}>Item 1</div>
<div style={{ backgroundColor: "#bbf7d0", padding: "8px" }}>Item 2</div>
<div style={{ backgroundColor: "#bfdbfe", padding: "8px" }}>Item 3</div>
</Grid>
</VStack>
<VStack gap={2}>
<h3 style={{ fontSize: "16px", fontWeight: "600" }}>Alignment: v="start" h="end"</h3>
<Grid cols={2} gap={4} v="start" h="end" style={{ height: "96px", backgroundColor: "#f3f4f6" }}>
<div style={{ backgroundColor: "#e9d5ff", padding: "8px" }}>Left</div>
<div style={{ backgroundColor: "#fed7aa", padding: "8px" }}>Right</div>
</Grid>
</VStack>
</VStack>
</VStack>
)
}

210
src/icon.tsx Normal file
View File

@ -0,0 +1,210 @@
import "hono/jsx"
import type { FC, JSX } from "hono/jsx"
import * as icons from "lucide-static"
import { Grid } from "./grid"
import { VStack, HStack } from "./stack"
export type IconName = keyof typeof icons
type IconProps = {
name: IconName
size?: number
class?: string
style?: JSX.CSSProperties
}
type IconLinkProps = IconProps & {
href?: string
target?: string
}
export const Icon: FC<IconProps> = (props) => {
const { name, size = 6, class: className, style } = props
const iconSvg = icons[name]
if (!iconSvg) {
throw new Error(`Icon "${name}" not found in Lucide icons`)
}
const pixelSize = sizeToPixels(size)
const iconStyle: JSX.CSSProperties = {
display: "block",
flexShrink: "0",
width: `${pixelSize}px`,
height: `${pixelSize}px`,
...style,
}
// Modify the SVG string to include our custom attributes
const modifiedSvg = iconSvg
.replace(/width="[^"]*"/, "")
.replace(/height="[^"]*"/, "")
.replace(/class="[^"]*"/, "")
.replace(
/<svg([^>]*)>/,
`<svg$1 style="display: block; flex-shrink: 0; width: ${pixelSize}px; height: ${pixelSize}px;" class="${className || ""}">`
)
return <div dangerouslySetInnerHTML={{ __html: modifiedSvg }} style={iconStyle} />
}
export const IconLink: FC<IconLinkProps> = (props) => {
const { href = "#", target, class: className, style, ...iconProps } = props
const linkStyle: JSX.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
transition: "opacity 0.2s",
...style,
}
return (
<a href={href} target={target} class={className} style={linkStyle}>
<Icon {...iconProps} />
</a>
)
}
export const Test = () => {
return (
<div style={{ padding: "24px" }}>
{/* === ICON TESTS === */}
{/* Size variations */}
<div style={{ marginBottom: "32px" }}>
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Icon Size Variations</h2>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
{([3, 4, 5, 6, 8, 10, 12, 16] as const).map((size) => (
<div key={size} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
<Icon name="Heart" size={size} />
<p style={{ fontSize: "14px" }}>{size}</p>
</div>
))}
</div>
</div>
{/* Styling with CSS classes */}
<div style={{ marginBottom: "32px" }}>
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Styling with CSS Classes</h2>
<Grid cols={5} gap={6}>
<VStack h="center" gap={2}>
<Icon name="Star" size={12} />
<p style={{ fontSize: "14px" }}>Default</p>
</VStack>
<VStack h="center" gap={2}>
<Icon name="Star" size={12} style={{ color: "#3b82f6" }} />
<p style={{ fontSize: "14px" }}>Blue Color</p>
</VStack>
<VStack h="center" gap={2}>
<Icon name="Star" size={12} class="stroke-1" />
<p style={{ fontSize: "14px" }}>Thin Stroke</p>
</VStack>
<VStack h="center" gap={2}>
<Icon name="Star" size={12} style={{ color: "#fbbf24", fill: "currentColor", stroke: "none" }} />
<p style={{ fontSize: "14px" }}>Filled</p>
</VStack>
<VStack h="center" gap={2}>
<Icon name="Star" size={12} style={{ color: "#a855f7", transition: "color 0.2s" }} />
<p style={{ fontSize: "14px" }}>Hover Effect</p>
</VStack>
</Grid>
</div>
{/* Advanced styling */}
<div style={{ marginBottom: "32px" }}>
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Advanced Styling</h2>
<Grid cols={4} gap={6}>
<VStack h="center" gap={2}>
<Icon name="Heart" size={12} style={{ color: "#ef4444", fill: "currentColor", stroke: "none" }} />
<p style={{ fontSize: "14px" }}>Filled Heart</p>
</VStack>
<VStack h="center" gap={2}>
<Icon name="Shield" size={12} style={{ color: "#16a34a", strokeWidth: "2" }} />
<p style={{ fontSize: "14px" }}>Thick Stroke</p>
</VStack>
<VStack h="center" gap={2}>
<Icon name="Sun" size={12} style={{ color: "#eab308" }} />
<p style={{ fontSize: "14px" }}>Sun Icon</p>
</VStack>
<VStack h="center" gap={2}>
<Icon name="Zap" size={12} style={{ color: "#60a5fa", filter: "drop-shadow(0 4px 6px rgba(0,0,0,0.3))" }} />
<p style={{ fontSize: "14px" }}>Drop Shadow</p>
</VStack>
</Grid>
</div>
{/* === ICON LINK TESTS === */}
{/* Basic icon links */}
<div style={{ marginBottom: "32px" }}>
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Icon Links</h2>
<div style={{ display: "flex", gap: "24px" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
<IconLink name="Home" size={8} href="/" />
<p style={{ fontSize: "14px" }}>Home Link</p>
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
<IconLink name="ExternalLink" size={8} href="https://example.com" target="_blank" />
<p style={{ fontSize: "14px" }}>External Link</p>
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
<IconLink name="Mail" size={8} href="mailto:hello@example.com" />
<p style={{ fontSize: "14px" }}>Email Link</p>
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "8px" }}>
<IconLink name="Phone" size={8} href="tel:+1234567890" />
<p style={{ fontSize: "14px" }}>Phone Link</p>
</div>
</div>
</div>
{/* Styled icon links */}
<div>
<h2 style={{ fontSize: "20px", fontWeight: "bold", marginBottom: "16px" }}>Styled Icon Links</h2>
<Grid cols={4} gap={6}>
<VStack h="center" gap={2}>
<IconLink
name="Download"
size={8}
href="#"
style={{
backgroundColor: "#3b82f6",
color: "white",
padding: "8px",
borderRadius: "8px",
}}
/>
<p style={{ fontSize: "14px" }}>Button Style</p>
</VStack>
<VStack h="center" gap={2}>
<IconLink
name="Settings"
size={8}
href="#"
style={{
border: "2px solid #d1d5db",
padding: "8px",
borderRadius: "9999px",
}}
/>
<p style={{ fontSize: "14px" }}>Circle Border</p>
</VStack>
<VStack h="center" gap={2}>
<IconLink name="Heart" size={8} href="#" style={{ color: "#ef4444" }} />
<p style={{ fontSize: "14px" }}>Red Heart</p>
</VStack>
<VStack h="center" gap={2}>
<IconLink name="Star" size={8} href="#" style={{ color: "#fbbf24", fill: "currentColor" }} />
<p style={{ fontSize: "14px" }}>Filled Star</p>
</VStack>
</Grid>
</div>
</div>
)
}
function sizeToPixels(size: number): number {
return size * 4
}

219
src/image.tsx Normal file
View File

@ -0,0 +1,219 @@
import "hono/jsx"
import type { FC, JSX } from "hono/jsx"
import { VStack, HStack } from "./stack"
import { Grid } from "./grid"
export type ImageProps = {
src: string
alt?: string
width?: number
height?: number
objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down"
style?: JSX.CSSProperties
}
export const Image: FC<ImageProps> = ({ src, alt = "", width, height, objectFit, style }) => {
const imageStyle: JSX.CSSProperties = {
width: width ? `${width}px` : undefined,
height: height ? `${height}px` : undefined,
objectFit: objectFit,
...style,
}
return <img src={src} alt={alt} style={imageStyle} />
}
export const Test = () => {
const sampleImages = [
"https://picsum.photos/seed/1/400/600", // Portrait
"https://picsum.photos/seed/2/600/400", // Landscape
"https://picsum.photos/seed/3/300/300", // Square
"https://picsum.photos/seed/4/200/100", // Small image
]
return (
<VStack gap={8} style={{ padding: "24px" }}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Image Examples</h2>
{/* Size variations */}
<VStack gap={4}>
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Size Variations</h3>
<HStack gap={4} wrap>
<VStack h="center" gap={2}>
<Image src={sampleImages[0]!} width={64} height={64} objectFit="cover" alt="64x64" />
<p style={{ fontSize: "14px" }}>64x64</p>
</VStack>
<VStack h="center" gap={2}>
<Image src={sampleImages[0]!} width={96} height={96} objectFit="cover" alt="96x96" />
<p style={{ fontSize: "14px" }}>96x96</p>
</VStack>
<VStack h="center" gap={2}>
<Image src={sampleImages[0]!} width={128} height={128} objectFit="cover" alt="128x128" />
<p style={{ fontSize: "14px" }}>128x128</p>
</VStack>
<VStack h="center" gap={2}>
<Image src={sampleImages[0]!} width={192} height={128} objectFit="cover" alt="192x128" />
<p style={{ fontSize: "14px" }}>192x128</p>
</VStack>
</HStack>
</VStack>
{/* Object fit variations */}
<VStack gap={4}>
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Object Fit Variations</h3>
<p style={{ fontSize: "14px", color: "#6b7280" }}>
Same image with different object-fit values
</p>
<HStack gap={6} wrap>
<VStack h="center" gap={2}>
<Image
src={sampleImages[0]!}
width={128}
height={128}
objectFit="cover"
style={{ border: "1px solid black" }}
alt="Object cover"
/>
<p style={{ fontSize: "14px" }}>object-fit: cover</p>
</VStack>
<VStack h="center" gap={2}>
<Image
src={sampleImages[0]!}
width={128}
height={128}
objectFit="contain"
style={{ border: "1px solid black", backgroundColor: "#f3f4f6" }}
alt="Object contain"
/>
<p style={{ fontSize: "14px" }}>object-fit: contain</p>
</VStack>
<VStack h="center" gap={2}>
<Image
src={sampleImages[0]!}
width={128}
height={128}
objectFit="fill"
style={{ border: "1px solid black" }}
alt="Object fill"
/>
<p style={{ fontSize: "14px" }}>object-fit: fill</p>
</VStack>
<VStack h="center" gap={2}>
<Image
src={sampleImages[0]!}
width={128}
height={128}
objectFit="scale-down"
style={{ border: "1px solid black", backgroundColor: "#f3f4f6" }}
alt="Object scale-down"
/>
<p style={{ fontSize: "14px" }}>object-fit: scale-down</p>
</VStack>
<VStack h="center" gap={2}>
<Image
src={sampleImages[0]!}
width={128}
height={128}
objectFit="none"
style={{ border: "1px solid black", backgroundColor: "#f3f4f6" }}
alt="Object none"
/>
<p style={{ fontSize: "14px" }}>object-fit: none</p>
</VStack>
</HStack>
</VStack>
{/* Styling examples */}
<VStack gap={4}>
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Styling Examples</h3>
<HStack gap={6} wrap>
<VStack h="center" gap={2}>
<Image
src={sampleImages[0]!}
width={128}
height={128}
objectFit="cover"
style={{ borderRadius: "8px", border: "4px solid #3b82f6" }}
alt="Rounded with border"
/>
<p style={{ fontSize: "14px" }}>Rounded + Border</p>
</VStack>
<VStack h="center" gap={2}>
<Image
src={sampleImages[1]!}
width={128}
height={128}
objectFit="cover"
style={{ boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)" }}
alt="With shadow"
/>
<p style={{ fontSize: "14px" }}>With Shadow</p>
</VStack>
<VStack h="center" gap={2}>
<Image
src={sampleImages[2]!}
width={128}
height={128}
objectFit="cover"
style={{
borderRadius: "9999px",
border: "4px solid #22c55e",
boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)",
}}
alt="Circular with effects"
/>
<p style={{ fontSize: "14px" }}>Circular + Effects</p>
</VStack>
</HStack>
</VStack>
{/* Common use cases */}
<VStack gap={6}>
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Common Use Cases</h3>
{/* Avatar */}
<VStack gap={2}>
<h4 style={{ fontWeight: "500" }}>Avatar</h4>
<Image
src={sampleImages[0]!}
width={48}
height={48}
objectFit="cover"
style={{ borderRadius: "9999px" }}
alt="Avatar"
/>
</VStack>
{/* Card image */}
<VStack gap={2} style={{ maxWidth: "384px" }}>
<h4 style={{ fontWeight: "500" }}>Card Image</h4>
<VStack gap={0} style={{ border: "1px solid #d1d5db", borderRadius: "8px", overflow: "hidden" }}>
<Image src={sampleImages[1]!} width={384} height={192} objectFit="cover" alt="Card image" />
<VStack gap={1} style={{ padding: "16px" }}>
<h5 style={{ fontWeight: "500" }}>Card Title</h5>
<p style={{ fontSize: "14px", color: "#6b7280" }}>Card description goes here</p>
</VStack>
</VStack>
</VStack>
{/* Gallery grid */}
<VStack gap={2}>
<h4 style={{ fontWeight: "500" }}>Gallery Grid</h4>
<Grid cols={3} gap={2}>
{sampleImages.map((src, i) => (
<Image
key={i}
src={src}
width={120}
height={120}
objectFit="cover"
style={{ borderRadius: "4px" }}
alt={`Gallery ${i}`}
/>
))}
</Grid>
</VStack>
</VStack>
</VStack>
)
}

28
src/index.tsx Normal file
View File

@ -0,0 +1,28 @@
export { Button } from "./button"
export type { ButtonProps } from "./button"
export { Icon, IconLink } from "./icon"
export type { IconName } from "./icon"
export { VStack, HStack } from "./stack"
export { Grid } from "./grid"
export { Divider } from "./divider"
export { Avatar } from "./avatar"
export type { AvatarProps } from "./avatar"
export { Image } from "./image"
export type { ImageProps } from "./image"
export { Input } from "./input"
export type { InputProps } from "./input"
export { Select } from "./select"
export type { SelectProps, SelectOption } from "./select"
export { Placeholder } from "./placeholder"
export { default as PlaceholderDefault } from "./placeholder"
export type { TailwindSize } from "./types"

181
src/input.tsx Normal file
View File

@ -0,0 +1,181 @@
import "hono/jsx"
import type { JSX, FC } from "hono/jsx"
import { VStack, HStack } from "./stack"
export type InputProps = JSX.IntrinsicElements["input"] & {
labelPosition?: "above" | "left" | "right"
children?: any
}
export const Input: FC<InputProps> = (props) => {
const { labelPosition = "above", children, style, ...inputProps } = props
const inputStyle: JSX.CSSProperties = {
height: "40px",
padding: "8px 12px",
borderRadius: "6px",
border: "1px solid #d1d5db",
backgroundColor: "white",
fontSize: "14px",
outline: "none",
...style,
}
if (!children) {
return <input style={inputStyle} {...inputProps} />
}
const labelStyle: JSX.CSSProperties = {
fontSize: "14px",
fontWeight: "500",
color: "#111827",
}
const labelElement = (
<label for={inputProps.id} style={labelStyle}>
{children}
</label>
)
if (labelPosition === "above") {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px", flex: 1, minWidth: 0 }}>
{labelElement}
<input style={inputStyle} {...inputProps} />
</div>
)
}
if (labelPosition === "left") {
return (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
{labelElement}
<input style={{ ...inputStyle, flex: 1 }} {...inputProps} />
</div>
)
}
if (labelPosition === "right") {
return (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
<input style={{ ...inputStyle, flex: 1 }} {...inputProps} />
{labelElement}
</div>
)
}
return null
}
export const Test = () => {
return (
<VStack gap={8} style={{ padding: "24px", maxWidth: "448px" }}>
{/* Basic inputs */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Basic Inputs</h2>
<VStack gap={4}>
<Input placeholder="Enter your name" />
<Input type="email" placeholder="Enter your email" />
<Input type="password" placeholder="Enter your password" />
</VStack>
</VStack>
{/* Custom styling */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Custom Styling</h2>
<VStack gap={4}>
<Input style={{ height: "32px", fontSize: "12px" }} placeholder="Small input" />
<Input placeholder="Default input" />
<Input style={{ height: "48px", fontSize: "16px" }} placeholder="Large input" />
</VStack>
</VStack>
{/* With values */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>With Values</h2>
<VStack gap={4}>
<Input value="John Doe" placeholder="Name" />
<Input type="email" value="john@example.com" placeholder="Email" />
</VStack>
</VStack>
{/* Disabled state */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Disabled State</h2>
<VStack gap={4}>
<Input disabled placeholder="Disabled input" style={{ opacity: 0.5, cursor: "not-allowed" }} />
<Input disabled value="Disabled with value" style={{ opacity: 0.5, cursor: "not-allowed" }} />
</VStack>
</VStack>
{/* Label above */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Above</h2>
<VStack gap={4}>
<Input placeholder="Enter your name">Name</Input>
<Input type="email" placeholder="Enter your email">
Email
</Input>
<Input type="password" placeholder="Enter your password">
Password
</Input>
</VStack>
</VStack>
{/* Label to the left */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Left</h2>
<VStack gap={4}>
<Input labelPosition="left" placeholder="Enter your name">
Name
</Input>
<Input labelPosition="left" type="email" placeholder="Enter your email">
Email
</Input>
<Input labelPosition="left" type="password" placeholder="Enter your password">
Password
</Input>
</VStack>
</VStack>
{/* Label to the right */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Right</h2>
<VStack gap={4}>
<Input labelPosition="right" placeholder="Enter your name">
Name
</Input>
<Input labelPosition="right" type="email" placeholder="Enter your email">
Email
</Input>
<Input labelPosition="right" type="password" placeholder="Enter your password">
Password
</Input>
</VStack>
</VStack>
{/* Horizontal layout */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Horizontal Layout</h2>
<HStack gap={4}>
<Input placeholder="First name">First</Input>
<Input placeholder="Last name">Last</Input>
<Input placeholder="Age">Age</Input>
</HStack>
</VStack>
{/* Custom styling */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Custom Input Styling</h2>
<VStack gap={4}>
<Input style={{ borderColor: "#93c5fd" }} placeholder="Custom styled input">
<span style={{ color: "#2563eb", fontWeight: "bold" }}>Custom Label</span>
</Input>
<Input labelPosition="left" placeholder="Required input">
<span style={{ color: "#dc2626", minWidth: "96px" }}>Required Field</span>
</Input>
</VStack>
</VStack>
</VStack>
)
}

245
src/placeholder.tsx Normal file
View File

@ -0,0 +1,245 @@
import "hono/jsx"
import { Avatar } from "./avatar"
import type { AvatarProps } from "./avatar"
import { Image } from "./image"
import type { ImageProps } from "./image"
import { VStack, HStack } from "./stack"
import { Grid } from "./grid"
export const Placeholder = {
Avatar(props: PlaceholderAvatarProps) {
const { size = 32, seed = "seed", type = "dylan", transparent, alt, style, rounded } = props
// Generate DiceBear avatar URL
const url = new URL(`https://api.dicebear.com/9.x/${type}/svg`)
url.searchParams.set("seed", seed)
url.searchParams.set("size", size.toString())
if (transparent) {
url.searchParams.set("backgroundColor", "transparent")
}
return <Avatar src={url.toString()} alt={alt} style={style} size={size} rounded={rounded} />
},
Image(props: PlaceholderImageProps) {
const { width = 200, height = 200, seed = 1, alt = "Placeholder image", objectFit, style } = props
// Generate Picsum Photos URL with seed for consistent images
const src = `https://picsum.photos/${width}/${height}?random=${seed}`
return <Image src={src} alt={alt} width={width} height={height} objectFit={objectFit} style={style} />
},
}
export const Test = () => {
return (
<VStack gap={8} style={{ padding: "24px" }}>
{/* === AVATAR TESTS === */}
{/* Show all available avatar styles */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>
All Avatar Styles ({allStyles.length} total)
</h2>
<Grid cols={10} gap={3}>
{allStyles.map((style) => (
<VStack h="center" gap={1} key={style}>
<Placeholder.Avatar type={style} size={48} />
<p style={{ fontSize: "12px", fontWeight: "500" }}>{style}</p>
</VStack>
))}
</Grid>
</VStack>
{/* Avatar size variations */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Avatar Size Variations</h2>
<HStack gap={4}>
{[24, 32, 48, 64].map((size) => (
<VStack h="center" gap={2} key={size}>
<Placeholder.Avatar size={size} />
<p style={{ fontSize: "14px" }}>{size}px</p>
</VStack>
))}
</HStack>
</VStack>
{/* Avatar styling combinations */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Avatar Styling Options</h2>
<HStack gap={6}>
<VStack h="center" gap={2}>
<Placeholder.Avatar rounded size={64} />
<p style={{ fontSize: "14px" }}>Rounded + Background</p>
</VStack>
<VStack h="center" gap={2}>
<div style={{ backgroundColor: "#e5e7eb", padding: "8px" }}>
<Placeholder.Avatar rounded transparent size={64} />
</div>
<p style={{ fontSize: "14px" }}>Rounded + Transparent</p>
</VStack>
<VStack h="center" gap={2}>
<Placeholder.Avatar size={64} />
<p style={{ fontSize: "14px" }}>Square + Background</p>
</VStack>
<VStack h="center" gap={2}>
<div style={{ backgroundColor: "#e5e7eb", padding: "8px" }}>
<Placeholder.Avatar transparent size={64} />
</div>
<p style={{ fontSize: "14px" }}>Square + Transparent</p>
</VStack>
</HStack>
</VStack>
{/* Avatar seed variations */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>
Avatar Seeds (Same Style, Different People)
</h2>
<HStack gap={4}>
{["alice", "bob", "charlie", "diana"].map((seed) => (
<VStack h="center" gap={2} key={seed}>
<Placeholder.Avatar seed={seed} size={64} />
<p style={{ fontSize: "14px" }}>"{seed}"</p>
</VStack>
))}
</HStack>
</VStack>
{/* === IMAGE TESTS === */}
<VStack gap={6}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Placeholder Images</h2>
{/* Size variations */}
<VStack gap={3}>
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>Size Variations</h3>
<HStack gap={4}>
{[
{ width: 100, height: 100 },
{ width: 150, height: 100 },
{ width: 200, height: 150 },
{ width: 250, height: 200 },
].map(({ width, height }) => (
<VStack h="center" gap={2} key={`${width}x${height}`}>
<Placeholder.Image width={width} height={height} seed={1} />
<p style={{ fontSize: "14px" }}>
{width}×{height}
</p>
</VStack>
))}
</HStack>
</VStack>
{/* Different seeds - show variety */}
<VStack gap={3}>
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>
Different Images (Different Seeds)
</h3>
<HStack gap={4}>
{[1, 2, 3, 4, 5].map((seed) => (
<VStack h="center" gap={2} key={seed}>
<Placeholder.Image width={150} height={150} seed={seed} />
<p style={{ fontSize: "14px" }}>Seed {seed}</p>
</VStack>
))}
</HStack>
</VStack>
{/* With custom styles */}
<VStack gap={3}>
<h3 style={{ fontSize: "18px", fontWeight: "600" }}>With Custom Styles</h3>
<HStack gap={6}>
<VStack h="center" gap={2}>
<Placeholder.Image
width={150}
height={150}
seed={1}
objectFit="cover"
style={{ borderRadius: "8px", border: "4px solid #3b82f6" }}
/>
<p style={{ fontSize: "14px" }}>Rounded + Border</p>
</VStack>
<VStack h="center" gap={2}>
<Placeholder.Image
width={150}
height={150}
seed={2}
objectFit="cover"
style={{ boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)" }}
/>
<p style={{ fontSize: "14px" }}>With Shadow</p>
</VStack>
<VStack h="center" gap={2}>
<Placeholder.Image
width={150}
height={150}
seed={3}
objectFit="cover"
style={{
borderRadius: "9999px",
border: "4px solid #22c55e",
boxShadow: "0 10px 15px rgba(0, 0, 0, 0.3)",
}}
/>
<p style={{ fontSize: "14px" }}>Circular + Effects</p>
</VStack>
</HStack>
</VStack>
</VStack>
</VStack>
)
}
// Type definitions
type PlaceholderAvatarProps = Omit<AvatarProps, "src"> & {
seed?: string
type?: DicebearStyleName
transparent?: boolean
}
type PlaceholderImageProps = Omit<ImageProps, "src" | "alt"> & {
width?: number
height?: number
seed?: number
alt?: string
}
// All supported DiceBear HTTP styleNames. Source: https://www.dicebear.com/styles
const allStyles = [
"adventurer",
"adventurer-neutral",
"avataaars",
"avataaars-neutral",
"big-ears",
"big-ears-neutral",
"big-smile",
"bottts",
"bottts-neutral",
"croodles",
"croodles-neutral",
"dylan",
"fun-emoji",
"glass",
"icons",
"identicon",
"initials",
"lorelei",
"lorelei-neutral",
"micah",
"miniavs",
"notionists",
"notionists-neutral",
"open-peeps",
"personas",
"pixel-art",
"pixel-art-neutral",
"rings",
"shapes",
"thumbs",
] as const
type DicebearStyleName = (typeof allStyles)[number]
// Default export for convenience
export default Placeholder

263
src/select.tsx Normal file
View File

@ -0,0 +1,263 @@
import "hono/jsx"
import type { JSX, FC } from "hono/jsx"
import { VStack, HStack } from "./stack"
export type SelectOption = {
value: string
label: string
disabled?: boolean
}
export type SelectProps = Omit<JSX.IntrinsicElements["select"], "children"> & {
options: SelectOption[]
placeholder?: string
labelPosition?: "above" | "left" | "right"
children?: any
}
export const Select: FC<SelectProps> = (props) => {
const { options, placeholder, labelPosition = "above", children, style, ...selectProps } = props
// If a label is provided but no id, generate a random id so the label can be clicked
if (children && !selectProps.id) {
selectProps.id = `random-${Math.random().toString(36)}`
}
const selectStyle: JSX.CSSProperties = {
height: "40px",
padding: "8px 32px 8px 12px",
borderRadius: "6px",
border: "1px solid #d1d5db",
backgroundColor: "white",
fontSize: "14px",
outline: "none",
appearance: "none",
backgroundImage: `url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQgNkw4IDEwTDEyIDYiIHN0cm9rZT0iIzZCNzI4MCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPC9zdmc+")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 8px center",
backgroundSize: "16px 16px",
...style,
}
const selectElement = (
<select style={selectStyle} {...selectProps}>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
selected={selectProps.value === option.value}
>
{option.label}
</option>
))}
</select>
)
if (!children) {
return selectElement
}
const labelStyle: JSX.CSSProperties = {
fontSize: "14px",
fontWeight: "500",
color: "#111827",
}
const labelElement = (
<label for={selectProps.id} style={labelStyle}>
{children}
</label>
)
if (labelPosition === "above") {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px", flex: 1, minWidth: 0 }}>
{labelElement}
{selectElement}
</div>
)
}
if (labelPosition === "left") {
return (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
{labelElement}
<select style={{ ...selectStyle, flex: 1 }} {...selectProps}>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
</div>
)
}
if (labelPosition === "right") {
return (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flex: 1 }}>
<select style={{ ...selectStyle, flex: 1 }} {...selectProps}>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
{labelElement}
</div>
)
}
return null
}
export const Test = () => {
const months = [
{ value: "01", label: "January" },
{ value: "02", label: "February" },
{ value: "03", label: "March" },
{ value: "04", label: "April" },
{ value: "05", label: "May" },
{ value: "06", label: "June" },
{ value: "07", label: "July" },
{ value: "08", label: "August" },
{ value: "09", label: "September" },
{ value: "10", label: "October" },
{ value: "11", label: "November" },
{ value: "12", label: "December" },
]
const years = Array.from({ length: 10 }, (_, i) => ({
value: String(2024 + i),
label: String(2024 + i),
}))
const countries = [
{ value: "us", label: "United States" },
{ value: "ca", label: "Canada" },
{ value: "uk", label: "United Kingdom" },
{ value: "de", label: "Germany" },
{ value: "fr", label: "France" },
{ value: "au", label: "Australia", disabled: true },
]
return (
<VStack gap={8} style={{ padding: "24px", maxWidth: "448px" }}>
{/* Basic selects */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Basic Selects</h2>
<VStack gap={4}>
<Select options={months} placeholder="Select month" />
<Select options={years} placeholder="Select year" />
<Select options={countries} placeholder="Select country" />
</VStack>
</VStack>
{/* With values */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>With Values</h2>
<VStack gap={4}>
<Select options={months} value="03" />
<Select options={years} value="2025" />
</VStack>
</VStack>
{/* Disabled state */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Disabled State</h2>
<VStack gap={4}>
<Select
options={months}
disabled
placeholder="Disabled select"
style={{ opacity: 0.5, cursor: "not-allowed" }}
/>
<Select options={years} disabled value="2024" style={{ opacity: 0.5, cursor: "not-allowed" }} />
</VStack>
</VStack>
{/* Custom styling */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Custom Styling</h2>
<VStack gap={4}>
<Select options={countries} style={{ borderColor: "#93c5fd" }} placeholder="Custom styled select" />
<Select options={months} style={{ height: "32px", fontSize: "12px" }} placeholder="Small select" />
</VStack>
</VStack>
{/* Label above */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Above</h2>
<VStack gap={4}>
<Select options={months} placeholder="Select month">
Birth Month
</Select>
<Select options={years} placeholder="Select year">
Birth Year
</Select>
<Select options={countries} placeholder="Select country">
Country
</Select>
</VStack>
</VStack>
{/* Label to the left */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Left</h2>
<VStack gap={4}>
<Select labelPosition="left" options={months} placeholder="Select month">
Month
</Select>
<Select labelPosition="left" options={years} placeholder="Select year">
Year
</Select>
<Select labelPosition="left" options={countries} placeholder="Select country">
Country
</Select>
</VStack>
</VStack>
{/* Label to the right */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Label Right</h2>
<VStack gap={4}>
<Select labelPosition="right" options={months} placeholder="Select month">
Month
</Select>
<Select labelPosition="right" options={years} placeholder="Select year">
Year
</Select>
</VStack>
</VStack>
{/* Horizontal layout (like card form) */}
<VStack gap={4}>
<h2 style={{ fontSize: "20px", fontWeight: "bold" }}>Horizontal Layout</h2>
<HStack gap={4}>
<Select options={months} placeholder="MM">
Expires
</Select>
<Select options={years} placeholder="YYYY">
Year
</Select>
</HStack>
</VStack>
</VStack>
)
}

193
src/stack.tsx Normal file
View File

@ -0,0 +1,193 @@
import type { TailwindSize } from "./types"
import "hono/jsx"
import type { FC, PropsWithChildren, JSX } from "hono/jsx"
import { Grid } from "./grid"
export const VStack: FC<VStackProps> = (props) => {
return (
<Stack
direction="col"
mainAxis={props.v}
crossAxis={props.h}
wrap={props.wrap}
gap={props.gap}
style={props.style}
>
{props.children}
</Stack>
)
}
export const HStack: FC<HStackProps> = (props) => {
return (
<Stack
direction="row"
mainAxis={props.h}
crossAxis={props.v}
wrap={props.wrap}
gap={props.gap}
style={props.style}
>
{props.children}
</Stack>
)
}
const Stack: FC<StackProps> = (props) => {
const gapPx = props.gap ? props.gap * 4 : 0
const baseStyles: JSX.CSSProperties = {
display: "flex",
flexDirection: props.direction === "row" ? "row" : "column",
flexWrap: props.wrap ? "wrap" : "nowrap",
gap: `${gapPx}px`,
}
if (props.mainAxis) {
baseStyles.justifyContent = getJustifyContent(props.mainAxis)
}
if (props.crossAxis) {
baseStyles.alignItems = getAlignItems(props.crossAxis)
}
const combinedStyles = {
...baseStyles,
...props.style,
}
return <div style={combinedStyles}>{props.children}</div>
}
export const Test = () => {
const mainAxisOpts: MainAxisOpts[] = ["start", "center", "end", "between", "around", "evenly"]
const crossAxisOpts: CrossAxisOpts[] = ["start", "center", "end", "stretch", "baseline"]
return (
<VStack gap={8} style={{ padding: "16px" }}>
{/* HStack layout matrix */}
<VStack gap={2}>
<h2 style={{ fontSize: "18px", fontWeight: "bold" }}>HStack Layout</h2>
<div style={{ overflowX: "auto" }}>
<Grid cols={7} gap={1} style={{ gridTemplateColumns: "auto repeat(6, 1fr)" }}>
{/* Header row: blank + h labels */}
<div></div>
{mainAxisOpts.map((h) => (
<div key={h} style={{ fontSize: "14px", fontWeight: "500", textAlign: "center" }}>
h: {h}
</div>
))}
{/* Each row: v label + HStack cells */}
{crossAxisOpts.map((v) => [
<div key={v} style={{ fontSize: "14px", fontWeight: "500" }}>
v: {v}
</div>,
...mainAxisOpts.map((h) => (
<HStack
key={`${h}-${v}`}
h={h}
v={v}
style={{ backgroundColor: "#f3f4f6", padding: "8px", height: "96px", border: "1px solid #9ca3af" }}
>
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#ef4444" }}>Aa</div>
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#22c55e" }}>Aa</div>
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#3b82f6" }}>Aa</div>
</HStack>
)),
])}
</Grid>
</div>
</VStack>
{/* VStack layout matrix */}
<VStack gap={2}>
<h2 style={{ fontSize: "18px", fontWeight: "bold" }}>VStack Layout</h2>
<div style={{ overflowX: "auto" }}>
<Grid cols={6} gap={1} style={{ gridTemplateColumns: "auto repeat(5, 1fr)" }}>
{/* Header row: blank + h labels */}
<div></div>
{crossAxisOpts.map((h) => (
<div key={h} style={{ fontSize: "14px", fontWeight: "500", textAlign: "center" }}>
h: {h}
</div>
))}
{/* Each row: v label + VStack cells */}
{mainAxisOpts.map((v) => [
<div key={v} style={{ fontSize: "14px", fontWeight: "500" }}>
v: {v}
</div>,
...crossAxisOpts.map((h) => (
<VStack
key={`${h}-${v}`}
v={v}
h={h}
style={{ backgroundColor: "#f3f4f6", padding: "8px", height: "168px", border: "1px solid #9ca3af" }}
>
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#ef4444" }}>Aa</div>
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#22c55e" }}>Aa</div>
<div style={{ textAlign: "center", padding: "4px", backgroundColor: "#3b82f6" }}>Aa</div>
</VStack>
)),
])}
</Grid>
</div>
</VStack>
</VStack>
)
}
type StackDirection = "row" | "col"
type StackProps = {
direction: StackDirection
mainAxis?: string
crossAxis?: string
wrap?: boolean
gap?: TailwindSize
style?: JSX.CSSProperties
children?: any
}
type MainAxisOpts = "start" | "center" | "end" | "between" | "around" | "evenly"
type CrossAxisOpts = "start" | "center" | "end" | "stretch" | "baseline"
type CommonStackProps = PropsWithChildren & {
wrap?: boolean
gap?: TailwindSize
style?: JSX.CSSProperties
}
type VStackProps = CommonStackProps & {
v?: MainAxisOpts // main axis for vertical stack
h?: CrossAxisOpts // cross axis for vertical stack
}
type HStackProps = CommonStackProps & {
h?: MainAxisOpts // main axis for horizontal stack
v?: CrossAxisOpts // cross axis for horizontal stack
}
function getJustifyContent(axis: string): string {
const map: Record<string, string> = {
start: "flex-start",
center: "center",
end: "flex-end",
between: "space-between",
around: "space-around",
evenly: "space-evenly",
}
return map[axis] || "flex-start"
}
function getAlignItems(axis: string): string {
const map: Record<string, string> = {
start: "flex-start",
center: "center",
end: "flex-end",
stretch: "stretch",
baseline: "baseline",
}
return map[axis] || "stretch"
}

1
src/types.ts Normal file
View File

@ -0,0 +1 @@
export type TailwindSize = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96

37
test/server.tsx Normal file
View File

@ -0,0 +1,37 @@
import { Hono } from 'hono'
import { readdirSync } from 'fs'
import { join } from 'path'
const port = process.env.PORT ?? '3100'
const app = new Hono()
app.get('/:file', async c => {
const file = c.req.param('file') ?? ''
const fileName = (file).replace('.', '')
const path = join(process.env.PWD ?? '.', `/src/${fileName}.tsx`)
if (!(await Bun.file(path).exists()))
return c.text('404 Not Found', 404)
const page = await import(path + `?t=${Date.now()}`)
return c.html(<><h1>{file}</h1><page.Test req={c.req} /></>)
})
app.get('/', c => {
return c.html(<>
<h1>Test Files</h1>
<ul style='font-size:150%'>{testFiles().map(x => <li><a href={`/${x}`}>{x}</a></li>)}</ul>
</>)
})
function testFiles(): string[] {
return readdirSync('./test')
.filter(x => x.endsWith('.tsx') && !x.startsWith('server'))
.map(x => x.replace('.tsx', ''))
.sort()
}
export default {
fetch: app.fetch,
port
}

36
tsconfig.json Normal file
View File

@ -0,0 +1,36 @@
{
"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,
"baseUrl": ".",
"paths": {
"#*": [
"src/*"
]
},
}
}