From 6344a513ce8066e61ff0eae90280e1e386a63915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 15 Jul 2026 23:32:28 +0200 Subject: [PATCH] feat(ui): shared primitives (Button, Badge, Card, Input) on the tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second step of the visual-redesign (tokens -> primitives -> surfaces): a small set of shared, token-driven primitives in @trails-cool/ui so both apps compose from one styled vocabulary instead of hand-rolling bg-white/text-gray-* per screen. - @trails-cool/ui now ships React components (Button variants+sizes, Badge tones, Card raised/subtle, Input) plus a tiny cn() helper. React is a peer dep; package builds with no bundling step. - Co-located jsdom unit tests (@testing-library/react) assert roles, token classes, variant switching, and behavior — run in the existing Unit Tests gate, cross-platform, no snapshot baseline needed. - Tailwind @source directive in both apps' styles.css so the package's token utility classes get generated. - Dev-only /dev/ui gallery route in the planner renders every primitive in all states — a zero-dependency stand-in for Storybook. Excluded from production builds (NODE_ENV guard in routes.ts). No app surfaces are refactored yet; adopting the primitives in the topbar/sidebar/etc. is the surface task groups. Co-Authored-By: Claude Opus 4.8 --- apps/journal/app/styles.css | 3 ++ apps/planner/app/routes.ts | 5 ++ apps/planner/app/routes/dev.ui.tsx | 73 ++++++++++++++++++++++++++++++ apps/planner/app/styles.css | 3 ++ packages/ui/package.json | 18 ++++++++ packages/ui/src/Badge.test.tsx | 22 +++++++++ packages/ui/src/Badge.tsx | 21 +++++++++ packages/ui/src/Button.test.tsx | 55 ++++++++++++++++++++++ packages/ui/src/Button.tsx | 44 ++++++++++++++++++ packages/ui/src/Card.test.tsx | 22 +++++++++ packages/ui/src/Card.tsx | 20 ++++++++ packages/ui/src/Input.test.tsx | 26 +++++++++++ packages/ui/src/Input.tsx | 15 ++++++ packages/ui/src/cn.ts | 4 ++ packages/ui/src/index.ts | 9 ++++ packages/ui/tsconfig.json | 10 ++++ packages/ui/vitest.config.ts | 1 + pnpm-lock.yaml | 13 ++++++ 18 files changed, 364 insertions(+) create mode 100644 apps/planner/app/routes/dev.ui.tsx create mode 100644 packages/ui/src/Badge.test.tsx create mode 100644 packages/ui/src/Badge.tsx create mode 100644 packages/ui/src/Button.test.tsx create mode 100644 packages/ui/src/Button.tsx create mode 100644 packages/ui/src/Card.test.tsx create mode 100644 packages/ui/src/Card.tsx create mode 100644 packages/ui/src/Input.test.tsx create mode 100644 packages/ui/src/Input.tsx create mode 100644 packages/ui/src/cn.ts create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/vitest.config.ts diff --git a/apps/journal/app/styles.css b/apps/journal/app/styles.css index 8eff5ef..a6335a7 100644 --- a/apps/journal/app/styles.css +++ b/apps/journal/app/styles.css @@ -1,6 +1,9 @@ @import "tailwindcss"; @import "@trails-cool/ui/theme.css"; +/* Scan the shared UI package so its token utility classes are generated. */ +@source "../../../packages/ui/src"; + @keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } diff --git a/apps/planner/app/routes.ts b/apps/planner/app/routes.ts index 53f07f0..aac17d0 100644 --- a/apps/planner/app/routes.ts +++ b/apps/planner/app/routes.ts @@ -9,4 +9,9 @@ export default [ route("api/pois", "routes/api.pois.ts"), route("api/save-to-journal", "routes/api.save-to-journal.ts"), route("session/:id", "routes/session.$id.tsx"), + // Dev-only component gallery for the shared @trails-cool/ui primitives — + // our lightweight stand-in for Storybook. Not compiled in production builds. + ...(process.env.NODE_ENV === "production" + ? [] + : [route("dev/ui", "routes/dev.ui.tsx")]), ] satisfies RouteConfig; diff --git a/apps/planner/app/routes/dev.ui.tsx b/apps/planner/app/routes/dev.ui.tsx new file mode 100644 index 0000000..9933fd4 --- /dev/null +++ b/apps/planner/app/routes/dev.ui.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from "react"; +import { Badge, Button, Card, Input } from "@trails-cool/ui"; + +/** + * Dev-only gallery for the shared UI primitives. Renders every primitive in + * its variants/states on one page so components can be built and eyeballed in + * isolation — the lightweight alternative to Storybook. Registered only in + * non-production builds (see routes.ts). + */ +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +export default function DevUi() { + return ( +
+
+

UI primitives

+

+ Shared @trails-cool/ui components on + the design-system tokens. +

+
+ +
+ + + + +
+ +
+ + +
+ +
+ Neutral + Elevation + NIGHT 2 +
+ +
+
+ +
+
+ +
+
+ +
+ +

Subtle card

+

On a subtle surface tone.

+
+ +

Raised card

+

Elevated with a soft shadow.

+
+
+
+ ); +} diff --git a/apps/planner/app/styles.css b/apps/planner/app/styles.css index 8eff5ef..a6335a7 100644 --- a/apps/planner/app/styles.css +++ b/apps/planner/app/styles.css @@ -1,6 +1,9 @@ @import "tailwindcss"; @import "@trails-cool/ui/theme.css"; +/* Scan the shared UI package so its token utility classes are generated. */ +@source "../../../packages/ui/src"; + @keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } diff --git a/packages/ui/package.json b/packages/ui/package.json index fb62fe5..45e622b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,10 +3,28 @@ "version": "0.0.1", "type": "module", "exports": { + ".": "./src/index.ts", "./theme.css": "./src/theme.css" }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "test": "vitest run", + "lint": "eslint .", + "typecheck": "tsc" + }, "dependencies": { "@fontsource-variable/geist-mono": "catalog:", "@fontsource-variable/outfit": "catalog:" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" } } diff --git a/packages/ui/src/Badge.test.tsx b/packages/ui/src/Badge.test.tsx new file mode 100644 index 0000000..ea97fc1 --- /dev/null +++ b/packages/ui/src/Badge.test.tsx @@ -0,0 +1,22 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { Badge } from "./Badge.tsx"; + +afterEach(cleanup); + +describe("Badge", () => { + it("renders neutral tone by default", () => { + render(New); + const badge = screen.getByText("New"); + expect(badge.className).toContain("bg-bg-subtle"); + expect(badge.className).toContain("text-text-md"); + }); + + it("applies the stop tone for overnight markers", () => { + render(NIGHT); + const badge = screen.getByText("NIGHT"); + expect(badge.className).toContain("bg-stop-bg"); + expect(badge.className).toContain("text-stop"); + }); +}); diff --git a/packages/ui/src/Badge.tsx b/packages/ui/src/Badge.tsx new file mode 100644 index 0000000..f1bbccc --- /dev/null +++ b/packages/ui/src/Badge.tsx @@ -0,0 +1,21 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "./cn.ts"; + +export type BadgeTone = "neutral" | "accent" | "stop"; + +export interface BadgeProps extends HTMLAttributes { + tone?: BadgeTone; +} + +const base = + "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"; + +const tones: Record = { + neutral: "bg-bg-subtle text-text-md", + accent: "bg-accent-bg text-accent", + stop: "bg-stop-bg text-stop", +}; + +export function Badge({ tone = "neutral", className, ...props }: BadgeProps) { + return ; +} diff --git a/packages/ui/src/Button.test.tsx b/packages/ui/src/Button.test.tsx new file mode 100644 index 0000000..cf6037a --- /dev/null +++ b/packages/ui/src/Button.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { Button } from "./Button.tsx"; + +afterEach(cleanup); + +describe("Button", () => { + it("renders its children and defaults to type=button", () => { + render(); + const btn = screen.getByRole("button", { name: "Save" }); + expect(btn).toHaveProperty("type", "button"); + }); + + it("applies the primary variant token classes by default", () => { + render(); + const btn = screen.getByRole("button", { name: "Go" }); + expect(btn.className).toContain("bg-accent"); + expect(btn.className).toContain("text-text-inv"); + }); + + it("switches variant and size classes", () => { + render( + , + ); + const btn = screen.getByRole("button", { name: "Cancel" }); + expect(btn.className).toContain("text-text-md"); + expect(btn.className).toContain("h-7"); + expect(btn.className).not.toContain("bg-accent"); + }); + + it("merges a caller className without dropping base classes", () => { + render(); + const btn = screen.getByRole("button", { name: "Wide" }); + expect(btn.className).toContain("w-full"); + expect(btn.className).toContain("rounded-md"); + }); + + it("fires onClick and blocks it when disabled", () => { + const onClick = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Tap" })); + expect(onClick).toHaveBeenCalledTimes(1); + + rerender( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Tap" })); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/Button.tsx b/packages/ui/src/Button.tsx new file mode 100644 index 0000000..b11663c --- /dev/null +++ b/packages/ui/src/Button.tsx @@ -0,0 +1,44 @@ +import type { ButtonHTMLAttributes } from "react"; +import { cn } from "./cn.ts"; + +export type ButtonVariant = "primary" | "secondary" | "ghost"; +export type ButtonSize = "sm" | "md"; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; +} + +const base = + "inline-flex items-center justify-center gap-1.5 rounded-md font-medium " + + "transition-colors focus-visible:outline-none focus-visible:ring-2 " + + "focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-bg " + + "disabled:pointer-events-none disabled:opacity-50"; + +const variants: Record = { + primary: "bg-accent text-text-inv hover:bg-accent-dim", + secondary: + "border border-border bg-bg-raised text-text-hi hover:bg-bg-subtle", + ghost: "text-text-md hover:bg-bg-subtle hover:text-text-hi", +}; + +const sizes: Record = { + sm: "h-7 px-2.5 text-xs", + md: "h-9 px-3.5 text-sm", +}; + +export function Button({ + variant = "primary", + size = "md", + className, + type = "button", + ...props +}: ButtonProps) { + return ( +