From 49a84716280610e5406d31b551ecd7194516c494 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 7 Jul 2025 15:32:18 -0700 Subject: [PATCH] werewolf --- .bun-version | 2 +- packages/werewolf-ui/.gitattributes | 2 + packages/werewolf-ui/.gitignore | 34 ++ packages/werewolf-ui/README.md | 5 + packages/werewolf-ui/package.json | 24 ++ packages/werewolf-ui/src/examples/cards.tsx | 315 ++++++++++++++++++ packages/werewolf-ui/src/index.css | 42 +++ packages/werewolf-ui/src/lib/avatar.tsx | 109 ++++++ packages/werewolf-ui/src/lib/break.tsx | 41 +++ packages/werewolf-ui/src/lib/button.tsx | 105 ++++++ packages/werewolf-ui/src/lib/grid.tsx | 185 ++++++++++ packages/werewolf-ui/src/lib/icon.tsx | 204 ++++++++++++ packages/werewolf-ui/src/lib/image.tsx | 174 ++++++++++ packages/werewolf-ui/src/lib/input.tsx | 185 ++++++++++ packages/werewolf-ui/src/lib/linebreak.tsx | 55 +++ packages/werewolf-ui/src/lib/placeholder.tsx | 232 +++++++++++++ packages/werewolf-ui/src/lib/select.tsx | 291 ++++++++++++++++ packages/werewolf-ui/src/lib/stack.tsx | 177 ++++++++++ .../src/routes/examples/[example].tsx | 28 ++ packages/werewolf-ui/src/routes/index.tsx | 41 +++ .../werewolf-ui/src/routes/tests/[test].tsx | 77 +++++ packages/werewolf-ui/src/server.tsx | 19 ++ packages/werewolf-ui/src/types.ts | 33 ++ packages/werewolf-ui/tsconfig.json | 18 + 24 files changed, 2397 insertions(+), 1 deletion(-) create mode 100644 packages/werewolf-ui/.gitattributes create mode 100644 packages/werewolf-ui/.gitignore create mode 100644 packages/werewolf-ui/README.md create mode 100644 packages/werewolf-ui/package.json create mode 100644 packages/werewolf-ui/src/examples/cards.tsx create mode 100644 packages/werewolf-ui/src/index.css create mode 100644 packages/werewolf-ui/src/lib/avatar.tsx create mode 100644 packages/werewolf-ui/src/lib/break.tsx create mode 100644 packages/werewolf-ui/src/lib/button.tsx create mode 100644 packages/werewolf-ui/src/lib/grid.tsx create mode 100644 packages/werewolf-ui/src/lib/icon.tsx create mode 100644 packages/werewolf-ui/src/lib/image.tsx create mode 100644 packages/werewolf-ui/src/lib/input.tsx create mode 100644 packages/werewolf-ui/src/lib/linebreak.tsx create mode 100644 packages/werewolf-ui/src/lib/placeholder.tsx create mode 100644 packages/werewolf-ui/src/lib/select.tsx create mode 100644 packages/werewolf-ui/src/lib/stack.tsx create mode 100644 packages/werewolf-ui/src/routes/examples/[example].tsx create mode 100644 packages/werewolf-ui/src/routes/index.tsx create mode 100644 packages/werewolf-ui/src/routes/tests/[test].tsx create mode 100644 packages/werewolf-ui/src/server.tsx create mode 100644 packages/werewolf-ui/src/types.ts create mode 100644 packages/werewolf-ui/tsconfig.json diff --git a/.bun-version b/.bun-version index a96f385..5ab1538 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.2.16 \ No newline at end of file +1.2.18 \ No newline at end of file diff --git a/packages/werewolf-ui/.gitattributes b/packages/werewolf-ui/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/packages/werewolf-ui/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/packages/werewolf-ui/.gitignore b/packages/werewolf-ui/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/werewolf-ui/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/werewolf-ui/README.md b/packages/werewolf-ui/README.md new file mode 100644 index 0000000..c0760d4 --- /dev/null +++ b/packages/werewolf-ui/README.md @@ -0,0 +1,5 @@ +# werewolfUI + +- 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). diff --git a/packages/werewolf-ui/package.json b/packages/werewolf-ui/package.json new file mode 100644 index 0000000..45f16a4 --- /dev/null +++ b/packages/werewolf-ui/package.json @@ -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" + } +} \ No newline at end of file diff --git a/packages/werewolf-ui/src/examples/cards.tsx b/packages/werewolf-ui/src/examples/cards.tsx new file mode 100644 index 0000000..26fed91 --- /dev/null +++ b/packages/werewolf-ui/src/examples/cards.tsx @@ -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 ( + +
+ + + +
+
+ + + + +
+
+ ) +} + +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 ( + +

Team Members

+
Invite your team members to collaborate.
+ {teamMembers.map((member) => ( + + +
+

{member.name}

+

{member.email}

+
+ + Email + + + Password + + +
+ + ) +} + +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 ( + +

Payment Method

+
Add a new payment method to your account.
+ + + + + + + + Name + + + Card Number + + + + + + CVV + + + +
+ ) +} + +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 ( + + + +
+

Isaac Flath

+

Library Creator

+
+
+ + +

Alexandria, VA

+ + + +
+
+ ) +} + +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 ( + + + + + + + + +

People with access

+ {teamMembers.map((member) => ( + + +
+

{member.name}

+

{member.email}

+
+ + Select Date + +
+ ) +} + +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 ( + +

Notification

+

Choose what you want to be notified about.

+ {sectionContent.map((item) => ( + + +
+

{item.title}

+

{item.description}

+
+
+ ))} +
+ ) +} diff --git a/packages/werewolf-ui/src/index.css b/packages/werewolf-ui/src/index.css new file mode 100644 index 0000000..a71eb94 --- /dev/null +++ b/packages/werewolf-ui/src/index.css @@ -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; +} diff --git a/packages/werewolf-ui/src/lib/avatar.tsx b/packages/werewolf-ui/src/lib/avatar.tsx new file mode 100644 index 0000000..ad5535a --- /dev/null +++ b/packages/werewolf-ui/src/lib/avatar.tsx @@ -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 = (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 {props.alt} +} + +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 ( +
+ {/* Size variations */} +
+

Size Variations

+
+ {["6", "8", "12", "16", "24"].map((size) => ( +
+ +

+ w-{size} h-{size} +

+
+ ))} +
+
+ + {/* Rounded vs Square */} +
+

Rounded vs Square

+
+
+ +

Square

+
+
+ +

Rounded

+
+
+
+ + {/* Different images */} +
+

Different Images

+
+ {sampleImages.map((src, index) => ( +
+ +

Image {index + 1}

+
+ ))} +
+
+ + {/* Custom classes */} +
+

With Custom Classes

+
+
+ +

With Border

+
+
+ +

With Shadow

+
+
+ +

Border + Shadow

+
+
+
+
+ ) +} diff --git a/packages/werewolf-ui/src/lib/break.tsx b/packages/werewolf-ui/src/lib/break.tsx new file mode 100644 index 0000000..b1c2e5b --- /dev/null +++ b/packages/werewolf-ui/src/lib/break.tsx @@ -0,0 +1,41 @@ +import "hono/jsx" +import { FC, PropsWithChildren } from "hono/jsx" + +type BreakProps = PropsWithChildren & { + class?: string +} + +export const Break: FC = ({ children, class: className }) => { + return ( +
+
+ {children && ( + <> + {children} +
+ + )} +
+ ) +} + +export const Test = () => { + return ( +
+

Break Examples

+ +
+

Would you like to continue

+ OR WOULD YOU LIKE TO +

Submit to certain dealth

+
+ + {/* Just a line */} +
+

Look a line 👇

+ +

So cool, so straight!

+
+
+ ) +} diff --git a/packages/werewolf-ui/src/lib/button.tsx b/packages/werewolf-ui/src/lib/button.tsx new file mode 100644 index 0000000..ec94f57 --- /dev/null +++ b/packages/werewolf-ui/src/lib/button.tsx @@ -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 = (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 + + + + + + + + {/* Sizes */} +
+

Button Sizes

+
+ + + +
+
+ + {/* With custom content */} +
+

Custom Content

+
+ + +
+
+ + {/* Native attributes work */} +
+

Native Attributes

+
+ + + +
+
+ + ) +} diff --git a/packages/werewolf-ui/src/lib/grid.tsx b/packages/werewolf-ui/src/lib/grid.tsx new file mode 100644 index 0000000..2048bd3 --- /dev/null +++ b/packages/werewolf-ui/src/lib/grid.tsx @@ -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 = (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
{children}
+} + +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 ( +
+
+

Grid Examples

+ + {/* Simple 3-column grid */} +
+

Simple 3 columns: cols=3

+ +
Item 1
+
Item 2
+
Item 3
+
Item 4
+
Item 5
+
Item 6
+
+
+ + {/* Responsive grid */} +
+

Responsive: cols={sm: 1, md: 2, lg: 3}

+ +
Card 1
+
Card 2
+
Card 3
+
Card 4
+
+
+ + {/* More responsive examples */} +
+

More responsive: cols={sm: 2, lg: 4, xl: 6}

+ +
Item A
+
Item B
+
Item C
+
Item D
+
Item E
+
Item F
+
+
+ + {/* Payment method example */} +
+

Payment buttons example

+ + + + + +
+ + {/* Alignment examples */} +
+

Alignment: v="center" h="center"

+ +
Item 1
+
Item 2
+
Item 3
+
+
+ +
+

Alignment: v="start" h="end"

+ +
Left
+
Right
+
+
+
+
+ ) +} + +// 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", +] diff --git a/packages/werewolf-ui/src/lib/icon.tsx b/packages/werewolf-ui/src/lib/icon.tsx new file mode 100644 index 0000000..4fc37d8 --- /dev/null +++ b/packages/werewolf-ui/src/lib/icon.tsx @@ -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 = (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 ( + + ) +} + +export const IconLink: FC = (props) => { + const { href = "#", target, class: className, ...iconProps } = props + + return ( + + + + ) +} + +export const Test = () => { + return ( +
+ {/* === ICON TESTS === */} + + {/* Size variations */} +
+

Icon Size Variations

+
+ {([3, 4, 5, 6, 8, 10, 12, 16] as const).map((size) => ( +
+ +

{size}

+
+ ))} +
+
+ + {/* Styling with CSS classes */} +
+

Styling with CSS Classes

+
+
+ +

Default

+
+
+ +

Blue Color

+
+
+ +

Thin Stroke

+
+
+ +

Filled

+
+
+ +

Hover Effect

+
+
+
+ + {/* Advanced styling */} +
+

Advanced Styling

+
+
+ +

Filled Heart

+
+
+ +

Thick Stroke

+
+
+ +

Animated

+
+
+ +

Drop Shadow

+
+
+
+ + {/* === ICON LINK TESTS === */} + + {/* Basic icon links */} +
+

Icon Links

+
+
+ +

Home Link

+
+
+ +

External Link

+
+
+ +

Email Link

+
+
+ +

Phone Link

+
+
+
+ + {/* Styled icon links */} +
+

Styled Icon Links

+
+
+ +

Button Style

+
+
+ +

Circle Border

+
+
+ +

Scale + Thick

+
+
+ +

Filled + Rotate

+
+
+
+
+ ) +} + +const getIconContent = (svgString: string): string => { + const match = svgString.match(/]*>(.*?)<\/svg>/s) + return match ? match[1] : "" +} diff --git a/packages/werewolf-ui/src/lib/image.tsx b/packages/werewolf-ui/src/lib/image.tsx new file mode 100644 index 0000000..bafc86b --- /dev/null +++ b/packages/werewolf-ui/src/lib/image.tsx @@ -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 = ({ src, alt, class: className }) => { + return {alt} +} + +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 ( +
+

Image Examples with Tailwind Classes

+ + {/* Size variations */} +
+

Size Variations

+
+
+ 16x16 +

w-16 h-16

+
+
+ 24x24 +

w-24 h-24

+
+
+ 32x32 +

w-32 h-32

+
+
+ 48x32 +

w-48 h-32

+
+
+
+ + {/* Object fit variations */} +
+

Object Fit Variations

+

Same image with different object-fit classes

+
+
+ Object cover +

object-cover

+
+
+ Object contain +

object-contain

+
+
+ Object fill +

object-fill

+
+
+ Object scale-down +

object-scale-down

+
+
+ Object none +

object-none

+
+
+
+ + {/* Styling examples */} +
+

Styling Examples

+
+
+ Rounded with border +

Rounded + Border

+
+
+ With shadow +

With Shadow

+
+
+ Circular with effects +

Circular + Effects

+
+
+
+ + {/* Responsive sizing */} +
+

Responsive Sizing

+
+
+ Full width +

w-full h-48 (responsive width)

+
+
+ Responsive sizes +
+

w-16 sm:w-24 md:w-32 lg:w-48

+

Resize window to see changes

+
+
+
+
+ + {/* Common use cases */} +
+

Common Use Cases

+
+ {/* Avatar */} +
+

Avatar

+ Avatar +
+ + {/* Card image */} +
+

Card Image

+
+ Card image +
+
Card Title
+

Card description goes here

+
+
+
+ + {/* Gallery grid */} +
+

Gallery Grid

+
+ {sampleImages.map((src, i) => ( + {`Gallery + ))} +
+
+
+
+
+ ) +} diff --git a/packages/werewolf-ui/src/lib/input.tsx b/packages/werewolf-ui/src/lib/input.tsx new file mode 100644 index 0000000..f571196 --- /dev/null +++ b/packages/werewolf-ui/src/lib/input.tsx @@ -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 = (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 + } + + const labelElement = ( + + ) + + if (labelPosition === "above") { + return ( +
+ {labelElement} + +
+ ) + } + + if (labelPosition === "left") { + return ( +
+ {labelElement} + +
+ ) + } + + if (labelPosition === "right") { + return ( +
+ + {labelElement} +
+ ) + } + + return null +} + +export const Test = () => { + return ( +
+ {/* Basic inputs */} +
+

Basic Inputs

+
+ + + +
+
+ + {/* Custom styling */} +
+

Custom Styling

+
+ + + +
+
+ + {/* With values */} +
+

With Values

+
+ + +
+
+ + {/* Disabled state */} +
+

Disabled State

+
+ + +
+
+ + {/* Label above */} +
+

Label Above

+
+ Name + + Email + + + Password + +
+
+ + {/* Label to the left */} +
+

Label Left

+
+ + Name + + + Email + + + Password + +
+
+ + {/* Label to the right */} +
+

Label Right

+
+ + Name + + + Email + + + Password + +
+
+ + {/* Horizontal layout */} +
+

Horizontal Layout

+
+ First + Last + Age +
+
+ + {/* Custom styling */} +
+

Custom Input Styling

+
+ + Custom Label + + + Required Field + +
+
+ + {/* With values and disabled states */} +
+

Input States

+
+ With Value + + Disabled + + + Small + +
+
+
+ ) +} diff --git a/packages/werewolf-ui/src/lib/linebreak.tsx b/packages/werewolf-ui/src/lib/linebreak.tsx new file mode 100644 index 0000000..0250d33 --- /dev/null +++ b/packages/werewolf-ui/src/lib/linebreak.tsx @@ -0,0 +1,55 @@ +import "hono/jsx" +import { FC, PropsWithChildren } from "hono/jsx" + +type BreakProps = PropsWithChildren & { + class?: string +} + +export const Break: FC = ({ children, class: className }) => { + return ( +
+
+ {children && ( + <> + {children} +
+ + )} +
+ ) +} + +export const Test = () => { + return ( +
+

Break Examples

+ + {/* With text */} +
+

Would you like to sign in?

+ OR DO YOU SUBMIT TO +

Certain death

+
+ + {/* Simple divider */} +
+

Payment methods

+ +

Billing information

+
+ + {/* Different text examples */} +
+

Existing user?

+ OR +

Create new account

+
+ +
+

Personal info

+ STEP 2 +

Payment details

+
+
+ ) +} diff --git a/packages/werewolf-ui/src/lib/placeholder.tsx b/packages/werewolf-ui/src/lib/placeholder.tsx new file mode 100644 index 0000000..78be2ab --- /dev/null +++ b/packages/werewolf-ui/src/lib/placeholder.tsx @@ -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 + }, + + 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 {alt} + }, +} + +export const Test = () => { + return ( +
+ {/* === AVATAR TESTS === */} + + {/* Show all available avatar styles */} +
+

All Avatar Styles ({allStyles.length} total)

+
+ {allStyles.map((style) => ( +
+ +

{style}

+
+ ))} +
+
+ + {/* Avatar size variations */} +
+

Avatar Size Variations

+
+ {[24, 32, 48, 64].map((size) => ( +
+ +

{size}px

+
+ ))} +
+
+ + {/* Avatar styling combinations */} +
+

Avatar Styling Options

+
+
+ +

Rounded + Background

+
+
+
+ +
+

Rounded + Transparent

+
+
+ +

Square + Background

+
+
+
+ +
+

Square + Transparent

+
+
+
+ + {/* Avatar seed variations */} +
+

Avatar Seeds (Same Style, Different People)

+
+ {["alice", "bob", "charlie", "diana"].map((seed) => ( +
+ +

"{seed}"

+
+ ))} +
+
+ + {/* === IMAGE TESTS === */} +
+

Placeholder Images

+ + {/* Size variations */} +
+

Size Variations

+
+ {[ + { width: 100, height: 100 }, + { width: 150, height: 100 }, + { width: 200, height: 150 }, + { width: 250, height: 200 }, + ].map(({ width, height }) => ( +
+ +

+ {width}×{height} +

+
+ ))} +
+
+ + {/* Different seeds - show variety */} +
+

Different Images (Different Seeds)

+
+ {[1, 2, 3, 4, 5].map((seed) => ( +
+ +

Seed {seed}

+
+ ))} +
+
+ + {/* With custom classes */} +
+

With Custom Classes

+
+
+ +

Rounded + Border

+
+
+ +

With Shadow

+
+
+ +

Circular + Effects

+
+
+
+
+
+ ) +} + +// Type definitions +type PlaceholderAvatarProps = Omit & { + seed?: string + type?: DicebearStyleName + transparent?: boolean +} + +type PlaceholderImageProps = Omit & { + 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 diff --git a/packages/werewolf-ui/src/lib/select.tsx b/packages/werewolf-ui/src/lib/select.tsx new file mode 100644 index 0000000..8672b02 --- /dev/null +++ b/packages/werewolf-ui/src/lib/select.tsx @@ -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 & { + options: SelectOption[] + placeholder?: string + labelPosition?: "above" | "left" | "right" + children?: any +} + +export const Select: FC = (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 ( + + ) + } + + const labelElement = ( + + ) + + if (labelPosition === "above") { + return ( +
+ {labelElement} + +
+ ) + } + + if (labelPosition === "left") { + return ( +
+ {labelElement} + +
+ ) + } + + if (labelPosition === "right") { + return ( +
+ + {labelElement} +
+ ) + } + + 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 ( +
+ {/* Basic selects */} +
+

Basic Selects

+
+ + + + + + Birth Month + + + +
+
+ + {/* Label to the left */} +
+

Label Left

+
+ + + +
+
+ + {/* Label to the right */} +
+

Label Right

+
+ + +
+
+ + {/* Horizontal layout (like card form) */} +
+

Horizontal Layout

+
+ + +
+
+ + {/* Custom styling */} +
+

Custom Select Styling

+
+ + +
+
+ + {/* With values and disabled states */} +
+

Select States

+
+ + + +
+
+
+ ) +} diff --git a/packages/werewolf-ui/src/lib/stack.tsx b/packages/werewolf-ui/src/lib/stack.tsx new file mode 100644 index 0000000..b5aee9b --- /dev/null +++ b/packages/werewolf-ui/src/lib/stack.tsx @@ -0,0 +1,177 @@ +import { TailwindSize } from "@/types" +import "hono/jsx" +import { FC, PropsWithChildren } from "hono/jsx" + +export const VStack: FC = (props) => { + return ( + + {props.children} + + ) +} + +export const HStack: FC = (props) => { + return ( + + {props.children} + + ) +} + +const Stack: FC = (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
{props.children}
+} + +export const Test = () => { + const mainAxisOpts: MainAxisOpts[] = ["start", "center", "end", "between", "around", "evenly"] + const crossAxisOpts: CrossAxisOpts[] = ["start", "center", "end", "stretch", "baseline"] + + return ( +
+ {/* HStack layout matrix */} +
+

HStack Layout

+
+
+ {/* Header row: blank + h labels */} +
+ {mainAxisOpts.map((h) => ( +
+ h: {h} +
+ ))} + + {/* Each row: v label + HStack cells */} + {crossAxisOpts.map((v) => [ +
+ v: {v} +
, + ...mainAxisOpts.map((h) => ( + +
Aa
+
Aa
+
Aa
+
+ )), + ])} +
+
+
+ + {/* VStack layout matrix */} +
+

VStack Layout

+
+
+ {/* Header row: blank + h labels */} +
+ {crossAxisOpts.map((h) => ( +
+ h: {h} +
+ ))} + + {/* Each row: v label + VStack cells */} + {mainAxisOpts.map((v) => [ +
+ v: {v} +
, + ...crossAxisOpts.map((h) => ( + +
Aa
+
Aa
+
Aa
+
+ )), + ])} +
+
+
+
+ ) +} + +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", +] diff --git a/packages/werewolf-ui/src/routes/examples/[example].tsx b/packages/werewolf-ui/src/routes/examples/[example].tsx new file mode 100644 index 0000000..4217242 --- /dev/null +++ b/packages/werewolf-ui/src/routes/examples/[example].tsx @@ -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) { + let component = ( + <> +

Example {exampleName} not Found

+

Available examples: cards

+ + ) + + if (exampleName === "cards") { + component = + } + + return ( +
+

{exampleName}

+ {component} +
+ ) +} diff --git a/packages/werewolf-ui/src/routes/index.tsx b/packages/werewolf-ui/src/routes/index.tsx new file mode 100644 index 0000000..9f3595e --- /dev/null +++ b/packages/werewolf-ui/src/routes/index.tsx @@ -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) { + return ( +
+

Werewolf UI

+

Tests

+ + {tests.sort().map((test) => ( + + {test} + + ))} + + +

Examples

+ + {examples.map((example) => ( + + {example} + + ))} + +
+ ) +} + +export default App diff --git a/packages/werewolf-ui/src/routes/tests/[test].tsx b/packages/werewolf-ui/src/routes/tests/[test].tsx new file mode 100644 index 0000000..254995d --- /dev/null +++ b/packages/werewolf-ui/src/routes/tests/[test].tsx @@ -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) { + let component = ( + <> +

Test {testName} not Found

+

Available tests: cards

+ + ) + + if (testName === "avatar") { + component = + } else if (testName === "break") { + component = + } else if (testName === "button") { + component = + } else if (testName === "grid") { + component = + } else if (testName === "icon") { + component = + } else if (testName === "image") { + component = + } else if (testName === "input") { + component = + } else if (testName === "linebreak") { + component = + } else if (testName === "placeholder") { + component = + } else if (testName === "select") { + component = + } else if (testName === "stack") { + component = + } + + return ( +
+

+ {testName} + {" : "} + + back + +

+ + {component} +
+ ) +} diff --git a/packages/werewolf-ui/src/server.tsx b/packages/werewolf-ui/src/server.tsx new file mode 100644 index 0000000..cc96005 --- /dev/null +++ b/packages/werewolf-ui/src/server.tsx @@ -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}`) diff --git a/packages/werewolf-ui/src/types.ts b/packages/werewolf-ui/src/types.ts new file mode 100644 index 0000000..8c10404 --- /dev/null +++ b/packages/werewolf-ui/src/types.ts @@ -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}` diff --git a/packages/werewolf-ui/tsconfig.json b/packages/werewolf-ui/tsconfig.json new file mode 100644 index 0000000..cd90ae8 --- /dev/null +++ b/packages/werewolf-ui/tsconfig.json @@ -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"] +}