diff --git a/.gitignore b/.gitignore index 96fab4f..1249ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ coverage # Vercel .vercel +# Astro +.astro + # Build Outputs .next/ out/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 6eef9e4..7e4a6b5 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -18,5 +18,5 @@ "typescript/no-unnecessary-type-parameters": "off", "typescript/no-unsafe-type-assertion": "off" }, - "ignorePatterns": ["**/dist/**", "**/coverage/**"] + "ignorePatterns": ["**/dist/**", "**/coverage/**", "**/.astro/**"] } diff --git a/apps/landing/PRODUCT.md b/apps/landing/PRODUCT.md new file mode 100644 index 0000000..8f0f212 --- /dev/null +++ b/apps/landing/PRODUCT.md @@ -0,0 +1,46 @@ +# Product + + + +## Platform + +web + +## Users + +TypeScript and React engineers drowning in nested loading / error / empty UI. + +## Product Purpose + +`@bearstudio/ui-state` is an OSS TypeScript helper. `getUiState` plus `match` plus `exhaustive()` map pending, error, and the statuses a screen already has onto one typed chain. Success is a compile error when a status is unmatched — not a new store. + +## Positioning + +exhaustive() is a type error until every status is matched. Statuses are inferred from set(), not a fixed enum. React is a peer. There is no store. + +## Operating Context + +Library landing (Persuade). +Engineers arrive from GitHub, read a short example, and wire getUiState to query flags they already have. + +## Capabilities and Constraints + +- Surface API: getUiState, set(), match, exhaustive(), when(). +- Do not demo is() as a type guard; it does not narrow on current main (PR #5). +- MIT. React peer. No store, no context, no extra runtime. +- Do not invent download counts or testimonials. + +## Brand Commitments + +Voice: specific, technical, no hype. Anti-references: Relume/v0 indigo bento, fake browser chrome, equal feature cards, Unleash. Identity: cream/ink tokens, IBM Plex Sans and Mono. + +## Evidence on Hand + +No download counts, testimonials, or case studies. Do not fabricate them. Facts: MIT, React peer, no store, exhaustive() fails the type check until every status is matched. + +## Product Principles + +1. A missed status is a type error, not a blank screen. +2. Statuses come from the app, not a library enum. +3. No new store; wire to flags the screen already has. +4. Marketing copy stays factual; no hype words. diff --git a/apps/landing/README.md b/apps/landing/README.md new file mode 100644 index 0000000..f097cf4 --- /dev/null +++ b/apps/landing/README.md @@ -0,0 +1,35 @@ +# landing + +Marketing site for `@bearstudio/ui-state`. Unpublished; no changeset. + +## Run + +From the repo root (Node 24, pnpm 10.34.5): + +```bash +pnpm install +pnpm --filter landing dev +``` + +Root `pnpm dev` / `pnpm build` / `pnpm check-types` / `pnpm lint` also +include this app via turbo. `dev` depends on `^build` so the library +dist exists before the Astro island imports it. + +## Stack + +- Astro 6 SSG + one React island (`client:visible`) +- Tailwind 4 via `@tailwindcss/vite` (not `@astrojs/tailwind`) +- Tokens in `@theme inline` +- `@bearstudio/ui-state` as `workspace:*` +- Fonts: Astro 6 Fonts API, Fontsource, latin + latin-ext, self-hosted + +## Typecheck split + +Library stays NodeNext (`react-library.json`). This app does **not** +extend `react-library.json`. It extends +`@bearstudio/typescript-config/astro.json` (`moduleResolution: Bundler`). + +`check-types` is `tsc --noEmit` on `src/**/*.ts(x)` (the island). +`astro check` is not the source of truth on TypeScript 7 (no compiler API). + +Lint: oxlint. Format: oxfmt. diff --git a/apps/landing/astro.config.ts b/apps/landing/astro.config.ts new file mode 100644 index 0000000..c8aed20 --- /dev/null +++ b/apps/landing/astro.config.ts @@ -0,0 +1,39 @@ +import react from "@astrojs/react"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig, fontProviders } from "astro/config"; + +export default defineConfig({ + output: "static", + compressHTML: true, + integrations: [react()], + vite: { + plugins: [tailwindcss()], + }, + fonts: [ + { + name: "Roboto Slab", + cssVariable: "--font-roboto-slab", + provider: fontProviders.fontsource(), + weights: [700], + styles: ["normal"], + subsets: ["latin", "latin-ext"], + }, + { + name: "Inter", + cssVariable: "--font-inter", + provider: fontProviders.fontsource(), + weights: [400, 500, 600], + styles: ["normal"], + subsets: ["latin", "latin-ext"], + }, + { + name: "IBM Plex Mono", + cssVariable: "--font-ibm-plex-mono", + provider: fontProviders.fontsource(), + weights: [400, 500], + styles: ["normal"], + subsets: ["latin", "latin-ext"], + fallbacks: ["monospace"], + }, + ], +}); diff --git a/apps/landing/package.json b/apps/landing/package.json new file mode 100644 index 0000000..e1880f5 --- /dev/null +++ b/apps/landing/package.json @@ -0,0 +1,30 @@ +{ + "name": "landing", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "check-types": "tsc --noEmit", + "lint": "oxlint .", + "format": "oxfmt" + }, + "dependencies": { + "@astrojs/react": "6.0.4", + "@bearstudio/ui-state": "workspace:*", + "@tanstack/highlight": "0.0.10", + "astro": "7.2.7", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@bearstudio/typescript-config": "workspace:*", + "@tailwindcss/vite": "4.3.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "tailwindcss": "4.3.3", + "typescript": "7.0.2" + } +} diff --git a/apps/landing/public/apple-touch-icon.png b/apps/landing/public/apple-touch-icon.png new file mode 100644 index 0000000..11644b6 Binary files /dev/null and b/apps/landing/public/apple-touch-icon.png differ diff --git a/apps/landing/public/favicon-96x96.png b/apps/landing/public/favicon-96x96.png new file mode 100644 index 0000000..e8b164d Binary files /dev/null and b/apps/landing/public/favicon-96x96.png differ diff --git a/apps/landing/public/favicon.ico b/apps/landing/public/favicon.ico new file mode 100644 index 0000000..5d845d4 Binary files /dev/null and b/apps/landing/public/favicon.ico differ diff --git a/apps/landing/public/favicon.svg b/apps/landing/public/favicon.svg new file mode 100644 index 0000000..70183f3 --- /dev/null +++ b/apps/landing/public/favicon.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/apps/landing/src/components/BearMascot.tsx b/apps/landing/src/components/BearMascot.tsx new file mode 100644 index 0000000..3e36f4d --- /dev/null +++ b/apps/landing/src/components/BearMascot.tsx @@ -0,0 +1,258 @@ +type BearMood = "loading" | "sad" | "empty" | "lost" | "hungry" | "happy"; + +type BearMascotProps = { + mood: BearMood; + className?: string; + spin?: boolean; +}; + +const FUR = "#0a2f39"; +const BELLY = "#135d71"; +const MUZZLE = "#e4eef0"; +const GOLD = "#ffbf0f"; +const INK = "#08242b"; +const TEAR = "#67a7b6"; +const TONGUE = "#c45c4a"; + +function HoneyPot({ filled }: { filled: boolean }) { + return ( + + + + {filled ? null : } + + ); +} + +function BearFace({ mood }: { mood: BearMood }) { + switch (mood) { + case "loading": + return ( + <> + + + + + + + + + ); + case "sad": + return ( + <> + + + + + + + + + + + ); + case "empty": + return ( + <> + + + + + + + + ); + case "lost": + return ( + <> + + + + + + + + + + + ); + case "hungry": + return ( + <> + + + + + + + + + ); + case "happy": + return ( + <> + + + + + + + + + ); + default: { + const exhaustiveMood: never = mood; + return exhaustiveMood; + } + } +} + +export function BearMascot({ mood, className = "", spin }: BearMascotProps) { + const shouldSpin = spin ?? mood === "loading"; + + return ( + + + + ); +} + +export type { BearMood }; diff --git a/apps/landing/src/components/CompareCode.astro b/apps/landing/src/components/CompareCode.astro new file mode 100644 index 0000000..94e6a7e --- /dev/null +++ b/apps/landing/src/components/CompareCode.astro @@ -0,0 +1,67 @@ +--- +import { highlightDiff } from "../lib/highlight"; + +const html = highlightDiff([ + { + kind: "deleted", + code: `// Convenient to factor, but this bypasses inference and type safety. +const bears = query.data ?? []; +const search = filters.search;`, + }, + { kind: "common", code: "" }, + { + kind: "inserted", + code: `// The logic that defines every possible UI state. +const ui = getUiState((set) => { + if (query.isPending) return set("pending"); + if (query.isError && query.error.code === "FORBIDDEN") return set("forbidden"); + if (query.isError) return set("error"); + // Correctly inferred here — no need to fall back with ?? []. + const bears = query.data; + if (!bears.length && filters.search) return set("empty-search", { search: filters.search }); + if (!bears.length) return set("empty"); + return set("default", { bears }); +});`, + }, + { kind: "common", code: "" }, + { + kind: "common", + code: `return ( + <>`, + }, + { + kind: "deleted", + code: ` // State logic mixed with each render = a mess. + {query.isPending && } + {!query.isPending && query.isError && query.error.code === "FORBIDDEN" && } + {!query.isPending && query.isError && query.error.code !== "FORBIDDEN" && } + {!query.isPending && !query.isError && !bears.length && search && } + {!query.isPending && !query.isError && !bears.length && !search && } + {!query.isPending && !query.isError && !!bears.length && }`, + }, + { kind: "common", code: "" }, + { + kind: "inserted", + code: ` // Clear render handling for each state, with inferred types. + {ui.match("pending", () => ) + .match("forbidden", () => ) + .match("error", () => ) + .match("empty-search", ({ search }) => ) + .match("empty", () => ) + .match("default", ({ bears }) => ) + .exhaustive()}`, + }, + { + kind: "common", + code: ` +);`, + }, +]); +--- + +
+
+ Before code highlighted in red, after code highlighted in green +
+
+
diff --git a/apps/landing/src/components/CopyCommand.tsx b/apps/landing/src/components/CopyCommand.tsx new file mode 100644 index 0000000..953ca3d --- /dev/null +++ b/apps/landing/src/components/CopyCommand.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef, useState } from "react"; + +type CopyCommandProps = { + command: string; +}; + +function CopyIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +async function copyText(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to execCommand. + } + } + + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + return ok; + } catch { + return false; + } +} + +export function CopyCommand({ command }: CopyCommandProps) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(undefined); + + useEffect(() => { + return () => { + window.clearTimeout(timeoutRef.current); + }; + }, []); + + async function copy() { + const ok = await copyText(command); + if (!ok) { + return; + } + setCopied(true); + window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { + setCopied(false); + }, 2000); + } + + return ( + + ); +} diff --git a/apps/landing/src/components/StateDemo.tsx b/apps/landing/src/components/StateDemo.tsx new file mode 100644 index 0000000..8fb48ed --- /dev/null +++ b/apps/landing/src/components/StateDemo.tsx @@ -0,0 +1,207 @@ +import { useMemo, useState } from "react"; +import { getUiState } from "@bearstudio/ui-state"; +import { BearMascot } from "./BearMascot"; + +type Bear = { name: string }; + +type QueryError = { + code: "FORBIDDEN" | "UNKNOWN"; +}; + +type Query = { + isPending: boolean; + isError: boolean; + error: QueryError | undefined; + data: Bear[] | undefined; +}; + +type QueryStatus = "pending" | "error" | "forbidden" | "success" | "empty"; + +type QueryStatusOption = { + id: QueryStatus; + label: string; +}; + +const BEARS: Bear[] = [{ name: "Bruno" }, { name: "Nala" }, { name: "Koda" }]; + +const QUERY_STATUS_OPTIONS = [ + { id: "pending", label: "pending" }, + { id: "error", label: "error" }, + { id: "forbidden", label: "forbidden" }, + { id: "success", label: "success" }, + { id: "empty", label: "empty" }, +] as const satisfies ReadonlyArray; + +function getQuery(status: QueryStatus): Query { + switch (status) { + case "pending": + return { + isPending: true, + isError: false, + error: undefined, + data: undefined, + }; + case "forbidden": + return { + isPending: false, + isError: true, + error: { code: "FORBIDDEN" }, + data: undefined, + }; + case "error": + return { + isPending: false, + isError: true, + error: { code: "UNKNOWN" }, + data: undefined, + }; + case "empty": + return { + isPending: false, + isError: false, + error: undefined, + data: [], + }; + case "success": + return { + isPending: false, + isError: false, + error: undefined, + data: BEARS, + }; + default: { + const exhaustiveCheck: never = status; + throw new Error("Unhandled query status", { cause: exhaustiveCheck }); + } + } +} + +export function StateDemo() { + const [queryStatus, setQueryStatus] = useState("success"); + const [search, setSearch] = useState(""); + + const ui = useMemo(() => { + const query = getQuery(queryStatus); + const term = search.trim(); + const bears = (query.data ?? []).filter((bear) => + bear.name.toLowerCase().includes(term.toLowerCase()), + ); + + return getUiState((set) => { + if (query.isPending) return set("pending"); + if (query.isError && query.error?.code === "FORBIDDEN") + return set("forbidden"); + if (query.isError) return set("error"); + if (!bears.length && term) return set("empty-search", { search: term }); + if (!bears.length) return set("empty"); + return set("default", { bears }); + }); + }, [queryStatus, search]); + + return ( +
+
+ + +
+ +
+

+ {ui + .match("pending", () => "Bears") + .match("forbidden", () => "Can't enter the den") + .match("error", () => "Couldn't load bears") + .match("empty", () => "No bears yet") + .match( + "empty-search", + ({ search: term }) => `No results for ${term}`, + ) + .match("default", ({ bears }) => + bears.length === 1 ? "1 bear" : `${bears.length} bears`, + ) + .exhaustive()} +

+ {ui + .match("pending", () => ( + + )) + .match("forbidden", () => ( + + )) + .match("error", () => ( + + )) + .match("empty", () => ( + + )) + .match("empty-search", () => ( + + )) + .match("default", () => ( + + )) + .exhaustive()} +

+ {ui + .match("pending", () => "Loading…") + .match("forbidden", () => "Forbidden") + .match("error", () => "Request failed") + .match("empty", () => "Nothing in the den") + .match("empty-search", ({ search: term }) => `"${term}"`) + .match("default", ({ bears }) => + bears.map((bear) => bear.name).join(" · "), + ) + .exhaustive()} +

+
+
+ ); +} diff --git a/apps/landing/src/env.d.ts b/apps/landing/src/env.d.ts new file mode 100644 index 0000000..f964fe0 --- /dev/null +++ b/apps/landing/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/landing/src/layouts/Layout.astro b/apps/landing/src/layouts/Layout.astro new file mode 100644 index 0000000..3570880 --- /dev/null +++ b/apps/landing/src/layouts/Layout.astro @@ -0,0 +1,72 @@ +--- +import { Font } from "astro:assets"; +import { highlightThemeCss } from "../lib/highlight"; +import "../styles/global.css"; + +type Props = { + title: string; + description: string; +}; + +const { title, description } = Astro.props; +const canonical = "https://github.com/BearStudio/ui-state"; +const jsonLd = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + name: "BearStudio", + url: "https://www.bearstudio.fr", + }, + { + "@type": "SoftwareApplication", + name: "@bearstudio/ui-state", + applicationCategory: "DeveloperApplication", + operatingSystem: "Web", + license: "https://opensource.org/licenses/MIT", + url: canonical, + }, + { + "@type": "WebPage", + name: title, + description, + url: canonical, + }, + ], +}; +--- + + + + + + + + {title} + + + + + + + + + + + + + + + ${highlightThemeCss}`} /> +