This commit is contained in:
Corey Johnson 2025-07-07 15:32:18 -07:00
parent 56262f1f6f
commit 49a8471628
24 changed files with 2397 additions and 1 deletions

View File

@ -1 +1 @@
1.2.16
1.2.18

2
packages/werewolf-ui/.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

34
packages/werewolf-ui/.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

View File

@ -0,0 +1,5 @@
# werewolfUI <howl>
- h1, h2, h3, etc... are kind of annoying. They are too generic.
- I didn't make "card" beccause it didn't feel like it added enough.
- Importing all these components always annoys me. Import them all as a single object (like $ or W or just one letter).

View File

@ -0,0 +1,24 @@
{
"name": "werewolfUI",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.tsx",
"module": "src/index.tsx",
"scripts": {
"dev": "bun --hot src/server.tsx",
"start": "NODE_ENV=production bun src/server.tsx"
},
"prettier": {
"semi": false,
"printWidth": 110
},
"dependencies": {
"hono": "^4.8.3",
"lucide-static": "^0.525.0",
"tailwindcss": "^4.0.6"
},
"devDependencies": {
"@types/bun": "latest"
}
}

View File

@ -0,0 +1,315 @@
import { HStack, VStack } from "@/lib/stack"
import Placeholder from "@/lib/placeholder"
import { Icon, IconLink, IconName } from "@/lib/icon"
import { Button } from "@/lib/button"
import { Input } from "@/lib/input"
import { Select } from "@/lib/select"
import { Grid } from "@/lib/grid"
import { Break } from "@/lib/break"
export const Cards = () => {
return (
<Grid>
<div>
<PaymentMethod />
<CreateAccount />
<TeamCard />
</div>
<div>
<TeamMembers />
<ShareDocument />
<DateCard />
<Notification />
</div>
</Grid>
)
}
const TeamMembers = () => {
/*
team_members = [("Sofia Davis", "m@example.com", "Owner"),("Jackson Lee", "p@example.com", "Member"),]
def TeamMemberRow(name, email, role):
return DivFullySpaced(
DivLAligned(
DiceBearAvatar(name, 10,10),
Div(P(name, cls=(TextT.sm, TextT.medium)),
P(email, cls=TextPresets.muted_sm))),
Button(role, UkIcon('chevron-down', cls='ml-4')),
DropDownNavContainer(map(NavCloseLi, [
A(Div('Viewer', NavSubtitle('Can view and comment.'))),
A(Div('Developer', NavSubtitle('Can view, comment and edit.'))),
A(Div('Billing', NavSubtitle('Can view, comment and manage billing.'))),
A(Div('Owner', NavSubtitle('Admin-level access to all resources.')))])))
TeamMembers = Card(*[TeamMemberRow(*member) for member in team_members],
header = (H4('Team Members'),Subtitle('Invite your team members to collaborate.')))
*/
const teamMembers = [
{ name: "Sofia Davis", email: "m@example.com", role: "owner" },
{ name: "Jackson Lee", email: "p@example.com", role: "member" },
]
const roleOptions = [
{ value: "owner", label: "Owner" },
{ value: "developer", label: "Developer" },
{ value: "billing", label: "Billing" },
{ value: "member", label: "Member" },
]
return (
<VStack gap="1" class="m-3 p-4 rounded-xs border border-gray-300">
<h2>Team Members</h2>
<h5 class="text-gray-500">Invite your team members to collaborate.</h5>
{teamMembers.map((member) => (
<HStack h="between" gap="3" class="w-full p-2 hover:bg-gray-50 rounded">
<Placeholder.Avatar size="10" rounded seed={member.name} />
<div class="flex-1">
<p class="font-medium">{member.name}</p>
<p class="text-sm text-gray-500">{member.email}</p>
</div>
<Select options={roleOptions} value={member.role} />
</HStack>
))}
</VStack>
)
}
const CreateAccount = () => {
/*
CreateAccount = Card(
Grid(Button(DivLAligned(UkIcon('github'),Div('Github'))),Button('Google')),
DividerSplit("OR CONTINUE WITH", text_cls=TextPresets.muted_sm),
LabelInput('Email', id='email', placeholder='m@example.com'),
LabelInput('Password', id='password',placeholder='Password', type='Password'),
header=(H3('Create an Account'),Subtitle('Enter your email below to create your account')),
footer=Button('Create Account',cls=(ButtonT.primary,'w-full')))
*/
return (
<>
<VStack gap="4" class="m-3 p-4 rounded-xs border border-gray-300">
<h2>Create an Account</h2>
<h5>Enter your email below to create your account</h5>
<HStack h="evenly" gap="4">
<Button variant="outline" class="grow">
<Icon name="Github" />
Github
</Button>
<Button variant="outline" class="grow">
<Icon name="Mail" />
Google
</Button>
</HStack>
<Break>OR CONTINUE WITH</Break>
<Input id="email" placeholder="m@example.com">
Email
</Input>
<Input id="password" placeholder="Password" type="Password">
Password
</Input>
<Button variant="primary">Create Account</Button>
</VStack>
</>
)
}
const PaymentMethod = () => {
/*
Grid(Button(DivCentered(Card1Svg, "Card"), cls='h-20 border-2 border-primary'),
Button(DivCentered(PaypalSvg, "PayPal"), cls='h-20'),
Button(DivCentered(AppleSvg, "Apple"), cls='h-20')),
Form(LabelInput('Name', id='name', placeholder='John Doe'),
LabelInput('Card Number', id='card_number', placeholder='m@example.com'),
Grid(LabelSelect(*Options(*calendar.month_name[1:],selected_idx=0),label='Expires',id='expire_month'),
LabelSelect(*Options(*range(2024,2030),selected_idx=0), label='Year', id='expire_year'),
LabelInput('CVV', id='cvv',placeholder='CVV', cls='mt-0'))),
header=(H3('Payment Method'),Subtitle('Add a new payment method to your account.')))
*/
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),
}))
return (
<VStack gap="1" class="m-3 p-4 rounded-xs border border-gray-300">
<h2>Payment Method</h2>
<h5 class="text-gray-500">Add a new payment method to your account.</h5>
<HStack h="evenly" gap="4" class="mt-3">
<Button variant="outline" class="grow h-20 flex flex-col">
<Icon name="CreditCard" />
<p class="text-xs">Card</p>
</Button>
<Button variant="outline" class="grow h-20 flex flex-col">
<Icon name="Apple" />
<p class="text-xs">Apple</p>
</Button>
<Button variant="outline" class="grow h-20 flex flex-col">
<Icon name="DollarSign" />
<p class="text-xs">PayPal</p>
</Button>
</HStack>
<VStack gap="4">
<Input id="name" placeholder="John Doe">
Name
</Input>
<Input id="card_number" placeholder="1234 5678 9012 3456">
Card Number
</Input>
<HStack gap="4">
<Select id="expire_month" options={months} placeholder="MM">
Expires
</Select>
<Select id="expire_year" options={years} placeholder="YYYY">
Year
</Select>
<Input id="cvv" placeholder="CVV">
CVV
</Input>
</HStack>
</VStack>
</VStack>
)
}
const TeamCard = () => {
/*
TeamCard = Card(
DivLAligned(
DiceBearAvatar("Isaac Flath", h=24, w=24),
Div(H3("Isaac Flath"), P("Library Creator"))),
footer=DivFullySpaced(
DivHStacked(UkIcon("map-pin", height=16), P("Alexandria, VA")),
DivHStacked(*(UkIconLink(icon, height=16) for icon in ("mail", "linkedin", "github")))),
cls=CardT.hover)
*/
return (
<VStack gap="3" class="m-3 p-4 rounded-xs border border-gray-300">
<HStack gap="2" v="center">
<Placeholder.Avatar size="20" rounded seed="Isaac Flath" />
<div>
<h2>Isaac Flath</h2>
<h4>Library Creator</h4>
</div>
</HStack>
<HStack h="between" gap="3" v="center">
<Icon name="MapPin" size={16} />
<p class="grow">Alexandria, VA</p>
<IconLink name="Mail" size={16} />
<IconLink name="Linkedin" size={16} />
<IconLink name="Github" size={16} />
</HStack>
</VStack>
)
}
const ShareDocument = () => {
/*
ShareDocument = Card(
DivLAligned(Input(value='http://example.com/link/to/document'),Button('Copy link', cls='whitespace-nowrap')),
Divider(),
H4('People with access', cls=TextPresets.bold_sm),
*[TeamMemberRow(*member) for member in team_members],
header = (H4('Share this document'),Subtitle('Anyone with the link can view this document.')))
DateCard = Card(Button('Jan 20, 2024 - Feb 09, 2024'))
section_content =(('bell','Everything',"Email digest, mentions & all activity."),
('user',"Available","Only mentions and comments"),
('ban', "Ignoring","Turn of all notifications"))
*/
const teamMembers = [
{ name: "Olivia Martin", email: "m@example.com", role: "r+w" },
{ name: "Isabella Nguyen", email: "b@example.com", role: "r" },
{ name: "Sofia Davis", email: "p@example.com", role: "r" },
]
const options = [
{ value: "r+w", label: "Read and write access" },
{ value: "r", label: "Read-only access" },
{ value: "p", label: "Pending access", disabled: true },
]
return (
<VStack gap="1" class="m-3 p-4 rounded-xs border border-gray-300">
<HStack h="start" gap="3">
<Input value="http://example.com/link/to/document" class="grow" />
<Button variant="outline">Copy link</Button>
</HStack>
<Break>
<Icon name="Circle" size={16} class="stroke-gray-300" />
</Break>
<h4 class="font-bold">People with access</h4>
{teamMembers.map((member) => (
<HStack h="between" gap="3" class="w-full p-2 hover:bg-gray-50 rounded">
<Placeholder.Avatar size="10" rounded seed={member.name} />
<div class="flex-1">
<p class="font-medium">{member.name}</p>
<p class="text-sm text-gray-500">{member.email}</p>
</div>
<Select options={options} value={member.role} />
</HStack>
))}
</VStack>
)
}
const DateCard = () => {
const date: string = new Date().toISOString().slice(0, 16)
return (
<VStack gap="3" class="m-3 p-4 rounded-xs border border-gray-300">
<Input type="datetime-local" value={date}>
Select Date
</Input>
</VStack>
)
}
const Notification = () => {
/*
Notifications = Card(
NavContainer(
*[NotificationRow(*row) for row in section_content],
cls=NavT.secondary),
header = (H4('Notification'),Subtitle('Choose what you want to be notified about.')),
body_cls='pt-0')
*/
const sectionContent: { icon: IconName; title: string; description: string }[] = [
{ icon: "Bell", title: "Everything", description: "Email digest, mentions & all activity." },
{ icon: "User", title: "Available", description: "Only mentions and comments" },
{ icon: "Ban", title: "Ignoring", description: "Turn off all notifications" },
]
return (
<VStack gap="3" class="m-3 p-4 rounded-xs border border-gray-300">
<h2>Notification</h2>
<h4>Choose what you want to be notified about.</h4>
{sectionContent.map((item) => (
<HStack gap="3" v="center">
<Icon name={item.icon} size={16} />
<div class="flex-1">
<p class="text-sm">{item.title}</p>
<p class="text-sm text-gray-400">{item.description}</p>
</div>
</HStack>
))}
</VStack>
)
}

View File

@ -0,0 +1,42 @@
@import "tailwindcss";
@theme {
--color-background: #ffffff;
--color-foreground: #09090b;
--color-primary: #18181b;
--color-primary-foreground: #fafafa;
--color-secondary: #f4f4f5;
--color-secondary-foreground: #18181b;
--color-muted: #f4f4f5;
--color-muted-foreground: #71717a;
--color-destructive: #ef4444;
--color-destructive-foreground: #fafafa;
--color-border: #e4e4e7;
--color-input: #e4e4e7;
--color-ring: #18181b;
}
h1 {
@apply text-2xl font-bold;
}
h2 {
@apply text-xl font-bold;
}
h3 {
@apply text-lg font-bold;
}
h4 {
@apply text-sm;
}
h5 {
@apply text-xs;
}

View File

@ -0,0 +1,109 @@
import "hono/jsx"
import { FC } from "hono/jsx"
export type AvatarProps = {
src: string
alt?: string
class?: string
size?: string // Tailwind size class (e.g., "8", "12", "16")
rounded?: boolean
}
export const Avatar: FC<AvatarProps> = (props) => {
let { size = "8", rounded, class: className } = props
// Build class names
const sizeClasses = [`w-${size}`, `h-${size}`]
const roundedClass = rounded ? "rounded-full" : ""
const combinedClassName = [className, ...sizeClasses, roundedClass].filter(Boolean).join(" ")
return <img src={props.src} alt={props.alt} class={combinedClassName} />
}
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 (
<div class="p-6 space-y-8">
{/* Size variations */}
<div>
<h2 class="text-xl font-bold mb-4">Size Variations</h2>
<div class="flex gap-4">
{["6", "8", "12", "16", "24"].map((size) => (
<div key={size} class="flex flex-col space-y-2">
<Avatar src={sampleImages[0]} size={size} alt="Sample" />
<p class="text-sm">
w-{size} h-{size}
</p>
</div>
))}
</div>
</div>
{/* Rounded vs Square */}
<div>
<h2 class="text-xl font-bold mb-4">Rounded vs Square</h2>
<div class="flex gap-6">
<div class="flex flex-col space-y-2">
<Avatar src={sampleImages[0]} size="16" alt="Sample" />
<p class="text-sm">Square</p>
</div>
<div class="flex flex-col space-y-2">
<Avatar src={sampleImages[0]} size="16" rounded alt="Sample" />
<p class="text-sm">Rounded</p>
</div>
</div>
</div>
{/* Different images */}
<div>
<h2 class="text-xl font-bold mb-4">Different Images</h2>
<div class="flex gap-4">
{sampleImages.map((src, index) => (
<div key={src} class="flex flex-col space-y-2">
<Avatar src={src} size="16" rounded alt={`Sample ${index + 1}`} />
<p class="text-sm">Image {index + 1}</p>
</div>
))}
</div>
</div>
{/* Custom classes */}
<div>
<h2 class="text-xl font-bold mb-4">With Custom Classes</h2>
<div class="flex gap-6">
<div class="flex flex-col space-y-2">
<Avatar
src={sampleImages[0]}
size="16"
rounded
class="border-4 border-blue-500"
alt="With border"
/>
<p class="text-sm">With Border</p>
</div>
<div class="flex flex-col space-y-2">
<Avatar src={sampleImages[1]} size="16" rounded class="shadow-lg" alt="With shadow" />
<p class="text-sm">With Shadow</p>
</div>
<div class="flex flex-col space-y-2">
<Avatar
src={sampleImages[2]}
size="16"
rounded
class="border-4 border-green-500 shadow-lg"
alt="Border + shadow"
/>
<p class="text-sm">Border + Shadow</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,41 @@
import "hono/jsx"
import { FC, PropsWithChildren } from "hono/jsx"
type BreakProps = PropsWithChildren & {
class?: string
}
export const Break: FC<BreakProps> = ({ children, class: className }) => {
return (
<div class={`flex items-center my-4 ${className || ""}`}>
<div class="flex-1 border-t border-gray-300"></div>
{children && (
<>
<span class="px-3 text-sm text-gray-500 bg-white">{children}</span>
<div class="flex-1 border-t border-gray-300"></div>
</>
)}
</div>
)
}
export const Test = () => {
return (
<div class="p-4 space-y-8 max-w-md">
<h2 class="text-lg font-bold mb-4">Break Examples</h2>
<div>
<p>Would you like to continue</p>
<Break>OR WOULD YOU LIKE TO</Break>
<p>Submit to certain dealth</p>
</div>
{/* Just a line */}
<div>
<p>Look a line 👇</p>
<Break />
<p>So cool, so straight!</p>
</div>
</div>
)
}

View File

@ -0,0 +1,105 @@
import "hono/jsx"
import { JSX, FC } from "hono/jsx"
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", class: className, ...buttonProps } = props
const baseClasses = [
"inline-flex",
"items-center",
"justify-center",
"font-medium",
"transition-colors",
"focus-visible:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-offset-2",
"disabled:pointer-events-none",
"disabled:opacity-50",
"cursor-pointer",
"rounded-sm",
]
const variantClasses = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-border bg-background text-foreground hover:bg-secondary",
ghost: "text-foreground hover:bg-secondary",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
}
const sizeClasses = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
}
const classes = [...baseClasses, variantClasses[variant], sizeClasses[size], className]
.filter(Boolean)
.join(" ")
return <button {...buttonProps} class={classes} />
}
export const Test = () => {
return (
<div class="p-6 space-y-8">
{/* Variants */}
<div>
<h2 class="text-xl font-bold mb-4">Button Variants</h2>
<div class="flex 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>
</div>
</div>
{/* Sizes */}
<div>
<h2 class="text-xl font-bold mb-4">Button Sizes</h2>
<div class="flex items-end gap-4">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
</div>
{/* With custom content */}
<div>
<h2 class="text-xl font-bold mb-4">Custom Content</h2>
<div class="flex gap-4">
<Button variant="primary">
<span>🚀</span>
<span class="ml-2">Launch</span>
</Button>
<Button variant="outline" class="flex-col h-20 w-24">
<span class="text-2xl">💳</span>
<span class="text-xs mt-1">Card</span>
</Button>
</div>
</div>
{/* Native attributes work */}
<div>
<h2 class="text-xl font-bold mb-4">Native Attributes</h2>
<div class="flex gap-4">
<Button onClick={() => alert("Clicked!")} variant="primary">
Click Me
</Button>
<Button disabled variant="secondary">
Disabled
</Button>
<Button type="submit" variant="outline">
Submit
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,185 @@
import { TailwindSize } from "@/types"
import "hono/jsx"
import { FC, PropsWithChildren } from "hono/jsx"
type GridProps = PropsWithChildren & {
cols?: GridCols
gap?: TailwindSize
v?: keyof typeof alignItemsClasses
h?: keyof typeof justifyItemsClasses
class?: string
}
type GridCols = number | { sm?: number; md?: number; lg?: number; xl?: number }
export const Grid: FC<GridProps> = (props) => {
const { cols = 2, gap = 4, v, h, class: className, children } = props
const classes = [
"grid",
getColumnsClass(cols),
gap && `gap-${gap}`,
v && alignItemsClasses[v],
h && justifyItemsClasses[h],
className,
]
return <div class={classes.join(" ")}>{children}</div>
}
function getColumnsClass(cols: GridCols): string {
if (typeof cols === "number") {
return `grid-cols-${cols}`
}
const classes = []
if (cols.sm) classes.push(`grid-cols-${cols.sm}`)
if (cols.md) classes.push(`md:grid-cols-${cols.md}`)
if (cols.lg) classes.push(`lg:grid-cols-${cols.lg}`)
if (cols.xl) classes.push(`xl:grid-cols-${cols.xl}`)
return classes.length ? classes.join(" ") : "grid-cols-1"
}
const alignItemsClasses = {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
} as const
const justifyItemsClasses = {
start: "justify-items-start",
center: "justify-items-center",
end: "justify-items-end",
stretch: "justify-items-stretch",
} as const
export const Test = () => {
return (
<div class="p-4 space-y-8">
<div>
<h2 class="text-lg font-bold mb-4">Grid Examples</h2>
{/* Simple 3-column grid */}
<div class="mb-6">
<h3 class="text-md font-semibold mb-2">Simple 3 columns: cols=3</h3>
<Grid cols={3} gap={4}>
<div class="bg-red-200 p-4 text-center">Item 1</div>
<div class="bg-green-200 p-4 text-center">Item 2</div>
<div class="bg-blue-200 p-4 text-center">Item 3</div>
<div class="bg-yellow-200 p-4 text-center">Item 4</div>
<div class="bg-purple-200 p-4 text-center">Item 5</div>
<div class="bg-pink-200 p-4 text-center">Item 6</div>
</Grid>
</div>
{/* Responsive grid */}
<div class="mb-6">
<h3 class="text-md font-semibold mb-2">Responsive: cols=&#123;sm: 1, md: 2, lg: 3&#125;</h3>
<Grid cols={{ sm: 1, md: 2, lg: 3 }} gap={4}>
<div class="bg-red-200 p-4 text-center">Card 1</div>
<div class="bg-green-200 p-4 text-center">Card 2</div>
<div class="bg-blue-200 p-4 text-center">Card 3</div>
<div class="bg-yellow-200 p-4 text-center">Card 4</div>
</Grid>
</div>
{/* More responsive examples */}
<div class="mb-6">
<h3 class="text-md font-semibold mb-2">More responsive: cols=&#123;sm: 2, lg: 4, xl: 6&#125;</h3>
<Grid cols={{ sm: 2, lg: 4, xl: 6 }} gap={4}>
<div class="bg-red-200 p-4 text-center">Item A</div>
<div class="bg-green-200 p-4 text-center">Item B</div>
<div class="bg-blue-200 p-4 text-center">Item C</div>
<div class="bg-yellow-200 p-4 text-center">Item D</div>
<div class="bg-purple-200 p-4 text-center">Item E</div>
<div class="bg-pink-200 p-4 text-center">Item F</div>
</Grid>
</div>
{/* Payment method example */}
<div class="mb-6">
<h3 class="text-md font-semibold mb-2">Payment buttons example</h3>
<Grid cols={3} gap={4}>
<button class="h-20 border-2 border-gray-300 rounded p-2 flex flex-col items-center justify-center hover:border-blue-500">
<div class="text-2xl">💳</div>
<span class="text-xs">Card</span>
</button>
<button class="h-20 border-2 border-gray-300 rounded p-2 flex flex-col items-center justify-center hover:border-blue-500">
<div class="text-2xl">🍎</div>
<span class="text-xs">Apple</span>
</button>
<button class="h-20 border-2 border-gray-300 rounded p-2 flex flex-col items-center justify-center hover:border-blue-500">
<div class="text-2xl">💰</div>
<span class="text-xs">PayPal</span>
</button>
</Grid>
</div>
{/* Alignment examples */}
<div class="mb-6">
<h3 class="text-md font-semibold mb-2">Alignment: v="center" h="center"</h3>
<Grid cols={3} gap={4} v="center" h="center" class="h-32 bg-gray-100">
<div class="bg-red-200 p-2">Item 1</div>
<div class="bg-green-200 p-2">Item 2</div>
<div class="bg-blue-200 p-2">Item 3</div>
</Grid>
</div>
<div class="mb-6">
<h3 class="text-md font-semibold mb-2">Alignment: v="start" h="end"</h3>
<Grid cols={2} gap={4} v="start" h="end" class="h-24 bg-gray-100">
<div class="bg-purple-200 p-2">Left</div>
<div class="bg-orange-200 p-2">Right</div>
</Grid>
</div>
</div>
</div>
)
}
// Tailwind safelist for dynamic classes
const _tailwindSafelist = [
"grid",
"grid-cols-1",
"grid-cols-2",
"grid-cols-3",
"grid-cols-4",
"grid-cols-5",
"grid-cols-6",
"sm:grid-cols-1",
"sm:grid-cols-2",
"sm:grid-cols-3",
"sm:grid-cols-4",
"md:grid-cols-1",
"md:grid-cols-2",
"md:grid-cols-3",
"md:grid-cols-4",
"lg:grid-cols-1",
"lg:grid-cols-2",
"lg:grid-cols-3",
"lg:grid-cols-4",
"lg:grid-cols-5",
"lg:grid-cols-6",
"xl:grid-cols-1",
"xl:grid-cols-2",
"xl:grid-cols-3",
"xl:grid-cols-4",
"xl:grid-cols-5",
"xl:grid-cols-6",
"gap-0",
"gap-1",
"gap-2",
"gap-3",
"gap-4",
"gap-5",
"gap-6",
"gap-8",
"items-start",
"items-center",
"items-end",
"items-stretch",
"justify-items-start",
"justify-items-center",
"justify-items-end",
"justify-items-stretch",
]

View File

@ -0,0 +1,204 @@
import "hono/jsx"
import { FC } from "hono/jsx"
import * as icons from "lucide-static"
export type IconName = keyof typeof icons
type IconProps = {
name: IconName
size?: number | string
class?: string
}
type IconLinkProps = IconProps & {
href?: string
target?: string
}
export const Icon: FC<IconProps> = (props) => {
const { name, size = 24, class: className } = props
const iconSvg = icons[name]
if (!iconSvg) {
throw new Error(`Icon "${name}" not found in Lucide icons`)
}
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
width={size}
height={size}
class={className}
style={{
display: "block",
flexShrink: 0,
}}
dangerouslySetInnerHTML={{ __html: getIconContent(iconSvg) }}
/>
)
}
export const IconLink: FC<IconLinkProps> = (props) => {
const { href = "#", target, class: className, ...iconProps } = props
return (
<a
href={href}
target={target}
class={`inline-flex items-center justify-center hover:opacity-70 transition-opacity ${className || ""}`}
>
<Icon {...iconProps} />
</a>
)
}
export const Test = () => {
return (
<div class="p-6 space-y-8">
{/* === ICON TESTS === */}
{/* Size variations */}
<div>
<h2 class="text-xl font-bold mb-4">Icon Size Variations</h2>
<div class="flex items-center gap-4">
{([3, 4, 5, 6, 8, 10, 12, 16] as const).map((size) => (
<div key={size} class="flex flex-col items-center space-y-2">
<Icon name="Heart" size={size} />
<p class="text-sm">{size}</p>
</div>
))}
</div>
</div>
{/* Styling with CSS classes */}
<div>
<h2 class="text-xl font-bold mb-4">Styling with CSS Classes</h2>
<div class="grid grid-cols-5 gap-6">
<div class="flex flex-col items-center space-y-2">
<Icon name="Star" size={8} />
<p class="text-sm">Default</p>
</div>
<div class="flex flex-col items-center space-y-2">
<Icon name="Star" size={8} class="text-blue-500" />
<p class="text-sm">Blue Color</p>
</div>
<div class="flex flex-col items-center space-y-2">
<Icon name="Star" size={8} class="stroke-1" />
<p class="text-sm">Thin Stroke</p>
</div>
<div class="flex flex-col items-center space-y-2">
<Icon name="Star" size={8} class="text-yellow-400 stroke-0 fill-current" />
<p class="text-sm">Filled</p>
</div>
<div class="flex flex-col items-center space-y-2">
<Icon name="Star" size={8} class="text-purple-500 hover:text-purple-700 transition-colors" />
<p class="text-sm">Hover Effect</p>
</div>
</div>
</div>
{/* Advanced styling */}
<div>
<h2 class="text-xl font-bold mb-4">Advanced Styling</h2>
<div class="grid grid-cols-4 gap-6">
<div class="flex flex-col items-center space-y-2">
<Icon name="Heart" size={10} class="text-red-500 stroke-0 fill-current" />
<p class="text-sm">Filled Heart</p>
</div>
<div class="flex flex-col items-center space-y-2">
<Icon name="Shield" size={10} class="text-green-600 stroke-2" />
<p class="text-sm">Thick Stroke</p>
</div>
<div class="flex flex-col items-center space-y-2">
<Icon name="Sun" size={10} class="text-yellow-500 animate-spin" />
<p class="text-sm">Animated</p>
</div>
<div class="flex flex-col items-center space-y-2">
<Icon name="Zap" size={10} class="text-blue-400 drop-shadow-lg" />
<p class="text-sm">Drop Shadow</p>
</div>
</div>
</div>
{/* === ICON LINK TESTS === */}
{/* Basic icon links */}
<div>
<h2 class="text-xl font-bold mb-4">Icon Links</h2>
<div class="flex gap-6">
<div class="flex flex-col items-center space-y-2">
<IconLink name="Home" size={32} href="/" />
<p class="text-sm">Home Link</p>
</div>
<div class="flex flex-col items-center space-y-2">
<IconLink name="ExternalLink" size={32} href="https://example.com" target="_blank" />
<p class="text-sm">External Link</p>
</div>
<div class="flex flex-col items-center space-y-2">
<IconLink name="Mail" size={32} href="mailto:hello@example.com" />
<p class="text-sm">Email Link</p>
</div>
<div class="flex flex-col items-center space-y-2">
<IconLink name="Phone" size={32} href="tel:+1234567890" />
<p class="text-sm">Phone Link</p>
</div>
</div>
</div>
{/* Styled icon links */}
<div>
<h2 class="text-xl font-bold mb-4">Styled Icon Links</h2>
<div class="grid grid-cols-4 gap-6">
<div class="flex flex-col items-center space-y-2">
<IconLink
name="Download"
size={32}
href="#"
class="bg-blue-500 text-white p-2 rounded-lg hover:bg-blue-600 transition-colors"
/>
<p class="text-sm">Button Style</p>
</div>
<div class="flex flex-col items-center space-y-2">
<IconLink
name="Settings"
size={32}
href="#"
class="border-2 border-gray-300 p-2 rounded-full hover:border-gray-500 hover:bg-gray-50 transition-all"
/>
<p class="text-sm">Circle Border</p>
</div>
<div class="flex flex-col items-center space-y-2">
<IconLink
name="Heart"
size={32}
href="#"
class="text-red-500 hover:scale-125 transition-transform stroke-[3]"
/>
<p class="text-sm">Scale + Thick</p>
</div>
<div class="flex flex-col items-center space-y-2">
<IconLink
name="Star"
size={32}
href="#"
class="text-yellow-400 fill-current stroke-1 hover:rotate-12 transition-transform"
/>
<p class="text-sm">Filled + Rotate</p>
</div>
</div>
</div>
</div>
)
}
const getIconContent = (svgString: string): string => {
const match = svgString.match(/<svg[^>]*>(.*?)<\/svg>/s)
return match ? match[1] : ""
}

View File

@ -0,0 +1,174 @@
import "hono/jsx"
import { FC } from "hono/jsx"
export type ImageProps = {
src: string
alt?: string
class?: string
}
export const Image: FC<ImageProps> = ({ src, alt, class: className }) => {
return <img src={src} alt={alt} class={className} />
}
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 (
<div class="p-6 space-y-8">
<h2 class="text-xl font-bold mb-4">Image Examples with Tailwind Classes</h2>
{/* Size variations */}
<div>
<h3 class="text-lg font-semibold mb-4">Size Variations</h3>
<div class="flex gap-4 flex-wrap">
<div class="flex flex-col space-y-2">
<Image src={sampleImages[0]} class="w-16 h-16 object-cover" alt="16x16" />
<p class="text-sm">w-16 h-16</p>
</div>
<div class="flex flex-col space-y-2">
<Image src={sampleImages[0]} class="w-24 h-24 object-cover" alt="24x24" />
<p class="text-sm">w-24 h-24</p>
</div>
<div class="flex flex-col space-y-2">
<Image src={sampleImages[0]} class="w-32 h-32 object-cover" alt="32x32" />
<p class="text-sm">w-32 h-32</p>
</div>
<div class="flex flex-col space-y-2">
<Image src={sampleImages[0]} class="w-48 h-32 object-cover" alt="48x32" />
<p class="text-sm">w-48 h-32</p>
</div>
</div>
</div>
{/* Object fit variations */}
<div>
<h3 class="text-lg font-semibold mb-4">Object Fit Variations</h3>
<p class="text-sm text-gray-600 mb-4">Same image with different object-fit classes</p>
<div class="flex gap-6 flex-wrap">
<div class="flex flex-col space-y-2">
<Image src={sampleImages[0]} class="w-32 h-32 object-cover border" alt="Object cover" />
<p class="text-sm">object-cover</p>
</div>
<div class="flex flex-col space-y-2">
<Image
src={sampleImages[0]}
class="w-32 h-32 object-contain border bg-gray-100"
alt="Object contain"
/>
<p class="text-sm">object-contain</p>
</div>
<div class="flex flex-col space-y-2">
<Image src={sampleImages[0]} class="w-32 h-32 object-fill border" alt="Object fill" />
<p class="text-sm">object-fill</p>
</div>
<div class="flex flex-col space-y-2">
<Image
src={sampleImages[0]}
class="w-32 h-32 object-scale-down border bg-gray-100"
alt="Object scale-down"
/>
<p class="text-sm">object-scale-down</p>
</div>
<div class="flex flex-col space-y-2">
<Image src={sampleImages[0]} class="w-32 h-32 object-none border bg-gray-100" alt="Object none" />
<p class="text-sm">object-none</p>
</div>
</div>
</div>
{/* Styling examples */}
<div>
<h3 class="text-lg font-semibold mb-4">Styling Examples</h3>
<div class="flex gap-6 flex-wrap">
<div class="flex flex-col space-y-2">
<Image
src={sampleImages[0]}
class="w-32 h-32 object-cover rounded-lg border-4 border-blue-500"
alt="Rounded with border"
/>
<p class="text-sm">Rounded + Border</p>
</div>
<div class="flex flex-col space-y-2">
<Image src={sampleImages[1]} class="w-32 h-32 object-cover shadow-lg" alt="With shadow" />
<p class="text-sm">With Shadow</p>
</div>
<div class="flex flex-col space-y-2">
<Image
src={sampleImages[2]}
class="w-32 h-32 object-cover rounded-full border-4 border-green-500 shadow-lg"
alt="Circular with effects"
/>
<p class="text-sm">Circular + Effects</p>
</div>
</div>
</div>
{/* Responsive sizing */}
<div>
<h3 class="text-lg font-semibold mb-4">Responsive Sizing</h3>
<div class="space-y-4">
<div>
<Image src={sampleImages[0]} class="w-full h-48 object-cover rounded" alt="Full width" />
<p class="text-sm mt-2">w-full h-48 (responsive width)</p>
</div>
<div class="flex gap-4">
<Image
src={sampleImages[1]}
class="w-16 sm:w-24 md:w-32 lg:w-48 h-24 object-cover rounded"
alt="Responsive sizes"
/>
<div>
<p class="text-sm">w-16 sm:w-24 md:w-32 lg:w-48</p>
<p class="text-xs text-gray-600">Resize window to see changes</p>
</div>
</div>
</div>
</div>
{/* Common use cases */}
<div>
<h3 class="text-lg font-semibold mb-4">Common Use Cases</h3>
<div class="space-y-6">
{/* Avatar */}
<div>
<h4 class="font-medium mb-2">Avatar</h4>
<Image src={sampleImages[0]} class="w-12 h-12 rounded-full object-cover" alt="Avatar" />
</div>
{/* Card image */}
<div class="max-w-sm">
<h4 class="font-medium mb-2">Card Image</h4>
<div class="border rounded-lg overflow-hidden">
<Image src={sampleImages[1]} class="w-full h-48 object-cover" alt="Card image" />
<div class="p-4">
<h5 class="font-medium">Card Title</h5>
<p class="text-sm text-gray-600">Card description goes here</p>
</div>
</div>
</div>
{/* Gallery grid */}
<div>
<h4 class="font-medium mb-2">Gallery Grid</h4>
<div class="grid grid-cols-3 gap-2">
{sampleImages.map((src, i) => (
<Image
key={i}
src={src}
class="w-full aspect-square object-cover rounded"
alt={`Gallery ${i}`}
/>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,185 @@
import "hono/jsx"
import { JSX, FC } from "hono/jsx"
export type InputProps = JSX.IntrinsicElements["input"] & {
labelPosition?: "above" | "left" | "right"
children?: any
}
export const Input: FC<InputProps> = (props) => {
const { labelPosition = "above", children, class: className, ...inputProps } = props
const classes = [
"h-10 px-3 py-2 rounded-md border border-input bg-background text-sm",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
]
if (!children) {
return <input class={classes.join(" ")} {...inputProps} />
}
const labelElement = (
<label for={inputProps.id} class="text-sm font-medium text-gray-900 dark:text-gray-100">
{children}
</label>
)
if (labelPosition === "above") {
return (
<div class="space-y-1 flex flex-col flex-1 min-w-0">
{labelElement}
<input class={classes.join(" ")} {...inputProps} />
</div>
)
}
if (labelPosition === "left") {
return (
<div class="flex items-center space-x-1 flex-1">
{labelElement}
<input class={[...classes, "flex-1"].join(" ")} {...inputProps} />
</div>
)
}
if (labelPosition === "right") {
return (
<div class="flex items-center space-x-1 flex-1">
<input class={[...classes, "flex-1"].join(" ")} {...inputProps} />
{labelElement}
</div>
)
}
return null
}
export const Test = () => {
return (
<div class="p-6 space-y-8 max-w-md">
{/* Basic inputs */}
<div>
<h2 class="text-xl font-bold mb-4">Basic Inputs</h2>
<div class="space-y-4">
<Input placeholder="Enter your name" />
<Input type="email" placeholder="Enter your email" />
<Input type="password" placeholder="Enter your password" />
</div>
</div>
{/* Custom styling */}
<div>
<h2 class="text-xl font-bold mb-4">Custom Styling</h2>
<div class="space-y-4">
<Input class="h-8 text-xs" placeholder="Small input" />
<Input placeholder="Default input" />
<Input class="h-12 text-base" placeholder="Large input" />
</div>
</div>
{/* With values */}
<div>
<h2 class="text-xl font-bold mb-4">With Values</h2>
<div class="space-y-4">
<Input value="John Doe" placeholder="Name" />
<Input type="email" value="john@example.com" placeholder="Email" />
</div>
</div>
{/* Disabled state */}
<div>
<h2 class="text-xl font-bold mb-4">Disabled State</h2>
<div class="space-y-4">
<Input disabled placeholder="Disabled input" />
<Input disabled value="Disabled with value" />
</div>
</div>
{/* Label above */}
<div>
<h2 class="text-xl font-bold mb-4">Label Above</h2>
<div class="space-y-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>
</div>
</div>
{/* Label to the left */}
<div>
<h2 class="text-xl font-bold mb-4">Label Left</h2>
<div class="space-y-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>
</div>
</div>
{/* Label to the right */}
<div>
<h2 class="text-xl font-bold mb-4">Label Right</h2>
<div class="space-y-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>
</div>
</div>
{/* Horizontal layout */}
<div>
<h2 class="text-xl font-bold mb-4">Horizontal Layout</h2>
<div class="flex gap-4">
<Input placeholder="First name">First</Input>
<Input placeholder="Last name">Last</Input>
<Input placeholder="Age">Age</Input>
</div>
</div>
{/* Custom styling */}
<div>
<h2 class="text-xl font-bold mb-4">Custom Input Styling</h2>
<div class="space-y-4">
<Input class="border-blue-300 focus-visible:ring-blue-500" placeholder="Custom styled input">
<span class="text-blue-600 font-bold">Custom Label</span>
</Input>
<Input labelPosition="left" placeholder="Required input">
<span class="text-red-600 min-w-24">Required Field</span>
</Input>
</div>
</div>
{/* With values and disabled states */}
<div>
<h2 class="text-xl font-bold mb-4">Input States</h2>
<div class="space-y-4">
<Input value="John Doe">With Value</Input>
<Input disabled value="Disabled field">
Disabled
</Input>
<Input class="h-8 text-xs" placeholder="Small input">
Small
</Input>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,55 @@
import "hono/jsx"
import { FC, PropsWithChildren } from "hono/jsx"
type BreakProps = PropsWithChildren & {
class?: string
}
export const Break: FC<BreakProps> = ({ children, class: className }) => {
return (
<div class={`flex items-center my-4 ${className || ""}`}>
<div class="flex-1 border-t border-gray-300"></div>
{children && (
<>
<span class="px-3 text-sm text-gray-500 bg-white">{children}</span>
<div class="flex-1 border-t border-gray-300"></div>
</>
)}
</div>
)
}
export const Test = () => {
return (
<div class="p-4 space-y-8 max-w-md">
<h2 class="text-lg font-bold mb-4">Break Examples</h2>
{/* With text */}
<div>
<p>Would you like to sign in?</p>
<Break>OR DO YOU SUBMIT TO</Break>
<p>Certain death</p>
</div>
{/* Simple divider */}
<div>
<p>Payment methods</p>
<Break />
<p>Billing information</p>
</div>
{/* Different text examples */}
<div>
<p>Existing user?</p>
<Break>OR</Break>
<p>Create new account</p>
</div>
<div>
<p>Personal info</p>
<Break>STEP 2</Break>
<p>Payment details</p>
</div>
</div>
)
}

View File

@ -0,0 +1,232 @@
import "hono/jsx"
import { Avatar, AvatarProps } from "@/lib/avatar"
import { Image, ImageProps } from "@/lib/image"
export const Placeholder = {
Avatar(props: PlaceholderAvatarProps) {
const { size = 32, seed = "seed", type = "dylan", transparent, alt, class: className, 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} class={className} size={size} rounded={rounded} />
},
Image(props: PlaceholderImageProps) {
const {
width = 200,
height = 200,
seed = 1,
alt = "Placeholder image",
objectFit,
class: className,
} = 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} class={className} />
},
}
export const Test = () => {
return (
<div class="p-6 space-y-8">
{/* === AVATAR TESTS === */}
{/* Show all available avatar styles */}
<div>
<h2 class="text-xl font-bold mb-4">All Avatar Styles ({allStyles.length} total)</h2>
<div class="grid grid-cols-10 gap-3">
{allStyles.map((style) => (
<div key={style} class="flex flex-col space-y-1">
<Placeholder.Avatar type={style} size={48} />
<p class="text-xs font-medium">{style}</p>
</div>
))}
</div>
</div>
{/* Avatar size variations */}
<div>
<h2 class="text-xl font-bold mb-4">Avatar Size Variations</h2>
<div class="flex gap-4">
{[24, 32, 48, 64].map((size) => (
<div key={size} class="flex flex-col space-y-2">
<Placeholder.Avatar size={size} />
<p class="text-sm">{size}px</p>
</div>
))}
</div>
</div>
{/* Avatar styling combinations */}
<div>
<h2 class="text-xl font-bold mb-4">Avatar Styling Options</h2>
<div class="flex gap-6">
<div class="flex flex-col space-y-2">
<Placeholder.Avatar rounded size={64} />
<p class="text-sm">Rounded + Background</p>
</div>
<div class="flex flex-col space-y-2">
<div class="bg-gray-200 p-2">
<Placeholder.Avatar rounded transparent size={64} />
</div>
<p class="text-sm">Rounded + Transparent</p>
</div>
<div class="flex flex-col space-y-2">
<Placeholder.Avatar size={64} />
<p class="text-sm">Square + Background</p>
</div>
<div class="flex flex-col space-y-2">
<div class="bg-gray-200 p-2">
<Placeholder.Avatar transparent size={64} />
</div>
<p class="text-sm">Square + Transparent</p>
</div>
</div>
</div>
{/* Avatar seed variations */}
<div>
<h2 class="text-xl font-bold mb-4">Avatar Seeds (Same Style, Different People)</h2>
<div class="flex gap-4">
{["alice", "bob", "charlie", "diana"].map((seed) => (
<div key={seed} class="flex flex-col space-y-2">
<Placeholder.Avatar seed={seed} size={64} />
<p class="text-sm">"{seed}"</p>
</div>
))}
</div>
</div>
{/* === IMAGE TESTS === */}
<div>
<h2 class="text-xl font-bold mb-4">Placeholder Images</h2>
{/* Size variations */}
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3">Size Variations</h3>
<div class="flex gap-4">
{[
{ width: 100, height: 100 },
{ width: 150, height: 100 },
{ width: 200, height: 150 },
{ width: 250, height: 200 },
].map(({ width, height }) => (
<div key={`${width}x${height}`} class="flex flex-col space-y-2">
<Placeholder.Image width={width} height={height} seed={1} />
<p class="text-sm">
{width}×{height}
</p>
</div>
))}
</div>
</div>
{/* Different seeds - show variety */}
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3">Different Images (Different Seeds)</h3>
<div class="flex gap-4">
{[1, 2, 3, 4, 5].map((seed) => (
<div key={seed} class="flex flex-col space-y-2">
<Placeholder.Image width={150} height={150} seed={seed} />
<p class="text-sm">Seed {seed}</p>
</div>
))}
</div>
</div>
{/* With custom classes */}
<div>
<h3 class="text-lg font-semibold mb-3">With Custom Classes</h3>
<div class="flex gap-6">
<div class="flex flex-col space-y-2">
<Placeholder.Image
width={150}
height={150}
seed={1}
objectFit="cover"
class="rounded-lg border-4 border-blue-500"
/>
<p class="text-sm">Rounded + Border</p>
</div>
<div class="flex flex-col space-y-2">
<Placeholder.Image width={150} height={150} seed={2} objectFit="cover" class="shadow-lg" />
<p class="text-sm">With Shadow</p>
</div>
<div class="flex flex-col space-y-2">
<Placeholder.Image
width={150}
height={150}
seed={3}
objectFit="cover"
class="rounded-full border-4 border-green-500 shadow-lg"
/>
<p class="text-sm">Circular + Effects</p>
</div>
</div>
</div>
</div>
</div>
)
}
// 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

View File

@ -0,0 +1,291 @@
import "hono/jsx"
import { JSX, FC } from "hono/jsx"
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, class: className, ...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 classes = [
"h-10 px-3 py-2 rounded-md border border-input bg-background text-sm",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2",
"disabled:cursor-not-allowed disabled:opacity-50",
"appearance-none bg-no-repeat bg-[right_8px_center] bg-[length:16px_16px] pr-8",
"bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQgNkw4IDEwTDEyIDYiIHN0cm9rZT0iIzZCNzI4MCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPC9zdmc+')]",
className,
]
if (!children) {
return (
<select class={classes.join(" ")} {...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>
)
}
const labelElement = (
<label for={selectProps.id} class="text-sm font-medium text-gray-900 dark:text-gray-100">
{children}
</label>
)
if (labelPosition === "above") {
return (
<div class="space-y-1 flex flex-col flex-1 min-w-0">
{labelElement}
<select class={classes.join(" ")} {...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 === "left") {
return (
<div class="flex items-center space-x-1 flex-1">
{labelElement}
<select class={[...classes, "flex-1"].join(" ")} {...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 class="flex items-center space-x-1 flex-1">
<select class={[...classes, "flex-1"].join(" ")} {...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 (
<div class="p-6 space-y-8 max-w-md">
{/* Basic selects */}
<div>
<h2 class="text-xl font-bold mb-4">Basic Selects</h2>
<div class="space-y-4">
<Select options={months} placeholder="Select month" />
<Select options={years} placeholder="Select year" />
<Select options={countries} placeholder="Select country" />
</div>
</div>
{/* With values */}
<div>
<h2 class="text-xl font-bold mb-4">With Values</h2>
<div class="space-y-4">
<Select options={months} value="03" />
<Select options={years} value="2025" />
</div>
</div>
{/* Disabled state */}
<div>
<h2 class="text-xl font-bold mb-4">Disabled State</h2>
<div class="space-y-4">
<Select options={months} disabled placeholder="Disabled select" />
<Select options={years} disabled value="2024" />
</div>
</div>
{/* Custom styling */}
<div>
<h2 class="text-xl font-bold mb-4">Custom Styling</h2>
<div class="space-y-4">
<Select
options={countries}
class="border-blue-300 focus-visible:ring-blue-500"
placeholder="Custom styled select"
/>
<Select options={months} class="h-8 text-xs" placeholder="Small select" />
</div>
</div>
{/* Label above */}
<div>
<h2 class="text-xl font-bold mb-4">Label Above</h2>
<div class="space-y-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>
</div>
</div>
{/* Label to the left */}
<div>
<h2 class="text-xl font-bold mb-4">Label Left</h2>
<div class="space-y-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>
</div>
</div>
{/* Label to the right */}
<div>
<h2 class="text-xl font-bold mb-4">Label Right</h2>
<div class="space-y-4">
<Select labelPosition="right" options={months} placeholder="Select month">
Month
</Select>
<Select labelPosition="right" options={years} placeholder="Select year">
Year
</Select>
</div>
</div>
{/* Horizontal layout (like card form) */}
<div>
<h2 class="text-xl font-bold mb-4">Horizontal Layout</h2>
<div class="flex gap-4">
<Select options={months} placeholder="MM">
Expires
</Select>
<Select options={years} placeholder="YYYY">
Year
</Select>
</div>
</div>
{/* Custom styling */}
<div>
<h2 class="text-xl font-bold mb-4">Custom Select Styling</h2>
<div class="space-y-4">
<Select
class="border-blue-300 focus-visible:ring-blue-500"
options={countries}
placeholder="Custom styled select"
>
<span class="text-blue-600 font-bold">Custom Label</span>
</Select>
<Select labelPosition="left" options={months} placeholder="Required select">
<span class="text-red-600 min-w-24">Required Field</span>
</Select>
</div>
</div>
{/* With values and disabled states */}
<div>
<h2 class="text-xl font-bold mb-4">Select States</h2>
<div class="space-y-4">
<Select options={months} value="03">
With Value
</Select>
<Select disabled options={years} value="2024">
Disabled
</Select>
<Select class="h-8 text-xs" options={countries} placeholder="Small select">
Small
</Select>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,177 @@
import { TailwindSize } from "@/types"
import "hono/jsx"
import { FC, PropsWithChildren } from "hono/jsx"
export const VStack: FC<VStackProps> = (props) => {
return (
<Stack
direction="col"
mainAxis={props.v}
crossAxis={props.h}
wrap={props.wrap}
gap={props.gap}
class={props.class}
>
{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}
class={props.class}
>
{props.children}
</Stack>
)
}
const Stack: FC<StackProps> = (props) => {
const classes = [
"flex",
`flex-${props.direction}`,
props.wrap ? "flex-wrap" : "",
props.gap ? `gap-${props.gap}` : "",
props.mainAxis ? `justify-${props.mainAxis}` : "",
props.crossAxis ? `items-${props.crossAxis}` : "",
props.class ?? "",
]
.filter(Boolean)
.join(" ")
return <div class={classes}>{props.children}</div>
}
export const Test = () => {
const mainAxisOpts: MainAxisOpts[] = ["start", "center", "end", "between", "around", "evenly"]
const crossAxisOpts: CrossAxisOpts[] = ["start", "center", "end", "stretch", "baseline"]
return (
<div class="p-4 space-y-8">
{/* HStack layout matrix */}
<div>
<h2 class="text-lg font-bold mb-2">HStack Layout</h2>
<div class="overflow-auto">
<div class="grid grid-cols-7 gap-1">
{/* Header row: blank + h labels */}
<div></div>
{mainAxisOpts.map((h) => (
<div key={h} class="text-sm font-medium text-center">
h: {h}
</div>
))}
{/* Each row: v label + HStack cells */}
{crossAxisOpts.map((v) => [
<div key={v} class="text-sm font-medium">
v: {v}
</div>,
...mainAxisOpts.map((h) => (
<HStack key={`${h}-${v}`} h={h} v={v} class="bg-gray-100 p-2 h-24 border border-gray-400">
<div class="text-center p-1 bg-red-500">Aa</div>
<div class="text-center p-1 bg-green-500">Aa</div>
<div class="text-center p-1 bg-blue-500">Aa</div>
</HStack>
)),
])}
</div>
</div>
</div>
{/* VStack layout matrix */}
<div>
<h2 class="text-lg font-bold mb-2">VStack Layout</h2>
<div class="overflow-auto">
<div class="grid grid-cols-6 gap-1">
{/* Header row: blank + h labels */}
<div></div>
{crossAxisOpts.map((h) => (
<div key={h} class="text-sm font-medium text-center">
h: {h}
</div>
))}
{/* Each row: v label + VStack cells */}
{mainAxisOpts.map((v) => [
<div key={v} class="text-sm font-medium">
v: {v}
</div>,
...crossAxisOpts.map((h) => (
<VStack key={`${h}-${v}`} v={v} h={h} class="bg-gray-100 p-2 h-42 border border-gray-400">
<div class="text-center p-1 bg-red-500">Aa</div>
<div class="text-center p-1 bg-green-500">Aa</div>
<div class="text-center p-1 bg-blue-500">Aa</div>
</VStack>
)),
])}
</div>
</div>
</div>
</div>
)
}
type StackDirection = "row" | "col"
type StackProps = {
direction: StackDirection
mainAxis?: string
crossAxis?: string
wrap?: boolean
gap?: TailwindSize
class?: string
children?: any
}
type MainAxisOpts = "start" | "center" | "end" | "between" | "around" | "evenly"
type CrossAxisOpts = "start" | "center" | "end" | "stretch" | "baseline"
type CommonStackProps = PropsWithChildren & {
wrap?: boolean
gap?: TailwindSize
class?: string
}
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
}
// Tailwind will purge any unused styles. Since we use dynamic class names, we need to safelist the classes we use in this file.
const _tailwindSafelist = [
"flex-row",
"flex-col",
"flex-wrap",
"gap-0",
"gap-1",
"gap-2",
"gap-3",
"gap-4",
"gap-5",
"gap-6",
"gap-8",
"gap-10",
"gap-12",
"items-start",
"items-center",
"items-end",
"items-stretch",
"items-baseline",
"justify-start",
"justify-center",
"justify-end",
"justify-between",
"justify-around",
"justify-evenly",
]

View File

@ -0,0 +1,28 @@
import { LoaderProps } from "@workshop/nano-remix"
import "@/index.css"
import { Cards } from "@/examples/cards"
export const loader = async (req: Request) => {
const exampleName = req.url.split("/").pop() || ""
return { exampleName }
}
export default function Example({ exampleName }: LoaderProps<typeof loader>) {
let component = (
<>
<h1>Example {exampleName} not Found</h1>
<p>Available examples: cards</p>
</>
)
if (exampleName === "cards") {
component = <Cards />
}
return (
<div>
<h1 class="p-4">{exampleName}</h1>
{component}
</div>
)
}

View File

@ -0,0 +1,41 @@
import { LoaderProps } from "@workshop/nano-remix"
import "../index.css"
import { readdir } from "node:fs/promises"
import { join, basename } from "node:path"
import { VStack } from "@/lib/stack"
export const loader = async (req: Request) => {
const examples = await readdir(join(import.meta.dir, "../examples"))
const tests = await readdir(join(import.meta.dir, "../lib"))
return {
examples: examples.map((file) => basename(file, ".tsx")),
tests: tests.map((file) => basename(file, ".tsx")),
}
}
export function App({ examples, tests }: LoaderProps<typeof loader>) {
return (
<div class="m-8">
<h1 class="text-3xl font-bold mb-4">Werewolf UI</h1>
<h2>Tests</h2>
<VStack>
{tests.sort().map((test) => (
<a class="underline text-blue-500" key={test} href={`/tests/${test}`}>
{test}
</a>
))}
</VStack>
<h2 class="mt-8">Examples</h2>
<VStack>
{examples.map((example) => (
<a class="underline text-blue-500" key={example} href={`/examples/${example}`}>
{example}
</a>
))}
</VStack>
</div>
)
}
export default App

View File

@ -0,0 +1,77 @@
import { LoaderProps } from "@workshop/nano-remix"
import "@/index.css"
/*
break
button
grid
icon
image
input
linebreak
placeholder
select
stack
*/
import { Test as AvatarTest } from "@/lib/avatar"
import { Test as BreakTest } from "@/lib/break"
import { Test as ButtonTest } from "@/lib/button"
import { Test as GridTest } from "@/lib/grid"
import { Test as IconTest } from "@/lib/icon"
import { Test as ImageTest } from "@/lib/image"
import { Test as InputTest } from "@/lib/input"
import { Test as LinebreakTest } from "@/lib/linebreak"
import { Test as PlaceholderTest } from "@/lib/placeholder"
import { Test as SelectTest } from "@/lib/select"
import { Test as StackTest } from "@/lib/stack"
export const loader = async (req: Request) => {
const testName = req.url.split("/").pop() || ""
return { testName }
}
export default function Test({ testName }: LoaderProps<typeof loader>) {
let component = (
<>
<h1>Test {testName} not Found</h1>
<p>Available tests: cards</p>
</>
)
if (testName === "avatar") {
component = <AvatarTest />
} else if (testName === "break") {
component = <BreakTest />
} else if (testName === "button") {
component = <ButtonTest />
} else if (testName === "grid") {
component = <GridTest />
} else if (testName === "icon") {
component = <IconTest />
} else if (testName === "image") {
component = <ImageTest />
} else if (testName === "input") {
component = <InputTest />
} else if (testName === "linebreak") {
component = <LinebreakTest />
} else if (testName === "placeholder") {
component = <PlaceholderTest />
} else if (testName === "select") {
component = <SelectTest />
} else if (testName === "stack") {
component = <StackTest />
}
return (
<div>
<h1 class="p-4">
{testName}
{" : "}
<a class="underline text-blue-500 italic" href="/">
back
</a>
</h1>
{component}
</div>
)
}

View File

@ -0,0 +1,19 @@
import { serve } from "bun"
import { nanoRemix } from "@workshop/nano-remix"
import { join } from "node:path"
const server = serve({
routes: {
"/*": (req) => {
const routePath = join(import.meta.dir, "routes")
return nanoRemix(req, { routePath })
},
},
development: process.env.NODE_ENV !== "production" && {
hmr: true,
console: true,
},
})
console.log(`🚀 Server running at ${server.url}`)

View File

@ -0,0 +1,33 @@
type TailwindSizeNumber =
| 0
| 1
| 2
| 3
| 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
export type TailwindSize = TailwindSizeNumber | `${TailwindSizeNumber}`

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "Preserve",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["dist", "node_modules"]
}