diff --git a/crates/wasmeld-console/web/package-lock.json b/crates/wasmeld-console/web/package-lock.json index 4fccf20..5fdee15 100644 --- a/crates/wasmeld-console/web/package-lock.json +++ b/crates/wasmeld-console/web/package-lock.json @@ -8,8 +8,11 @@ "name": "wasmeld-console-web", "version": "0.1.0", "dependencies": { + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", "lucide-solid": "1.27.0", - "solid-js": "1.9.14" + "solid-js": "1.9.14", + "tailwind-merge": "3.6.0" }, "devDependencies": { "@tailwindcss/vite": "4.3.3", @@ -545,9 +548,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -565,9 +565,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -585,9 +582,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -605,9 +599,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -625,9 +616,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -645,9 +633,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -665,9 +650,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -685,9 +667,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -892,9 +871,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -912,9 +888,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -932,9 +905,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -952,9 +922,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -972,9 +939,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -992,9 +956,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1012,9 +973,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1032,9 +990,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1205,9 +1160,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1225,9 +1177,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1245,9 +1194,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1265,9 +1211,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1285,9 +1228,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1305,9 +1245,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1550,9 +1487,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1570,9 +1504,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1590,9 +1521,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1610,9 +1538,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2224,6 +2149,27 @@ ], "license": "CC-BY-4.0" }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2565,9 +2511,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2589,9 +2532,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2613,9 +2553,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2637,9 +2574,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3040,6 +2974,16 @@ "node": ">=0.10.0" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", diff --git a/crates/wasmeld-console/web/package.json b/crates/wasmeld-console/web/package.json index 7af48b0..4bbd83b 100644 --- a/crates/wasmeld-console/web/package.json +++ b/crates/wasmeld-console/web/package.json @@ -13,8 +13,11 @@ "format:check": "oxfmt --check ." }, "dependencies": { + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", "lucide-solid": "1.27.0", - "solid-js": "1.9.14" + "solid-js": "1.9.14", + "tailwind-merge": "3.6.0" }, "devDependencies": { "@tailwindcss/vite": "4.3.3", diff --git a/crates/wasmeld-console/web/src/components/ui/button.tsx b/crates/wasmeld-console/web/src/components/ui/button.tsx new file mode 100644 index 0000000..12455de --- /dev/null +++ b/crates/wasmeld-console/web/src/components/ui/button.tsx @@ -0,0 +1,113 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { splitProps, type JSX } from "solid-js"; +import { cn } from "../../lib/cn"; + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md border text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-45", + { + variants: { + variant: { + primary: "border-brand bg-brand text-white hover:border-brand-strong hover:bg-brand-strong", + secondary: "border-line bg-white text-ink hover:bg-canvas", + danger: "border-coral-strong bg-white text-coral-strong hover:bg-coral-soft", + }, + size: { + default: "min-h-10 px-4 py-2", + small: "min-h-9 px-4 py-1.5", + }, + }, + defaultVariants: { + variant: "secondary", + size: "default", + }, + }, +); + +export type ButtonProps = JSX.ButtonHTMLAttributes & + VariantProps; + +/** + * Standard text or icon-and-text command button. + * + * Size variants define a minimum target height instead of fixing the block + * height, so wrapped labels and enlarged user fonts can grow without clipping. + * The component defaults to `type="button"` so using it inside a form never + * submits accidentally. Set `type="submit"` explicitly for form actions. + */ +export function Button(props: ButtonProps) { + const [local, rest] = splitProps(props, ["variant", "size", "class", "children", "type"]); + return ( + + ); +} + +export const iconButtonVariants = cva( + "inline-flex shrink-0 items-center justify-center rounded-md border border-line bg-white text-muted transition-colors hover:bg-canvas hover:text-ink disabled:cursor-not-allowed disabled:opacity-45", + { + variants: { + tone: { + default: "", + danger: "text-coral-strong", + }, + size: { + default: "size-9", + small: "size-8", + }, + }, + defaultVariants: { + tone: "default", + size: "default", + }, + }, +); + +export type IconButtonProps = Omit, "aria-label"> & + VariantProps & { + "aria-label": string; + }; + +/** + * Square icon-only button for familiar toolbar and row actions. + * + * Callers must provide an accessible `aria-label`; `title` is recommended when + * the icon's meaning benefits from a visible hover explanation. + */ +export function IconButton(props: IconButtonProps) { + const [local, rest] = splitProps(props, ["tone", "size", "class", "children", "type"]); + return ( + + ); +} + +export type IconLinkProps = Omit, "aria-label"> & + Pick, "size"> & { + "aria-label": string; + }; + +/** Anchor counterpart to IconButton for downloads and external navigation. */ +export function IconLink(props: IconLinkProps) { + const [local, rest] = splitProps(props, ["size", "class", "children"]); + return ( + + {local.children} + + ); +} diff --git a/crates/wasmeld-console/web/src/components/ui/card.tsx b/crates/wasmeld-console/web/src/components/ui/card.tsx new file mode 100644 index 0000000..0648e7d --- /dev/null +++ b/crates/wasmeld-console/web/src/components/ui/card.tsx @@ -0,0 +1,68 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { splitProps, type JSX } from "solid-js"; +import { Dynamic } from "solid-js/web"; +import { cn } from "../../lib/cn"; + +type CardElement = "article" | "aside" | "div" | "section"; + +export const cardVariants = cva("rounded-md border border-line bg-surface", { + variants: { + padding: { + none: "", + small: "p-4", + medium: "p-5", + }, + elevation: { + flat: "", + raised: "shadow-sm", + }, + }, + defaultVariants: { + padding: "none", + elevation: "flat", + }, +}); + +export type CardProps = JSX.HTMLAttributes & + VariantProps & { + as?: CardElement; + }; + +/** + * Generic bordered surface. + * + * `as` preserves document semantics at the call site; Card only owns visual + * framing and must not encode page or domain behavior. + */ +export function Card(props: CardProps) { + const [local, rest] = splitProps(props, ["as", "padding", "elevation", "class", "children"]); + return ( + + {local.children} + + ); +} + +export type CardHeaderProps = JSX.HTMLAttributes; + +/** Standard Card heading/action row. */ +export function CardHeader(props: CardHeaderProps) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( +
+ {local.children} +
+ ); +} diff --git a/crates/wasmeld-console/web/src/components/ui/field.tsx b/crates/wasmeld-console/web/src/components/ui/field.tsx new file mode 100644 index 0000000..6c3c628 --- /dev/null +++ b/crates/wasmeld-console/web/src/components/ui/field.tsx @@ -0,0 +1,34 @@ +import { splitProps, type JSX } from "solid-js"; +import { cn } from "../../lib/cn"; + +export type FieldProps = JSX.LabelHTMLAttributes; + +/** + * Associates a label and form control while keeping field layout consistent. + * + * Keep validation and help text at the call site: those messages are domain + * content, while Field only owns spacing and label typography. + */ +export function Field(props: FieldProps) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( + + ); +} + +export type FieldLabelProps = JSX.HTMLAttributes; + +export function FieldLabel(props: FieldLabelProps) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( + + {local.children} + + ); +} diff --git a/crates/wasmeld-console/web/src/components/ui/input.tsx b/crates/wasmeld-console/web/src/components/ui/input.tsx new file mode 100644 index 0000000..b7df070 --- /dev/null +++ b/crates/wasmeld-console/web/src/components/ui/input.tsx @@ -0,0 +1,33 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { splitProps, type JSX } from "solid-js"; +import { cn } from "../../lib/cn"; + +export const inputVariants = cva( + "w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong", + { + variants: { + density: { + default: "min-h-10 py-2", + compact: "min-h-9 py-1.5", + }, + }, + defaultVariants: { + density: "default", + }, + }, +); + +export type InputProps = JSX.InputHTMLAttributes & + VariantProps; + +/** Native single-line input with a minimum, rather than fixed, control height. */ +export function Input(props: InputProps) { + const [local, rest] = splitProps(props, ["density", "class"]); + return ( + + ); +} diff --git a/crates/wasmeld-console/web/src/components/ui/segmented-control.tsx b/crates/wasmeld-console/web/src/components/ui/segmented-control.tsx new file mode 100644 index 0000000..6dd3ed0 --- /dev/null +++ b/crates/wasmeld-console/web/src/components/ui/segmented-control.tsx @@ -0,0 +1,50 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { splitProps, type JSX } from "solid-js"; +import { cn } from "../../lib/cn"; + +export function SegmentedControl(props: JSX.HTMLAttributes) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( +
+ {local.children} +
+ ); +} + +export const segmentVariants = cva( + "min-h-8 rounded-sm px-3 py-1 text-xs font-semibold text-muted transition-colors", + { + variants: { + active: { + true: "bg-white text-ink shadow-sm", + false: "hover:text-ink", + }, + }, + defaultVariants: { + active: false, + }, + }, +); + +export type SegmentProps = JSX.ButtonHTMLAttributes & + VariantProps; + +/** One mutually exclusive option inside a SegmentedControl. */ +export function Segment(props: SegmentProps) { + const [local, rest] = splitProps(props, ["active", "class", "children", "type"]); + return ( + + ); +} diff --git a/crates/wasmeld-console/web/src/components/ui/select.tsx b/crates/wasmeld-console/web/src/components/ui/select.tsx new file mode 100644 index 0000000..04ac32b --- /dev/null +++ b/crates/wasmeld-console/web/src/components/ui/select.tsx @@ -0,0 +1,35 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { splitProps, type JSX } from "solid-js"; +import { cn } from "../../lib/cn"; + +export const selectVariants = cva( + "w-full rounded-md border border-line bg-white px-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong", + { + variants: { + density: { + default: "min-h-10 py-2", + compact: "min-h-9 py-1.5", + }, + }, + defaultVariants: { + density: "default", + }, + }, +); + +export type SelectProps = JSX.SelectHTMLAttributes & + VariantProps; + +/** Native select with browser behavior preserved and a content-growable height. */ +export function Select(props: SelectProps) { + const [local, rest] = splitProps(props, ["density", "class", "children"]); + return ( + + ); +} diff --git a/crates/wasmeld-console/web/src/components/ui/table.tsx b/crates/wasmeld-console/web/src/components/ui/table.tsx new file mode 100644 index 0000000..02c2804 --- /dev/null +++ b/crates/wasmeld-console/web/src/components/ui/table.tsx @@ -0,0 +1,82 @@ +import { splitProps, type JSX } from "solid-js"; +import { cn } from "../../lib/cn"; + +export type TableProps = JSX.HTMLAttributes & { + containerClass?: string; +}; + +/** Responsive native table with a horizontal overflow boundary. */ +export function Table(props: TableProps) { + const [local, rest] = splitProps(props, ["class", "containerClass", "children"]); + return ( +
+ + {local.children} +
+
+ ); +} + +export function TableHeader(props: JSX.HTMLAttributes) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( + + {local.children} + + ); +} + +export function TableBody(props: JSX.HTMLAttributes) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( + + {local.children} + + ); +} + +export function TableRow(props: JSX.HTMLAttributes) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( + + {local.children} + + ); +} + +export function TableHead(props: JSX.ThHTMLAttributes) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( + + {local.children} + + ); +} + +export function TableCell(props: JSX.TdHTMLAttributes) { + const [local, rest] = splitProps(props, ["class", "children"]); + return ( + + {local.children} + + ); +} diff --git a/crates/wasmeld-console/web/src/components/ui/textarea.tsx b/crates/wasmeld-console/web/src/components/ui/textarea.tsx new file mode 100644 index 0000000..417243d --- /dev/null +++ b/crates/wasmeld-console/web/src/components/ui/textarea.tsx @@ -0,0 +1,43 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { splitProps, type JSX } from "solid-js"; +import { cn } from "../../lib/cn"; + +export const textareaVariants = cva( + "min-h-36 w-full rounded-md border border-line bg-white px-3 py-3 text-sm text-ink outline-none transition-colors focus:border-brand disabled:cursor-not-allowed disabled:opacity-45 aria-invalid:border-coral-strong", + { + variants: { + resize: { + vertical: "resize-y", + none: "resize-none", + }, + typography: { + mono: "font-mono", + sans: "font-sans", + }, + }, + defaultVariants: { + resize: "vertical", + typography: "mono", + }, + }, +); + +export type TextareaProps = JSX.TextareaHTMLAttributes & + VariantProps; + +/** Standard multiline input with explicit resize and typography variants. */ +export function Textarea(props: TextareaProps) { + const [local, rest] = splitProps(props, ["resize", "typography", "class", "children"]); + return ( + + ); +} diff --git a/crates/wasmeld-console/web/src/lib/cn.ts b/crates/wasmeld-console/web/src/lib/cn.ts new file mode 100644 index 0000000..a3fa071 --- /dev/null +++ b/crates/wasmeld-console/web/src/lib/cn.ts @@ -0,0 +1,16 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Merges conditional Tailwind classes using the same pattern as shadcn. + * + * `clsx` evaluates conditional input while `tailwind-merge` lets a caller's + * class override a component default from the same Tailwind class group: + * + * ```ts + * cn("h-10 px-4", compact && "h-8", props.class) + * ``` + */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +}